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:
- A custom bridge designed for Circle's Bridged (upgradable) USDC.
- 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 whenaddress(usdcToken) == address(0)
. - Similarly, the comment on line 247 in
ZkStack_CustomGasToken_Adapter.sol
does not take into consideration the case when bridging USDC whenaddress(usdcToken) == address(0)
. - The flags
zkUSDCBridgeDisabled
andcctpUSDCBridgeDisabled
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.