Table of Contents
Summary
- Type
- Cross-Chain
- Timeline
- From 2025-05-15
- To 2025-05-26
- Languages
- Solidity
- Total Issues
- 13 (13 resolved)
- Critical Severity Issues
- 0 (0 resolved)
- High Severity Issues
- 1 (1 resolved)
- Medium Severity Issues
- 3 (3 resolved)
- Low Severity Issues
- 3 (3 resolved)
- Notes & Additional Information
- 6 (6 resolved)
Scope
OpenZeppelin conducted a differential audit of the across-protocol/contracts repository, with the base at commit 7362cd0 (master) and the head at commit b84dbfa.
In scope were the following files:
contracts
├── external
│ └── interfaces
│ ├── IERC20Auth.sol
│ └── IPermit2.sol
├── handlers
│ └── MulticallHandler.sol
├── interfaces
│ └── SpokePoolPeripheryInterface.sol
├── libraries
│ └── PeripherySigningLib.sol
└── SpokePoolPeriphery.sol
System Overview
The Across protocol is a cross-chain bridge designed for fast and cost-effective transfers of ERC-20 tokens and native assets across various networks. It allows users (depositors) to lock assets on an origin chain, which are then made available to them on a destination chain by relayers who front their own capital. The protocol refunds the relayers by sending funds available on the chain of relayers' choice or by tapping into the HubPool on Ethereum if a specific chain does not have enough funds. This audit focuses on a new set of peripheral smart contracts intended to enhance the functionality, flexibility, and user experience of interacting with the Across V3 ecosystem.
SpokePoolPeriphery
The SpokePoolPeriphery
contract acts as a user-facing entry point to the Across protocol, significantly expanding the options available for initiating cross-chain transfers. Its core functionalities include the following:
- Swap and Bridge: A flagship feature allowing users to bridge assets even if they do not hold the specific token required by the SpokePool. The contract can take a user-specified
swapToken
, execute a trade on a designated external exchange to convert it into theinputToken
accepted by Across, and then initiate the bridge deposit - all within a single, atomic transaction. This functionality supports proportional output adjustment, where the amount of tokens to be received on the destination chain can be proportionally increased if the swap yields moreinputToken
than the user's specified minimum. - Versatile Token Authorization: The contract integrates multiple industry-standard mechanisms for authorizing token transfers from users, providing flexibility and enabling gas-efficient interactions:
- Standard ERC-20
transferFrom
for pre-approved tokens - Native currency (e.g., ETH) deposits, which are automatically wrapped into their WETH equivalent if a non-zero
msg.value
is provided - EIP-2612
permit
- Permit2 (
permitWitnessTransferFrom
) for batch approvals and more advanced signature-based permissions via the canonicalPermit2
contract - EIP-3009
receiveWithAuthorization
for tokens supporting this ERC-20 extension
- Standard ERC-20
- Isolated Swap Execution via
SwapProxy
: To enhance security and modularity, all swap operations are delegated to a dedicatedSwapProxy
contract. TheSpokePoolPeriphery
deploys this proxy and transfers tokens to it for swapping. TheSwapProxy
then handles token approvals to the specified exchange or to the Permit2 contract and executes the swap calldata on the target exchange. Finally, the output token is transferred back to theSpokePoolPeriphery
contract.
MulticallHandler
Changes
The changes to the MulticallHandler
contract involve the addition of the makeCallWithBalance
function which can be used to fill given calldata with specified tokens' balances of the MulticallHandler
contract and to call the target contract using this modified calldata. This feature is useful whenever the amount of tokens that arrive on a target chain is not known when the calldata is specified, which can be the case when the swap and bridge functionality from the SpokePoolPeriphery
contract is used, and a depositor does not know the output amount from the swap when they sign the data for the deposit.
It is worth noting that the depositors themselves are responsible for providing the correct tokens and offsets where the balances should be filled, keeping in mind that balances may be represented in smaller types than uint256
and that specifying wrong offsets may lead to unintended consequences, such as loss of funds. Users should also keep in mind that the makeCallWithBalance
function will not work with exchanges that require providing a negative token amount as a parameter as it is only capable of filling the calldata with non-negative balances. All depositors are encouraged to study the makeCallWithBalance
function's documentation in order to understand all of its risks and limitations.
PeripherySigningLib
The PeripherySigningLib
is a library that supports the signature-based features of SpokePoolPeriphery
. Its contributions include:
- Standardized Hashing: Provides functions to compute EIP-712-compliant typed data hashes for the
BaseDepositData
,Fees
,DepositData
, andSwapAndDepositData
structs. This ensures consistent and secure signature generation and verification. - Signature Deserialization: Offers a utility function to parse a raw byte signature into its
v, r, s
components, simplifying signature handling in the main contract logic.
Security Model and New Trust Assumptions
The introduction of these peripheral contracts expands the Across protocol's functionality and, consequently, introduces new elements to its security model and specific trust assumptions:
- The
swapAndBridge
functionality relies on external exchanges specified by the user (or a trusted frontend). The security of user funds during a swap is contingent upon the security of the chosen exchange and the integrity of therouterCalldata
provided. A compromised exchange or malicious calldata could lead to a loss of funds. - Users (or frontends acting on their behalf) are responsible for the correctness and safety of parameters like exchange addresses, router calldata for swaps,
MulticallHandler
instructions, and EIP-712 signed messages, as incorrect or malicious inputs can lead to failed transactions, loss of funds, or unintended interactions. It is assumed that users only utilize trusted exchanges, specify reasonable minimum token output amounts, and provide correct EIP-712 signatures. Furthermore, it is assumed that they specify the correct calldata for theMulticallHandler
contract and that they take care of transferring any tokens remaining in this contract to their accounts at the end of each interaction with it. - The use of Permit2 implies trust in the security and operational integrity of the canonical
Permit2
contract. It is assumed that this contract behaves in a correct manner. It is also important to note that theSpokePoolPeriphery
contract depends on the existence of thePermit2
contract on the chain where it is deployed. We assume that theSpokePoolPeriphery
contract will be only deployed on blockchains where thePermit2
contract exists. - The users are responsible for submitting correct data for swaps, which includes, but is not limited to, specifying reasonable minimum swap output token amount and deposit output amount. It is assumed that users specify correct data for deposits and swaps.
- Submitters (e.g., relayers) should always simulate the signed swap transaction off-chain before submission. Since
swapProxy
blindly calls the user-specifiedexchange
with arbitraryrouterCalldata
, a malicious signer can point it at a contract that, for example, enters an infinite loop or performs a return bomb attack exhausting all the gas before reverting. Without simulation, the relayer bears the full gas cost of a failing call (a gas-griefing attack) and receives no compensation.
High Severity
Incorrect Nonce Passed to the Permit2.permit
Function
The performSwap
function of the SwapProxy
contract allows for providing tokens for a swap to a specified exchange using several different methods. In particular, it allows for approving tokens for the swap through the Permit2
contract. In order to do that, it approves the given token amount to the Permit2
contract and calls the permit
function of the Permit2
contract.
However, the nonce specified for that call is global for the entire contract, whereas the Permit2
contract stores a separate nonce for each (owner, token, spender) tuple. As a result, any attempt to use a different (token, spender) pair from the ones used in the first performSwap
function call will result in the revert due to nonce mismatch.
Consider storing and using separate nonces for each (token, spender) pair in the SwapProxy
contract.
Update: Resolved in pull request #1013 at commit 3cd99c4
.
Medium Severity
Possible Replay Attacks on SpokePoolPeriphery
The SpokePoolPeriphery
contract allows users to deposit or swap-and-deposit tokens into a SpokePool. In order to do that, the assets are first transferred from the depositor's account, optionally swapped to a different token, and then finally deposited into a SpokePool.
Assets can be transferred from the depositor's account in several different ways, including approval followed by the transferFrom
call, approval through the ERC-2612 permit
function followed by transferFrom
, transfer through the Permit2
contract, and transfer through the ERC-3009 receiveWithAuthorization
function. The last three methods require additional user signatures and may be executed by anyone on behalf of a given user. However, the data to be signed for deposits or swaps and deposits with ERC-2612 permit
and with ERC-3009 receiveWithAuthorization
does not contain a nonce, and, as such, the signatures used for these methods once can be replayed later.
The attack can be performed if a victim signs data for a function relying on the ERC-2612 permit
function and wants to deposit tokens once again using the same method and token within the time window determined by the depositQuoteTimeBuffer
parameter. In such a case, an attacker can first approve tokens on behalf of the victim and then call the swapAndBridgeWithPermit
function or the depositWithPermit
function, providing a signature for a deposit or swap-and-deposit from the past, that includes fewer tokens than the approved amount.
As a result, the tokens will be deposited and potentially swapped, using the data from an old signature, forcing the victim to either perform an unintended swap or bridge the tokens to a different chain than intended. Furthermore, since the attack consumes some part of the permit
approval, it will not be possible to deposit tokens on behalf of a depositor using the new signature until the full amount of tokens is approved by them once again. A similar attack is also possible in the case of functions that rely on the ERC-3009 receiveWithAuthorization
function, but it requires the amount of tokens being transferred to be identical to the amount from the past.
Consider adding a nonce field into the SwapAndDepositData
and DepositData
structs and storing a nonce for each user in the SpokePoolPeriphery
contract, which should be incremented when a signature is verified and accepted.
Update: Resolved in pull request #1015. The Across team has added a permitNonces
mapping and extended both SwapAndDepositData
and DepositData
with a nonce
field. In swapAndBridgeWithPermit
and depositWithPermit
, the contract now calls _validateAndIncrementNonce(signatureOwner, nonce)
before verifying the EIP-712 signature, ensuring each permit-based operation can only be executed once. ERC-3009 paths continue to rely on the token’s own nonce; a replay here would require a token to implement both ERC-2612 and ERC-3009, a user to reuse the exact same nonce in both signatures, and both are executed within the narrow fillDeadlineBuffer
. Given the unlikely convergence of these conditions, the risk is negligible in practice.
Possible DoS Attack on Swapping via Permit2
The SwapProxy
contract contains the performSwap
function, which allows the caller to execute a swap in two ways: by approving or sending tokens to the specified exchange, or by approving tokens through the Permit2
contract. However, since it is possible to supply any address as the exchange
parameter and any call data through the routerCalldata
parameter of the performSwap
function, the SwapProxy
contract may be forced to perform an arbitrary call to an arbitrary address.
This could be exploited by an attacker, who could force the SwapProxy
contract to call the invalidateNonces
function of the Permit2
contract, specifying an arbitrary spender and a nonce higher than the current one. As a result, the nonce for the given (token, spender) pair will be updated. If the performSwap
function is called again later, it will attempt to use a subsequent nonce, which has been invalidated by the attacker and the code inside Permit2
will revert due to nonces mismatch.
As the performSwap
function is the only place where the nonce passed to the Permit2
contract is updated, the possibility of swapping a given token on a certain exchange will be blocked forever, which impacts all the functions of the SpokePoolPeriphery
contract related to swapping tokens. The attack may be performed for many different (tokens, exchange) pairs.
Consider not allowing the exchange
parameter to be equal to the Permit2
contract address.
Update: Resolved in pull request #1016 at commit 713e76b
.
Incorrect EIP-712 Encoding
The PeripherySigningLib
library contains the EIP-712 encodings of certain types as well as helper functions to generate their EIP-712 compliant hashed data. However, the data type of the SwapAndDepositData
struct is incorrect as it contains the TransferType
member of an enum type, which is not supported by the EIP-712 standard.
Consider replacing the TransferType
enum name used to generate the SwapAndDepositData
struct's data type with uint8
in order to be compliant with EIP-712.
Update: Resolved in pull request #1017 at commit c9aaec6
.
Low Severity
deposit
Will Not Work for Non-EVM Target Chains
The deposit
function of the SpokePoolPeriphery
contract allows users to deposit native value to the SpokePool. However, its recipient
and exclusiveRelayer
arguments are both of type address
and are cast to bytes32
. As a result, it is not possible to bridge wrapped native tokens to non-EVM blockchains.
Consider changing the type of the recipient
and exclusiveRelayer
arguments of the deposit
function so that callers are allowed to specify non-EVM addresses for deposits.
Update: Resolved in pull request #1018 at commit 3f34af6
.
Integer Overflow in _swapAndBridge
In the _swapAndBridge
function, the adjusted output amount is calculated as the product of depositData.outputAmount
and returnAmount
divided by minExpectedInputTokenAmount
. If depositData.outputAmount * returnAmount
exceeds 2^256–1
, the transaction will revert immediately on the multiply step, even when the eventual division result would fit. This intermediate overflow is invisible to users, who only see a generic failure without an explanatory error message.
Consider using OpenZeppelin’s Math.mulDiv(a, b, c)
to compute floor(a*b/c)
without intermediate overflow. Alternatively, consider documenting the possible overflow scenario.
Update: Resolved in pull request #1020 at commit e872f04
by documenting the potential overflow scenario.
Inflexible Fee Recipient Field Blocks Open Relaying
Currently, every DepositData
and SwapAndDepositData
payload must include a hard-coded fee recipient address, and upon successful deposit or swap-and-bridge, the periphery pays submission fees to that exact address. While this ensures that the user knows in advance exactly who will receive their fee, it also prevents open relayer competition or fallback options when the chosen relayer underperforms or is unavailable.
Consider keeping the explicit fee recipient field option in SwapAndDepositData
but introduce a "zero‐address" convention:
- If the fee recipient is equal to the zero address, the periphery should default to using
msg.sender
as the payee. - If the fee recipient is not the zero address, transfer fees to the signed
recipient
.
Update: Resolved in pull request #1021 at commit f2218c0
.
Notes & Additional Information
Function Renaming Suggestion
The deposit
function of the SpokePoolPeriphery
contract allows users to deposit native value to the SpokePool. While it is possible to specify the inputToken
parameter, it is not possible to deposit other tokens through this function. As a result, it could be renamed to depositNative
or a similar name in order to make this fact clear.
Consider renaming the deposit
function in order to improve the readability of the codebase.
Update: Resolved in pull request #1019 at commit a69ad79
.
Optimization Opportunities
Throughout the codebase, multiple opportunities for code optimization were identified:
- The checks validating that a given address refers to a contract in lines 204, 231, and 553 are not necessary in cases where the addresses do not refer to contracts. This is because the subsequent calls in lines 207, 233, and 555 will revert as the Solidity compiler inserts similar code-size checks before each high-level call.
- The "0x" string passed to the
permit
call could be replaced with "". - This check could be removed as the same check is already performed in SpokePools.
- The
replacement
argument of themakeCallWithBalance
function could be stored incalldata
instead ofmemory
. - The use of the
Lockable
contract is inefficient. OpenZeppelin’sReentrancyGuard
delivers significantly lower gas overhead by using a two‐worduint256
status in place of abool
, reducing SSTORE costs, and swapping long revert strings for a 4-byte custom error to shrink both the bytecode and the revert gas cost. For deployments on chains that support EIP-1153 (transient storage), adoptingReentrancyGuardTransient
can nearly eliminate reentrancy‐guard gas costs.
Consider implementing the above suggestions in order to improve the gas efficiency of the codebase.
Update: Resolved in pull request #1022 at commit c3e7f3d
.
Insufficient Documentation
Throughout the codebase, multiple instances of insufficient documentation were identified: - The makeCallWithBalance
function of the MulticallHandler
contract allows for replacing specified offsets of a given call data with the current token or native balances. However, the purpose of this function and the correct way of using it may not be immediately clear to the users. As such, the function would benefit from the additional documentation describing its purpose, limitations, and correct usage. One additional limitation that could be listed is that this function is not capable of filling negative balances. Hence, decentralized exchanges, which require input token amounts to be negative, would not be supported. - The documentation of the swapAndBridge
, swapAndBridgeWithPermit
, swapAndBridgeWithPermit2
, and swapAndBridgeWithAuthorization
functions could mention the fact that they do not support native value as the output token of the swaps and, as a result, it is only possible to deposit non-native tokens to a SpokePool through these functions. - The PeripherySigningLib
library does not include any top-of-file NatSpec annotations describing its purpose, usage, or any relevant details. Without a contract-level NatSpec comment block, readers and automated documentation tools will not have a concise overview of what this library is for or how to integrate with it.
Consider expanding the documentation in the aforementioned instances in order to improve the clarity of the codebase.
Update: Resolved in pull request #1023 at commit 047283e
.
Typographical Errors
Throughout the codebase, multiple instances of typographical errors were identified: - In line 48 of the MulticallHandler.sol
file, "calldData" should be "callData". - In line 113 of the SpokePoolPeripheryInterface.sol
file, "on" could be removed. - In line 500 of the SpokePoolPeriphery.sol
file, "depositData/swapAndDepositData" could be "DepositData/SwapAndDepositData".
Consider correcting all instances of typographical errors in order to improve the clarity and readability of the codebase.
Update: Resolved in pull request #1024 at commit 18296cb
.
Unused Code
Throughout the codebase, multiple instances of unused code were identified: - In the SpokePoolPeriphery.sol
file, the InvalidSignatureLength
error is unused - In the SpokePoolPeripheryInterface.sol
file, the import is unused
To improve the overall clarity and maintainability of the codebase, consider removing any instances of unused code.
Update: Resolved in pull request #1025 at commit 767cb9f
.
Misleading Documentation
Throughout the codebase, multiple instances of misleading documentation were identified:
- The
swapAndBridgeWithPermit
anddepositWithPermit
functions are documented to fail if the provided token does not support the EIP-2612permit
function. However, the implementation contradicts this statement because, in both functions, the call topermit
is wrapped in atry/catch
block, and any failure is silently ignored. - This comment refers to the
transferWithAuthorization
function, whereas it should mention thereceiveWithAuthorization
function instead. - The documentation for the
SpokePoolPeriphery
contract and theSpokePoolPeripheryInterface
interface contains an outdated comment claiming that certain variables are not marked immutable or set in the constructor to allow deterministic deployment. This is no longer true as the variables are now immutable and set in the constructor.
Consider fixing the instances mentioned above in order to enhance the clarity of the codebase.
Update: Resolved in pull request #1026 at commit f8f484a
.
Conclusion
The under-review changes made to the periphery contracts introduced new possibilities for depositing assets to SpokePools. They enable third-party entities to deposit or swap-and-deposit funds on behalf of any user who provides a valid signature. Furthermore, they protect users from losing their native tokens in case they specify an incorrect SpokePool address for the deposit.
While the audit uncovered several issues related to swap logic and signature handling, the code was found to be solid and well-organized. The Risk Labs team is appreciated for being responsive and answering the audit team's questions throughout the audit.