Bridged USDC Support Audit

Table of Contents

Summary

Type
Cross-Chain
Timeline
From 2025-04-22
To 2025-04-24
Languages
Solidity
Total Issues
5 (5 resolved)
Critical Severity Issues
0 (0 resolved)
High Severity Issues
0 (0 resolved)
Medium Severity Issues
0 (0 resolved)
Low Severity Issues
1 (1 resolved)
Notes & Additional Information
2 (2 resolved)
Client Reported Issues
2 (2 resolved)

Scope

OpenZeppelin performed a diff audit of the Across Protocol contracts repository at commit 77761d7. Specifically, the changes introduced by pull request #941 and pull request #944 were audited.

In scope were the following files:

 contracts
├── Lens_SpokePool.sol
├── ZkSync_SpokePool.sol
└── chain-adapters
    ├── ZkStack_Adapter.sol
    └── ZkStack_CustomGasToken_Adapter.sol

System Overview

The Across Protocol is a cross-chain bridging protocol that enables fast token transfers between different blockchains. At the core of the protocol is the HubPool contract on the Ethereum mainnet which serves as a central liquidity hub and cross-chain administrator for all contracts within the system. This pool governs the SpokePool contracts deployed on various networks that either initiate token deposits or serve as the final destination for transfers.

The changes introduced in pull request #941 add support for bridging USDC tokens between Ethereum (L1) and ZK-Stack-based rollups (L2) using Circle's bridged USDC standard. Previously, bridging was done over the default ERC-20 bridge on ZK-Stack-based networks. The bridged USDC standard is an intermediate step towards full support of Circle's Cross-Chain Transfer Protocol (CCTP). The changes in this pull request update both the L1 and L2 contracts so that they can later be compatible with Circle’s CCTP.

To summarize, the bridging mechanism for USDC tokens, previously limited to the standard ERC-20 bridge, has been enhanced. The updates introduce support for two additional routing protocols defined during deployment:

  1. A custom bridge designed for Circle's Bridged (upgradable) USDC.
  2. Circle's Cross-Chain Transfer Protocol (CCTP) bridges.

The pull request is meant for the Lens protocol. However, the current implementation is modular and can be adopted by any ZK-Stack-based project.

The changes introduced with pull request #944 implement a recommendation from an earlier audit. In essence, the SHARED_BRIDGE global variable has been removed from the ZkStack_Adapter.sol contract, and the direct call BRIDGE_HUB.sharedBridge() is used instead. The motivation is to avoid potential issues in case the bridge contract address gets updated after the adapter contract has been deployed.

 

Low Severity

Custom Gas Tokens Can Get Stuck In HubPool

The ZkStack_CustomGasToken_Adapter contract is used to send messages from L1 to ZK Stack-based chains with a custom gas token. The public functions within this contract are expected to be called via delegatecall, which will execute this contract's logic within the context of the originating contract. Particularly, the HubPool will delegatecall the relayTokens function. This relayTokens function is used to bridge tokens to a ZK Stack chain. This function calls _pullCustomGas to define txBaseCost on line 186 to compute the amount of gas tokens needed and, more importantly, pulls the needed gas token amount from the funder.

However, in the case when bridging using the CCTP bridge, the custom gas token is not needed, and thus the pulled tokens will end up stuck in the HubPool.

This issue can be generalized to other computations as well. For instance, the sharedBridge should only be defined when l1Token is different than usdcToken in ZkStack_CustomGasToken_Adapter, and txBaseCost should not be computed when using the CCTP bridge in ZkStack_Adapter.

Consider only pulling custom gas tokens within the relayTokens function when they are needed. More generally, consider avoiding unnecessary computation to define variables that will be used later to reduce gas costs.

Update: Resolved in pull request #975 at commit 1725a57.

Notes & Additional Information

Missing and Misleading Documentation

Throughout the codebase there are a few places with missing or misleading documentation. For instance:

  • The comment on line 203 in ZkStack_Adapter.sol does not take into consideration the case when bridging USDC when address(usdcToken) == address(0).
  • Similarly, the comment on line 247 in ZkStack_CustomGasToken_Adapter.sol does not take into consideration the case when bridging USDC when address(usdcToken) == address(0).
  • The flags zkUSDCBridgeDisabled and cctpUSDCBridgeDisabled are mutually exclusive, which is enforced with checks in the constructors [1] [2] [3]. Consider explicitly documenting this requirement in the constructor's arguments documentation.

Consider addressing the above instances and updating the documentation to reflect the latest changes in the functionality.

Update: Resolved in pull request #973 at commit fa39e0d.

Relay Tokens From L1 Emits Empty Transaction Hash When Relaying USDC Through CCTP

The relayTokens functions [1] [2] are used to bridge tokens from L1 to ZK Stack. Both instances define a txHash variable [1] [2] which will later be assigned to the transaction hash value returned by the BRIDGE_HUB when initiating a bridging transaction. However, in both cases [1] [2] when CCTP is enabled, the _transferUsdc function is used, bypassing the BRIDGE_HUB. This function does not return a transaction hash, however, causing the relayTokens functions to emit an empty ZkStackMessageRelayed event [1] [2]. This behavior can be confusing for users, especially when trying to index the event based on the emitted hash.

In case no off-chain components rely on the emitted event, consider removing the emitted event to avoid confusion. Otherwise, consider adding thorough documentation for the ZkStackMessageRelayed event, outlining its expected behavior.

Update: Resolved in pull request #976 at commit cc4fa0a and pull request #982 at commit 1a23663.

Client Reported

Potential Revert in USDC Relayer Refunds via Custom Bridge

The executeSlowRelayLeaf function is used to execute a leaf stored as part of a root bundle to refund the relayer. This will send the relayer the amount they sent to the recipient, plus a relayer fee. This function will invoke _distributeRelayerRefunds. If the amount to return in the leaf is positive, then send L2 -> L1 message to bridge tokens back via a chain-specific bridging method by calling the _bridgeTokensToHubPool function.

However, in case the l2TokenAddress to bridge is the USDC token and the ZK Stack custom USDC bridge is being used, the refund will revert when withdrawing through the custom ZK Stack USDC bridge on line 154 in ZkSync_SpokePool, since the caller, in this case the HubPool, hasn't granted enough approval to the zkUSDCBridge to transfer tokens from the HubPool to the bridge.

A fix was delivered alongside the issue in pull request #967 by approving the needed amountToReturn of USDC to the zkUSDCBridge before calling the withdraw function.

Update: Resolved in pull request #967 at commit 0bcd27a.

Restricted USDC Bridging via Shared Bridge with Custom Gas Token

In the ZkStack_CustomGasToken_Adapter contract, the relayTokens function facilitates bridging tokens to ZK Stack-based chains. To execute this function, the ZkStack_CustomGasToken_Adapter contract first pulls the required amount of the custom gas token from the CUSTOM_GAS_TOKEN_FUNDER. Subsequently, it should approve the calculated txBaseCost for use by the sharedBridge.

However, in line 230, instead of approving the txBaseCost amount to the intended sharedBridge contract, the approval is incorrectly directed towards the USDC_SHARED_BRIDGE contract. This will lead to the failure of the relayTokens function, specifically when attempting to bridge USDC via the shared bridge because the sharedBridge will not have the necessary allowance to deduct the txBaseCost in custom gas tokens.

Consider directing the approval of the txBaseCost amount of custom gas tokens to the sharedBridge contract address instead of the USDC_SHARED_BRIDGE contract address.

Update: Resolved in pull request #981 at commit 6db3e38.

 

Conclusion

OpenZeppelin conducted a diff audit of the changes introduced to the Across Protocol contracts in pull request #941 and pull request #944. The main update consists of modularized support for bridging USDC tokens between Ethereum and ZK-Stack-based chains, particularly to fit the needs for Lens (L2) using Circle's bridged (upgradable) USDC standard. Although currently meant for the Lens protocol, the added modularity is implemented in such a way that it can be reused for any ZK-Stack-based project that needs to customize their USDC bridging logic.

Overall the implementation was found to be sound. Only one client-reported issue and one low-severity issue were reported, along with various recommendations aimed at improving the documentation. The Across Protocol team is appreciated for their responsiveness throughout the audit.

Request Audit