- August 17, 2026
OpenZeppelin Security
OpenZeppelin Security
Security Audits
Summary
Type: Cross Chain
Timeline: From 2026-05-04 → To 2026-05-20
Languages: Solidity
Findings
Total issues: 15 (13 resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 2 (2 resolved) · Low: 5 (5 resolved)
Notes & Additional Information
8 notes raised (6 resolved)
Client Reported Issues
0 issues reported (0 resolved)
Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- Security Model and Trust Assumptions
- Medium Severity
- Low Severity
- Balance Substitutions With Fake Tokens Allow Arbitrary Call Data Changes
- Incorrect Memory-Safe Annotation in CommandsCodec
- refundAdmin Does Not Control Mint Destination and May Revert on Zero Transfer
- Balance Delta in refundAdmin Can Be Inflated via Reentrancy
- AcrossFunder Exclusive Submitter Check Has No Time Bound
- Notes & Additional Information
- Documentation Improvement Suggestions
- Unreachable Conditional Branch in EIP-3009 Funding Functions
- Missing Named Error on lzCompose Caller Check
- Unused bytes Parameter Declared as memory Instead of calldata
- Missing Bips Validation in Balance Substitutions
- External Call Encoding Lacks Explicit Substitution Count, Limiting Extensibility
- FLAG_ALLOW_REVERT Silently Catches Out-of-Gas Reverts
- Naming Suggestions
- Conclusion
- Appendix
Scope
OpenZeppelin performed an audit of the across-protocol/contracts-v5 repository at commit f9578f0.
In scope were the following files:
src
├── adapters
│ └── AcrossAuctionDepositAdapter.sol
├── funders
│ ├── AcrossFunder.sol
│ ├── CCTPFunder.sol
│ ├── OFTFunder.sol
│ └── PrefundedFunder.sol
├── interfaces
│ ├── IExecutor.sol
│ ├── IExecutorAdapter.sol
│ ├── IFunder.sol
│ ├── IGateway.sol
│ ├── IPlanner.sol
│ └── IRequirement.sol
├── libraries
│ ├── BalanceSub.sol
│ ├── Commands.sol
│ ├── CommandsCodec.sol
│ ├── FundingCodec.sol
│ └── GatewayIds.sol
├── modules
│ ├── GatewayContext.sol
│ └── GatewayFunding.sol
├── planners
│ └── OffchainAuctionPlanner.sol
├── types
│ └── Common.sol
├── vault
│ └── SponsorshipVault.sol
├── Executor.sol
├── Gateway.sol
└── GatewaySstore.sol
Additionally, a diff audit has been performed on the src/libraries/external/memview/TypedMemView.sol file against the summa-tx/memview-sol@79a08cb version of this file.
System Overview
Contracts V5 is the Across Protocol periphery layer for cross-chain execution. Users sign abstract orders (steps) that contain one or more execution paths, and third-party submitters compete to execute those paths on the user's behalf. The system is intent-based: the user declares what should happen (a path's command tape), commits funding instructions for it, and any submitter satisfying the on-chain constraints calls into the Gateway to deliver the result, in exchange for whatever incentives the path encodes.
The on-chain perimeter has five principal components:
- The
Gatewayis the upgradeable entry point: it authenticates the submitter, verifies the disclosed path against the user-signed Merkle root, drives the funding loop, and invokes the chosen executor. A persistent-storage variant (GatewaySstore) is provided for chains that lack transient storage support. - The
Executorruns a user-committed command tape composed of requirement checks, external calls, balance utilities, and nested plans. It is a non-upgradeable dispatcher: every action it performs is committed in the user's signed path, and any runtime data the submitter supplies (JIT inputs) only feeds commands explicitly flagged to consume it. - Funders implement the
IFunderinterface and supply destination-side bridge integrations:CCTPFunderclaims Circle CCTP V2 mints,OFTFunderconsumes LayerZero V2 OFT compose messages,AcrossFunderdrives Across V3 fills, andPrefundedFunderreleases pre-deposited credits. - Adapters and planners extend the Executor with stateful (
AcrossAuctionDepositAdapter) and view-only (OffchainAuctionPlanner) execution logic, binding off-chain auction resolutions into on-chain paths via authority signatures. - The
SponsorshipVaultis independent of Gateway execution and custodies a single token, paying out claims authorized by an EIP-712 signature from a designated claim authority.
Execution is bounded by a single Gateway.execute() call. The Gateway resolves the path identifier and witness, then iterates funding entries to pull tokens into the executor (or, for cross-chain flows, to drive the underlying bridge claim). It then dispatches the path message to the executor, which walks the command tape. During the call, the Gateway maintains a transient execution context (currentSubmitter and currentPathId) that funders, adapters, and planners can read, ensuring that downstream contracts validate authorizations against the active execution rather than against persistent state. The context is reset when execute() returns.
The protocol assumes several off-chain participants. Submitters are open-set actors that pay gas to execute paths and earn whatever incentives the path encodes. Auction authorities are user-selected off-chain signers that resolve competitive parameters such as output amounts and winning relayers, binding them to specific paths via signatures over Gateway-pinned domains. Relayers (in the Across sense) fill cross-chain orders on destination chains. None of these actors is on-chain privileged; they all act subject to the path's on-chain constraints.
Security Model and Trust Assumptions
Gateway Owner: Holds the UUPS upgrade authority on the Gateway proxy. The owner can replace the Gateway implementation with arbitrary code, which determines every downstream behavior of the system (funding semantics, command dispatch, execution context). Users and submitters trust the owner not to push malicious upgrades that re-interpret previously signed steps. A compromise of the owner key is equivalent to a compromise of every signed-but-not-executed order. The Executor and the funders are not upgradeable, so they bound the blast radius of a Gateway upgrade to the call-boundary contract.
Funder Admins: Each funder that holds pre-committed or in-flight balances exposes admin-only recovery functions (PrefundedFunder.refundAdmin, CCTPFunder.refundAdmin, CCTPFunder.refundAdminNoMint, OFTFunder.refundAdmin, OFTFunder.refundAdminNoCompose). These are gated by ADMIN_REFUND_FACILITATOR_ROLE (CCTPFunder, OFTFunder) or DEFAULT_ADMIN_ROLE (PrefundedFunder). The role can move funds out of the contract to an admin-specified recipient. Users committing funds to these funders trust the admin not to misroute recoveries. For OFTFunder, the DEFAULT_ADMIN_ROLE additionally controls the trustedOApps allowlist, which gates which LayerZero OApps can drive fund(). Adding a malicious or compromised OApp gives that OApp the ability to drain any token the funder happens to hold (see Additional Considerations).
Submitter: The address that calls Gateway.execute(). Any address can be a submitter unless the path commits a SUBMITTER_REQ (or an authority materializes one via OffchainAuctionPlanner). The submitter selects the disclosed path from the user's signed step, constructs the funding-entry array, supplies the executor message (JIT inputs), and pays gas. Its capabilities are bounded by the path commitment: the submitter cannot alter the command tape, the static inputs, or the chosen executor, but it does decide which funding entries to include and which path to execute when the step contains multiple. Users trust the submitter only to execute a path they have already authorized; the path's on-chain constraints (requirement commands, witness bindings, deadlines) are the safety boundary. A path-committed SUBMITTER_REQ narrows the trust to a single address; if that address is unavailable or uncooperative, the destination path cannot execute, and recovery depends on the funder admin paths or, for AcrossFunder, on Across's native fillDeadline refund mechanism. Recovery may introduce delays and depends on admin availability, so SUBMITTER_REQ should only be used with submitters the user fully trusts.
Auction Authority: A user-chosen off-chain signer referenced by both AcrossAuctionDepositAdapter (for source-chain Across deposit resolution) and OffchainAuctionPlanner (for materializing destination-chain requirements). The authority signs over a domain that pins the Gateway address, the active pathId, and an auction identifier. Its capabilities include resolving the output amount above the user-committed floor, naming the winning relayer, and (for the planner) producing balance and submitter requirements. No on-chain check bounds the authority above the floor or constrains the relayer. A compromised or malicious authority could sign weakened BALANCE_REQ thresholds or excessively high output amounts that make deposits unfillable on the destination chain, locking user funds until fillDeadline expires. Beyond authority misbehavior, a relayer can independently grief an auction by overbidding such that the order is never filled on destination. Determining whether a bid is economically reasonable requires off-chain context that the contracts cannot verify. Users trust both the authority and the off-chain auction infrastructure to reject unreasonable bids and select reliable relayers.
External Bridge Operators: The protocol integrates with Circle CCTP V2, LayerZero V2, and Across V3. Each carries its own off-chain trust assumptions. Circle's attestation service signs CCTP burn messages. LayerZero's Decentralized Verifier Networks configured per OApp validate cross-chain messages; compromised DVNs could cause an OApp to accept fabricated messages, delivering tokens that were never burned on the source chain. Across's SpokePool and Dataworker infrastructure matches deposits with fills and processes repayments. A compromise in any of these external systems could result in unauthorized token minting or delivery to the funders, bypassing the integrity guarantees the on-chain validation layers assume.
Source-Chain Committer (User and Sponsor): Parties committing assets on the source chain (signing funding authorizations, prefunding witnesses, sponsoring paths) accept that destination execution is not guaranteed. Even with correctly configured parameters, destination execution can fail for reasons outside any actor's control: no relayer may fill an Across order before fillDeadline, CCTP attestations or LayerZero messages may arrive after destination deadlines, token price movements during bridging delays can cause destination BALANCE_REQ checks to fail, and the destination submitter may simply never act. Source-chain commitments (funding entries, sponsorships, swap fees) are consumed regardless of the destination outcome. Refund mechanisms vary by bridge and may not return value to the original funder or sponsor.
Additional Considerations
Funding entries and commitment levels. Each funding entry carries a commitment level that determines which witness the signature is checked against. COMMITMENT_STEP resolves the witness to the Merkle root (stepId); COMMITMENT_PATH resolves it to a specific leaf (pathId). The intended default is COMMITMENT_STEP, so the user signs once over the root and the submitter selects the optimal path at execution time. Because all paths under a step share the same witness, a single nonce consumption on whichever path executes first provides natural mutual exclusion across the tree. COMMITMENT_PATH is reserved for additive sponsorship that targets a specific path without committing the user's step-level authorization. Signing the same funding pull (identical token and amount) at both commitment levels produces two distinct authorizations that are independently consumable in the same execute() call, effectively doubling the amount pulled. The contracts do not guard against this combination, as it falls outside the intended usage.
Replay across path leaves. The Gateway does not enforce single execution of a path leaf within a step. Replay protection is delegated entirely to the funding layer (Permit2 nonces, EIP-3009 nonces, the transferFrom caller restriction, or external funder state). A user who signs independent funding authorizations at COMMITMENT_PATH for multiple paths in the same tree can have all of them executed by a submitter, since each authorization is consumed independently. Path construction is expected to go through the Across API, which assembles entries according to these conventions.
Balance requirements as the user-side safety boundary. The Executor processes a user-defined command tape that can include balance-modifying operations (DEX calls, transfers, wraps) and assertion-only requirements such as BALANCE_REQ. A well-placed balance requirement after a balance-modifying action ensures the executor received the expected output regardless of submitter behavior. Placement is the user's responsibility; the protocol does not enforce it. A missing or misplaced requirement gives the submitter room to extract value. Cross-chain adapter actions are a notable exception: tokens leave the source chain, so local balance checks cannot apply. For these, correctness relies on the adapter's own validation (authority signatures, minimum output floors).
Refund paths for caught reverts. The FLAG_ALLOW_REVERT flag suppresses reverts from CALL, ADAPTER_CALL, and PLAN_* commands. The suppression only unwinds state changes made inside the failing inner call. Funding transfers happen in the Gateway's funding loop before the executor is invoked, so when a FLAG_ALLOW_REVERT command reverts, the funded tokens remain at the executor while the loop continues and the transaction completes with a StepExecuted event despite the action not having taken effect. Paths that use the flag should pair the optional command with a follow-up BALANCE_REQ to force a top-level revert when the post-condition is not met, or a TRANSFER that routes any remaining balance to a user-controlled recipient when continuation is intended. Without such a refund step, tokens become stranded at the executor and, on shared executors, can be absorbed by any later path consuming balance-portion amounts or sweeping the contract balance.
Subplans cannot consume JIT inputs. Plan commands materialize a nested (subCommands, subInputs) pair and execute it through a self-call to executeSubPlan, which always invokes _executeCommands with an empty JIT array. Any subplan command flagged FLAG_READS_JIT reverts at runtime when the inner loop attempts to read from an empty jitInputs slot. This isolation is intentional: it preserves the planner's role as the sole author of its returned subplan and prevents submitter-supplied JIT data from flowing into a tape after the planner has already produced it. Path constructors and planner authors must therefore avoid emitting FLAG_READS_JIT commands inside subplans. When a planner needs runtime-resolved data, the parent PLAN_FROM_PLANNER command should carry FLAG_READS_JIT so the planner receives the JIT payload as the second argument to IPlanner.plan(plannerInput, jitInput) and bakes the resolved values into the static subInputs it returns.
Transitive custody at the AcrossFunder and the Executor. Both contracts are designed for transitive custody of tokens within a single Gateway.execute() call, and both assume zero resting balance as a security invariant. Any persistent balance accumulated between transactions is exposed to extraction. In the AcrossFunder, a submitter who supplies a relayData.recipient pointing at themselves causes spokePool.fillRelay to drain the pre-existing balance to that address before the final safeTransfer to the Gateway recipient succeeds against the leftover from the submitter's own deposit. In the Executor, any balance lingering between executions can be absorbed by a later path that uses balance-portion amounts or sweeps address(this).balance, and any forceApprove granted to a spender persists across calls, allowing that spender to drain new balances of the same token whenever they land at the executor. Accidental transfers, refund-flow remainders, or dust should be promptly swept so that both contracts remain at zero resting balance.
CCTPFunder validation scope. CCTPFunder validates only the subset of CCTP message fields that bind the message to its Gateway execution context: outer recipient must equal the configured cctpTokenMessenger, destinationCaller must equal the funder itself, inner mintRecipient must equal the funder, hookData must be exactly 32 bytes matching the Gateway witness, and the resolved local token must match the funder's configured token. Users or the API constructing source-chain CCTP burns are responsible for formatting these fields correctly, as any mismatch causes the destination redemption to revert. Because the minted amount on destination is amount - feeExecuted where feeExecuted can be up to the user-committed maxFee, destination paths should set balance requirements against amount - maxFee rather than the nominal amount to avoid reverting and locking funds behind admin recovery.
OFTFunder OApp compatibility. Source-chain OFT transfers targeting OFTFunder are irrevocable once sent, so users must verify destination OApp compatibility before bridging. If the destination OApp does not call sendCompose during lzReceive, the tokens arrive at the funder with no compose message queued, making them unclaimable through the normal fund flow and requiring admin intervention via refundAdminNoCompose. If the OApp is not registered in trustedOApps, neither fund nor refundAdmin succeeds, and if a compose message is queued, refundAdminNoCompose also reverts, leaving the tokens stuck until the OApp is re-trusted. The funder also hardcodes compose index 0 in every lzCompose call; trusted OApps are expected to use compose index 0 exclusively, since composes at other indices cannot be consumed.
Cross-chain decimal differences. When bridging tokens across chains, the destination token may use different decimals than the source token. The protocol does not normalize amounts across chains, so a BALANCE_REQ threshold set against an assumed decimal can become trivially satisfiable when the destination token has more decimals than expected. Users constructing cross-chain paths must account for decimal differences when setting balance thresholds.
Executor amount encoding and infinite-approval limitation. All amount fields in Executor commands (approvals, transfers, WETH wrapping, and call values) use a packed encoding in _resolveAmount: the low 240 bits carry a literal amount unless they equal uint240.max, which acts as a sentinel switching to balance-portion mode where the high 16 bits specify a fraction in basis points of the executor's current token balance. As a consequence, type(uint256).max (the canonical infinite approval that most ERC-20 tokens treat as non-decreasing) is unreachable, capping literal approvals at uint240.max - 1 and causing standard allowance decrement behavior on each transfer.
ERC-2612 permit cross-step interference. The FUNDING_ERC2612_PERMIT funding type sets a plain ERC-20 allowance with no binding to a step or path, unlike other funding types that are witness-bound. Since permit signatures are publicly executable, a permit intended for one step can be consumed to set the allowance for an entirely different step's execution. If the executed step's permit deadline has since passed, the user must re-authorize (via a new permit or a manual approval) to fund the original step, even though the permit approval for that step was already correctly executed.
Smart-contract submitter approval risk in AcrossFunder. The AcrossFunder pulls tokens from the active Gateway submitter via safeTransferFrom based on gateway.currentSubmitter(), requiring only a blanket ERC-20 approval with no per-fill signature from the submitter. For EOA submitters this is safe, as they directly control the transaction parameters. Smart-contract submitters that hold standing approvals to the funder must independently validate the funding entries and relay data before calling Gateway.execute(), as any caller able to influence the execution parameters could trigger arbitrary fills at the submitter's expense.
Token compatibility in the AcrossFunder fill flow. During the fund function flow, AcrossFunder is both the filler and the deposit recipient. For ERC-20 tokens, the SpokePool performs a direct safeTransferFrom(msg.sender, recipient, amount), which resolves to a self-transfer on the funder. Tokens that revert when the source and destination addresses are the same are not compatible with this flow.
Signed-transfer authorizations restricted to EOA signers. The FUNDING_SIGNED_TRANSFER_FROM funding mechanism in GatewayFunding verifies authorization signatures via ECDSA.recover and compares the recovered address against the signed from field. Smart-contract accounts that authenticate via EIP-1271 cannot satisfy this check, since the recovered address is either a random EOA (for a non-ECDSA payload) or reverts on malformed signatures. Smart accounts are therefore unable to use this funding mechanism and must rely on alternatives that support contract-wallet signers, such as FUNDING_TRANSFER_FROM (where the smart account is the direct caller), FUNDING_PERMIT2_WITNESS (Permit2 routes signature checks through SignatureChecker), or FUNDING_EIP3009_BYTES (the bytes-signature variant used by FiatTokenV2_2 and similar tokens, which routes through SignatureChecker internally). If broader smart-account support is intended for this funding type, ECDSA.recover could be replaced with SignatureChecker.isValidSignatureNow from the OpenZeppelin Contracts library, which transparently handles both ECDSA and EIP-1271 signers.
Medium Severity
DoS Attacks Possible on SponsorshipVault.claim
The SponsorshipVault contract uses an offchain accounting model and relies on an EIP-712 authorization in claim(). The signed payload commits to expectedAccountHash, and the function enforces synchronization via expectedAccountHash == accountHash[msg.sender] before accepting a signature.
However, depositTo() is permissionless and forwards to _deposit(), which unconditionally updates accountHash[account] after pulling tokens from the caller. As a result, any third party can invalidate a victim's previously signed claim by donating a nonzero amount to the victim via depositTo, causing the victim's claim to revert. A mempool observer can front-run a pending claim with a dust depositTo, and can sustain a denial-of-service by repeatedly forcing re-signing.
Consider restricting third-party deposits that mutate accountHash (for example, by requiring an account-signed authorization for third-party deposits), or introducing a reasonable minimum donation amount, so that the DoS attack becomes expensive.
Update: Resolved in pull request #99. The team stated:
Added a minimum deposit amount to make an attack infeasible
OFTFunder Does Not Pin IOFT(oApp).token, Allowing Token Redirection
OFTFunder releases bridged funds during Gateway execution by calling the LayerZero endpoint's lzCompose and then transferring tokens to the recipient passed by the Gateway. Compose delivery is authenticated by the endpoint and by the trustedOApps allowlist.
However, the paid-out token is determined at transfer time by calling the IOFT(oApp).token function inside fund and also inside the admin rescue flows, since the token address for an OApp added to the allowlist is only emitted as an event during addition, but is never cached on-chain. If a trusted OApp is upgradeable or compromised, it can change the return value of token() after being allowlisted, causing OFTFunder to transfer an arbitrary ERC-20 that it holds, including balances originating from other trusted OApps.
Consider pinning each trusted OApp's token address at the time of allowlisting and using the pinned value in payouts.
Update: Resolved in pull request #87. The team stated:
Record an associated OFT
token()upon registration
Low Severity
Balance Substitutions With Fake Tokens Allow Arbitrary Call Data Changes
Balance substitutions in the Executor allow call commands to resolve token amounts dynamically at execution time. Each BalanceSub entry specifies a token address, a proportion in basis points, and a calldata offset; the _applySubstitutions function reads the executor's balance of that token, scales it, and writes the result at the specified offset in the call payload. Because the token address is user-controlled, a user can deploy a fake ERC-20 that returns configurable balances so that substitutions resolve to entirely different results and rewrite the calldata as desired.
Many external operations (bridge deposits, protocol interactions) do not leave on-chain state that can be verified after the call. Their success is inferred solely from the absence of a revert. When substitutions can rewrite calldata arbitrarily, this inference breaks: a call can succeed while performing a completely different action than the committed path describes (for example, a successful bridge transaction does not guarantee that the bridge was initiated to the expected blockchain or recipient). Off-chain simulation does not mitigate this either, since the user controls the fake token's state and can make it return expected values during simulation. This affects any third party that commits resources (gas, sponsorship funds) based on the assumption that a committed path's runtime behavior matches its simulated or statically reviewed behavior.
Consider documenting that path simulation is not sufficient to verify runtime behavior when balance substitutions reference user-controlled tokens. Callers that commit resources based on expected path outcomes should independently verify that substitution token addresses correspond to legitimate tokens and that substitution offsets target only the intended amount fields, for example by maintaining a whitelist of known token addresses and rejecting paths with unrecognized substitution tokens or unusual offsets.
Update: Resolved in pull request #96. The team stated:
Added a comment about this
Incorrect Memory-Safe Annotation in CommandsCodec
The _loadWord function in CommandsCodec is annotated with "memory-safe", which promises the Solidity compiler that the assembly block only accesses memory within Solidity-managed bounds. This annotation allows the Yul optimizer to rearrange memory operations around the assembly block and avoid unnecessary memory pointer extensions, under the assumption that the block respects Solidity's memory model.
However, multiple decoders in the library call _loadWord at offsets where the 32-byte mload extends past the logical end of the bytes memory input buffer, for example decodeExternalCall or decodeDeadlineReq. The excess bytes are discarded by narrowing casts, so the decoded values are correct, but the underlying read touches memory beyond the buffer.
Consider removing the "memory-safe" annotation from the _loadWord function in order to prevent the optimizer from making assumptions about memory access patterns that the out-of-bounds reads may violate.
Update: Resolved in pull request #72. The team stated:
Removed annotation
refundAdmin Does Not Control Mint Destination and May Revert on Zero Transfer
The refundAdmin function in CCTPFunder is the rescue path for attested CCTP messages that cannot pass the normal fund validation. It skips envelope and hookData checks, but still calls receiveMessage and transfers the balance delta to an admin-supplied recipient. This recipient parameter gives the impression that the admin controls where recovered tokens are delivered. However, the actual mint destination is determined by the mintRecipient field inside the CCTP burn message.
When a user correctly sets destinationCaller to the funder but mistakenly sets mintRecipient to a different address, the normal fund path correctly rejects the message. The admin could then attempt recovery via refundAdmin, but the CCTP transmitter would mint tokens to the wrong mintRecipient, the funder's balance delta would be zero, and the transfer to the specified recipient would move nothing. For tokens like USDC that allow zero-value transfers, the transaction succeeds silently, the CCTP nonce is consumed, and the RefundAdmin event reports amount = 0. For tokens that revert on zero-value transfers, the entire transaction reverts, preserving the nonce but leaving the admin with no recovery path through this function.
Consider wrapping the transfer in the refundAdmin function with an if (amount > 0) guard so that the nonce is consumed without triggering a zero-transfer revert, and documenting that the recipient parameter only controls token delivery when mintRecipient in the CCTP message equals address(this).
Update: Resolved in pull request #73. The team stated:
Gated a transfer behind a zero-check
Balance Delta in refundAdmin Can Be Inflated via Reentrancy
In CCTPFunder, the refundAdmin function determines the transfer amount by measuring the token balance delta around the receiveMessage call in _redeemMessage. However, the CCTP V2 MessageTransmitterV2.receiveMessage function has no reentrancy guard. It marks the nonce as used, then calls handleReceiveMessage on the message's recipient field, which is an arbitrary address embedded in the CCTP message and not necessarily the TokenMessengerV2.
Since refundAdmin exists to handle malformed messages that fail the normal fund envelope validation, an attacker can craft a CCTP message with recipient set to their own contract and destinationCaller set to the CCTPFunder, and then request a refund. The attack works when a second CCTP message exists with destinationCaller = bytes32(0) and mintRecipient pointing at the CCTPFunder, representing tokens stranded in the funder due to a user error or API bug. The attack scenario is described below:
- Admin calls
refundAdminfor the attacker's message, passing the attacker-controlled address as therefundAdminfunction'srecipientparameter. _redeemMessagerecordsbalanceBeforeand callsreceiveMessagefor the first message.- The
MessageTransmitterV2callshandleReceiveMessageon the attacker's contract (the messagerecipient). No minting occurs because it is not theTokenMessengerV2. - The attacker's callback calls
receiveMessagefor the victim's message directly on the transmitter. Since that message hasdestinationCaller = bytes32(0), anyone can call it. The transmitter processes it throughTokenMessengerV2, which mints tokens to theCCTPFunder. - Control returns to the
CCTPFunder. The balance delta now includes the victim message's minted amount, and the inflated total is transferred to the attacker.
Consider validating that the CCTP message recipient is the expected cctpTokenMessenger inside the refundAdmin function.
Update: Resolved in pull request #74. The team stated:
Validated recipient to be a canonical TokenMessenger
AcrossFunder Exclusive Submitter Check Has No Time Bound
AcrossFunder enables Across fills as part of Gateway execution on the destination chain. The origin deposit's relayData.message can encode an optional exclusiveSubmitter that restricts which submitter may trigger the destination Gateway execution that calls into the fund function. Unlike Across's own exclusiveRelayer mechanism, which is time-bounded by exclusivityDeadline and falls back to permissionless fills, the exclusiveSubmitter check has no expiration. If the exclusive submitter refuses to act, no alternative fill path exists (the handleV3AcrossMessage callback blocks third-party fills when a message is present), and the user's funds remain locked in the origin SpokePool until fillDeadline passes and the Across refund mechanism activates.
Consider adding a time-bounded exclusivity to the submitter check, mirroring Across's own exclusivityDeadline pattern. After a configurable deadline, the submitter requirement would be dropped so that any submitter could trigger the fill.
Update: Resolved in pull request #94. The team stated:
Removed the exclusive relayer check in
AcrossFunderalltogether. Reasoning:
if a user wants to specify exclusive relayer, let them add
SUBMITTER_REQif an offchain auction wants to specify, let them submit a
PLAN_FROM_PLANNERwithOffchainAuctionPlanneron dst: that will resolve a submitter on dst directly by using a second auction signature instead of propagating this requirement cross-chain
Notes & Additional Information
Documentation Improvement Suggestions
Several documentation comments do not accurately reflect the current contract state or could be improved:
- The NatSpec comment for
_pullTransferFrominGatewayFunding.solrefers toOrderGateway, the previous name of theGatewaycontract. Consider updating the comment to referenceGatewayso the documentation reflects the current contract naming. - The comment in
GatewayIds.solstates that bothstepIdandpathIdcan collide across chains. However,pathIdcannot collide across chains becausechainIdis hashed into the leaf. ForstepId, while a Merkle root could theoretically be reused across chains, in practice each step is constructed for a single chain. Consider rephrasing to reflect that cross-chain collisions are not a practical concern for either identifier. - The NatSpec for
messagein theIExecutor.executefunction describes it as "The step message," but Gateway passespath.messageat the call site. Consider updating the NatSpec to "The path message" to accurately reflect the source of the parameter. - In the
refundAdminNoComposefunction ofOFTFunder, thecomposeQueuecheck ensures that no pending compose exists for the given guid, but it does not validate that the guid itself corresponds to a real OFT transfer. LayerZero'scomposeQueuemapping returnsbytes32(0)for any guid that was never used, so a fabricated or mistyped guid silently passes validation. Since on-chain guid authenticity verification is not practically achievable without additional parameters, consider adding a comment clarifying that the check only guards against pending composes and does not validate guid authenticity. - In the same function, the inline comment describes the consumed compose queue state as "return data set to some marker". The actual LayerZero constant is
RECEIVED_MESSAGE_HASH. Consider replacing "some marker" with the concrete constant name. - In the
SubmitterInputsstruct, the comment "Always a Merkle proof" is attached to thepathfield but describes theprooffield on the following line. Consider moving it abovebytes32[] proof.
Consider applying the above suggestions to keep comments and NatSpec aligned with the current contract behavior and reduce the risk of misinterpretation by future maintainers and integrators.
Update: Resolved in pull request #76. The team stated:
Thanks, fixed comments
Unreachable Conditional Branch in EIP-3009 Funding Functions
The _pullEIP3009 and _pullEIP3009Bytes functions in GatewayFunding each guard their final safeTransfer with if (to != address(this)). The to == address(this) branch is unreachable in any successful execution, since the Gateway cannot act as the executor in the current version of the protocol. The to argument is the executor address forwarded from Gateway.execute, and the Gateway contract does not implement execute(bytes, bytes), fallback, or receive.
Consider removing the conditional and calling safeTransfer unconditionally in both functions.
Update: Acknowledged, not resolved. The team stated:
We’re planning to add support for Gateway native commands in the future + having this check makes functionality a bit more self-contained (it promises to deliver to
to, and that’s what it does, rather than propagating an implicit assumption of who thetowill be down to the lowest level functions)
Missing Named Error on lzCompose Caller Check
The lzCompose function in OFTFunder enforces that the LayerZero compose call was initiated by the funder itself through require(executor == address(this));. The bare require statement provides no error data on failure. The other validation checks in the same function (UntrustedEndpoint, UntrustedOApp) and across the rest of OFTFunder (WitnessMismatch, UntrustedGateway, OAppEndpointMismatch) use named custom errors. A revert from this specific check therefore produces no useful information for off-chain tooling or debuggers attempting to interpret the failure.
Consider replacing the bare require with a named custom error such as UntrustedExecutor, matching the convention used by the surrounding checks.
Update: Resolved in pull request #78. The team stated:
added named error
Unused bytes Parameter Declared as memory Instead of calldata
In the handleV3AcrossMessage function of AcrossFunder, the last parameter is an unnamed bytes memory that is never read. Using memory forces the payload from calldata to be copied into memory on function entry, even though the data is unused.
Consider changing the parameter from bytes memory to bytes calldata in order to save gas during the execution.
Update: Resolved in pull request #79. The team stated:
good point
Missing Bips Validation in Balance Substitutions
In the _applySubstitutions function, the bips value from each BalanceSub entry is passed directly to _portion without checking whether it exceeds BIPS_DENOMINATOR (10,000).
This is inconsistent with the _resolveAmount function, which validates bips > BIPS_DENOMINATOR and reverts with InvalidAmountBips when the check fails. The same guard is missing from the substitution path.
Consider adding a bips validation check in _applySubstitutions consistent with the existing check in _resolveAmount, to fail early with a clear error rather than silently computing an invalid amount.
Update: Resolved in pull request #100. The team stated:
Added bips bound check in
_portion
External Call Encoding Lacks Explicit Substitution Count, Limiting Extensibility
In the CALL / ADAPTER_CALL encoding defined in the decodeExternalCall function, the number of balance substitutions is derived from the trailing input length rather than stored explicitly. The decoder treats all bytes after the value field as substitution entries, computing their count as (input.length - valueEnd) / WIRE_SIZE. This works correctly for the current format but makes the encoding non-extensible: because the substitution array is the terminal variable-length segment with no explicit boundary, any new optional field appended after it would be misinterpreted as additional (or malformed) substitution entries.
The encoding already uses a short-circuit pattern for optional fields, checking input.length at each boundary to determine whether value and subs are present. Storing an explicit substitution count (for example, a single uint8 byte before the subs array) would preserve this pattern and allow future fields to be appended after the substitution array without breaking backward compatibility. Without it, extending the payload would require introducing a new command type or a breaking change to the encoding format.
Consider adding an explicit substitution count field to the encoding to keep the format forward-compatible with future extensions.
Update: Acknowledged, not resolved. The team stated:
Even if we added the length prefix byte, adding trailing bytes in this command encoding would still constitute a breaking change anyway. So we prefer to keep as is
FLAG_ALLOW_REVERT Silently Catches Out-of-Gas Reverts
The Executor's command loop allows individual commands to be marked with FLAG_ALLOW_REVERT, which catches any revert and continues execution. This is intended for non-critical commands where failure is acceptable, such as an optional action that may have already been performed.
However, the submitter controls both the transaction gas limit and the size of JIT data arrays, which consume gas for memory expansion and copying. This enables them to engineer an out-of-gas condition in a specific command marked with the flag, causing it to fail silently, not because the action was already performed, but because it never had enough gas to execute. Path constructors may assume that a caught revert indicates a domain-specific reason (for example, a nonce already consumed or an approval already set) and rely on the side effects having already occurred. An out-of-gas revert breaks this assumption: the side effects never happened, but execution proceeds as if they did.
Consider documenting that FLAG_ALLOW_REVERT catches all revert reasons including out-of-gas, and that critical post-conditions must always be enforced by subsequent requirement commands rather than relying on the assumption that a caught revert implies a prior equivalent action already succeeded.
Update: Resolved in pull request #102. The team stated:
Added documention RE OOG errors with this flag
Naming Suggestions
Several names across the codebase could be made more descriptive:
- In
CCTPFunder, the_validateMessageEnvelopefunction validates fields from both the outer CCTP message (recipient, destination caller) and the inner burn message body (mint recipient,hookData, local token). "Envelope" typically refers only to the outer wrapper, excluding the body, which may cause confusion about the full scope of the checks. - The
DEADLINE_KIND_VALID_BEFOREandDEADLINE_KIND_VALID_AFTERconstants suggest the boundary timestamp itself is excluded, but the checks in the Executor also accept the current timestamp in both cases.
Consider revising these names to better reflect what they represent.
Update: Resolved in pull request #101. The team stated:
Changed the naming + adjusted the DEADLINE_REQ behavior slightly.
KIND_DEADLINE now uses an inclusive bound, while
VALID_AFTER an exclusive one, allowing two different leaves to set Timestamp setting to the same
Xin both commands, while using different kinds and have those leaves be exclusive WRT block timestamp (no timestamp can satisfy both)
Conclusion
The audit covered the Across V5 periphery contracts, comprising the upgradeable Gateway, the Executor command dispatcher and bridge funder integrations.
The codebase is well-structured and carefully written, with clear separation between the Gateway's funding boundary, the Executor's command dispatch, and the pluggable funder adapters. Due to the very general nature of the protocol, it is crucial for integrators and path constructors to be aware of the trust boundaries and operational constraints present, which are documented in the Additional Considerations section above.
The Across team was responsive throughout the engagement and provided thorough context on the intended design and integration patterns, which facilitated a productive review.
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.
Looking for a security partner?