AnnouncementAugust 26, 2026 · Ignis AI Labs · 26 min read

Umbrae Hack Postmortem

On 6 August 2026, Umbrae suffered an MEV exploit across five pools. This postmortem covers what happened, how we responded, and what we changed.

Contents

Umbrae DLMM · Base mainnet (chain 8453) · Incident date 6 August 2026

On 6 August 2026, Umbrae suffered an exploit by an MEV bot operator that resulted in a loss of 25,745.68 USDC, 0.3282 WETH and 0.01415 cbBTC across five pools. Over the following days we patched the bug, negotiated with the operator, reimbursed the majority of the affected users, and put the protocol through another full internal audit.

80% of the funds were returned by the operator, who kept 20% as a negotiated cut. A second, unrelated party who had picked up assets from the same pools returned 100% of what they held and asked for nothing in return. Of what came back, 20,230.23 USDC is already with the liquidity providers who lost it. The remainder is in final reconciliation and goes to the remaining affected holders on the same basis. Every recovered unit is accounted for in the reimbursement section below.

This is the full account: what happened and why, what we did in the first hour, how we reached the operator, how the funds came back, what has changed in the contracts since, and what we should have done better. It also carries two announcements: the new DLMM is live, and a bug bounty program is on the way.

Taken across 5 pools25,745.681346 USDC + 0.32820014 WETH + 0.01414915 cbBTC
Attack transactions86
Attack window10h 34m (07:32:23–18:07:03 UTC)
Pools pausedAll 15, at 18:25:41 UTC
Public notice posted on chain18:26:43 UTC, 62 seconds after the pause
Recovered from the operator80.00%, exact on all three assets
Recovered from second party100% of holdings, no bounty requested
Reimbursed to LPs so far20,230.23 USDC, distributed 23 August
Reimbursement in progressRemaining recovered assets, final tranche (~20% of the total)
Defect live in production141 days

What happened

Umbrae is a discretised liquidity market maker. Liquidity sits in bins, each bin has a fixed price, and LP shares in a bin entitle the holder to a pro-rata slice of that bin's reserves. The bug was in how those shares were priced.

In plain English first

When you deposit into a liquidity pool, the pool gives you shares (receipts) that you later trade back in for your slice of whatever the pool holds. The bug was in how the pool priced those receipts: it counted tokens instead of valuing them. One WETH and one USDC were each counted as "1," the way a cashier would count bills without looking at whether each one was a $1 or a $100.

Most of the time this mistake was invisible, because most bins in the pool only ever hold one token, so there is nothing to confuse. The exception is the bin sitting at the current trading price, which holds both tokens at once. There, the miscounting had a price tag.

So the attacker repeated one move, forty times per transaction: deposit a small, correctly priced mix into that bin, receive receipts the pool had over-valued, and immediately trade the receipts back. Because the receipts were priced by token count while the payout came from what the bin actually held, every round trip walked out with slightly more of the expensive token (WETH) than went in. Tiny swaps in between refilled the bin so the trick could run again. A single cycle took about $18 at first, decaying to cents as the bin drained. But it ran over three thousand times across ten hours and five pools, until roughly $26,000 belonging to liquidity providers had been siphoned out.

Everything below is the same story told precisely.

Both token reserves are stored normalised to eighteen decimal places, so that integer arithmetic is comparable across tokens with different precision. The share calculation then added them together and used the sum as the bin's liquidity:

// deployed implementation 0x76c3…Ab47
uint256 binLiquidity   = uint256(bin.reserveX) + uint256(bin.reserveY);
uint256 addedLiquidity = amountX + amountY;
shares = LiquidityCalculator.calculateShares(
    addedLiquidity, bin.totalShares, binLiquidity
);

reserveX is a quantity of WETH. reserveY is a quantity of USDC. Adding them produces a number with no meaning. Normalising decimals makes two integers comparable. It does not make one WETH worth one USDC. To compare them they must first be expressed in one unit: value in token Y at the bin's own fixed price P:

binValue     = reserveX × P + reserveY
depositValue = amountX  × P + amountY

The price factor was not missing from the protocol. The swap engine applied it. The active-bin deposit splitter applied it; that is what made deposits value-balanced in the first place. It was omitted in exactly one place: the calculation that decides how many shares a deposit is worth.

Why only one bin was exploitable

The faulty sum ran on every bin, but it only mattered on one. In a liquidity book, bins below the current price hold only the quote token and bins above hold only the base token. A deposit into a one-sided bin is single-token: you put in the token the bin already holds, and burning immediately gives back the same token. There is nothing to exchange, and the broken sum is accidentally harmless because one of its two terms is always zero.

The active bin is the exception. It is the only bin that holds both tokens at once, and three of its properties combine into the exploit:

  • Its reserve composition is whatever prior swap flow left behind.
  • New deposits are forced into a price-balanced composition by the splitter.
  • Burns return the bin's current composition, pro rata.

So a depositor could hand over one mix and immediately take back a different one, with the exchange rate between them set by the broken raw-unit sum. Every one of the operator's mints targeted the bin the pool reported as active at that moment. There were zero mismatches.

One cycle, from the real transaction data

At bin 8396168, where the bin price was 1,912.607 USDC per WETH:

DEPOSITED  →  12,053,346,550,951,932 shares
  WETH   0.001045146672489903
  USDC   1.998954

BURNED same shares, same transaction
  WETH   0.010720189952716504
  USDC   1.989278

NET  +0.009675043280226601 WETH   −0.009676 USDC   =  +18.50 USDC of value
     10.3× more WETH-heavy out than in

Raw token units were very nearly conserved: 0.009676 USDC given up, 0.009675 WETH received. Value was not. At 1,912 USDC per WETH, swapping one raw USDC unit for one raw WETH unit creates roughly 1,912 USDC out of nothing.

Writing X, Y for the bin's reserves and dX, dY for the deposit, an immediate mint-and-burn nets the depositor (dY·X − dX·Y) / (X + Y + dX + dY) of token X and the negative of that in token Y. It pays whenever the bin holds more WETH by value than a value-balanced deposit would, which is why profit decayed with every cycle, as each extraction pushed the bin back toward neutral. In the first transaction, cycle 0 returned 18.50 USDC and cycle 39 returned 0.31.

The shape of the attack

Each transaction in the opening burst was identical: 42 swaps, 40 mints, 40 burns:

1 × preparation swap        20 USDC → WETH

40 × {
     cycle swap             5 USDC → WETH
     mint(activeId, 2e18)
     burn(exact shares just minted)
   }

1 × settlement swap        all accumulated WETH → USDC

The swaps were not where value was created; every one executed at a legitimate bin price. They did two jobs. While the active bin still held WETH, the small swap sourced inventory for the next deposit. When the bin ran out, the same call crossed into the next bin holding WETH: a fresh, one-sided, essentially pure-WETH bin, the most favourable composition the exploit can encounter. Each transaction closed by selling every wei of extracted WETH back into the pool, so net WETH per transaction is zero.

86 transactions ran across blocks 49,606,098–49,625,138. Seventy-nine landed in a dense 26-minute burst on the 10 bp pool; the rest were spread across the following ten hours and four other pools.

PoolTxsNet taken
WETH/USDC 10 bp 0x93029Af6…52AEB80+25,755.497280 USDC, +0.09872491 WETH
USDC/cbBTC 25 bp 0x0703D2AD…E676b2−5.464001 USDC, +0.01384135 cbBTC
WETH/USDC 5 bp 0x584a5fe2…b937b1−2.346190 USDC, +0.18601075 WETH
WETH/USDC 20 bp 0x1375fDdA…0E5332−1.630413 USDC, +0.04346448 WETH
USDC/cbBTC 15 bp 0xe6b0Ed09…5626a1−0.375330 USDC, +0.00030780 cbBTC
Net out of pools8625,745.681346 USDC, 0.32820014 WETH, 0.01414915 cbBTC

Negative USDC on the four smaller pools means the operator paid USDC in and took the other asset out.

We had seen this mistake before, in a different bug

The part of this report we least enjoy writing, and the part we most need to get exactly right.

Forty days before the exploit, we found and diagnosed a different bug that grew from the same underlying mistake. In late June, a community member lost a few hundred dollars depositing through our own interface: the position manager's active-bin deposit math could silently reduce a deposit to zero, and the pair then absorbed the user's tokens as an uncredited donation. No attacker was involved: the user's own transaction, sent in good faith through our UI, cost them their funds. We traced it, wrote it up on 27 June, locked down the frontend path that made it reachable, and scheduled the contract fix.

That June bug and the bug exploited on 6 August are not the same bug, and we want to be careful not to blur them. They live in different contracts (the position manager's deposit splitter versus the pair's share pricing), they fail in different directions (funds stranded uncredited versus funds actively extracted), and they hurt different people (the depositor versus every LP in the bin). What they share is the root mistake: adding raw quantities of two different tokens together as if they were the same unit of value.

And here is the uncomfortable part. While writing up the June bug, we noticed the same mistake existed in the pair's share math, and we flagged it in the same report:

binLiquidity = reserveX + reserveY is the same units-conflation bug at the bin level. It happens to work for non-active bins (one side is always zero) but it is wrong on principle and will bite again… Replace with a real price-weighted invariant (L = X·P + Y). This is a bigger lift and can ride along with the work already on the v-next docket.

Read that quote carefully, because we didn't. It says "wrong on principle" and "will bite again," and then schedules the fix as cleanup work for a future release. What we did not do is ask the question that mattered: is there a reachable state where both halves of that sum are non-zero, and the wrong total decides how much a mint is worth? There was: the active bin. Forty days later, an MEV bot answered the question for us. The June report even contained the missing half of the reasoning: its main finding is specifically about the active bin's two-sided behaviour. Two pages of the same document, never joined.

So why did a finding we had written down wait for a future release? Because remediating it properly is expensive in the only currencies an early-stage team has: time and momentum. A contract fix is not an edit. It is an audit, a redeployment, a migration and weeks of verification; the version we eventually shipped after the pause took the better part of a month, all of it taken from the momentum we were building. Faced with a note that read "wrong on principle, harmless in practice," we made the calculation many teams make: fold it into the next scheduled upgrade and keep going.

That calculation was wrong, and it is worth being precise about why it felt right at the time: we did not foresee the mint-and-burn loop. Depositing and immediately withdrawing in the same transaction, hundreds of times an hour, using the pool's own share math as the exchange rate. It is a genuinely clever way to drain a pool, and it was not on our list of things that line of code could do. In hindsight, the correct call on 27 June was to pause the pools and fix everything then, at the cost of that momentum. We ended up paying the cost anyway in August, with our users' funds added on top.

The failure was triage, not diagnosis. "It happens to work for non-active bins" was true, and we treated it as mitigating, when it was actually a description of exactly where the bug was waiting. The rule we have taken from it: a finding whose severity rests on "the dangerous input never reaches this line" must name what enforces that, and be re-rated as if the enforcement is absent.

The new stack fixes both bugs: the share-pricing defect that was exploited in August, and the June donation bug whose report had pointed at it.


How we stepped up immediately

We paused every pool. At 18:25:41 UTC, a single transaction from our 3-of-4 Ops Safe paused all 15 registered pairs. The choice that mattered was which pause. Two levers existed: the protocol-wide emergency pause reverts every call on every pair, including burn; it would have stopped the operator and frozen every LP's exit at the same time. The per-pair pause blocks swaps, mints, flash loans and fee compounding while leaving withdrawals and fee claims open.

We used the per-pair pause. It stopped the attack without trapping anyone, and LPs have used the open exit since. The protocol-wide pause remains deliberately inactive for the same reason.

We posted a public notice on chain, 62 seconds later. At 18:26:43 UTC, from the same Safe that had just paused the pools:

I'm Elijah Moses, CEO of Ignis AI Labs (Umbrae DLMM, Base). We've fully traced this wallet's mint/burn extraction on our WETH/USDC pool… We're treating this as a whitehat action and will negotiate a bounty for return of the remaining funds. Bug is being patched, pools paused. Terms open.

Signed, named, from an address whose control we could prove, one minute after containment. That message is what made everything in the recovery section possible.

We contacted SEAL 911. SEAL 911 is the Security Alliance's emergency hotline for live crypto incidents: a volunteer network of security researchers you can page when something is actively going wrong on chain. Their guidance shaped how we handled the response: what to contain first, how to reason about blast radius across a shared beacon, and how to think about the difference between stopping the bleeding and trapping the people we were trying to protect.

The hardest part of the first hour was not the arithmetic. It was deciding which lever to pull while incomplete information was still arriving, knowing the wrong one would have frozen every LP's exit alongside the operator's. Talking that through with people who had seen the shape before is what turned a judgement call into a decision we could defend. They owed us nothing, and they answered anyway.

We opened a full internal analysis. Within twenty-four hours we had traced every transaction to the bin, produced a first accounting, and taken the decision that shaped everything after: the compromised stack would not be repaired and reopened. Correcting the arithmetic does not correct the state: the operator's cycles left over-minted share supplies on the attacked bins, and code cannot un-mint history. The pools would stay paused and withdrawal-only, and a fresh stack would be built and deployed instead.

What we should have done better: we were too slow to pause

Containment, once we were moving, took minutes. Detection is what took hours. The attack ran for ten and a half hours before the pause landed, and while seventy-nine of the 86 transactions were packed into a 26-minute burst, the first extraction came much earlier, and nothing paged a human being. By the time the burst was visible to us, the bulk of the damage was already done. That gap is ours, and it is the part of the response we are least satisfied with.

We have changed the protocols so it cannot work that way again. Pool activity is now under automated monitoring with alerting on anomalous mint/burn patterns, so an extraction cycle raises a human immediately instead of surfacing in a review afterwards. And the pause decision no longer depends on assembling the right people ad hoc: there is a documented runbook, a defined on-call escalation path, and a pre-authorised route to the per-pair pause we used on 6 August. The judgement call we got right in the first hour should not have needed the first hour to get to.

And beneath the response layer sits a stronger guarantee: the specific bugs behind this incident are now impossible by construction, at any response speed. The share-pricing fix means a mint followed by a burn can only ever return less than was deposited. The extraction loop is not merely patched against this attacker's transactions; it is arithmetically excluded, enforced by the contract itself and proven on a fork of live Base state, where the same loop that returned 74.54× under the old code now leaves the attacker poorer. The June donation path fails loudly instead of silently absorbing funds. The anomaly monitoring then sits on top of all of that as a just-in-case layer, not because this exploit can recur, but so that if anything we have not foreseen ever starts behaving strangely, a human is paged in minutes instead of finding out hours later. The code removes the prize; the monitoring watches for whatever we haven't thought of.


How we contacted the operator

Every message in this section is on chain and anyone can read it. Nothing was agreed privately.

On 7 August we sent a message directly to the operator's address in transaction calldata, and mirrored it on Blockscan:

Hello Whitehat, we've seen that you've exploited a vulnerability in us and rescued the user funds from us. We are looking forward to you sending back the funds for a little bounty amount as reward… Please reach out.

Five days later, they replied:

I've received your message. I'm glad to refund you regarding this incident.

  1. Please provide on-chain evidence to prove that 0x753d36… is the owner of, or a critically related party to, 0x93029Af6….
  2. Please provide the exact related transactions (there appear to be multiple) and the total amount of funds involved.

As a general MEV bot, this type of refund can be considered a form of MEV protection. In line with common MEV protection practices, I am prepared to refund 80% of the total funds.

Both requests were reasonable and we met them.

A note on terms, because accuracy matters here. We called the operator a whitehat in our opening messages, on chain, and those messages stand as sent. But that word is not accurate, and we do not want this report to repeat it. A whitehat finds a vulnerability and reports it without touching user funds. What happened on 6 August was something else: the exploit was executed first, against live pools, and the conversation about returning the money started afterwards, with the return conditioned on keeping 20%. The space has a name for that: a gray hat, someone who runs the exploit and then negotiates the funds back minus a cut, using the funds themselves as leverage to guarantee their payout.

We engaged on those terms for one reason: it was the path that ended with 80% of user funds coming back instead of none. That is a pragmatic decision about recovery, not an endorsement of the practice. Nobody should read this report as approval of exploiting a live protocol and negotiating afterwards, including by us. The legitimate way to disclose a vulnerability to Umbrae is the bug bounty program announced at the end of this report.

Ownership was proved by privileged action, not by assertion. Anyone can claim to be a protocol. Very few people can point at a transaction where the protocol's own factory accepted a privileged call from the address doing the claiming. The same Safe that was asking for the funds had paused all 15 pairs in a transaction anyone can verify, and the factory accepts that call only from its designated emergency pauser.

The accounting was sent so they could rebuild it without trusting us. We sent the full 86-transaction, five-pool table from the Safe itself, along with the block range, the token addresses and the log filters, and told them to enumerate it independently rather than take our word for the number.

They did, and found a pool we had missed. Our first accounting had covered only the WETH/USDC 10 bp pool; they pointed out that the cbBTC pools had also been touched. The five-pool table above is the corrected figure. Their correction increased the amount they owed us, and we think that is worth stating plainly.

We accepted the 80/20 terms, restated the corrected totals, and named a single destination address with an explicit instruction not to send anywhere else.


The return of funds and reimbursement

The operator returned 80% in four transactions on 22 August, exact to the last unit on all three assets. They sent a dust transfer of 0.00000011 WETH first to confirm the address, then the balance.

AssetReturnedShare of taken
USDC20,596.54507780.00%
WETH0.2625601180.00%
cbBTC0.0113193280.00%

The remaining 20% (5,149.14 USDC, 0.0656 WETH and 0.0028 cbBTC) was retained by the operator as the agreed cut. That was the negotiated cost of getting the other 80% back, and it is not recoverable.

A second party returned 100%, and asked for nothing. Separately, an unrelated address contacted us holding roughly $549 of assets from the same pools:

We currently hold the following assets connected to the affected Umbrae pools and would like to arrange their full return. We are requesting no bounty and will return 100%.

They asked us to confirm the destination from our own wallet first, which we did. Four transfers followed on 21 August: 0.00721204 cbBTC, 0.045819766489148727 WETH, 0.00003952 WBTC and 0.09624 USDC. Then a message we appreciated more than the money: "we're glad we could help get the funds back to the team."

We are not going to pretend an exploit is a favour, and we are not going to dress up a negotiated 20% cut as one either. But the record should be accurate about both parties: the operator engaged on the terms they stated, corrected our accounting against their own interest, and returned exactly what was agreed. The second party simply returned everything, which is what responsible handling of someone else's funds actually looks like.

Reimbursement: exactly where every recovered unit stands

On 23 August we distributed the bulk of the recovered USDC to the affected liquidity providers, in two transactions from the Ops Safe:

RecipientAmount
0x92c1fc338a32083b139fdc098ae4ab7658c065e910,210.15 USDC
0x6adaa4154092fb118eb73931f82512c8b75c92596,550.79 USDC
0xeb1d06c2d9439225bf14491c84e4755ab0471fd33,469.29 USDC
Total distributed20,230.23 USDC

The largest recipient is the provider who had deposited 5.398 WETH and 10,144.53 USDC across 51 bins two and a half hours before the attack began, directly inside the range the operator traversed.

Everything that came back sits in exactly one of three places:

  • Distributed to LPs: 20,230.23 USDC, per the table above: 23 August, done and on chain.
  • Held in the Ops Safe, in final reconciliation: 366.41 USDC (the undistributed remainder of the operator's return, plus the second party's 0.09624), 0.30838 WETH, 0.01853 cbBTC and 0.00003952 WBTC. This final tranche (roughly a fifth of the reimbursement) goes to the remaining affected holders on the same per-bin loss basis as the first distribution. We will publish the transactions when they land, as we did for the first round.
  • Retained by the operator: 5,149.14 USDC, 0.0656 WETH and 0.0028 cbBTC, per the negotiated terms described above.

We want to be direct about what that means: every holder's recovery comes out of what was returned, and 20% of what was taken is not coming back. What we control is that everything we did recover goes back to the people it was taken from. Most of it already has, and the rest follows as soon as the reconciliation is final.

If this happens to you

Three things made this work, and none of them were clever.

Reach out early and in public. Our first notice went out within a minute of the pause, in calldata, from an address we could prove control of. It cost nothing and it opened the only channel that mattered.

Prove ownership by action, not by assertion. A transaction where your own contracts accepted a privileged call from you is worth more than any amount of explaining.

Publish an accounting the other side can check without you. An accounting that can only be verified by trusting you will not survive contact with someone who has no reason to.


What we fixed

Shares are priced by value, in one unit

The common unit is token-Y value at the bin's own fixed price, normalised to eighteen decimals. One function answers that question for both sides of the share division, and every caller uses it:

// bin side: ceil the X leg so existing value is never understated
binLiquidity = (reserveX * priceNormalized) / PRECISION;
if (mulmod(reserveX, priceNormalized, PRECISION) != 0) binLiquidity += 1;
binLiquidity += reserveY;

// deposit side: floor the X leg so deposited value is never overstated
addedLiquidity = (amountX * priceNormalized) / PRECISION + amountY;

Rounding is deliberately directed against the minter at all three steps: the bin's existing value rounds up, the incoming deposit rounds down, and the share division floors. A mint followed immediately by a burn can now only lose dust. It can never gain. That is the property whose absence cost 25,746 USDC.

Three supporting changes make the unit trustworthy: there is now one canonical price function (an older helper truncated to zero for any whole-token price below about 1e-6 with a six-decimal quote token, and is now view-only with zero call sites); valuation fails closed at the extremes rather than substituting a cap, because clamping was tried and drained the other LPs in the bin; and the bin struct no longer carries its own share counter, so there is a single record of share supply instead of two that could drift.

One definition of a valid bin

Minting and swapping used to disagree about which bins were legitimate: minting checked only that a bin id sat inside a very wide band, while swapping refused any bin whose price had saturated. Liquidity could be deposited into a bin the pool could never trade through. There is now a single predicate, called at the top of the mint loop before any state is written and by the swap path in place of its own checks.

A swap cannot move the price arbitrarily far

A separate Critical, found in our own audit sweep and fixed before any redeployment. The guard on how far a swap could walk bounded a bin count, which is not a bound on anything economic: at a 100 basis point bin step, one hop already moves price 1%. An attacker could plant a single bin at an absurd price and have an ordinary trade fill against it; measured worst case, 0.0000014 of the quote token planted captured 99.94% of a 1,000-token trade. The bound is now on price movement, not hop count, and it applies regardless of what minimum output the caller passes. The two unbounded legacy swap entry points now revert.

Mint accounting is strict, and surplus returns to whoever paid it

The old mint accepted any balance at least as large as expected and kept the difference as an anonymous donation. Mint now computes the exact expected balance, reverts if less arrived, and refunds any surplus to the economic payer rather than to msg.sender, which under the old behaviour sent a router user's surplus to the router.

Structural changes

  • A pair nobody can quote can no longer exist. Extension wiring is now atomic inside pair creation, which reverts if no default extension is configured.
  • The compromised stack cannot be revived by accident. A deployment guard hard-codes the incident addresses and every deployment and upgrade script asserts against it before broadcasting. There is no bypass.
  • An emergency pause no longer traps LPs. Its replacement blocks only the value-moving doors and delegates everything else to the live implementation, so withdrawals, fee claims and all views keep working. A protocol-wide pause also expires by itself after seven days, so a lost key cannot freeze trading forever.
  • The governance delay is a compile-time constant. It was a constructor argument, and it shipped as 600 seconds while our documentation claimed 48 hours. The new timelock's constructor takes only the multisig address, so there is no delay parameter to pass wrongly. A change to pair code now needs at least 96 hours of public, indexable notice.

For other builders: how to avoid this class

This exploit was not simple, but every link in its chain was avoidable. The five rules that would have broken it:

  • Normalising decimals is not normalising value. Two integers at eighteen decimals share a precision, not a value. Anywhere reserves of two different tokens are combined into one number, the combination must happen in a single unit, priced at the relevant rate. That rule has no exceptions for "internal" accounting like share pricing.
  • Test the round-trip property, not just the arithmetic. The one-line invariant that catches this bug class: a mint followed immediately by a burn must never return more value than was deposited. Fuzz that across bin compositions, with special attention to any state that holds both tokens at once.
  • Find the place where both assets coexist, and interrogate it. In any bin- or book-based design, one-sided states make broken two-sided math look correct, because one term is always zero. The state where both sides are live (here, the active bin) is where those assumptions die. If a code path "happens to work" everywhere except one reachable state, it does not work.
  • Rate findings by reachability, not by the path the reviewer was reading. A severity that rests on "the dangerous input never reaches this line" must name what enforces it, and the finding should be re-rated as if the enforcement were absent.
  • Give every state a single definition of validity. If two entry points (here, mint and swap) disagree about which states are legitimate, value can be parked where the protocol can never touch it. One predicate, called everywhere.

The audit that followed

After the fix landed we put the whole stack through another full internal audit, our tenth review to date. It ran as a code-truth manual review across roughly 14,600 lines of contracts, with eight parallel specialist reviewers working separate surfaces and every finding required to carry either a numeric trace or a passing proof-of-concept. Comments in the code were treated as untrusted; where a comment and the code disagreed, the code won.

It found eleven issues, including one Critical that was not the incident bug (the swap price-distance flaw described above). That one was fixed before any redeployment. The rest were fixed, deleted, or accepted with a written reason.

We also wrote the incident's own bug class into a static scanner that now runs nightly against the whole repository. Its first rule is named for this exploit and its description says so explicitly. The README of that tool opens with the reason it exists: this repository was exploited through a publicly-known vulnerability class that nobody had scanned the code against. This tool exists so that sentence is never written again.

Verification

The full suite is 1,518 unit tests and 28 fork tests, all passing. The two measurements that matter are both fork tests run against live Base state:

  • Against the deployed vulnerable implementation, the loop still works: the first cycle returns 74.54× the deposited value. The control, the same loop on a non-active bin, returns 1.00× every time, which is the cleanest confirmation that the two-sided bin is the precondition.
  • Against the fixed implementation, at the same bin step and live market bin, the attacker ends $0.0096 poorer over six cycles. An honest LP's round trip returns exactly 1.0000×, and the honest bin retains 98.8% of its liquidity against 5.1% under the vulnerable code.

Where things stand

The compromised stack. All 15 pairs remain paused, verified by direct storage read. Withdrawals and fee claims are open and both WETH/USDC pools now hold dust. The implementation is unchanged and will not be upgraded. There is no deadline on any existing position. The legacy stack stays withdrawal-only throughout, so anyone still holding a position there can exit on their own schedule.

The replacement: the DLMM is live. A fresh stack was deployed to Base mainnet on 20 August on new addresses, behind a 48-hour timelock, with the beacon owned by that timelock and the emergency pauser able to pause but not to configure or upgrade. With this announcement, the new DLMM is live: pools are open for liquidity and trading on the new contracts, carrying every fix described in this report. Umbrae is back in flow.

Reimbursement completes. The recovered assets still held in the Ops Safe are in final reconciliation and go out on the same basis as the 23 August distribution. We will publish the transactions when they land.

A bug bounty program is coming. We are setting up a permanent bug bounty program so that the next person who finds a vulnerability in Umbrae has a faster, better-paid, and legitimate route to us, one where no user's funds ever move. The specifics (scope, reward structure, and the disclosure process) are still being worked out and will be published separately when they are final.


Moving forward

Our goal is to make Umbrae as secure as the protocol deserves to be. We are an early-stage team and we are not going to pretend we had the security budget of a mature protocol on 6 August. What we could control was how we responded, and even there we were slower to detect and pause than we should have been, and we have said so plainly and changed the protocols behind it. What we have tried to do is make every part of the response verifiable rather than asserted.

As TVL grows and funding allows, we will bring security in-house: a dedicated security team, and external audits on a yearly cadence rather than per-release. Until then, the changes described above are what stands between this bug class and our users, and they are all readable on chain.

To SEAL 911, and to the second party who returned everything and asked for nothing: thank you.

Any errors in this report are ours alone. Corrections are welcome and will be published as further revisions.


Appendix: on-chain references

Contracts

RoleAddress
WETH/USDC 10 bp pool0x93029Af62bDCb11a4f71a9BDF1D8bb092c052AEB
USDC/cbBTC 25 bp0x0703D2ADC33d2C6d7b5ec2f72f535bF6aEAE676b
WETH/USDC 5 bp0x584a5fe2EF1bE44e9e8Dd0e6720C74F7cffb937b
WETH/USDC 20 bp0x1375fDdA808645DdE5f6B0623815F255a8D0E533
USDC/cbBTC 15 bp0xe6b0Ed09C323C34E55c1b468ab84E9417F85626a
Vulnerable pair implementation0x76c325266E38fC92DBfE244876bA66343B00Ab47
Shared beacon (legacy)0xa1BF1667bE1Dab835E224CDA666728f3e5194496
Factory (legacy)0x17Da44dcbdffD8c715be7A368E19F252C2940Fee
Ops Safe (3-of-4)0xa01f8cAc471ec71EE48E8Fe085B3FD8571e70A13
Timelock (new, 48h)0x9BF658140EFdad76eF12238Ff5140BB39100FAB8
Factory (new)0x67C34eA976De4e66FF791c1Ee10A6A671c0641cf
Beacon (new)0xbD2215ae818bF63D531B3f29B25f293f61E12b6F

Incident and response

EventHash
LP deposit, block 49,601,8040x21f12d315893bc15b43edf65d3d89fbe3c65541faf215ab2ce4c6e9a45779c85
First attack tx, block 49,606,0980xbcb84074b85a6180a7c31a1da27cf3b2418524066ccdda804001059fd05aa697
Pause all 15 pools, block 49,625,6970x03dfcc3c57f7f6d5e270feb246ab2e3c415fe4166b06516f528bdcb4daee2197
Public on-chain notice, block 49,625,7280xb017ad5990d386aa9380fa240414a8a5a965dbc7e734b6caabfc43b3b48d98da

Negotiation and returns

Message or transferHash
Us → operator, first contact0x82701016fae6bf360371c2f6fbeeb7b5a9fed209a43b4d2d4bbb5cb908121d67
Operator → us, 80% offer0x5bde08d5c08c9c5207fe7a7d5ab787ef8095c4d384fabc012d1f0e113182ecea
Ops Safe → operator, accounting and ownership proof0xc3b80469d37071bc09ef49d5fcf3ca8df824e558674a1c72bab0272011b055b6
Us → operator, terms accepted0x8ac4227a33fe9b532ece51997fb8e66168a5c0bc035c2b46830f72a54201a3a3
Return: WETH (test transfer)0xd5d3946b816df5977cd499343e3da076b989c65644a8f877d8731e2dd387cfc8
Return: WETH0x785d5d7e75402ed50f597b588c7e7fe6409f0c48e1264a0bc135d86476bcedc9
Return: cbBTC0xb0d2b204341f57918bb2ec84cae49d1612369821fc69e8bd2fcb27edf81dca09
Return: USDC0xeffc6a86a5000c13baa17f1bd2f0baa0eed4da2bbd53b8ba10eb164c80a5c85c
Second party → us, offer to return 100%0xd27062e065e32dc10bb1952737a9f8e9b79eaa300e0bbde96fc33eab211cd9e6
Us → second party, address confirmation0x70c23c2cdf5d9e02242f6eae2843c8a8f2b82b8be97ed78b0c099664102f5882
Second party → us, returns complete0x56ad8e8bc1fe0c6d031c3825ae84b8aedd971ad0b04d6bfcdd254271f7e8c75e
Second party → us, transaction list0x281fb0b3c52f2b42be330f344aa9ff9870c3aadd78273a78d43b0011358687fe
Return: cbBTC0x61ab5fc0e69e3e65303d91572e8ccfbb66b155bcb7a9e20cb28521ab9bea14d7
Return: WETH0xe3ed8e7858453d46cc99a0841ae33cf243dfef2a8ec4d4b2abd25909b63c73ea
Return: WBTC0xace2f6bfb2785a3bc5c749b1aab98612ecabf574a1a19920ace3506214879e60
Return: USDC0x4eeff5eff340915a47ca2837f47fe8cbafa86c82e231b0a9c3ae20e7ce2786d4

Addresses involved

Observed roleAddress
Executor contract (5,962 bytes, selector 0x98651688)0x74513519689b1fb427747624a4dd87b3849d39cd
Operator: transaction origin, returned 80%0x2352a1fca90182509dca9c12b2cad582a38e8b82
Second party: returned 100%0x989db151b127ce734b090c645e15c588bb1e4a3f

Roles above are inferred from observed on-chain behaviour. This report does not attribute any address to a real-world person or entity.

Verification method

Figures in this report are derived, in order of precedence, from ERC-20 transfer deltas, per-bin reserve and liquidity events, swap start and end bin ids, and direct contract calls at historical and current blocks. Where source and documentation disagreed, chain state was treated as authoritative. Containment state was re-verified for publication by reading the pause flag directly from pool storage rather than through a function call.