- April 2, 2026
OpenZeppelin Security
OpenZeppelin Security
Security Audits
Summary
Type: Cross Chain
Timeline: From 2026-02-23 → To 2026-03-02
Languages: Solidity
Findings
Total issues: 10 (6 resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 1 (0 resolved) · Low: 1 (0 resolved)
Notes & Additional Information
8 notes raised (6 resolved)
Scope
OpenZeppelin performed a diff audit of the across-protocol/contracts repository at commit 47c1bea9.
In scope were the changes made to the following files:
contracts
├── TransferProxy.sol
├── chain-adapters
│ └── DonationBox.sol
├── handlers
│ └── HyperliquidDepositHandler.sol
├── interfaces
│ ├── ICounterfactualDeposit.sol
│ ├── ICounterfactualDepositFactory.sol
│ └── ICounterfactualImplementation.sol
├── libraries
│ └── TronClones.sol
└── periphery
├── counterfactual
│ ├── AdminWithdrawManager.sol
│ ├── CounterfactualConstants.sol
│ ├── CounterfactualDeposit.sol
│ ├── CounterfactualDepositCCTP.sol
│ ├── CounterfactualDepositFactory.sol
│ ├── CounterfactualDepositFactoryTron.sol
│ ├── CounterfactualDepositOFT.sol
│ ├── CounterfactualDepositSpokePool.sol
│ └── WithdrawImplementation.sol
└── mintburn
└── HyperCoreFlowExecutor.sol
System Overview
Across Protocol is a cross-chain bridge that uses an optimistic relay model: relayers fill user deposit intents on destination chains and are later reimbursed from a central HubPool on Ethereum mainnet. The protocol's smart contract suite includes SpokePool contracts deployed on each supported chain, a HubPool on L1, chain-specific adapters for L1-L2 messaging, and a growing periphery layer that handles token approvals, gasless deposits, and integration with third-party bridging standards such as CCTP (Circle) and OFT (LayerZero).
This audit covers six commits made to the master branch (dc576507..47c1bea9). The changes introduce two major features and one set of minor dependency upgrades:
- Counterfactual Deposit System (primary scope): A new subsystem that lets users receive a deterministic, reusable deposit address before any contract is deployed. Users send tokens to a CREATE2-predicted address; a relayer later deploys a minimal EIP-1167 clone there and triggers the deposit in a single transaction. The architecture is Merkle-dispatched: each clone stores only a 32-byte Merkle root as its immutable argument, and every execution must supply a valid Merkle proof linking the requested
(implementation, params)pair to that root. This enables a single address to support multiple bridge types (Across SpokePool, CCTP, OFT) plus a withdrawal escape hatch, all without redeploying. TransferProxy: A stateless contract that mimics the SpokePooldepositinterface but executes a same-chain ERC-20 transfer instead of a cross-chain bridge. This allows the existingSpokePoolPeripheryswap-and-bridge methods (permit, permit2, EIP-3009 gasless flows) to be reused for local-only swaps without any changes to the periphery contracts.- Minor changes: The
DonationBoxcontract migrates fromOwnabletoAccessControlwith a dedicatedWITHDRAWER_ROLE, allowing multiple authorized addresses (e.g., bothHyperliquidDepositHandlerandHyperCoreFlowExecutor) to withdraw funds. TheHyperliquidDepositHandlerconstructor is updated to accept an externally-deployedDonationBoxaddress rather than deploying one internally. Several contracts (HyperliquidDepositHandler,HyperCoreFlowExecutor,DonationBox) have their OpenZeppelin imports migrated fromcontracts-v4tocontracts(v5).
Counterfactual Deposit Architecture
The counterfactual deposit system is composed of four architectural layers:
- Factory layer:
CounterfactualDepositFactoryis a permissionless, stateless factory that deploys clones viaClones.cloneDeterministicWithImmutableArgsand forwards execution calldata.CounterfactualDepositFactoryTronextends the base factory to override address prediction with Tron's0x41CREATE2 prefix (via theTronCloneslibrary) instead of the EVM's0xff. - Dispatcher layer:
CounterfactualDepositis the Merkle-dispatched proxy. All clones are instances of this contract. On eachexecute()call, it decodes the Merkle root from its immutable args, verifies the supplied proof against a leaf ofkeccak256(abi.encode(implementation, keccak256(params))), and delegatecalls into the proven implementation. - Implementation layer: Bridge-specific contracts that execute the actual deposit:
CounterfactualDepositSpokePoolverifies an EIP-712 signature from a trusted signer, enforces a two-component fee cap (maxFeeFixed + maxFeeBps × amount), and callsSpokePool.deposit(). Uses no explicit nonce; replay protection comes from token-balance consumption and signature deadlines.CounterfactualDepositCCTPandCounterfactualDepositOFTmarshal parameters and delegate signature verification and nonce management to their respectiveSponsoredSrcPeripherycontracts.WithdrawImplementationallows either an admin or the user to withdraw arbitrary tokens or native ETH from a clone address.
- Admin layer:
AdminWithdrawManagerprovides two trust-minimized withdrawal paths: a direct path for a privileged bot (directWithdrawer) and a signed path where anyone can trigger a withdrawal, but the recipient is hardcoded to theuseraddress committed in the Merkle leaf.
Counterfactual Deposit Lifecycle
The end-to-end flow proceeds in three phases:
- Address prediction (off-chain). A Merkle tree is built whose leaves are
keccak256(abi.encode(implementation, keccak256(params)))for each authorized action (deposit via SpokePool, deposit via CCTP, withdraw, etc.). The factory'spredictDepositAddress(dispatcher, merkleRoot, salt)returns the deterministic clone address. This address is shared with the user. - Funding. The user transfers tokens (or native ETH) to the predicted address. No contract exists there yet.
- Execution. A relayer calls
factory.deployIfNeededAndExecute(), which deploys the clone if necessary and forwards the execute calldata. The clone's dispatcher verifies the Merkle proof, then delegatecalls into the target implementation (e.g.,CounterfactualDepositSpokePool), which verifies signatures, validates fees, and calls into the underlying bridge protocol. An execution fee is paid to the relayer to incentivize timely execution.
TransferProxy Design
TransferProxy exposes deposit() and unsafeDeposit() with signatures matching SpokePool, but enforces three invariants: destinationChainId == block.chainid, inputToken == outputToken, and inputAmount == outputAmount. It then performs a simple safeTransferFrom from the caller to the recipient. If a non-empty message is provided and the recipient is a contract, it calls handleV3AcrossMessage() on the recipient, enabling composition with MulticallHandler.
Security Model and Trust Assumptions
The counterfactual deposit system's security relies on a combination of Merkle-proof-based authorization, EIP-712 cryptographic signatures, and trust in several privileged off-chain components. The TransferProxy is stateless and trustless by design. The integrity of the counterfactual system is contingent on correct off-chain Merkle tree construction, honest behavior of trusted signers, and proper configuration of all contracts.
- Merkle Tree Construction: The Merkle root committed to each clone defines the complete set of authorized actions. A maliciously constructed tree could authorize arbitrary implementations or parameters. Tree construction occurs entirely off-chain by the Across API and is assumed to be correct. Specifically, the Merkle tree construction process is trusted to:
- only include legitimate, audited implementation addresses as leaves
- commit correct deposit parameters (fee bounds, token addresses, recipients) that reflect the user's intent
- include exactly one
WithdrawImplementationleaf with the correctadminanduseraddresses so that the escape hatch is always available - use unique salts to prevent address collisions between unrelated users
- Bridge Signers: Each bridge implementation relies on a trusted signer to authorize execution parameters at the time of deposit. Signer compromise is bounded but not harmless:
- SpokePool signer (immutable in
CounterfactualDepositSpokePool): Signs EIP-712 structs controllinginputAmount,outputAmount,fillDeadline, and other execution parameters. A compromised signer could set unfavorableoutputAmountvalues causing deposits to be unfillable, but the fee check (totalFee <= maxFeeFixed + maxFeeBps × inputAmount / BPS_SCALAR) bounds the maximum value extractable beyond what the user committed to at address-generation time. The signer is also trusted to set reasonable deadlines and not sign multiple conflicting quotes for the same clone balance. - CCTP and OFT signers (embedded in their respective
SrcPeripherycontracts): Signature verification and nonce management are fully delegated to theSponsoredCCTPSrcPeripheryandSponsoredOFTSrcPeripherycontracts. These signers are similarly bounded bymaxFeeBpsparameters committed in the Merkle leaf.
- SpokePool signer (immutable in
- Replay Protection Model: The
CounterfactualDepositSpokePoolimplementation uses no explicit nonce. Replay protection relies on three complementary mechanisms:- Token-balance consumption: Executing a deposit drains the clone's token balance, preventing immediate replay.
- Signature deadlines: Each signature includes a
signatureDeadlineafter which it becomes invalid. - Clone-specific EIP-712 domain: The domain separator includes
address(this)(the clone address), preventing cross-clone replay.
If a clone is re-funded after execution, a new signature with a new deadline is required from the signer. The CCTP and OFT implementations delegate nonce tracking to their respective SrcPeriphery contracts.
- Fee Bound Assumptions: The
CounterfactualDepositSpokePooluses a two-component fee cap where thestableExchangeRate(committed at address-generation time) converts betweenoutputTokenandinputTokento compute the relayer fee. This rate is assumed to remain accurate for the lifetime of the clone. For volatile or de-pegging token pairs, a stale exchange rate could cause the fee check to either over-constrain (blocking valid deposits) or under-constrain (allowing excessive fee extraction). - Withdrawal Escape Hatch: Each clone's Merkle tree includes a
WithdrawImplementationleaf with both anadminaddress (typicallyAdminWithdrawManager) and auseraddress (the depositor's EOA or multisig). Either party can initiate a withdrawal independently, ensuring users retain access to their funds even if the admin infrastructure becomes unavailable. TheAdminWithdrawManager's signed withdrawal path hardcodes the recipient to theuseraddress from the Merkle leaf, preventing redirect attacks even if the signer is compromised. TransferProxyTrust Model:TransferProxyis stateless, holds no funds, and requires explicitsafeTransferFromapproval from the caller. The chain-ID, token-equality, and amount-equality checks prevent misuse as a cross-chain or token-swap primitive. It trusts that recipient contracts implementinghandleV3AcrossMessage()will not perform harmful reentrant actions.DonationBoxAccess Model: TheDonationBoxcontract has been migrated from single-ownerOwnableto role-basedAccessControl. Any account grantedWITHDRAWER_ROLEcan withdraw arbitrary amounts of any token at any time, with no per-transaction limits. The security of sponsorship funds depends entirely on careful management of role grants by theDEFAULT_ADMIN_ROLEholder.- OpenZeppelin v4 to v5 Migration: Several contracts now import from OpenZeppelin v5. Notably,
Ownablein v5 requires passing the initial owner to the constructor rather than defaulting tomsg.sender, andReentrancyGuardhas been moved fromsecurity/toutils/. Incorrect migration could lead to ownership or reentrancy guard misconfiguration. - Tron Compatibility: The
CounterfactualDepositFactoryTronandTronCloneslibrary adapt address prediction for Tron's TVM, which uses0x41instead of0xffas the CREATE2 derivation prefix. The actual deployment viaClones.cloneDeterministicWithImmutableArgsworks unchanged on Tron (thecreate2opcode natively uses the correct prefix), so only the prediction function is overridden. An incorrect prefix would cause predicted addresses to diverge from actual deployed addresses, resulting in funds sent to undeployable locations.
Privileged Roles
The counterfactual deposit system introduces several privileged roles across its contracts. The system is designed to minimize trust requirements — most contracts are permissionless and stateless, but the withdrawal management and signer infrastructure carry significant privileges. The security of the protocol relies on these roles being managed securely and operated honestly.
owner(AdminWithdrawManager, inherited fromOwnable): The most powerful role in the withdrawal subsystem. It has the authority to:- update the
directWithdraweraddress viasetDirectWithdrawer(), which controls who can perform unrestricted withdrawals from any clone - update the
signeraddress viasetSigner(), which controls who can authorize signed withdrawals to users
- update the
directWithdrawer(AdminWithdrawManager): A trusted address (intended for an automated bot or multisig) that can calldirectWithdraw()to withdraw funds from any clone to an arbitrary recipient. This is the most powerful withdrawal capability — unlike the signed path, it is not restricted to paying the committed user address.signer(AdminWithdrawManager): Signs EIP-712 withdrawal authorizations that anyone can submit on-chain. The signed path is trust-minimized: the recipient is always forced to theuseraddress committed in the clone's Merkle leaf, preventing redirect attacks even if this key is compromised. A compromised signer could trigger premature withdrawals but cannot steal funds.signer(CounterfactualDepositSpokePool, immutable): Signs EIP-712 execution parameters (inputAmount,outputAmount,fillDeadline, etc.) for SpokePool deposits. This address is set at implementation deployment time and cannot be changed. The signer is trusted to provide fair quotes and not sign conflicting parameters for the same clone balance.DEFAULT_ADMIN_ROLE(DonationBox): It can grant and revoke theWITHDRAWER_ROLEto any address, controlling which contracts or EOAs can withdraw sponsorship funds. This role is granted to the deployer at construction time.WITHDRAWER_ROLE(DonationBox): It can withdraw any amount of any ERC-20 token held by theDonationBox. Intended to be granted toHyperliquidDepositHandler,HyperCoreFlowExecutor, and similar contracts that need to access sponsorship funds for account activation and bridging fees.owner(HyperliquidDepositHandler, inherited fromOwnable): It can update thesignerandspokePooladdresses, add supported tokens with their Hypercore bridge parameters, and sweep funds from the contract, the DonationBox, or Hypercore accounts.
Medium Severity
EIP-712 Signature Not Bound to Merkle Leaf Enables Cross-Route Replay in CounterfactualDepositSpokePool
The EXECUTE_DEPOSIT_TYPEHASH used by CounterfactualDepositSpokePool for EIP-712 signature verification covers only execution-time parameters from SpokePoolSubmitterData: inputAmount, outputAmount, exclusiveRelayer, exclusivityDeadline, quoteTimestamp, fillDeadline, and signatureDeadline. It does not include any of the nine route-specific fields committed in SpokePoolDepositParams: destinationChainId, inputToken, outputToken, recipient, message, stableExchangeRate, maxFeeFixed, maxFeeBps, and executionFee. The EIP-712 domain separator binds the signature to the clone address but not to a specific leaf within that clone's Merkle tree.
The architecture is designed for a single clone to hold multiple deposit leaves targeting different destination chains — a single-destination clone has limited practical value. Each leaf commits to its own route configuration, including fee parameters calibrated to that destination's gas costs and relayer economics. However, because the signature is leaf-agnostic, an untrusted relayer who observes a pending signature intended for Leaf A can call clone.execute(implementation, paramsB, submitterData, proofB) using Leaf B's parameters and proof instead. The Merkle proof for Leaf B is independently valid, and the signature verification succeeds because none of the differing leaf fields are part of the signed struct. This grants the relayer unilateral choice over which route the user's funds take, enabling several attack vectors:
- The relayer can extract the maximum
executionFeeacross all leaves. TheexecutionFeeis deducted frominputAmountand transferred toexecutionFeeRecipient, which is supplied by the relayer insubmitterDataand not signed over. Because different destination chains have different gas costs, their leaves will carry differentexecutionFeevalues. A rational relayer always picks the leaf with the highest one, pocketingmax(executionFee) - min(executionFee)at the user's expense on every execution. This is a direct, guaranteed extraction with zero risk to the relayer. - The relayer can shop for the most permissive fee cap. The
_checkFeefunction enforcestotalFee <= maxFeeFixed + (maxFeeBps * inputAmount) / 10000, where each leaf has its ownmaxFeeFixed,maxFeeBps, andstableExchangeRatecalibrated for its destination. A leaf targeting an expensive chain such as Ethereum mainnet will have a more generous cap than one targeting a cheap rollup. The relayer selects the leaf with the widest fee tolerance. A higherstableExchangeRateon that leaf further reduces the computedrelayerFeeinside_checkFee, compounding the leniency. - The user's deposit is bridged to a chain they did not request. The funds arrive at the
recipientaddress committed in the chosen leaf, which may differ from what the user intended if different leaves specify different recipients — plausible when the user has distinct receiving addresses per chain or uses smart contract wallets that may not be deployed at the same address on all chains. In the worst case, the user does not control therecipientaddress on the relayer-chosen chain. - The
messagefield passed through toSpokePool.deposit()can trigger arbitrary logic on the destination fill. If one leaf has an empty message while another encodes a complex swap or hook, the relayer can trigger actions the user did not authorize or cause a fill to revert on the wrong chain, forcing a slow refund process.
As a concrete scenario, consider a clone whose tree contains two SpokePool leaves: Leaf 0 targeting Arbitrum with executionFee = 0.5 USDC, maxFeeBps = 100 (1%), and maxFeeFixed = 0.5 USDC; and Leaf 1 targeting Ethereum mainnet with executionFee = 5 USDC, maxFeeBps = 500 (5%), and maxFeeFixed = 10 USDC. The signer authorizes inputAmount = 1000 and outputAmount = 950 intending Leaf 0. The relayer executes with Leaf 1: 5 USDC is taken as executionFee instead of 0.5 (4.5 USDC stolen directly), the _checkFee cap becomes 60 USDC instead of 10.5 USDC, and funds arrive on the mainnet instead of Arbitrum.
Consider including a commitment to the leaf identity in the signed struct, for example, by adding a paramsHash field (the keccak256 of the encoded SpokePoolDepositParams) to both the EXECUTE_DEPOSIT_TYPEHASH and the hash constructed in _verifySignature. This binds each signature to exactly one Merkle leaf, preventing cross-route replay while preserving the existing separation between committed route params and dynamic submitter data.
Update: Acknowledged, not resolved. However, the team added documentation for this limitation in pull request #1357. The team stated:
We decided that the counterfactual system will not be used in a way where there are multiple implementation type leafs on a given CounterfactualDeposit proxy clone. A need for multiple implementation types indicates a different route altogether, so a new CounterfactualDeposit proxy clone would be deployed for this route.
Low Severity
Setting the SpokePool depositor to the clone disables speed-ups and updated fills
Across V3 supports depositor-driven updates to an unfilled deposit (output amount, recipient, and message) via speedUpDeposit, and relayer-side execution of those updates via fillRelayWithUpdatedDeposit. Both paths verify a depositor signature using _verifyUpdateV3DepositMessage, which in turn calls _verifyDepositorSignature and ultimately SignatureChecker.isValidSignatureNow.
However, CounterfactualDepositSpokePool.execute hard-codes the depositor as the clone address when calling SpokePool.deposit. The clone is an EIP-1167 proxy whose dispatcher implementation (CounterfactualDeposit) does not implement EIP-1271 and exposes only receive() and execute() (CounterfactualDeposit). As a result, depositor signature checks for updates fail on the origin chain, and updated fills also fail on the destination chain because the destination SpokePool verifies the signature against the same relayData.depositor (fillRelayWithUpdatedDeposit). This makes counterfactual deposits effectively immutable after execution, increasing the likelihood of unfilled deposits and capital lock until fillDeadline and refund.
Consider setting depositor to an EOA that can sign updates (for example, a user-controlled refund recipient committed in the Merkle leaf), so speedUpDeposit and fillRelayWithUpdatedDeposit remain usable. Alternatively, consider deploying a deterministic EIP-1271 validator at the same depositor address on all supported destination chains and wiring counterfactual deposits to use that validator as depositor.
Update: Acknowledged, not resolved. However, the team added documentation for this limitation in pull request #1357. The team stated:
We decided that the additional complexity and risks that come with supporting speed-ups and updated fills through the SpokePool flow outweigh the upsides. Speed-ups and updated flows are very rarely (if at all) used currently. Supporting them is not a requirement for the counterfactual system.
Notes & Additional Information
Missing Zero-Address Checks
When operations with address parameters are performed, it is crucial to ensure the address is not set to zero. Setting an address to zero is problematic because it has special burn/renounce semantics. This action should be handled by a separate function to prevent accidental loss of access during value or ownership transfers.
Throughout the codebase, multiple instances of missing zero-address checks were identified:
- The
_directWithdraweroperation within theAdminWithdrawManagercontract - The
_signeroperation within theAdminWithdrawManagercontract - The
_srcPeripheryoperation within theCounterfactualDepositCCTPcontract - The
counterfactualDepositImplementationoperation within theCounterfactualDepositFactorycontract - The
_execute(depositAddress, executeCalldata)operation within theCounterfactualDepositFactorycontract - The
counterfactualDepositImplementationoperation within theCounterfactualDepositFactorycontract - The
_execute(depositAddress, executeCalldata)operation within theCounterfactualDepositFactorycontract - The
counterfactualDepositImplementationoperation within theCounterfactualDepositFactorycontract - The
_execute(depositAddress, executeCalldata)operation within theCounterfactualDepositFactorycontract - The
_oftSrcPeripheryoperation within theCounterfactualDepositOFTcontract - The
_spokePooloperation within theCounterfactualDepositSpokePoolcontract - The
_signeroperation within theCounterfactualDepositSpokePoolcontract - The
_wrappedNativeTokenoperation within theCounterfactualDepositSpokePoolcontract
Consider adding a zero address check before assigning a state variable.
Update: Acknowledged, not resolved. The team stated:
Incorrect configurations are always a risk, and we can’t check for every possible incorrect configuration. So we acknowledge with no change.
Misleading NatSpec
The NatSpec for the execute function within CounterfactualDepositSpokePool, CounterfactualDepositCCTP, CounterfactualDepositOFT, and WithdrawImplementation are identical in that they are inherited from ICounterfactualImplementation. However, the implementations underlying these execute functions are similar only in their input parameters, while the business logic, revert paths, and parameter encodings are different. This creates uncertainty for integrators, especially since these implementations are interacted with via delegatecall.
Consider expanding the NatSpec of the aforementioned contracts to include an implementation-specific overview to avoid ambiguity.
Update: Resolved in pull request #1349 at commit e7cbd90.
Function Visibility Overly Permissive
Throughout the codebase, multiple instances of functions having overly permissive visibility were identified:
- The
_depositForBurnfunction inCounterfactualDepositCCTP.solwithinternalvisibility could be limited toprivate. - The
executefunction inCounterfactualDepositFactory.solwithpublicvisibility could be limited toexternal. - The
_executefunction inCounterfactualDepositFactory.solwithinternalvisibility could be limited toprivate. - The
_depositfunction inCounterfactualDepositOFT.solwithinternalvisibility could be limited toprivate. - The
_checkFeefunction inCounterfactualDepositSpokePool.solwithinternalvisibility could be limited toprivate. - The
_verifySignaturefunction inCounterfactualDepositSpokePool.solwithinternalvisibility could be limited toprivate.
To better convey the intended use of functions, consider changing a function's visibility to be only as permissive as required.
Update: Resolved in pull request #1348 at commit dd9d6cd.
Lack of Indexed Event Parameters
Throughout the codebase, multiple instances of events not having any indexed parameters were identified:
- The
CCTPDepositExecutedevent ofCounterfactualDepositCCTP.sol - The
OFTDepositExecutedevent ofCounterfactualDepositOFT.sol - The
SpokePoolDepositExecutedevent ofCounterfactualDepositSpokePool.sol
To improve the ability of off-chain services to search and filter for specific events, consider indexing event parameters.
Update: Resolved in pull request #1346 at commit cde97ee.
Missing Security Contact
Providing a specific security contact (such as an email address or ENS name) within a smart contract significantly simplifies the process for individuals to communicate if they identify a vulnerability in the code. This practice is quite beneficial as it permits the code owners to dictate the communication channel for vulnerability disclosure, eliminating the risk of miscommunication or failure to report due to a lack of knowledge on how to do so. In addition, if the contract incorporates third-party libraries and a bug surfaces in those, it becomes easier for their maintainers to contact the appropriate person about the problem and provide mitigation instructions.
Throughout the codebase, multiple instances of contracts not having a security contact were identified:
- The
ICounterfactualDepositinterface - The
ICounterfactualDepositFactoryinterface - The
AdminWithdrawManagercontract - The
ISponsoredCCTPSrcPeripheryinterface - The
CounterfactualDepositCCTPcontract - The
CounterfactualDepositFactorycontract - The
ISponsoredOFTSrcPeripheryinterface - The
CounterfactualDepositOFTcontract - The
CounterfactualDepositSpokePoolcontract
Consider adding a NatSpec comment containing a security contact above each contract definition. Using the @custom:security-contact convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.
Update: Resolved in pull request #1351 at commit 63eba2a.
Possible Duplicate Event Emissions
When a setter function does not check if the value being set is different from the existing one, it becomes possible to set the same value repeatedly, creating a potential for event spamming. Repeated emission of identical events can also confuse off-chain clients.
In the AdminWithdrawManager.sol, multiple instances of possible event spamming were identified:
- The
setDirectWithdrawersets thedirectWithdrawerand emits an event without checking if the value has changed. - The
setSignersets thesignerand emits an event without checking if the value has changed.
Consider adding a check that reverts the transaction if the value being set is identical to the existing one.
Update: Acknowledged, not resolved. The team stated:
Acknowledged with no change
Missing Docstrings
Throughout the codebase, multiple instances of missing docstrings were identified:
DirectWithdrawerUpdated,SignerUpdated,SIGNED_WITHDRAW_TYPEHASH,directWithdrawer,signer,setDirectWithdrawer, andsetSignerinAdminWithdrawManager.soldepositForBurnandCCTPDepositExecutedinCounterfactualDepositCCTP.soldepositandOFTDepositExecutedinCounterfactualDepositOFT.solSpokePoolDepositExecutedandEXECUTE_DEPOSIT_TYPEHASHinCounterfactualDepositSpokePool.sol
Consider thoroughly documenting all functions (and their parameters) that are part of any contract's public API. Functions implementing sensitive functionality, even if not public, should be clearly documented as well. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Resolved in pull request #1350 at commit 7eae2f2.
Leaf Parameters Pre-Hash Length Allows Ambiguity
The CounterfactualDeposit contract's execute function computes the Merkle leaf as: leaf = keccak256(abi.encode(implementation, keccak256(params))). The abi.encode(address, bytes32) is 64 bytes before hashing. The MerkleProof library warns against 64-byte leaf preimages because internal nodes are also hashed from 64 bytes (bytes32 || bytes32), creating leaf/internal-node ambiguity in generic constructions.
In the CounterfactualDeposit contract, the leaf preimage is structured (address word + keccak256(params)), so practical abuse is infeasible. However, for further hardening consider adding explicit leaf domain separation (for example, prefix or double-hash leaves) and keep off-chain tree construction aligned to remove leaf/internal-node ambiguity by construction.
Update: Resolved in pull request #1352 at commit 7b28c62.
Conclusion
The diff audit covered several changes to the Across protocol contracts. The primary focus was the addition of counterfactual deposit contracts (including admin and withdrawal related components thereof), which introduce a deterministic "deposit first, deploy later" architecture built around CREATE2/EIP-1167 clone deployments and backend-specific execution paths for SpokePool, CCTP, and OFT bridging. In addition, the changes also included a new Transfer Proxy to support same chain swaps, a Tron-compatible address derivation library, and updates to the OpenZeppelin imports used.
The reviewed changes generally show a clear separation of concerns between factory deployment, shared base logic, and bridge-specific behavior, which improves maintainability and reduces cross-component coupling.
A medium-severity issue was identified which is caused by the relayer having independent control over deposit flow parameters which may run counter to user intentions - allowing for maximal fee extraction or destination-chain selection under certain circumstances. Several low-severity issues and notes were also shared which pertain to code or documentation improvements. There remains an opportunity to harden signature-based authorization and improve documentation around authorization and replay assumptions.
The Across team is appreciated for their responsiveness during the audit.
Appendix
Issue Classification
OpenZeppelin classifies smart contract vulnerabilities on a 5-level scale:
- Critical
- High
- Medium
- Low
- Note/Information
Critical Severity
This classification is applied when the issue’s impact is catastrophic, threatening extensive damage to the client's reputation and/or causing severe financial loss to the client or users. The likelihood of exploitation can be high, warranting a swift response. Critical issues typically involve significant risks such as the permanent loss or locking of a large volume of users' sensitive assets or the failure of core system functionalities without viable mitigations. These issues demand immediate attention due to their potential to compromise system integrity or user trust significantly.
High Severity
These issues are characterized by the potential to substantially impact the client’s reputation and/or result in considerable financial losses. The likelihood of exploitation is significant, warranting a swift response. Such issues might include temporary loss or locking of a significant number of users' sensitive assets or disruptions to critical system functionalities, albeit with potential, yet limited, mitigations available. The emphasis is on the significant but not always catastrophic effects on system operation or asset security, necessitating prompt and effective remediation.
Medium Severity
Issues classified as being of medium severity can lead to a noticeable negative impact on the client's reputation and/or moderate financial losses. Such issues, if left unattended, have a moderate likelihood of being exploited or may cause unwanted side effects in the system. These issues are typically confined to a smaller subset of users' sensitive assets or might involve deviations from the specified system design that, while not directly financial in nature, compromise system integrity or user experience. The focus here is on issues that pose a real but contained risk, warranting timely attention to prevent escalation.
Low Severity
Low-severity issues are those that have a low impact on the client's operations and/or reputation. These issues may represent minor risks or inefficiencies to the client's specific business model. They are identified as areas for improvement that, while not urgent, could enhance the security and quality of the codebase if addressed.
Notes & Additional Information Severity
This category is reserved for issues that, despite having a minimal impact, are still important to resolve. Addressing these issues contributes to the overall security posture and code quality improvement but does not require immediate action. It reflects a commitment to maintaining high standards and continuous improvement, even in areas that do not pose immediate risks.
Ready to secure your code?