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
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:
swapToken
, execute a trade on a designated external exchange to convert it into the inputToken
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 more inputToken
than the user's specified minimum.transferFrom
for pre-approved tokensmsg.value
is providedpermit
permitWitnessTransferFrom
) for batch approvals and more advanced signature-based permissions via the canonical Permit2
contractreceiveWithAuthorization
for tokens supporting this ERC-20 extensionSwapProxy
: To enhance security and modularity, all swap operations are delegated to a dedicated SwapProxy
contract. The SpokePoolPeriphery
deploys this proxy and transfers tokens to it for swapping. The SwapProxy
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 the SpokePoolPeriphery
contract.MulticallHandler
ChangesThe 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:
BaseDepositData
, Fees
, DepositData
, and SwapAndDepositData
structs. This ensures consistent and secure signature generation and verification.v, r, s
components, simplifying signature handling in the main contract logic.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:
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 the routerCalldata
provided. A compromised exchange or malicious calldata could lead to a loss of funds.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 the MulticallHandler
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.Permit2
contract. It is assumed that this contract behaves in a correct manner. It is also important to note that the SpokePoolPeriphery
contract depends on the existence of the Permit2
contract on the chain where it is deployed. We assume that the SpokePoolPeriphery
contract will be only deployed on blockchains where the Permit2
contract exists.swapProxy
blindly calls the user-specified exchange
with arbitrary routerCalldata
, 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.Permit2.permit
FunctionThe 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
.
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.
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
.
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
.
deposit
Will Not Work for Non-EVM Target ChainsThe 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
.
_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.
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:
msg.sender
as the payee.recipient
.Update: Resolved in pull request #1021 at commit f2218c0
.
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
.
Throughout the codebase, multiple opportunities for code optimization were identified:
permit
call could be replaced with "".replacement
argument of the makeCallWithBalance
function could be stored in calldata
instead of memory
.Lockable
contract is inefficient. OpenZeppelin’s ReentrancyGuard
delivers significantly lower gas overhead by using a two‐word uint256
status in place of a bool
, 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), adopting ReentrancyGuardTransient
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
.
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
.
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
.
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
.
Throughout the codebase, multiple instances of misleading documentation were identified:
swapAndBridgeWithPermit
and depositWithPermit
functions are documented to fail if the provided token does not support the EIP-2612 permit
function. However, the implementation contradicts this statement because, in both functions, the call to permit
is wrapped in a try/catch
block, and any failure is silently ignored.transferWithAuthorization
function, whereas it should mention the receiveWithAuthorization
function instead.SpokePoolPeriphery
contract and the SpokePoolPeripheryInterface
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
.
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.