
Date: August 25, 2026
Chain: Stellar (Soroban)
Loss: 717,518.92 USDC drained from the Comet BLND-USDC pool
Class: same-token swap that let a pool's recorded reserve drift from its real balance
At 03:25 UTC on August 25, 2026, a new Stellar account was created, and seven minutes later it received 30,282 USDC from a Binance withdrawal account. Between 03:51 and 04:44 UTC it called a contract function it had named flash_seed 36 times, in rounds like one at 04:08 that borrowed 530,000 USDC from a Blend lending pool and repaid it before the transaction closed. By 04:54 UTC it had sent 747,800.92 USDC to a NEAR Intents deposit address, which left it 717,518.92 USDC ahead.
The money came out of the Comet BLND-USDC pool, the weighted AMM pool whose LP shares make up the backstop of Blend, a lending protocol on Stellar. Comet's swap functions accepted the same token as both the input and the output. When they did, the pool updated two copies of one reserve record and saved the second over the first, so after each such swap the reserve the pool used for pricing no longer matched the USDC it actually held.
Comet is a Balancer-style weighted pool written in Rust for Soroban, Stellar's smart contract platform. StellarExpert lists the BLND-USDC pool at CAS3...VEAM as a CometDEX pool built from the public comet-contracts-v1 source, created on May 2, 2024 by the account that issues BLND. Blend uses shares of this pool as the insurance layer behind its lending markets: backstop depositors hold Comet LP shares, and those shares absorb bad debt if a Blend pool takes a loss.
That role is why the pool's health mattered beyond its own traders. A weighted pool prices every swap from the balances it records for each token and the weights assigned to them, so those recorded balances are the whole basis of what the pool will pay out.
From the comet-contracts-v1 repository on GitHub, as the swap code stood at the May 2, 2024 release commit, these are the parts of execute_swap_exact_amount_in that read and write the reserves:
Further down, after computing the output amount, the function updates the input record:
Then, after a balance check, the output record:
And after transferring the tokens, it saves both:
The opening checks reject a frozen pool, a zero amount and negative limits, and nothing else about the two token addresses. The function then loads the reserve record for token_in and, separately, the record for token_out. If a caller passes USDC as both, in_record and out_record are two copies of the same USDC record. The swap adds the incoming amount to the first copy and subtracts the outgoing amount from the second, and then saves both under the same key. The second save wins, so the stored USDC reserve ends up lower by the amount paid out, with no credit for the amount paid in, even though the contract really did receive the deposit.
The pull request that later fixed it describes the same mechanism in plain terms: both records originate from the same map entry, they're mutated independently, the second Map::set overwrites the first, and the pool's recorded reserve can diverge from its actual token balance. Each self-swap pushes the recorded USDC reserve down while the real USDC stays in the contract. A weighted pool reads a smaller USDC reserve as scarcer USDC, so a trader swapping into the pool afterward gets a price set by a balance that no longer exists.
The lesson for builders is that a function written for two distinct things has to say so. Think of a bank teller who processes a transfer from your checking account to your checking account by writing two separate slips, one crediting the deposit and one debiting the withdrawal, then filing only the second. The pool's math depended on the rule that the recorded balance always equals what the contract holds, and a one-line check that token_in and token_out differ was the only thing protecting it.
Horizon shows the attacker's account, GCENJ4...WYHC, opening trustlines for USDC and BLND at 03:27:29 UTC, receiving the 30,282 USDC at 03:32:10, creating five helper accounts and deploying its own contracts between 03:46 and 03:49. The first flash_seed call landed at 03:51:02 UTC in ledger 64112340, and the calls kept coming until 04:44, alternating with a collect_seed function the attacker called 34 times.
One round, at 04:08:19 UTC, shows the pattern. The transaction opened by moving 530,000 USDC from a Blend lending pool into the attacker's contract at CBCY...44HM. The contract sent USDC into the Comet pool three times, 230,512.70, 173,014.28 and 230,685.71 USDC, and took USDC out three times, 172,495.24, 125,110.66 and 377,813.29 USDC, while 8,005,824.92 BLND left the pool and came straight back. A second attacker contract then returned 530,000.0000001 USDC to the Blend pool before the transaction closed. In that single round, the Comet pool paid out 675,419.20 USDC against 634,212.70 USDC in, a net 41,206.50 USDC, and flash_seed ran 36 times in all.
At 04:49:28 UTC the account sent 500,000 USDC to a NEAR Intents deposit address, and at 04:54:08 it sent another 247,800.92 USDC to the same address. After subtracting the 30,282 USDC it started with, the attacker came away with 717,518.92 USDC. StellarExpert now shows the pool holding about 392.75 BLND and 0.61 USDC.
Blend posted at 05:42 UTC that there was an ongoing incident with the BLND-USDC pool on Comet, that Blend's own contracts had no issue and that funds deposited in Blend pools weren't at risk. Later that day it clarified that because Blend backstops are made of Comet BLND-USDC LP shares, backstop depositors were affected, and said more information would follow. As of October 20, 2026, Blend hadn't posted a further update on X.
That evening, an on-chain message flagged by Defimon showed the YieldBlox Security Council offering the attacker a 15% white-hat bounty if 85% of the funds came back within 72 hours. News coverage citing DefiLlama reported that Blend paused its backstop pool and that Stellar's DeFi TVL fell from about $270 million on August 22 to about $98 million by August 27, most of it leaving after the pause rather than through the theft.
A fix appeared on the Comet repository on August 26 at 03:39 UTC as pull request #12, titled "fix: reject same-token swaps." It adds an ErrSameToken error and a check that token_in and token_out differ to both swap functions and both spot-price getters, along with regression tests confirming that a rejected self-swap leaves the recorded reserve, the contract's real balance and the user's balance unchanged. It was merged on September 2. No recovery of funds has been reported.
Comet's pool is written in Rust for Soroban, so this section describes how the engine treats the pool's swap logic itself, and the claim stays at the level of the bug class. Olympix reads a contract the way an attacker does, as states, transitions and entry points, and takes in the context around the code to infer the rules it depends on. In a weighted pool, the state that matters is the reserve record for each token, and the entry points that matter are every swap, join and exit that rewrites it.
The signal in this code is a function that takes two token parameters, loads a mutable record for each one from the same map, and writes both back, with nothing establishing that the two keys differ. From that signal, the engine narrows from every path through the swap functions, to the feasible ones a caller can reach with bound tokens, to the one where token_in equals token_out, which is the path that touches the invariant: after any swap, each token's recorded reserve must equal the pool's real balance of that token. Olympix has flagged this class of balance misaccounting before in other codebases.
The counterexample is one call. A caller swaps USDC for USDC, the recorded USDC reserve drops by the amount out while the contract's real USDC balance rises, and the two no longer match. Olympix returns that sequence, the minimal condition that triggers it (a single swap with matching input and output tokens), and a generated test that replays it, which is the first step of the August 25 drain, produced in development rather than on mainnet. Run automatically at every merge and before release, that check belongs to the commit that introduced the swap function, where the fix was the one assertion Comet added more than two years later.
Comet's swap math follows a well-understood weighted-pool design, and every line of it does what it says for two different tokens. The pool lost its reserves to the one case its parameters allowed and its logic never imagined. For builders writing any function that takes two keys into the same storage, the safe default is to state that they must differ, or to prove the function stays correct when they don't, and to test the equal case before it ships. To see how Olympix tests the inputs your contracts never expected against the balances they're supposed to protect, book a technical walkthrough.
Protocol: CometDEX (Comet BLND-USDC weighted pool, Blend backstop)
Chain: Stellar (Soroban)
Date: August 25, 2026, first exploit call at 03:51:02 UTC (ledger 64112340)
Loss: 717,518.92 USDC
Attacker account: GCENJ4...WYHC
Attacker contract: CBCY2H...44HM
Attacker contract (repayment): CCAJFJ...5GCL
Victim pool (Comet BLND-USDC): CAS3FL...VEAM
Flash-loan source (Blend pool): CAJJZS...BXBD
Funding tx (30,282 USDC): c76f9c...799f
First exploit tx: 41c898...c622
Sample exploit round (04:08:19 UTC): 2a3fad...8e61
Proceeds tx 1 (500,000 USDC): b4ed8e...24f8
Proceeds tx 2 (247,800.92 USDC): 3daa47...9645
Proceeds recipient (NEAR Intents): GDJ4JZ...QBJK
Blend: Clarity on backstop depositors and the Comet BLND-USDC incident
CometDEX GitHub: Pull request #12, fix: reject same-token swaps
Defimon Alerts: YieldBlox Security Council on-chain message to the Comet exploiter