Post mortem: Acurast Token Conversion — replay of conversion messages
Enabled guard intentionally left out on retry and inbound-message paths (bridge could still mint locked funds while disabled) but deduplication state cleared on eventual user unlock
Date: 2026-07-20
Authors: Simon
Status: FIXED (bridge fully operational again)
Realized loss: none
The replay was never successfully exploited; the realized loss is 0 ACU. Each of the 3,426 conversions processed on mainnet is the only one for its account and matches exactly one canary conversion of the same amount (3,444 were initiated on canary).
How this was established, verified independently in two ways:
- We matched every canary
ConversionInitiatedagainst mainnetConversionProcessedper account, using the canary and mainnet indexers. A replay would show up as an account processed on mainnet more often than it initiated on canary. - We read the mainnet pallet account balance from the archive node every 150k blocks. It fell from 65,000,000 to 6,663,034 ACU, a drop that exactly equals the sum of the indexed conversions, with nothing unexplained in any interval. It has not moved since the last conversion on 2026-05-11.
Summary:
pallet-token-conversion migrates the native token from the source ("canary") chain to an Acurast
chain: a user burns balance on the source side, a cross-chain message is delivered, and on the
receiving side the pallet funds the target account from the pallet account and places a vesting hold
that the user later unlocks. The pallet has a governance master switch, Enabled, toggled via
set_enabled — the intended emergency lever to stop the bridge.
The core issue is that the pallet's inbound replay protection is only a transient, per-account check
(LockedConversion existence) that is cleared when the user eventually unlocks, and it was
designed without accounting for the weak delivery guarantees of the underlying message protocol:
IBC provides at-least-once, not exactly-once, delivery (a message with the same nonce can be re-sent
after its TTL). A conversion message replayed after the user has unlocked is therefore no longer
deduplicated and re-runs fund + hold, re-minting the "migrated" funds from the pallet account.
This was compounded by the Enabled kill switch being intentionally limited to convert — the
retry and inbound process_conversion paths were deliberately left runnable so in-flight conversions
could complete, and unlock deliberately stays open — so setting Enabled = false could not act as
an emergency backstop against the replay.
Detection:
Reported by a user who found the issue while reviewing the cross-chain bridge
stack (hyperdrive / IBC / token-conversion) — specifically which state-changing entry points honored
the Enabled kill switch and how inbound conversion messages are deduplicated.
Root causes:
- Primary cause — replay protection is transient and cleared on unlock. The only inbound dedup
is the
LockedConversion[account]existence check (a duplicate is silently ignored while a lock exists), keyed by account rather than by a durable message id/nonce. That state is removed once the userunlocks their migrated funds. The design also overlooked that the underlying message protocol only guarantees at-least-once (not exactly-once) delivery — by design, a message with the same nonce can be legitimately re-sent after its TTL. As a result, a conversion message replayed after the user has unlocked is no longer recognized as a duplicate and re-runsfund+hold, re-minting the migrated funds. - Contributing cause — the kill switch was intentionally limited, so it could not backstop the
replay. By design,
ensure_enabled()guarded onlyconvert, andunlockis deliberately kept callable even when disabled (so users can always release already-migrated funds); the retry extrinsics and the inboundprocess_conversionpath were intentionally left runnable so in-flight conversions could still complete. The consequence was thatEnabled = falsedid not halt the fund-creating path and therefore could not serve as an emergency backstop against the replay above. The fix adds the guard anyway, so disabling now stops every path exceptunlock:fn ensure_enabled() -> Result<(), Error<T>> {if !Self::enabled() { return Err(Error::<T>::NotEnabled); }Ok(())} - Contributing cause — the fund-creating path is origin-less.
process_conversionis reached fromMessageProcessor::process(a decoded inbound message), not from a signed extrinsic, so the replay can be triggered purely by message delivery, without any attacker-controlled account action.
Impact:
- Nature: The primary emergency control (
set_enabled(false)) did not halt the fund-creating path. While disabled, an inbound or replayed conversion message could still mint locked funds from the pallet account to the target account. - Replay window: Because inbound dedup is per-account (
LockedConversionexistence) and IBC does not guarantee exactly-once delivery after TTL, a conversion message could in principle be reprocessed to re-credit an account (e.g. one that had alreadyunlock-ed its prior lock), drawing repeatedly on the pallet account. - Exploitability / realized loss: 0 ACU. No conversion was replayed on mainnet; see "Realized loss" at the top.
unlockdeliberately unaffected: the fix intentionally leavesunlockcallable while disabled so users can always release already-migrated, legitimately-held funds;unlocktouches only the caller's own hold and mints nothing.
Theoretical worst case:
The naive ceiling was the whole pallet account, but the vesting-slash and slot-freeing mechanics throttled and taxed the attack heavily. Three nested bounds applied:
1. Gross ceiling — the till. The replay re-ran process_conversion → fund, and fund was a
plain T::Currency::transfer(pallet_account → target, amount, Preservation::Protect) — it moved
funds out of the token-conversion pallet account, it did not mint, and Protect kept the account
above its existential deposit. So every replay drew down that one account and, once empty, fund
would have failed. The gross ceiling was therefore its drainable balance. The mainnet pallet account
was PalletId(*b"tcmaipid").into_account_truncating() = 0x6d6f646c74636d61697069640000…
(SS58 5EYCAe5jXDUbcYZWt8V1v6rWXGtjSsJzuHEnpy2kXtx3N8tu); its System::Account at the finalized
head on 2026-07-23 (0xb97d1fd58bf8faaff26b33bc8ef90eb02cdcb1f0ac5c5edf57a1a559b756e0f7) held
free 6,663,034.33 ACU, reserved 0, frozen 0 → gross ≈ 6.66M ACU (for reference, 3,426
conversions totalling 58.3M ACU had been processed over 2025-12-01 → 2026-05-11; most had since
been withdrawn, leaving 6.66M in the till).
2. The slot had to be re-opened, and re-opening it was taxed. A replay to an account was deduped
while that account's LockedConversion slot existed, so to replay again the holder first had to
unlock to clear it. But unlock was gated by MinLockDuration (84 days) and applied a
vesting slash: it kept amount_factor = lock_progress / MaxLockDuration and slashed the rest to the
Treasury (OnSlash = ResolveTo<Treasury>). At the earliest possible unlock
(lock_progress = MinLockDuration), with MaxLockDuration = 1344 days:
keep factor f = MinLock / MaxLock = 84 / 1344 = 1/16 = 6.25%
So to recycle a slot for the next replay the holder forfeited 15/16 (93.75%) of the amount to the
Treasury and kept 1/16. Per min-lock cycle: pallet −amount, attacker +amount/16, Treasury
+15·amount/16. Draining the entire till this way would have netted the attacker only
≈ 6,663,034 / 16 ≈ 416,440 ACU, with ≈ 6,246,595 ACU slashed back to the Treasury (i.e. the
protocol would have recovered it — drained from the bridge but not stolen).
3. It was rate-limited, and the rate cap was strategy-independent. Net gain per replayed message
was f · amount and the wait between replays was lock_progress ≥ MinLock, so the net extraction
rate was f · amount / lock_progress = amount / MaxLockDuration — independent of the chosen lock
duration. Unlocking early (min lock) drained the gross till faster but yielded the same net
per unit time; holding to full MaxLock avoided the slash but took 1344 days. Either way an
attacker would have netted at most amount / MaxLockDuration ≈ 27% of a replayed amount per
year, and the recipient was fixed to the message's own account (funds could not be redirected), so
this was per self-owned migration, not a free-for-all.
Realistic pre-containment window. Replay only became possible ≥ MinLockDuration after a
migration (earliest unlock ≈ 2026-02-23, 84 days after the first conversion) and ended at
containment on 2026-07-20 — a window of ≈ 147 days ≈ 1.75 min-lock cycles. In that window an
attacker could have completed at most ~1–2 replay cycles per slot, extracting ≤ 147/1344 ≈ 11% of
a migrated amount per slot net (the rest slashed to Treasury).
Bottom line. Gross bridge-till exposure was ≈ 6.66M ACU, but net attacker profit was bounded
by the vesting slash to ≤ ~416k ACU (1/16, only if the whole till were cycled) with the
remainder recovered by the Treasury, and was further throttled to amount / MaxLock (~27%/yr) — so
in the realistic ~147-day pre-containment window the extractable net was a low-single-digit-percent
fraction of migrated value. As of 9b84db3d the inbound MessageProcessor::process path was gutted
to a no-op, so the replay path was closed and the exploitable amount dropped to 0 for the
duration of the containment; the bridge has since been re-activated with the ensure_enabled() guard
fix in place. The realized loss was 0: no replay was ever executed (see "Realized loss" at the
top).
Timeline:
(all times UTC+01:00, from commit history)
| Time | Event |
|---|---|
| 2026-07-20 | Reported by a user |
| 2026-07-20 10:00 | Mitigation |
| 2026-07-20 17:14 | Fix: Self::ensure_enabled()?; added to retry_convert, retry_convert_for, retry_process_conversion, retry_process_conversion_for, and process_conversion (df3f0f07) |
| 2026-07-20 18:07 | Containment: hyperdrive send_to_proxy (outbound) and MessageProcessor::process (inbound ActionExecutor dispatch) gutted to no-ops — bridge deactivated (9b84db3d) |
Lessons Learned
What went wrong
- The weak delivery guarantees of the message protocol were overlooked. The underlying transport
provides at-least-once (not exactly-once) delivery by design — a message can legitimately be
re-delivered after its TTL — but the pallet's replay protection was not built to withstand that. It
relied on transient, per-account state (
LockedConversion) that is cleared onunlock, instead of durable per-message (id/nonce) accounting. The protocol behaving as specified is not the fault; designing dedup as if delivery were exactly-once is. - The emergency kill switch was intentionally scoped to
convertonly (a deliberate trade-off to let in-flight conversions complete), which left it unable to halt the replay path. A kill switch is only as strong as the state-changing paths it actually covers.
What went well
- A defense-in-depth response was available: closing the specific guard gap (
df3f0f07) and deactivating the whole bridge (9b84db3d) as containment while the incident was assessed. - The fix correctly preserved user access to funds by keeping
unlockopen when disabled.
Where we got lucky
- The strongest fund-creating path (
process_conversion) still had the per-accountLockedConversionidempotency check and theReceiveFromsender check, which limited trivial duplication while a lock was live and constrained who could originate messages.
Conclusion
Actions taken:
- Enforced
ensure_enabled()on every state-changing conversion path exceptunlock(df3f0f07). - Temporarily deactivated hyperdrive send/receive as containment so no cross-chain action executed
while the incident was assessed (
9b84db3d); the bridge has since been re-activated. - Shipped as
0.26.3/spec_version14 (7cacdc07).
Follow-ups:
- Move inbound replay protection to durable per-message (id/nonce) dedup in token-conversion,
rather than relying on
LockedConversionexistence plus the IBC TTL behavior. - Add tests asserting that, with
Enabled = false, every path exceptunlockrejects, including the inboundprocess_conversionmessage path. - Document the hyperdrive deactivation/re-activation procedure (as used with
9b84db3d) so it can be applied quickly in future incidents.