Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · September 2025

Stablecoin Bridge

for Citrea

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

10 resolved · 10 acknowledged

Scope

35 files in scope · 1,098 nSLOC
FilenSLOCLines
src/DestinationOUSDC.sol3569
src/DestinationOUSDT.sol3571
src/SourceOFTAdapter.sol1115
src/USDCRolesHolder.sol2533
script/ConfigSetup.s.sol179217
src/for_circle_takeover/DestinationOUSDCForTakeover.sol4379
src/for_circle_takeover/SourceOFTAdapterForTakeover.sol4255
script/usdt/deploy/01_USDTDeploy.s.sol3340
script/usdt/deploy/02_USDTBridgeDeploy.s.sol4654
script/usdt/deploy/03_USDTSrcBridgeSetLzConfig.s.sol3751
script/usdt/deploy/04_USDTDestBridgeSetLzConfig.s.sol3750
script/usdt/deploy/05_USDTSrcBridgeSetPeer.s.sol2128
script/usdt/deploy/06_USDTDestBridgeSetPeer.s.sol2128
script/usdt/deploy/07_USDTSetBridgeAsMinter.s.sol1825
script/usdt/deploy/08_USDTAndBridgeAssignRoles.s.sol3340
script/usdc/deploy/02_USDCBridgeDeploy.s.sol4553
script/usdc/deploy/03_USDCSrcBridgeSetLzConfig.s.sol3750
script/usdc/deploy/04_USDCDestBridgeSetLzConfig.s.sol3750
script/usdc/deploy/05_USDCSrcBridgeSetPeer.s.sol2128
script/usdc/deploy/06_USDCDestBridgeSetPeer.s.sol2128
script/usdc/deploy/07_USDCSetBridgeAsMinter.s.sol1925
script/usdc/deploy/08_USDCAndBridgeAssignRoles.s.sol3441
script/usdc/for_circle_takeover/01_USDCSrcBridgePrepareTakeover.s.sol2531
script/usdc/for_circle_takeover/02_USDCDestBridgePrepareTakeover.s.sol2531
script/usdc/for_circle_takeover/03_USDCSrcBridgeSetBlockedMsgLib.s.sol2026
script/usdc/for_circle_takeover/04_USDCDestBridgeSetBlockedMsgLib.s.sol2026
script/usdc/for_circle_takeover/05_USDCSrcBridgePause.s.sol2026
script/usdc/for_circle_takeover/06_USDCDestBridgePause.s.sol2026
script/usdc/for_circle_takeover/07_USDCRemoveBridgeAsMinter.s.sol1827
script/usdc/for_circle_takeover/08_USDCSrcBridgeSetCircle.s.sol2026
script/usdc/for_circle_takeover/09_USDCProxyAdminTransfer.s.sol1825
script/usdc/for_circle_takeover/10_USDCTransferOwner.s.sol2229
script/usdc/for_circle_takeover/11_USDCRolesHolderSetCircle.s.sol2026
script/usdc/for_circle_takeover/unpause/USDCDestBridgeUnpause.s.sol2026
script/usdc/for_circle_takeover/unpause/USDCSrcBridgeUnpause.s.sol2026

Findings 20

Main Review

19 findings · August 29 to September 3, 2025
  1. L-01 Low Missing ProxyAdmin Ownership Transfer Configuration Acknowledged
    Location
    Global
    Round
    Main Review

    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, TransparentUpgradeableProxy always deploys a fresh ProxyAdmin inside its constructor and stores that ProxyAdmin’s address as the proxy admin (immutable). The constructor argument you pass is not the proxy admin itself, but the owner of the newly created ProxyAdmin.

    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 ProxyAdmin contract per proxy whose owner() is the EOA from config (e.g., .bridge.init.proxyAdminOwner). From that point on, all upgrades of the bridge must go through ProxyAdmin.upgrade/upgradeAndCall, controlled by the ProxyAdmin.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 ProxyAdmin contracts 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 bridge Ownable owners/delegates and master-minter ownership, but does not transfer ownership of the bridge ProxyAdmin. The only ownership transfer you have is for the USDC token proxy (Circle’s AdminUpgradeabilityProxy) in script/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’s burnLockedUSDC call. For example:

    1. Right before Circle calls burnLockedUSDC() on the source adapter, the attacker, in this case the ProxyAdmin’s owner upgrades the SourceAdapter(SourceOFTAdapterForTakeover), drains all the USDC it holds, and then upgrades it again to its previous implementation.
    2. Circle’s burnLockedUSDC is executed. No USDC is burnt, but they think they have burnt the full amount.
    3. 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.

  2. L-02 Low Enabling Peers Before Granting Mint Authorization Configuration Resolved
    Location
    06_USDCDestBridgeSetPeer.s.sol; 07_USDCSetBridgeAsMinter.s.sol; 06_USDTDestBridgeSetPeer.s.sol; 07_USDTSetBridgeAsMinter.s.sol
    Round
    Main Review

    Description

    In the current deployment flow for both USDC and USDT, the destination peer is set in 06_*DestBridgeSetPeer.s.sol before the destination bridge is authorized to mint (USDC) or recognized as the oftContract (USDT), which happens in 07_*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), _credit calls FiatTokenV2_2.mint, which reverts until the destination bridge proxy is a minter:

    token_.mint(_to, _amountLD); // reverts until bridge is an authorized minter
    

    For USDT (src/DestinationOUSDT.sol), _credit calls crosschainMint, which is guarded by onlyAuthorizedSender in OFTExtension. Until oftContract is set to the destination bridge, calls revert:

    token_.crosschainMint(_to, _amountLD); // reverts until oftContract == dest bridge
    

    The 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, then 06_*DestBridgeSetPeer.s.sol and 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.

  3. L-03 Low Pausing _credit During ULN Confirmation Window Configuration Acknowledged
    Location
    DestinationOUSDCForTakeover.sol; SourceOFTAdapterForTakeover.sol
    Round
    Main Review

    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 DestinationOUSDCForTakeover and SourceOFTAdapterForTakeover apply whenNotPaused to both _debit and _credit. This creates a following risk:

    1. A user initiates a cross‑chain transfer.
    2. DVNs wait out the confirmation window on the source chain.
    3. During that window, the owner pauses the bridge.
    4. When the packet is finally delivered, the destination’s _credit (called by lzReceive) hits whenNotPaused and reverts. LayerZero marks the message failed until an explicit retry after unpause.
    5. 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 MasterMinter role 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 whenNotPaused from _credit in both takeover contracts. Guard only _debit so already verified packets continue to settle while new sends are blocked, what “pause and reconcile in‑flight” requires.

  4. L-04 Low USDC Recipient Blacklist DoS Acknowledged
    Location
    DestinationOUSDC.sol; SourceOFTAdapter.sol
    Round
    Main Review

    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.

  5. L-05 Low Zero‑Address Recipient DoS Resolved
    Location
    DestinationOUSDC.sol; DestinationOUSDCForTakeover.sol
    Round
    Main Review

    Description

    Both DestinationOUSDC and DestinationOUSDCForTakeover call token.mint(_to, _amountLD) in _credit without 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:

    1. The source _debit first consumes value (locks via transferFrom in Adapter or burns in Core).
    2. The LZ packet arrives. Destination _credit tries mint(0x0, amount) and reverts.
    3. 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 _credit to 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.

  6. L-06 Low Reconciled Bridged Supply Unexpected Behavior Resolved
    Location
    SourceOFTAdapterForTakeover.sol
    Round
    Main Review

    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, the burnLockedUSDC burns 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 _debit call by the actual received delta and decrease it on every reverse unlock/credit by the amount transferred out.

  7. L-07 Low Fee-On-Transfer USDT Validation Acknowledged
    Location
    SourceOFTAdapterForTakeover.sol; SourceOFTAdapter.sol
    Round
    Main Review

    Description

    On the source chain, the OFT Adapter’s _debit locks 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 amountReceivedLD equal to amountSentLD from _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 _credit then 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 _debit function, 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.

  8. L-08 Low Missing Contract Bytecode Verification Warning Resolved
    Location
    Global
    Round
    Main Review

    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.sol deploys SourceOFTAdapter and a TransparentUpgradeableProxyand 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 with broadcast. For the scripts that use multichain deployments like script/usdc/deploy/02_USDCBridgeDeploy.s.sol consider following Foundry’s multichain deployment guide: https://getfoundry.sh/forge/deploying/#multi-chain-deployments

  9. I-01 Informational USDC.e Proxy Initialization Can Be Front‑Run Frontrunning Acknowledged
    Location
    01_USDCDeploy.sh; deploy-fiat-token.s.sol
    Round
    Main Review

    Description

    The USDC deployment script deploy-fiat-token.s.sol, invoked by 01_USDCDeploy.sh from Circle's stablecoin-evm repository at commit c8c31b2, performs the proxy deployment and subsequent multi-version initializations as separate transactions under vm.startBroadcast(deployerPrivateKey), enabling an attacker to frontrun the unpermissioned initialize calls on the newly deployed FiatTokenProxy after its transaction executes but before the deployer's init transactions are run, allowing the attacker to hijack ownership by calling initialize first 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 with FiatTokenProxy proxy = new FiatTokenProxy(address(fiatTokenV2_2)) in one transaction, followed by separate transactions for proxy.changeAdmin(proxyAdmin) and the casts to FiatTokenV2_2 proxyAsV2_2 = FiatTokenV2_2(address(proxy)) to invoke proxyAsV2_2.initialize() which delegates to the implementation's V1 initializer setting the owner and other roles and then individual calls to proxyAsV2_2.initializeV2(), proxyAsV2_2.initializeV2_1() and proxyAsV2_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 CREATE opcode determinism and submit a competing transaction with higher gas fees to execute first, casting the proxy to FiatTokenV2_2 and calling initialize with the attacker's address as owner to set initialized = true in 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.sol script 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.

  10. I-02 Informational Missing Scripts Configuration Acknowledged
    Location
    Global
    Round
    Main Review

    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).

    1. No script calls burnLockedUSDC() on the source bridge: The takeover implementation src/for_circle_takeover/SourceOFTAdapterForTakeover.sol exposes burnLockedUSDC. This is the exact entrypoint Circle requires on the source chain to burn the locked USDC that backs the finalized bridged supply. However, under script/usdc/for_circle_takeover/ there is no script that invokes this function from the configured circle address. Existing takeover scripts cover preparing new impls, pausing/unpausing, removing minter, setting circle and transferring ProxyAdmin, but they never perform the actual burn.

    2. No script triggers USDCRolesHolder.transferUSDCRoles(address) (Circle-side finalization).src/USDCRolesHolder.sol correctly exposes transferUSDCRoles. You deploy the roles holder and set Circle with:

      • script/usdc/for_circle_takeover/10_USDCTransferOwner.s.sol
      • script/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:

    1. 12_USDCSrcBridgeBurnLockedUSDC.s.sol (run on source chain, from the configured circle address):
      • Fork srcRPC.
      • Call SourceOFTAdapter(srcUSDCBridgeProxy).burnLockedUSDC().
      • Recommended pre-checks: ensure the bridge is paused, log innerToken.balanceOf(proxy).
    2. 13_USDCRolesHolderTransferUSDCRoles.s.sol (run on destination chain, from the configured circle address):
      • Fork destRPC.
      • Obtain roles holder from FiatTokenV2_2(destUSDC).owner().
      • Call USDCRolesHolder(rolesHolder).transferUSDCRoles(<CIRCLE_IMPLEMENTATION_OWNER>).
      • Ensure your separate ProxyAdmin transfer (09_USDCProxyAdminTransfer.s.sol) has already been executed.

    Also update the guide to include these steps in the exact sequence (pause → remove minter → set Circle → burnLockedUSDC → transfer ProxyAdmin → transferUSDCRoles → unpause if applicable).

  11. I-03 Informational Consider Declaring USDC As Immutable Gas Optimization Resolved
    Location
    USDCRolesHolder.sol
    Round
    Main Review

    Description

    In the USDCRolesHolder contract, the usdc state variable is assigned in the constructor to reference the FiatTokenV2_2 interface at the provided usdcProxy address 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 the transferUSDCRoles function where it calls usdc.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 usdc declaration to FiatTokenV2_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.

  12. I-04 Informational Unlicensed Contracts Best Practices Resolved
    Location
    Global
    Round
    Main Review

    Description

    Several in‑scope contracts are marked with

    // SPDX-License-Identifier: UNLICENSED
    

    which 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.
  13. I-05 Informational Destination USDC Blacklist Initialized Empty Warning Acknowledged
    Location
    FiatToken.sol
    Round
    Main Review

    Description

    The USDC deployment script (script/usdc/deploy/01_USDCDeploy.sh) force‑sets an empty blacklist before running Circle’s deployer by writing [] into blacklist.remote.json: echo "[]" > blacklist.remote.json

    Circle’s FiatToken contracts actively maintain a blacklist on production chains (e.g., Arbitrum shows a designated Blacklister at 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.

  14. I-06 Informational Manual In‑Flight Message Check Is Error‑Prone Warning Resolved
    Location
    Global
    Round
    Main Review

    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 whenNotPaused guards 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 as INFLIGHT, CONFIRMING, PAYLOAD_STORED, BLOCKED, FAILED, and DELIVERED. 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 any non‑DELIVERED messages are returned.

  15. I-07 Informational Missing __Pausable_init Invocation Best Practices Resolved
    Location
    SourceOFTAdapterForTakeover.sol
    Round
    Main Review

    Description

    SourceOFTAdapterForTakeover inherits PausableUpgradeable but its initialize function 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_init when inheriting PausableUpgradeable). Omitting it deviates from the recommended initialization procedure for upgradeable contracts.

    Recommendation

    Call __Pausable_init inside the initialize function.

  16. I-08 Informational Missing Storage Gaps In Upgradeable Contracts Best Practices Acknowledged
    Location
    Global
    Round
    Main Review

    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.

  17. I-09 Informational Single Step Ownable Module Best Practices Resolved
    Location
    Global
    Round
    Main Review

    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 Ownable module, 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 call burnLockedUSDC at 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 Ownable2Step module (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 renounceOwnership function to prevent accidental loss of ownership.
    • Implement a timelock on the setPeer function and monitor pending changes.
  18. I-10 Informational Discrepancy In The TransparentUpgradeableProxy/ProxyAdmin Warning Acknowledged
    Location
    09_USDCProxyAdminTransfer.s.sol
    Round
    Main Review

    Description

    The TransparentUpgradeableProxy contracts deployed in all the scripts except script/usdc/deploy/01_USDCDeploy.sh are a v5+ Openzeppelin version: import "openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";. TransparentUpgradeableProxy v5+ use an immutable admin and auto‑deploys a ProxyAdmin in its constructor.

    However, USDC uses Circle’s FiatTokenProxy / Openzeppelin’s v4 AdminUpgradeabilityProxy: stablecoin-evm/contracts/upgradeability/AdminUpgradeabilityProxy.sol at master · circlefin/stablecoin-evm that uses a mutable admin slot with constructor allowing direct admin swaps via changeAdmin(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.

  19. I-11 Informational setCircle Allows Circle Address To Be Re Set Warning Acknowledged
    Location
    SourceOFTAdapterForTakeover.sol
    Round
    Main Review

    Description

    The setCircle function allows the owner to set the address for circle. However the function does not prohibit the ability to call setCircle again once the contract is given the zero allowance Minter role.

    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 burnLockedUSDC function. This function is callable by the address that is set as circle in the setCircle function. Therefore the code does not fully comply with the standard since the owner is allowed to change the circle address after it was already set.

    Recommendation

    Consider changing the logic of setCircle to

    function 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
  1. I-01 Informational InflightMsgCheckLzScan.sh Can Silently Succeed Configuration Resolved
    Location
    InflightMsgCheckLzScan.sh
    Round
    Remediation Review

    Description

    The takeover helper script/usdc/for_circle_takeover/InflightMsgCheckLzScan.sh relies on curl and jq to ensure there are no LayerZero messages in flight before pausing the bridge. When the HTTP call fails or returns malformed JSON, jq exits 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; then
    

    treats 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 pipefail to the shell prologue, capture the exit status of both curl and jq and 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.

More from Citrea

  1. Token

    12 findings 12 findings: 2 low, 10 informational

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.

Get a quote