ADR 028: Exploit Conciliation
Status
Accepted
Context
An exploit drained L1 assets from a compromised Asgard vault. THORChain's pool accounting still reflects the pre-exploit balances, creating a gap between what the protocol believes it holds and what is actually present on the underlying chains. Without corrective action, the mismatch would cause incorrect swap pricing, solvency failures, and unfair loss distribution.
The affected pools hold LP positions from the POL reserve, synthetic assets credited to the reserve module, saver LP positions (one-sided LPs backed by synths in Asgard), and regular LP positions held by a treasury multisig in several zero-saver pools.
Decision
Apply a one-time conciliation migration (Migrate15to16) that reduces pool accounting to match on-chain reality through a priority waterfall:
-
Vault zeroing — zero the compromised vault's keeper record so the protocol no longer accounts for assets it does not hold.
-
POL haircut (reserve-first, layer 1) — reduce
pool.BalanceRuneandpool.BalanceAssetfor affected pools by the reserve-bucket fraction of each shortfall, capped byreserve_pool_capacity— the maximum pool-depth reduction the reserve can fund without bleeding into LP value. Any residual past this ceiling is left for steps 3–4.5 below. The freed RUNE from the depth cut is disposed viasplitFreedRune: half is burned (Asgard → thorchain →BurnFromModule, with amint_burnevent) and half is retained in the Reserve module (Asgard →ReserveName). The odd 1e-8 unit, if any, goes to the burn.pol.RuneWithdrawnis updated to keepCurrentDeposit()accurate.RUNEPool.ReserveUnitsis re-solved once, at the very end of the waterfall (see "POL provider-whole solve" below), so that regular providers are held whole and the reserve absorbs the full loss. -
Reserve synth burn + self-neutral paired depth cut — burn synth balances held by the reserve module, and then immediately mutate the corresponding pool to keep the burn value-neutral for LPs. The bank-side burn alone would shift the freed synth claim to the LP side on the next
CalcUnitsrecompute, inflating LP unit value above the pre-burn target; to prevent that, the migration reducespool.BalanceRuneandpool.BalanceAssetviaadr028PairedCut— a closed-form derivation in two regimes:- Overhang regime (
2A > S − X):r = (T(S − X) + 2AL) / (2AT), whereT = L + SU(pre-burn),A = BalanceAsset,L = BalanceRune,S = synthSupplybefore burn,X = burnAmt. - Non-overhang regime (
2A ≤ S − X):r = (S − X) / S.
The freed RUNE from this paired cut is disposed via
splitFreedRune(burn-half + reserve-half), matching step 2's pattern. - Overhang regime (
-
Savers haircut (runtime overflow solve) — reduce every saver LP record pro-rata by a basis-point amount and reduce the synth vault pool's
LPUnitsandBalanceAssetby the same proportion; the freed synth supply is burned from Asgard. The basis points come from two sources:- For pure-savers pools (no step-2 entry, e.g. AVAX.USDC, GAIA.ATOM) the tabled
step3SaversHaircutBpsis used as-is. - For pools that already absorbed steps 1/2 + 4.5, the migration calls
adr028SolveOverflowBpsat runtime to derive the savers-side bps from the realized post-step-2 pool state, so the total pool units land at the DLP-neutral targetT_final = R_current · T_orig / R_orig. This overrides the tabled bps and absorbs the exact residual the reserve-first layers (POL + reserve burn) could not cover. The solver handles twoCalcUnitsregimes (clamped/overhang inversion vs non-overhang) and clamps the result at10000bps.
The pool-level
LPUnitsdeduction is derived from the exact sum of per-LP deductions to preserve the invariantpool.LPUnits == Σ lp.Units. If Asgard holds fewer synths than the computed burn amount, the burn is capped to the available balance and each LP's unit reduction is derived from the actual burned amount (lp.Units × burnAmt / BalanceAsset), so the unit and asset reductions stay proportional and value-per-unit is preserved even on the capped path. - For pure-savers pools (no step-2 entry, e.g. AVAX.USDC, GAIA.ATOM) the tabled
-
Treasury LP draw — for zero-saver pools where the treasury multisig holds LP coverage, reduce the treasury's LP position, the pool's
LPUnits,BalanceRune, andBalanceAssetby the residual shortfall. The freed RUNE is disposed viasplitFreedRune(burn-half + reserve-half). -
Stuck streaming swap — settle the compromised
ETH.DAI→ETH.USDTstreaming swap (tx5071A7…, 484/1182 sub-swaps done) and remove itsStreamingSwaprecord and swap-queue item so the remaining sub-swaps never execute. Two amounts are owed to the user:StreamingSwap.Out— the ETH.USDT already produced by the completed sub-swaps. The ETH.USDT pool was already debited for it; it simply never paid out because the swap never completed. It is scheduled as a normal ETH.USDT outbound from a live Asgard vault. Unlike a queued swap, an outbound persists in the outbound queue until ETH signing resumes, so it cannot be silently dropped during the exploit-response halts. The amount is read live from the record (trading is halted at upgrade height, soOutis final). This step is best-effort: if the destination is unknown or no active vault holds enough ETH.USDT to schedule the outbound (e.g. vault2v28was zeroed in step 1 and held it), the migration logs and skips it rather than aborting the upgrade, and the treasury covers this portion off-chain too.TradeTarget − Out— the unfulfilled remainder of the user's guaranteed minimum output. Its input DAI was stuck in the drained 2v28 vault and cannot be produced from the pool. The treasury refunds this to the user off-chain from its own funds and follows up for reimbursement from the reserve separately, so the migration moves no funds for it — it only logs the amount owed. (It is also not queued on-chain: a queued swap is consumed later inEndBlockunder full swap-handler validation, which would abort it while trading is halted or streaming swaps are paused during the exploit response, silently leaving the user uncompensated.) The amounts, beneficiary, and original tx are logged on-chain for auditability.
Regular (non-saver, non-POL) LP positions are not touched at any step.
Detailed Design
Waterfall ordering
Each step is applied only to pools where prior steps left a residual. Allocation flows in reserve-first priority order:
- The reserve's POL share absorbs the loss first, capped at
reserve_pool_capacity(step 2). This is the strict-waterfall layer 1. - The reserve's synth holdings absorb next, via the bank-side burn + the self-neutral paired depth cut (step 3).
- Savers absorb any remainder. For pools that already passed through layers 1–2, the basis points are solved at runtime by
adr028SolveOverflowBpsso the total pool units land at the DLP-neutral target (step 4). Pure-savers pools without a layer-1/2 entry use a tabled bps figure. - Treasury LP positions absorb the residual in zero-saver pools (step 5), in lieu of step 4.
Regular (non-saver, non-POL, non-treasury) LP positions are untouched at every layer.
RUNEPool.ReserveUnits solve (after all pool writes)
providerValuePre is captured before any pool write. After every pool write in the waterfall (POL haircut, reserve synth burn, savers haircut, treasury draw) has been applied, runePoolValue is re-read for vPost. Because the reserve synth burn (step 3) and the savers/treasury mutations (steps 4–5) also change the value of pools that hold POL, the solve is deferred to the end of the waterfall rather than performed immediately after the POL haircut — otherwise vPost would be stale and providers would be over-compensated. The new ReserveUnits is solved so that providers' share of the post-waterfall pool value equals their share before the haircut:
newTotalUnits = PoolUnits × vPost / providerValuePre
ReserveUnits′ = newTotalUnits − PoolUnits
This arithmetic is performed with GetUncappedShare (round-half-up) and SafeSub (clamps to zero). If the haircut exceeds the reserve entirely, ReserveUnits is set to zero.
Consensus version
ConsensusVersion is incremented from 15 to 16. The migration is registered via cfg.RegisterMigration(ModuleName, 15, m.Migrate15to16).
Amounts
All haircut amounts are derived from a live-chain snapshot and must be re-verified at upgrade height before release.
Consequences
Positive
- Pool accounting accurately reflects on-chain holdings, restoring correct swap pricing and solvency checks.
- Loss is allocated in priority order: reserve POL first, then reserve synths, then savers, then treasury — regular LPs bear no loss.
- Providers in
RUNEPoolare held whole via theReserveUnitsre-solve. - Saver LP invariant (
pool.LPUnits == Σ lp.Units) is explicitly maintained. - The stuck streaming-swap user is made whole: the ETH.USDT already produced by the completed sub-swaps is paid on-chain as an outbound, and the unfulfilled remainder of their guaranteed minimum output is refunded off-chain by the treasury.
Negative
- Savers in affected pools absorb a haircut proportional to the residual shortfall after POL and reserve synth coverage is exhausted.
- Treasury LP positions in zero-saver pools are partially liquidated.
Neutral
- The migration runs once at upgrade height and is not reversible.
- Snapshot amounts must be refreshed at the actual upgrade height; using stale figures would either under-correct or abort the migration.
- Freed-RUNE disposition is split, not burn-only. Each pool-depth reduction (steps 2, 3, 5) routes half of the freed RUNE to the Reserve module via
splitFreedRuneand burns the other half. At the snapshot height this retains approximately 5.89M RUNE in the Reserve rather than removing it from supply. This is a policy decision documented here for governance review — it changes RUNE supply outcomes versus a full burn and should be explicitly referenced in any upgrade proposal that approves this migration.