- August 17, 2026
OpenZeppelin Security
OpenZeppelin Security
Security Audits
Summary
Type: DeFi
Timeline: 2026-07-06 → 2026-07-22
Languages: Solidity
Findings
Total issues: 19 (12 resolved, 1 partially resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 2 (2 resolved) · Low: 7 (3 resolved)
Notes & Additional Information
8 note raised (7 resolved)
Client Reported Issues
2 reported issues (0 resolved, 1 partially resolved)
Scope
OpenZeppelin performed an audit of four pull requests spanning two Across Protocol repositories. The first, across-protocol/contracts, is the main production monorepo: it contains the full established Across system, including both the core intents engine (the L1 HubPool, the SpokePool and its per-chain variants, and the chain adapters) and the SpokePool periphery contracts that wrap user entry points. The second, across-protocol/contracts-v5, is a generic, programmable gateway for cross-chain execution that composes with the core intents engine of contracts. It is an added gateway and execution layer for fulfilling more complex intents, built around an upgradeable Gateway and a command-driven Executor. The V5 label refers to the generation of this order-execution framework, not to a parallel reimplementation of the hub-and-spoke system.
Each pull request was reviewed as an independent diff audit. The four scopes are presented below, one subsection per pull request.
PR #1420 (contracts)
Reviewed as the diff on the across-protocol/contracts repository from base commit eabc737 to head commit 1420bc5.
In scope were the following files:
contracts
├── periphery
│ ├── SpokePoolPeriphery.sol
│ └── ERC6492SignatureHandler.sol
└── interfaces
└── SpokePoolPeripheryInterface.sol
PR #1481 (contracts)
Reviewed as the diff on the across-protocol/contracts repository from base commit 639d0c9 to head commit 1fb0f54.
In scope were the following files:
contracts
├── spoke-pools
│ └── SpokePool.sol
├── libraries
│ └── HyperCoreLib.sol
└── periphery
└── mintburn
├── HyperCoreFlowExecutor.sol
└── SwapHandler.sol
The SpokePool contract is the base implementation for all per-chain SpokePool variants (Arbitrum_SpokePool, Optimism_SpokePool, and the other wrappers under contracts/spoke-pools/), which are deployed behind UUPS upgradeable proxies. Because this pull request changes the base contract, including the addition of a new immutable constructor argument, the per-chain wrappers were checked against the change. The per-chain SpokePool wrappers were confirmed not to disrupt the upgradeable proxies' storage layout using an automated LLM scan.
PR #106 (contracts-v5)
Reviewed as the diff on the across-protocol/contracts-v5 repository from base commit bef8ea3 to head commit 32d3931.
In scope were the following files:
src
├── Executor.sol
├── Gateway.sol
├── funding-adapters
│ ├── CCTPAdapter.sol
│ ├── FundingAdapter.sol
│ ├── OFTAdapter.sol
│ └── PrefundedAdapter.sol
├── libraries
│ ├── BalanceSub.sol
│ ├── Commands.sol
│ ├── CommandsCodec.sol
│ ├── ERC6492MemorySignatureHandler.sol
│ ├── FundingCodec.sol
│ ├── GatewayIds.sol
│ └── external
│ └── ERC6492SignatureHandler.sol
├── modules
│ ├── GatewayContext.sol
│ └── GatewayFunding.sol
├── planners
│ ├── AuthorityPlanner.sol
│ ├── AuthorityRequirementPlanner.sol
│ └── OffchainAuctionPlanner.sol
├── tron
│ ├── Tron_Executor.sol
│ ├── Tron_Gateway.sol
│ ├── Tron_OFTAdapter.sol
│ └── Tron_PrefundedAdapter.sol
├── vault
│ └── SponsorshipVault.sol
└── adapters
├── AcrossDepositDelegateAdapter.sol
├── HyperCoreAdapterBase.sol
├── HyperCoreSwapAdapter.sol
└── HyperCoreTransferAdapter.sol
PR #120 (contracts-v5)
Reviewed as the diff on the across-protocol/contracts-v5 repository from base commit 32d3931 to head commit 265b286. Note: the base of this pull request is the head of PR #106.
In scope were the following files:
src
├── Executor.sol
├── Gateway.sol
├── adapters
│ ├── AcrossDepositDelegateAdapter.sol
│ └── OFTSendDelegateAdapter.sol
├── counterfactual
│ ├── CounterfactualImports.sol
│ ├── CounterfactualPrefunder.sol
│ ├── executors
│ │ ├── CounterfactualDestinationExecutor.sol
│ │ ├── CounterfactualSameChainExecutor.sol
│ │ ├── base
│ │ │ ├── CounterfactualBridgeExecutorBase.sol
│ │ │ ├── CounterfactualExecutorBase.sol
│ │ │ └── CounterfactualSourceExecutorBase.sol
│ │ └── bridge
│ │ ├── CounterfactualCCTPBridgeExecutor.sol
│ │ ├── CounterfactualOFTBridgeExecutor.sol
│ │ └── CounterfactualSpokePoolBridgeExecutor.sol
│ ├── floor
│ │ └── FloorLib.sol
│ └── lib
│ ├── BeaconLib.sol
│ ├── CounterfactualTypes.sol
│ ├── QuoteAuth.sol
│ ├── SaltLib.sol
│ └── WireAmount.sol
├── interfaces
│ ├── IAcrossV5Planner.sol
│ └── IGateway.sol
├── libraries
│ ├── Commands.sol
│ ├── CommandsCodec.sol
│ ├── GatewayIds.sol
│ └── V5Witness.sol
├── modules
│ └── GatewayContext.sol
├── planners
│ ├── AuthorityPlanner.sol
│ ├── AuthorityRequirementPlanner.sol
│ └── OffchainAuctionPlanner.sol
├── tron
│ ├── TronImports.sol
│ ├── Tron_CounterfactualDestinationExecutor.sol
│ ├── Tron_CounterfactualOFTBridgeExecutor.sol
│ ├── Tron_CounterfactualPrefunder.sol
│ ├── Tron_CounterfactualSameChainExecutor.sol
│ └── Tron_CounterfactualSpokePoolBridgeExecutor.sol
└── types
└── Common.sol
System Overview
Across Protocol is a cross-chain bridge built on a hub-and-spoke intents model. A user locks tokens in an origin SpokePool, a relayer fronts the equivalent output on the destination SpokePool, and an optimistic settlement layer anchored at the L1 HubPool later reconciles deposits and fills through Merkle-root bundles verified by UMA's Optimistic Oracle. Relayers are reimbursed from pooled liquidity once their fills are proven, and slow fills from SpokePool reserves act as a fallback when no relayer acts.
Across V5 is an added gateway and execution layer that composes on top of this base system to fulfill more complex intents. Users sign abstract orders (steps) made up of one or more execution paths, and permissionless submitters compete to execute those paths. The upgradeable Gateway is the entry point: it authenticates the submitter, verifies the disclosed path against the user-signed Merkle root, drives a funding loop that pulls tokens or drives cross-chain bridge claims, and then dispatches the path to the non-upgradeable Executor, which walks a user-committed command tape. Funding adapters integrate destination-side bridge deliveries (Circle CCTP, LayerZero OFT, Across fills, and pre-deposited credits), planners bind off-chain resolutions such as auction outcomes into on-chain paths, and a SponsorshipVault pays out claims authorized by a designated authority. This layer, at an earlier iteration, was the subject of the previous Contracts V5 Audit, whose System Overview and Security Model remain applicable to the components reviewed here.
The four pull requests in scope extend this system along two axes, and together they are where the two repositories meet. In across-protocol/contracts, the main-system contracts gain first-class integration points with the V5 stack: PR #1420 adds counterfactual and contract-wallet signer support to the SpokePool periphery, while PR #1481 teaches the core SpokePool itself to participate in a V5 execution through new fill entrypoints (and hardens the shared HyperCore helpers for reuse), allowing V5-driven fills to settle directly in the main system. In across-protocol/contracts-v5, the V5 side that drives those flows is built out: PR #106 refactors the funding adapters and adds HyperCore and delegate adapters, and PR #120 adds a counterfactual-wallet execution framework. Each pull request is described in its own subsection below.
PR #1420: ERC-3009 Bytes-Signature and ERC-6492 Counterfactual Signer Support in the Periphery
This pull request broadens the range of signers that can fund deposits through the SpokePoolPeriphery.
Two new entry points, depositWithAuthorizationBytes and swapAndBridgeWithAuthorizationBytes, pull tokens through the extended EIP-3009 receiveWithAuthorization overload that accepts an unstructured bytes signature (as implemented by tokens such as USDC v2.2), exposed through the new IERC20AuthBytes interface. Because the bytes form accepts both EOA (ECDSA) and contract (EIP-1271) signatures, it enables smart-contract wallets to use the ERC-3009 deposit and swap-and-bridge flows, which the fixed v, r, s variants cannot support. The tail logic shared with the existing variants (witness binding, submission fee payment, and dispatch) was refactored into private helpers so that only the token-pull call differs between variants.
The pull request also adds the ERC6492SignatureHandler base contract, which the periphery now inherits. The handler performs only the deploy half of the ERC-6492 flow: when a signature carries the ERC-6492 magic suffix, it runs the embedded factory call to materialize the wallet and returns the unwrapped inner signature for the real verifier to check. Because the embedded factory target and calldata are fully attacker-controlled, the deploy call is routed through the canonical Multicall3 singleton rather than made directly, so the periphery is never the msg.sender of that call and cannot have its allowances or privileges abused. The call is made tolerating failure (for example, when the wallet is already deployed), and correctness ultimately rests on the downstream verifier.
PR #1481: SpokePool Across V5 Fills and HyperCore Reuse
This pull request makes the production SpokePool a participant in Across V5 execution and hardens the HyperCore helper contracts for reuse from the V5 repository.
The SpokePool gains two V5 fill entrypoints. In spoke-as-executor mode, executeAcrossV5 is called directly by the Gateway when the user's path commits the SpokePool itself as the executor; the committed input is a fixed struct that a submitter can fully parse before calling Gateway.execute(), so this mode pulls the output tokens from the Gateway's current submitter using the same standing approval relayers already hold for fillRelay, and the fill is terminal with an optional callback. In spoke-as-adapter mode, adapterExecuteAcrossV5 is invoked mid-sequence by the live execution's committed executor and pulls output tokens from msg.sender (the executing contract's own funded balance) rather than from a standing submitter approval, and permits no callback. Both modes share a _fillV5 core that validates the deposit-committed acceptance bounds against the submitter-supplied relay data before settling through the standard fill pipeline.
A V5_MAGIC_PREFIX tag distinguishes V5 deposits, whose message field encodes the Gateway execution witness. A new nonV5Fill modifier quarantines V5-tagged deposits from the V3 fill, updated-fill, slow-fill-request, and slow-fill-execution paths, so the witness binding cannot be bypassed through a non-V5 settlement path. The SpokePool constructor gains an immutable gateway argument, where the zero address deploys the contract with V5 fills disabled.
The pull request also hardens the shared HyperCore code for reuse. HyperCoreLib migrates its OpenZeppelin imports to the contracts v5 line and replaces unchecked uint64 narrowing casts with SafeCast. SwapHandler is decoupled from the FinalTokenInfo struct so that submitSpotLimitOrder receives spotIndex and isBuy directly, allowing it to be reused from the V5 repository, and HyperCoreFlowExecutor is updated to the new signature.
PR #106: Funding-Adapter Refactor, HyperCore Adapters, and Delegate Adapters
This pull request is a broad iteration of the V5 gateway and execution layer reviewed in the previous audit.
The funders are refactored into funding adapters, with a shared FundingAdapter base and CCTPAdapter, OFTAdapter, and PrefundedAdapter implementations. The authentication boundary at the funding step changes from requiring that msg.sender be the Gateway to requiring that it be the live step's currentExecutor(), which shifts responsibility for authenticating path-committed adapter inputs onto whoever assembles the Merkle root. The pull request adds HyperCore adapters (HyperCoreAdapterBase, HyperCoreSwapAdapter, and HyperCoreTransferAdapter) for HyperCore spot swaps and transfers, and an AcrossDepositDelegateAdapter that drives Across deposits through a delegatecall executed in the Executor's context. ERC-6492 handling is introduced on this side as well (ERC6492MemorySignatureHandler and the vendored external/ERC6492SignatureHandler). Two new planners, AuthorityPlanner and AuthorityRequirementPlanner, join the existing OffchainAuctionPlanner, and Tron variants (Tron_Executor, Tron_Gateway, Tron_OFTAdapter, and Tron_PrefundedAdapter) adapt the stack to Tron's execution environment. The Executor, Gateway, command libraries, GatewayContext and GatewayFunding modules, and SponsorshipVault are updated to support these changes.
PR #120: Counterfactual Wallet Execution
This pull request introduces support for counterfactual deposit addresses into Across V5. A counterfactual account is a beacon-upgradeable proxy whose address is deterministic and known ahead of deployment, allowing users to receive funds at an address before any contract is deployed there; deployment and execution happen atomically once a relayer acts on it. The PR reuses the audited v4 beacon/proxy/dispatcher substrate and adds the V5-specific logic to route counterfactual flows through the existing Gateway, Executor, and Planner components rather than calling bridges directly.
The new code introduces a shared CounterfactualPrefunder leaf that releases proxy funds only once bound to the Gateway's live path, a common executor base handling quote verification, swap floors, and authority requirements, and per-bridge source/destination executors for Across, CCTP, and OFT. Fees and delivery amounts are trusted only through a mandatory signed quote from the beacon signer, with no on-chain fallback, and a single derived salt binds each source deposit to its destination consumption, while per-bridge upgrades are handled by repointing a beacon getter rather than modifying the proxy's committed route leaf.
Security Model and Trust Assumptions
This engagement builds directly on the previous Contracts V5 Audit of the Across V5 gateway and execution layer. All trust assumptions, privileged-role descriptions, and additional considerations documented in that report continue to apply to the components in scope here, and the assumptions listed below apply in addition to them.
Privileged Roles
-
Gateway Owner: Holds the UUPS upgrade authority on the
Gatewayproxy and can replace the implementation with arbitrary code, which determines every downstream behavior of the system. Users and submitters trust the owner not to push malicious upgrades that reinterpret previously signed steps. The Executor and the adapters are not upgradeable, which bounds the blast radius of a Gateway upgrade to the call-boundary contract. -
Funding-Adapter Admins: The
ADMIN_REFUND_FACILITATOR_ROLE(onCCTPAdapterandOFTAdapter) and theDEFAULT_ADMIN_ROLE(onPrefundedAdapter) can move stranded or in-flight balances to an admin-specified recipient through therefundAdmin*functions. For a funding-adapter delivery that lands below the committed minimum, this admin recovery is the only path back to the user, so users committing funds trust the admin not to misroute recoveries and to act when recovery is needed. -
HyperCore Configuration Admin (
DEFAULT_ADMIN_ROLE): Configures Core token metadata and swap-route parameters throughsetCoreTokenInfoandsetSwapRouteConfig. Most of this configuration is not validated on-chain, so correct asset routing, fee accounting, bridge-safety sizing, and swap direction depend on correct admin configuration and maintenance. -
HyperCore Bot (
PERMISSIONED_BOT_ROLE): Sets each Core order's price, size, and timing and the per-swap payout. On-chain, this is bounded only by the per-user floor and aggregate handler solvency, not by best execution. -
Funds Sweeper (
FUNDS_SWEEPER_ROLE): Can sweep pooled surplus out of the HyperCore swap flow, which is relevant to the liveness of finalization tails.
Additional Trust Assumptions
-
Standing Token Approvals on the Shared Executor: The
Executoris shared across all orders, so its token approvals are global, mutable state. A max approval set by one execution persists and can be raised, lowered, or cleared by any later one. No actor should treat a prior approval, including a max one, as durable, even within the same transaction that set it, since some flows, such as a plan window, allow it to be modified through injected JIT data. -
No Support for Non-Standard ERC-20 Tokens: The contracts assume standard ERC-20 semantics (without fee-on-transfer, rebasing, or other non-standard mechanisms), where a transfer moves exactly the requested amount and an account's balance changes only through explicit transfers. Several paths account by nominal amount rather than by a measured balance delta (for example,
PrefundedAdapter.storecredits its ledger by the requested amount), so a token with non-standard behavior could break accounting. -
Adapter Input Is Authenticated Only by Root Construction, Not by the Gateway: The funding-adapter boundary moved from "
msg.senderis the Gateway" to "msg.senderis the live step'scurrentExecutor()". That check authenticates the path-committedinput(token,minAmount, andrecipient) only if the committed executor is closed and the root opens no submitter-controlled command windows (PLAN_FROM_JIT, planner, or freeDELEGATECALL). Committing an open or custom executor, or a closed executor whose root contains such a window, voids the guarantee, since the submitter can then invoke the adapter with uncommittedinput. Responsibility for the property therefore rests entirely on whoever assembles the Merkle root, not on any on-chain check the Gateway performs. -
Destination Bridge Deliveries Are Irreversible and Shortfalls Are Recoverable Only by an Admin: Unlike Across's normal model, where an unfillable intent refunds on the origin chain, a funding-adapter delivery has already landed irreversibly on the destination. If the delivered amount falls below the path-committed
minAmount(a mis-quote, or a bridge-fee increase between quote and delivery), the normal path reverts permanently (thestepIdis immutable, so the user cannot re-quote), and the funds are stuck until anADMIN_REFUND_FACILITATOR_ROLEholder rescues them via therefundAdmin*functions. There is no user self-heal. -
The Shared Executor Must Remain Storageless:
DELEGATECALLandADAPTER_DELEGATECALLrun the committed target's code in the Executor's context, so that target can write arbitrary Executor storage or transient slots, and on adelegatecallEnabledExecutor (the default on every configured chain) any order author can commit one. The Executor is safe from this at the moment because it holds no storage. -
msg.valuein a Delegate Adapter Is the Executor Frame's Value, Not a Per-Command Amount:DELEGATECALLandADAPTER_DELEGATECALLpreservemsg.value, so inside a delegate adaptermsg.valuereflects the value the Gateway forwarded to the Executor, which is constant across the entire tape and inflatable by the submitter viaexecute(). Delegate-adapter authors must derive amounts from resolved balances (asAcrossDepositDelegateAdapterdoes), never frommsg.value, which would double-count or over-send. -
The Gateway Can Only Be Deployed on Chains With a Multicall3: ERC-6492 counterfactual-wallet funding routes each attacker-controlled factory call through a Multicall3 singleton, so the Gateway is never that call's
msg.sender, and Multicall3's address is an immutable constructor argument whose code the constructor requires to be present. There is no disable path. A chain without a deployed Multicall3 therefore cannot host the Gateway until one is deployed. The same requirement applies to theSpokePoolPeripheryin PR #1420, whose constructor likewise requires a deployed Multicall3. -
HyperCore Token and Route Configuration Is Admin-Supplied and Largely Unvalidated: In
setCoreTokenInfoandsetSwapRouteConfig, only theTokenInfo(decimals andevmContract) is precompile-sourced, and the input and output tokens and theSwapHandlerare checked to exist on Core. Everything else is set byDEFAULT_ADMIN_ROLEwith no on-chain check:coreIndex,canBeUsedForAccountActivation,accountActivationFeeCore,bridgeSafetyBufferCore, and the route'sspotIndexandisBuy. In particular, nothing asserts that the precompile'sevmContractforcoreIndexequals thetokenkey, nor thatspotIndexandisBuymatch the actual market and base/quote orientation for the(inputToken, outputToken)pair. Correct asset routing, fee accounting, bridge-safety sizing, and swap direction rely entirely on correct admin configuration and maintenance. -
HyperCore Swap Execution Is Bot-Trusted, Not Enforced: The
PERMISSIONED_BOT_ROLEsets each Core order's price, size, and timing and the per-swap payout (handlerDebit), which on-chain is bounded only by the per-user floor and aggregate handler solvency, never by best execution and never tied to a swap's realized fill. Because funds are pooled per route, a faulty or malicious bot can execute poorly and reallocate the pool's surplus (output above users' committed floors) to any recipient, including skimming it to a self-owned swap. No user is paid below their floor, but the upside can be captured, and the tail of finalizations can stall (a liveness concern) until atopUpEVMorFUNDS_SWEEPER_ROLEsweep. -
HyperCore Swap Top-Ups Must Be Rounded Up Off-Chain by the Bot:
_topUpSwapHandlerbridges the down-roundedtopUpCore = floor(topUpEVM / scale), andfinalizeSwapFlowschecks solvency against that value. AtopUpEVMthat is not a whole-Core-unit multiple can leave the pool short by up to one Core unit and revert the batch. The bot must provisiontopUpEVMrounded up to a whole Core unit. The failure is liveness-only (no loss, retryable) and is fixed off-chain with no contract change needed. -
HyperCore Bridge Liquidity Is Trusted and Only Probabilistically Checked: EVM-to-Core deposits are released from a finite, globally-shared asset-bridge reserve.
isCoreAmountSafeToBridgeguards it with only a per-send, pre-block-snapshot balance check plus a fixed buffer, with no cumulative or cross-actor accounting. Same-block draws (organic volume or an adversarial direct bridge) can collectively over-draw the reserve, and since HyperCore does not verify sufficiency at settlement, the shortfall deposit is lost. This is inherent to relying on the HyperCore bridge and cannot be fully prevented at the adapter level. -
Spot-Balance Accounting Treats
totalas Fully Spendable and Ignoreshold:HyperCoreLib.spotBalance()returns onlySpotBalance.total, so the adapters' solvency and quote checks count locked balance as available. Any HyperCore mechanism that locks the read token (for example, a pending outbound send reserving funds) would break this and cause the accounting to over-count spendable balance. -
AcrossDepositDelegateAdapterPaths Must Not Be Reused: The AcrossunsafeDepositid iskeccak256(currentSubmitter, pathId, depositNonce), so the same submitter rerunning the same path (with the same committeddepositNonce) produces a duplicate deposit id. Each deposit must use a distinct path or a fresh committeddepositNonce.
Medium Severity
No Core-Side Recovery on HyperCoreTransferAdapter
The HyperCoreTransferAdapter contract bridges non-USDC transfers to its own HyperCore account before forwarding to the recipient, and retains the activation-fee residue there; if the forwarding send fails at Core settlement (e.g. an unvalidated destinationDex) after the deposit landed, the full amount is stranded on that account. Its inherited sweepers cover only EVM balances; unlike the swap adapter's sweepOnCore function, it has no Core-side recovery, so those balances are unrecoverable.
Consider adding a Core-side sweep to HyperCoreTransferAdapter mirroring sweepOnCore.
Update: Resolved in pull request #178.
Committed Delivery Dispatch Sequence Breaks Adapter-Mode SpokePool Fills
CounterfactualDestinationExecutor.executeAcrossV5 authenticates the salt and then immediately dispatches the committed Config.adapterCall as an ADAPTER_CALL. This is the first action in the flow, before any tape, swap window, or command can run. Executor._dispatchCall implements ADAPTER_CALL as a real external call (target.call{value}(...), not delegatecall), so inside the target, msg.sender == address(this) i.e. the CounterfactualDestinationExecutor instance itself . If Config.adapterCall targets SpokePool's mode-2 entrypoint, that entrypoint pulls funds via transferFrom(msg.sender, recipient, amount) per its documented design SPOKE_V5_FILLS.md
That pull model assumes the payer already holds the token and has already approved the SpokePool. However, CounterfactualDestinationExecutor has no tape at all (path.message is a fixed Config struct, not commands), and this dispatch is the delivery mechanism. At the moment of the call it holds no prior balance and has granted no prior allowance, and there is no earlier point in the flow where either could have been established. SpokePool.transferFrom(executor, recipient, amount) therefore reverts on allowance(executor, SpokePool) == 0, and CounterfactualDestinationExecutor bubbles the failure immediately, reverting the entire executeAcrossV5 call.
Consider adding an explicit pre-delivery approval step for adapter targets that require transferFrom from the executor (for example, approving the committed Config.adapterCall target for the committed delivered token) immediately before dispatch, preserving the existing "delivery before window" ordering.
Update: Resolved in pull request #184 at commit cf309dc.
Low Severity
Zero Signing Authority with Nonzero Param Modification Rules Allows Unauthenticated JIT Params
In AcrossDepositDelegateAdapter.adapterDelegateExecuteAcrossV5, if the paramModificationRules is nonzero, then _applyParamModifications is called. At this step, the signature is checked with authority only if authority, the address decoded from the lowest-order 20 bytes of paramModificationRules, is nonzero. Therefore, if a path commits paramModificationRules with allowAmountOut or allowExclusiveRelayer set but a zero authority, any submitter can supply an arbitrary JIT value for the enabled parameter, updating deposit.outputAmount or deposit.exclusiveRelayer without providing any signature. Since outputAmount is improvement-only this modification is benign; however, setting exclusiveRelayer to a relayer that never fills can delay the deposit for the entire exclusivityParameter window.
Consider enforcing that the flags of paramModificationRules are set to zero whenever authority is the zero address.
Update: Acknowledged, not resolved. The team stated:
This is by design, if
authorityis set to zero address then any submitter can set their ownoutputAmountandexclusiveRelayer. SettingoutputAmount, as you mentioned, is benign because it can only improve the originaloutputAmount. Allowing updates toexclusiveRelayeris also beneficial as it allows submitter to specify the exclusive relayer on the destination (their own wallet on destination) there by ensuring that only they can fill the deposit. Setting an invalid/unresponsiveexclusiveRelayercan only delay the deposit byexclusivityParameterwhich in practice is around 5-10s.
Account Activation Fee Resolved Per Delivery Against a Stale coreUserExists Snapshot
Both HyperCore adapters resolve the one-time account-activation fee through the _resolveActivationFee function, which reads the coreUserExists precompile. That precompile reflects Core state from before the current EVM block, whereas HyperCore actually activates a recipient (and charges the fee) only once, at settlement after the block. So every delivery to a not-yet-active recipient in the same block resolves a nonzero fee, even though at most one activation ever occurs. This surfaces in two ways, depending on whether the path subtracts the fee from the amount sent or only accounts it:
- Fee subtracted — the swap
_finalizeSingleSwapfunction (any token) and the non-USDC transfer path. For N deliveries to the same new recipient (repeatedADAPTER_CALLs in one tape, or several same-block executions), each sendsdebit − feebut Core charges only the first, stranding(N − 1) × fee. On the swap adapter the surplus is recoverable via the Core sweepers; on theHyperCoreTransferAdaptercontract it is stranded on the adapter's Core balance with no Core-side recovery. - Fee only accounted — the USDC transfer path sends the full amount but reports
amount − fee. If the recipient is activated earlier in the same block, the deposit wallet charges nothing, so the recipient receives more than accounted and the floor check may spuriously revert an otherwise-valid transfer. This is transient (retryable in a later block) and causes no loss.
The magnitude is bounded because activation only affects a recipient's first block, but it scales with the number of same-block deliveries to a single new recipient.
Consider deduplicating the activation fee per recipient within a single block, deducting it only for the first delivery to a given account, or reintroducing an explicit pre-activation step that funds the recipient in an earlier block so later deliveries observe it as active and skip the deduction.
Update: Acknowledged, not resolved. The team stated:
We decided to not fix this as in practice we would only have one delivery to the recipient per execution (the recipient picks one token they want on Hyperliquid at a time).
Unverified coreIndex-to-token Correspondence in setCoreTokenInfo
The setCoreTokenInfo function configures the per-token HyperCore parameters used by both HyperCore adapters. It takes an admin-supplied token (the EVM address the configuration is keyed under) and a separate admin-supplied coreIndex, then builds the stored CoreTokenInfo by reading the HyperCore token-info precompile for that coreIndex. The resulting record, including the precompile-reported evmContract, is stored under the token key without ever checking that the two refer to the same asset.
Because the coreIndex and the token key are independent inputs and nothing asserts that the precompile's returned evmContract equals token, an administrator can store a record whose coreIndex points at a different Core asset than the token it is filed under. The only later guard, in the _getExistingCoreTokenInfo function, checks that evmContract is non-zero, not that it matches the key. A mismatched configuration is therefore accepted silently, after which a flow pulls the EVM token recorded as the key while routing and quoting against the unrelated Core asset identified by coreIndex, misdirecting funds and computing amounts against the wrong token's decimals and reserve.
Consider asserting inside setCoreTokenInfo that the built tokenInfo.evmContract equals the supplied token, so a coreIndex that does not correspond to the token key is rejected at configuration time rather than surfacing later as misrouted flows.
Update: Resolved in pull request #182 at commit 02d145b. The team stated:
This check was intentionally omitted because USDC’s
tokenInfo.evmContractis set toCoreDepositWalletand nottoken. We partially resolved it by only checking iftokenInfo.evmContract == tokenfor non-USDC token. Checking for USDC requires bringing in theCoreDepositWalletaddress into the contract, which we don’t think is worth the extra complexity.
Truncated Stable Floor Is Indistinguishable From an Unpriced Pair
FloorLib.stableFloor returns 0 both for a genuinely unpriced pair and whenever integer division simply rounds gross down to 0. Every caller treats 0 identically as "no floor, skip the check", with no way to distinguish the two cases.
The rounding is not necessarily limited to dust: in the decOut < decIn branch, gross floors to 0 whenever amountIn · priceIn < priceOut · 10^(decIn - decOut). Such a swap silently loses its on-chain stable floor and is then protected only by the authority-signed plan, for a reason unrelated to price.
Consider having stableFloor distinguish between an "unpriced pair" and a "priced pair whose floor rounds to zero", or at least document this behavior.
Update: Resolved in pull request #190. The team stated:
This functionality is intended, updated documentation around it in the attached pull request.
Forgeable ctx Allows Sweeping Balances Held by the Shared Executor
Counterfactual source executors decode path.message into a committed CounterfactualContext (ctx) and then treat ctx.beaconand ctx.gateway as live dependencies. The quote gate reads stepId from IGateway(ctx.gateway).currentStepId() in _verifyQuote, and the quote signer / requirements authority is read from ICounterfactualBeacon(ctx.beacon).signer() in _resolveAuthority . At the same time, Gateway.execute is permissionless and only verifies the Merkle relationship between the caller-provided Step root and pathId, so path.message (and therefore ctx.*) is fully caller-controlled whenever the proof can be made to pass trivially, via a self-built single-leaf tree.
As a result, an attacker can provide a controlled ctx.beacon / ctx.gateway pair and satisfy both the quote signature and the authority requirements with attacker-chosen data while still calling into a real source executor. In addition, _prefund only checks balance >= ctx.amount after calling ICounterfactualDeposit(ctx.depositor).execute(...); with ctx.amount == 0 this check is vacuous. This enables reaching route resolution and approvals, allowing immediate draining of any executor-held balances and creation of persistent approvals that can be abused later.
Consider resolving ctx.gateway and ctx.beacon from trusted, admin-configured immutables instead of trusting the caller-supplied values, and require _prefund to prove that a genuine deposit occurred.
Update: Acknowledged, not resolved. The team stated:
Acknowledged with no change: executors are considered to be untrusted - they are intended to never hold token balances, but if they do it is assumed that any actor can sweep this balance from the executor. And from that assumption, its not possible for persistent approvals to be abused in any way in the future that results in loss of user’s funds.
Compromised OApp Can Drain OFTAdapter via Inflated amountLD
OFTAdapter._deliver transfers amount = OFTComposeMsgCodec.amountLD(lzMessage) after lzCompose succeeds, checked only against the committed minAmount. amountLD is a raw offset slice of the message bytes set by the composing OApp; lzCompose only authenticates that the message matches what was queued, not that the encoded amount was ever credited to the adapter.
The adapter's own oAppTokens comment already treats "a compromised or upgradeable OApp" as in-scope, but for a different vector (token redirection, not amount inflation). Such an OApp can queue an authentic compose message with an inflated amountLD while crediting less or nothing, and any submitter can relay it via ordinary adapterExecuteAcrossV5 JIT data. Payout is capped only by the adapter's live balance of the pinned token, such as stranded balances from unrelated deliveries.
The sibling CCTPAdapter._redeemMessage already uses the safer pattern: a balanceBefore / balanceAfter delta instead of trusting a claimed amount.
Consider binding payouts to an on-chain-verified credit rather than the message's self-reported amountLD, for example by measuring a balance delta around the lzCompose call in the same way that CCTPAdapter._redeemMessage already does.
Update: Acknowledged, not resolved. The team stated:
The finding is valid:
OFTAdapter._deliverpays out the compose message's self-reportedamountLD, and a compromised or upgradeable OApp could inflate that value to draw more than it actually credited, bounded by the adapter's live balance of the pinned token. We are accepting this rather than mitigating it, because no on-chain fix is available for a shared-pool adapter: OFT credits the adapter during a permissionless, out-of-bandlzReceive, so it can never bracket the real credit to measure it.
Non-Canonical refundAddress Can Brick Destination During refundAfter
CounterfactualDestinationExecutor decodes refundAddress as an arbitrary bytes32 and only converts it to an address inside the time-gated refund branch via Bytes32ToAddress.toAddress, which reverts on non-zero high bits. A non-canonical refundAddress is invisible before refundAfter, then permanently bricks the entire committed route once the refund branch becomes reachable, which is precisely when the refund path would be used.
The source-side Template.refundAddressrides "in bytes32 wire form (the dst may be non-EVM)," and nothing validates at commit or salt-folding time that a value destined for an EVM destination is properly zero-padded.
Consider validating the encoding of refundAddress unconditionally, immediately after decoding extraParams, rather than only inside the time-gated branch, so that a misconfigured route fails immediately and visibly on its first execution, well before the refund deadline, instead of silently appearing to work and then bricking irrecoverably when the refund path is needed.
Update: Resolved in pull request #185 at commit df19330. The team stated:
Instead of blocking execution if
refundAddressis invalid, we are treating invalidrefundAddressas not having refund path and checking ifrefundAddressis valid before attempting go through refund path
Notes & Additional Information
Dead Code
The internal pure function toTokenId is defined in HyperCoreLib, but is never called elsewhere.
Consider deleting toTokenId and any other dead code to simplify the codebase and avoid maintaining unused functionality.
Update: Resolved in pull request #1499 at commit 053687a.
Adapter Core-Account Existence Not Verified
Both HyperCore adapters perform Core-side sends from their own account: the HyperCoreTransferAdapter contract bridges a non-USDC transfer to its own Core account and forwards it to the recipient with a core-to-core send, and the sweepOnCore function sends from the adapter. HyperCore requires the account performing a CoreWriter action to exist before the EVM block, so if an adapter's Core account is first created by the same transaction's deposit, that forwarding send is rejected and the funds remain on the adapter's Core account (which, on the transfer adapter, has no Core-side recovery). Unlike the per-route SwapHandler, whose Core existence is enforced by the SwapHandlerNotOnCore check in the setSwapRouteConfig function, the adapter contracts' own Core accounts are never verified.
Consider verifying HyperCoreLib.coreUserExists(address(this)) in the HyperCoreAdapterBase constructor (mirroring SwapHandlerNotOnCore), so an adapter cannot be deployed and used before its Core account is activated.
Update: Acknowledged, not resolved. The team stated:
Since not activated adapter contract address on Core can easily be caught during testing, we don’t think its worth adding a verification check in the contract.
Misleading Key Name for Handler-to-Block Mapping
The lastPullFundsBlock mapping declares its key parameter as outputToken, while the documentation comment directly above it refers to the key as handler. Both accessors, the finalizeSwapFlows function and the sweepOnCoreFromSwapHandler function, index the mapping with the SwapHandler address rather than a token address. The declared parameter name and the comment are therefore both inconsistent with how the mapping is actually keyed.
Consider renaming the key parameter to reflect the SwapHandler address (for example, swapHandler) and aligning the documentation comment, so the declaration matches the actual usage.
Update: Resolved in pull request #180.
Dust Stranded in HyperCore Adapters
Both HyperCore adapters pull the full committed inputAmountEVM but bridge only quotedEvmAmount, where the input is rounded down to a whole Core unit (quotedEvmAmount <= inputAmountEVM). The transfer adapter's adapterExecuteAcrossV5 function pulls to the adapter and the swap _initiateSwapFlow function pulls to the SwapHandler, so the sub-Core-unit remainder (inputAmountEVM - quotedEvmAmount, below one Core unit per flow) stays there, neither bridged nor returned.
Consider pulling only quotedEvmAmount and reverting when it is zero, or documenting the current design decision of leaving the dust in the adapter.
Update: Resolved in pull request #183.
Misleading Comments
The following code comments do not accurately reflect the implementation: - The _pullPermit2Witness function unwraps its signature through the _handleERC6492SignatureMemory function exactly like the EIP-3009 path, yet the comment noting that the signature may be ERC-6492 wrapped appears only on the FUNDING_EIP3009_BYTES constant and is absent from the FUNDING_PERMIT2_WITNESS constant. - The comment on the executeAcrossV5 function states that every path into command execution goes through the Gateway's non-reentrant execute. However, a committed DELEGATECALL command targeting the Executor itself re-enters executeAcrossV5, since delegatecall preserves msg.sender as the Gateway and satisfies the OnlyGateway check. Command execution can therefore be re-initiated from within an in-progress execution without a separate Gateway call. - The comment on FLAG_ALLOW_REVERT states it makes the loop "swallow ANY revert from the command," but the flag is honored only for call-family commands (0x08–0x0f) that return a success flag. Requirement (0x00–0x07) and utility (0x10–0x17) commands revert directly, so their reverts propagate and abort the tape regardless of the flag; even for call-family commands only the external call's failure is caught, not in-frame decoding/substitution reverts.
Consider rewriting the above-mentioned comments so the documentation matches the implementation.
Update: Resolved in pull request #181.
Opaque Revert When the Execution Fee Exceeds the Prefunded Amount
_payExecutionFee checks executionFee against the beacon's absolute cap but never against ctx.amount, the amount _prefund just released. Since nothing changes the balance in between, paying a fee above ctx.amount simply fails inside _transferExact with a generic transfer-failure revert instead of a purpose-specific one.
Consider adding an explicit executionFee <= ctx.amount check in _payExecutionFee that reverts with a dedicated error, so a misquoted fee fails fast with a diagnosable cause instead of an opaque transfer-failure revert.
Update: Resolved in pull request #186 at commit 114c09d.
Unused Error
In OFTAdapter.sol, the ComposeNotPending error is unused.
To improve the overall clarity, intentionality, and readability of the codebase, consider either using or removing any currently unused errors.
Update: Resolved in pull request #187 at commit 74bcda5.
Todo Comments in the Code
Throughout the codebase, two instances of TODO comments were found:
-
The
todocomment in line 52 ofCounterfactualBridgeExecutorBase.sol. -
The
TODOcomment in line 11 ofBeaconLib.sol.
Consider removing all instances of TODO comments and instead tracking them in the issues backlog. Alternatively, consider linking each inline TODO to the corresponding issues backlog entry.
Update: Resolved in pull request #188 at commit cafd797.
Client Reported
Missing address(0) Native Token Support on the Destination Executor
CounterfactualDestinationExecutor cannot execute any destination route whose delivered asset is the native token. It unconditionally reads the delivered token's decimals via an ERC-20 call, which reverts on the native sentinel address(0) before any flow (refund, no-swap, or swap) even branches.
CounterfactualDestinationExecutor.executeAcrossV5 converts the salt-authenticated bridgeAmtReqWire into local units via WireAmount.fromWire(bridgeAmtReqWire, IERC20Metadata(inToken).decimals()). The token address is derived from Config.inputToken via Bytes32ToAddress.toAddress, which allows bytes32(0) and therefore inToken == address(0).
address(0) is treated as the native-asset sentinel in core helpers such as Executor._balanceOf and Executor._transferExact, and native ETH can be credited and delivered under address(0) elsewhere in the system. As a result, a native-asset destination route can reach executeAcrossV5, but it reverts on IERC20Metadata(address(0)).decimals(), making native inputToken routes non-executable.
The failure is a liveness and route-scoping gap rather than a value-safety issue: everything reverts atomically, so no funds are misdirected, but any destination chain or bridge integration whose delivered asset is inherently native (for example, a chain where the bridged asset is also the gas token) is completely non-functional through this executor, excluding chains in which the native gas token can be represented by an ERC-20 token with an address different to address(0), such as CELO.
Consider requiring delivery to land as the wrapped asset on this leg (matching the source executors and SpokePool's existing behavior toward contract recipients), paired with an explicit Config.inputToken != address(0) revert instead of the current opaque decimals() failure. Consider adding the same explicit guard to CounterfactualBridgeExecutorBase, which assumes the same convention but never asserts it on-chain, but not to CounterfactualSameChainExecutor, which already supports native correctly.
Update: Acknowledged, not resolved. The team stated:
We decided that no change is needed since none of our current bridges actually deliver native asset (
SpokePoolwraps native into wrapped native when delivering to a smart contract). And if any future bridges do support native asset delivery, theinTokenshould be configured to be wrapped native anyways, so theIERC20.decimals()call will still function.
Native-Asset Guard Prevents Source Chain Native Swaps On CCTP and OFT
The CCTP and OFT source executors rejected any route funded with the native asset, in the CCTP preflight and the OFT preflight. That condition describes the asset the prefunder releases rather than the asset the bridge leg carries, so it also rejected routes that fund with native value and swap it into the bridge token before the deposit, where the bridge leg is an ordinary ERC-20 transfer of the swap output. Both bridges can serve those routes, so the guard made an otherwise supported shape unexecutable.
Consider rejecting a route only when the input is native and no swap is committed, keeping every native-to-ERC-20 swap route reachable while closing the shape neither bridge can execute.
Update: Partially Resolved in pull request #193. The UMA team stated:
The route described falls under the category of a "misconfigured route". There are many possible misconfigured routes, that could result in loss of users funds. We can't check for all of them on-chain, so we think it makes sense to not rely on these checks in the contracts at all.
Conclusion
This audit reviewed four pull requests that extend the Across Protocol across two repositories. In across-protocol/contracts, the changes add counterfactual and contract-wallet signer support to the SpokePool periphery and give the core SpokePool native entrypoints for participating in Across V5 executions, alongside reuse hardening of the shared HyperCore helpers. In across-protocol/contracts-v5, the changes build out the V5 gateway and execution layer that drives those flows, refactoring the funding adapters and introducing HyperCore, delegate, and counterfactual-wallet execution components.
The codebase is of high quality, carefully written, and supported by clear documentation of the intended design and integration patterns. Because the V5 execution model is deliberately general, much of its safety rests on how paths and Merkle roots are assembled off-chain rather than on on-chain checks alone, so integrators and path constructors should remain mindful of the trust boundaries and operational constraints captured in the Security Model and Trust Assumptions section above. We are grateful to the Across team for being highly responsive throughout the engagement and providing thorough context on the design and integration patterns, and we look forwrad to supporting their work in the future.
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?