Background
Axeline Bridge delivers messages between Optimism, Avalanche and Arbitrum. Applications choose a verifier in the VerifierRegistry; most use the AttestationVerifier, where a threshold of attesters signs a digest of each message and the Executor delivers it to the receiving application on the destination chain.
The bridge’s own token application, axUSD, is deployed at the same address on every chain. That convenience is harmless as long as a message can only ever be valid on one destination.
Architecture
An Endpoint on each chain assigns nonces to outgoing messages and records delivered ones. The AttestationVerifier recovers attester signatures over the message digest and checks the threshold; the VerifierRegistry tells the endpoint which verifier an application uses; the Executor submits verified messages, subject to a per-asset outflow limiter.
The boundary that matters in this case is the digest: whatever it does not include, the attesters never approved.
Figure 1Axeline Bridge: components and paths involved in this case
Elements:
- Endpoint (protocol component): source: Optimism
- Attesters (external party): threshold signatures
- AttestationVerifier (protocol component, involved in this case): Threshold signatures from attesters
- Executor (protocol component): Delivers verified messages and retries failures
- Endpoint (protocol component, involved in this case): destinations: Avalanche, Arbitrum
- VerifierRegistry (protocol component): Per-application verifier configuration
- Sender relaying its own messages (adversary): resubmits delivered attestations
Connections:
- Endpoint to Attesters: MessageSent (data or call flow)
- Attesters to AttestationVerifier: signed digest (data or call flow)
- Executor to Endpoint: receiveMessage() (data or call flow)
- Endpoint to AttestationVerifier: verify(digest, sigs) (data or call flow)
- Endpoint to VerifierRegistry: verifierOf(app) (control path)
- Sender relaying its own messages to Endpoint: receiveMessage() on Arbitrum (attack path)
Components in the Axeline Bridge profile
Endpoint(involved in this case)
Sends and receives messages with nonces
VerifierRegistry
Per-application verifier configuration
AttestationVerifier(involved in this case)
Threshold signatures from attesters
Executor
Delivers verified messages and retries failures
Security challenge
On 22 July 2026 the cross-chain replay rule raised a critical alert: the digest of message 48211, a 206,000 axUSD transfer from Optimism delivered on Avalanche the previous evening, had just been accepted by the Arbitrum endpoint. Ninety seconds later a second Avalanche message, 48202, was replayed the same way, and axUSD outflows on Arbitrum reached 118% of the window limit.
Both messages had been sent by the same account, from Optimism to its own address on Avalanche. Relaying the attestations to Arbitrum credited it a second time.
The response had to stop further replays, establish whether attester keys were compromised or the protocol was accepting signatures it should not, and identify every message that was exposed.
Attack surface
The investigation listed every input the destination endpoint trusted when accepting a message.
| Surface | Exposure | Control |
|---|---|---|
| Digest fields | v1 digest omitted the destination chain ID and destination endpoint | v2 digest commits to source chain, destination chain, endpoint address and nonce |
| Delivered-message record | Kept per endpoint, so another endpoint never sees it | Sufficient once the digest is destination-bound |
| Receiver address | axUSD deployed at the same address on every chain | Destination check in the application as defence in depth |
| Outflow limiter | Configured to alert, not block | Blocking mode on every destination |
| Attester keys | Compromise would allow arbitrary messages | Unchanged; no compromise found |
Technical investigation
The first question was whether the attesters had signed anything unusual. They had not: the signatures accepted on Arbitrum were byte-for-byte the signatures produced for the Avalanche deliveries. The sender had simply relayed its own delivered messages to another endpoint.
Rebuilding the v1 digest showed why that worked. It hashed the source chain ID, nonce, sender, receiver and payload. The destination chain was carried in the payload envelope but not in the signed digest, and each endpoint recorded only the digests it had delivered itself.
Because axUSD has the same address on Avalanche and Arbitrum, the Arbitrum endpoint resolved the same receiver, the registry returned the same verifier, and the threshold check passed. Any attested message to an application deployed on more than one destination could be replayed once on each of the others.
Operations paused the executor on Arbitrum seven minutes after the first alert. Fourteen further replay attempts against other Avalanche deliveries reverted while it was paused. A scan of earlier deliveries found no replays before 22 July.
Figure 2Replay of an attested message on a second destination
- Sender (Optimism) (adversary) to Attesters: send 206,000 axUSD to self on Avalanche (nonce 48211) (normal step)
- Attesters (off-chain) to Endpoint (Avalanche): receiveMessage(msg, sigs). verified, credited (normal step)
- Sender (Optimism) (adversary) to Endpoint (Arbitrum): receiveMessage(msg, sigs). same attestations; digest unseen on Arbitrum (attack step)
- Endpoint (Arbitrum) (on-chain) to Sender (Optimism): credit 206,000 axUSD again (attack step)
- Endpoint (Arbitrum) (on-chain) to Monitoring: R-13: digest delivered on two destinations (defensive step)
- Sender (Optimism) (adversary) to Endpoint (Arbitrum): replay nonce 48202. 88 s later (attack step)
| Source nonce | Original delivery (Avalanche) | Replay (Arbitrum) | Amount (axUSD) |
|---|---|---|---|
| 48211 | 2026-07-21 19:40 | 2026-07-22 11:02 | 206,000 |
| 48202 | Earlier on 2026-07-21 | 2026-07-22 11:04 | 206,000 |
| Field | Verifier v1 | Verifier v2 |
|---|---|---|
| Source chain ID | Committed | Committed |
| Nonce | Committed | Committed |
| Sender and receiver | Committed | Committed |
| Payload hash | Committed | Committed |
| Destination chain ID | Not committed | Committed (struct and EIP-712 domain) |
| Destination endpoint address | Not committed | Committed (EIP-712 verifyingContract) |
| Verifier version | Not committed | Committed (EIP-712 version) |
Monitoring record
Open in Security Monitoring| Time (UTC) | Rule | Observation | Measured | Alert |
|---|---|---|---|---|
| 2026-07-21 19:40:33 | R-16Activity above baseline | Message 48211 delivered on AvalancheInformational | 1 (threshold 3 × 30-day median) | Logged |
| 2026-07-22 11:02:37 | R-13Cross-chain message replay | Digest of message 48211 delivered on a second destinationCritical | Condition matched | Alert |
| 2026-07-22 11:04:05 | R-13Cross-chain message replay | Second replay of message 48202 executedCritical | Condition matched | Alert |
| 2026-07-22 11:05:50 | R-14Bridge outflow against rate limit | axUSD outflow at 118% of the window limitHigh | 118 (threshold 80 % of window limit) | Alert |
| 2026-07-22 11:09:12 | R-15Pause state changed | Executor paused on ArbitrumMedium | Condition matched | Alert |
| 2026-07-22 12:40:00 | R-13Cross-chain message replay | 14 further replay attempts rejectedHigh | Condition matched | Alert |
| 2026-07-22 14:30:22 | R-09Protocol parameter change | Verifier v2 made the default for every applicationHigh | Recorded for the timeline | Logged |
| 2026-07-23 09:00:41 | R-15Pause state changed | Executor unpaused after verifier migrationMedium | Condition matched | Alert |
Timeline
Message 48211 delivered on Avalanche
Detection
Digest of message 48211 accepted on Arbitrum; R-13 alert
Exploitation
Message 48202 replayed on Arbitrum
Containment
Executor paused on Arbitrum
Fix
Verifier v2 made the default for every application
Verification
Receivers confirmed on v2; delivery resumed
Root cause
Attestations authorised a message, not a message on a particular chain at a particular contract. Replay protection was correct within one endpoint and absent across endpoints. The same class of defect appears whenever a signed payload omits the domain it is valid in, which is why EIP-712 separates the domain from the message.
The alert-only outflow limiter turned a correctness defect into a second replay: the limiter saw the outflow exceed its window and did nothing to stop it.
- Class
- Signature replay
- Severity
- Critical
- Components involved
- AttestationVerifierEndpoint
function digest(Message calldata m) public pure returns (bytes32) { // The destination chain and endpoint are not part of the signed digest. return keccak256(abi.encode(m.srcChainId, m.nonce, m.sender, m.receiver, keccak256(m.payload)));} function receiveMessage(Message calldata m, bytes[] calldata sigs) external { bytes32 d = verifier.digest(m); require(!delivered[d], "delivered"); // recorded on this endpoint only require(verifier.verify(d, sigs), "bad sigs"); delivered[d] = true; IReceiver(m.receiver).onMessage(m.srcChainId, m.sender, m.payload);}Risk evaluation
During the incident the replay is modelled as an open critical finding, and Operational Security is reduced for the alert-only limiter. Axeline still carries open items from its research assessment, so the after state is its current profile rather than a perfect score.
The score moves from 64.5 (CCC) to 71.5 (BB), a change of +7.0 points. The largest single contribution is Code Security: restoring it from 50.0 to 68.0 alone moves the score from 64.5 to 69.9.
| Component | Weight | Range | During case | Today | Score points |
|---|---|---|---|---|---|
| Code Security | 30% | 50.0 | 68.0 | +5.4 | |
| Operational Security | 20% | 66.0 | 72.0 | +1.2 | |
| Governance Risk | 15% | 70.0 | 70.0 | 0.0 | |
| Economic Security | 15% | 76.0 | 76.0 | 0.0 | |
| Infrastructure Resilience | 10% | 70.0 | 70.0 | 0.0 | |
| Transparency | 10% | 74.0 | 78.0 | +0.4 | |
| Quantix Risk Index | 100% | 64.5 | 71.5 | +7.0 |
Scores use the six weighted components of the Quantix Risk Index. Open findings reduce Code Security by a fixed penalty per severity; the current profile is the protocol’s entry in Quantix Intelligence.
Mitigation strategy
Verifier v2 signs EIP-712 typed data. The domain binds the destination chain ID and the destination endpoint, the message struct repeats the destination fields, and the endpoint rejects messages not addressed to block.chainid. v2 became the default for every application on the afternoon of the incident.
Delivery resumed the next morning once every receiver was confirmed on v2. The outflow limiter now blocks rather than alerts, and axUSD added its own destination check as defence in depth.
- 01Pause the executor on the affected destination
- 02Make verifier v2, with destination-bound EIP-712 digests, the default for every application
- 03Reject messages whose destination does not match the executing chain
- 04Switch the outflow limiter from alert to block
- 05Confirm every receiver on v2 before resuming delivery
-function digest(Message calldata m) public pure returns (bytes32) {- return keccak256(abi.encode(m.srcChainId, m.nonce, m.sender, m.receiver, keccak256(m.payload)));-}+bytes32 constant MESSAGE_TYPEHASH = keccak256(+ "Message(uint64 srcChainId,address srcEndpoint,uint64 nonce,address sender,uint64 dstChainId,address receiver,bytes32 payloadHash)"+);++// EIP-712 domain: name, version "2", chainId = this chain, verifyingContract = this endpoint+function digest(Message calldata m) public view returns (bytes32) {+ return _hashTypedDataV4(keccak256(abi.encode(+ MESSAGE_TYPEHASH, m.srcChainId, m.srcEndpoint, m.nonce, m.sender, m.dstChainId, m.receiver, keccak256(m.payload)+ )));+} function receiveMessage(Message calldata m, bytes[] calldata sigs) external {+ require(m.dstChainId == block.chainid, "wrong destination"); bytes32 d = verifier.digest(m);Security outcome
Two replays succeeded before containment, releasing 412,000 axUSD on Arbitrum. No further replay succeeded, no attester key was compromised, and the replayed amount was reconciled against the axUSD supply accounting on both chains.
The cross-chain replay rule and the outflow rule remain in the monitoring rule set, and the limiter now blocks at its window limit.
- Detection to containment
- 7 min
- Detection to first fix
- 3 h 28 min
- Detection to verified fix
- 21 h 58 min
- Risk Index change
- CCC → BB (+7.0)
Findings this case expands on
- AX-01Attested message hash omits the destination chain and endpoint, allowing cross-chain replayCriticalResolved
- AX-02Failed messages can be retried by anyone, indefinitely, under the current verifierHighPartially resolved
- AX-03Application owners can switch verifier or threshold instantly, including to a 1-of-1 configurationHighOpen
Lessons learned
- 01
A signature must bind every dimension of the context it is valid in: chain, contract and version.
- 02
Deterministic addresses across chains turn a missing domain into a replay.
- 03
Rules that correlate events across chains catch failures that single-chain rules cannot see.
- 04
A safety limit that only alerts is a dashboard, not a control.

