Calculated from recorded token losses using historical USD prices at the incident time.
0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f260x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6DEthereum0x9Ab872A34139015Da07EE905529a8842a6142971Ethereum0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345CEthereumIn Ethereum mainnet transaction 0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26 at block 17376907, an unprivileged adversary used a flash-loaned USDC position to skew Curve yPool pricing, then called the public ASResearch rebalancer ExchangeBetweenPools at 0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D. That call forced the ASResearch USDC bank 0x9Ab872A34139015Da07EE905529a8842a6142971 to issue its full 119023523157 raw USDC balance to the rebalancer, which immediately sold it into Curve at an attacker-manipulated rate.
The root cause is an access-control and execution-price failure. ExchangeBetweenPools.doExchange(uint256) is publicly callable, and it spends bank funds through ERC20TokenBank.issue(address,uint256) because the bank trusts the rebalancer contract itself rather than the original caller. The same function then calls curve.exchange_underlying(1, 2, camount, 0), so the victim sale accepts any output amount and can be sandwiched by same-transaction price manipulation.
ASResearch split treasury inventory across token banks. The exploited path moves value from the USDC bank into the USDT bank by swapping through Curve yPool.
ExchangeBetweenPools.from_bank() returns 0x9Ab872A34139015Da07EE905529a8842a6142971.ExchangeBetweenPools.to_bank() returns .0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345CExchangeBetweenPools.curve() returns Curve yPool 0x45F783CCE6B7FF23B2ab2D70e416cdb7D6055f51.ExchangeBetweenPools.minimum_amount() returns 100000000, so any call above 100 USDC can trigger the path.The verified ERC20TokenBank source is published for the ASResearch USDT bank, and the exploited USDC bank has the same runtime codehash (0x9e5f14df291a98ad34f4a106be0cecf93b01df2587cae9a7101a0f0d953edd3f). The USDC bank therefore uses the same issue logic. On-chain, USDC_BANK.list() returns 0x7d8E92748F13E30F99386cd253F2ca7a0659321A, and TrustList.is_trusted(ExchangeBetweenPools) returns true.
Three background facts make the incident exploitable:
This is an ATTACK case, not a benign arbitrage. The violated invariant is that only an authorized strategy with price protection should be able to move ASResearch bank assets, and a bank-funded rebalance must not execute at an attacker-controlled exchange rate. Instead, ASResearch delegated spend authority to ExchangeBetweenPools and exposed that authority through an unrestricted public function. The bank then honored issue(address(this), amount) because it checks whether msg.sender is trusted, not whether the external caller is authorized. Once the rebalancer received bank USDC, it approved Curve and executed exchange_underlying(..., 0), which removed any minimum-output protection. The attacker could therefore worsen the USDC to USDT price first, force the victim sale at the distorted rate, and then unwind the price move for profit. The exploit conditions were public and deterministic: sufficient victim inventory, the rebalancer remaining trusted by the bank, access to public flash liquidity, and the ability to move Curve yPool state before calling doExchange.
Collector-confirmed victim code excerpts:
function doExchange(uint256 amount) public returns(bool){
require(amount >= minimum_amount, "invalid amount");
require(amount <= ERC20TokenBankInterface(from_bank).balance(), "too much amount");
ERC20TokenBankInterface(from_bank).issue(address(this), amount);
uint256 camount = usdc.balanceOf(address(this));
usdc.safeApprove(address(curve), camount);
curve.exchange_underlying(1, 2, camount, 0);
uint256 namount = usdt.balanceOf(address(this));
usdt.safeTransfer(to_bank, namount);
return true;
}
function issue(address _to, uint _amount)
public
is_trusted(msg.sender)
returns (bool success){
require(_amount <= balance(), "not enough tokens");
(bool status,) = erc20_token_addr.call(
abi.encodeWithSignature("transfer(address,uint256)", _to, _amount)
);
require(status, "call failed");
emit issue_token(_to, _amount);
return true;
}
The relevant ACT pre-state is Ethereum mainnet immediately before block 17376907. At that point the ASResearch USDC bank still held 119023523157 raw USDC, ExchangeBetweenPools was still on the USDC bank trust list, Curve yPool at 0x45F783... was live, and the Uniswap V3 DAI/USDC pool at 0x5777d92f... exposed enough public USDC liquidity for a flash loan.
The exploit transaction is fully visible in the collected trace. The attacker contract first borrowed 120000000000 raw USDC from the Uniswap V3 pool, then sold that USDC into Curve to worsen the victim's pending execution rate:
UniswapV3Pool::flash(..., amount1: 120000000000)
Vyper_contract::exchange_underlying(1, 2, 120000000000, 0)
emit TokenExchangeUnderlying(
buyer: 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3,
sold_id: 1,
tokens_sold: 120000000000,
bought_id: 2,
tokens_bought: 71076010673
)
With Curve pricing displaced, the attacker invoked the public ASResearch rebalance path. The trace shows the exact trust and spend sequence:
ExchangeBetweenPools::doExchange(119023523157)
ERC20TokenBank::issue(ExchangeBetweenPools, 119023523157)
TrustList::is_trusted(ExchangeBetweenPools) -> true
emit Transfer(
from: ERC20TokenBank: [0x9Ab872A34139015Da07EE905529a8842a6142971],
to: ExchangeBetweenPools: [0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D],
value: 119023523157
)
Vyper_contract::exchange_underlying(1, 2, 119023523157, 0)
emit TokenExchangeUnderlying(
buyer: ExchangeBetweenPools: [0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D],
sold_id: 1,
tokens_sold: 119023523157,
bought_id: 2,
tokens_bought: 47391213
)
That trace excerpt is the code-level breakpoint. The trusted rebalancer withdraws the full bank balance and sells it with min_dy = 0, so the bank-funded swap inherits the attacker-distorted Curve price. The rebalancer then forwards only 47391213 raw USDT to the ASResearch USDT bank, far below the value of the USDC inventory it consumed.
After the victim sale moved the pool in the opposite direction, the attacker unwound the initial price-manipulation leg:
Vyper_contract::exchange_underlying(2, 1, 71076010673, 0)
emit TokenExchangeUnderlying(
buyer: 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3,
sold_id: 2,
tokens_sold: 71076010673,
bought_id: 1,
tokens_bought: 231512389841
)
UniswapV3Pool::flash(... paid1: 12000000)
The attacker repaid 120012000000 raw USDC to Uniswap V3 and retained 111500389841 raw USDC before later converting proceeds into ETH. This matches the success predicate in root_cause.json: the adversary started with zero USDC, paid a 12000000 raw USDC flash fee and 0.05359265021083305 ETH in gas, and still realized six-figure USDC profit.
The broken security principles are straightforward:
The adversary flow is a single-transaction, three-stage sequence.
0xc0ffeebabe5d496b2dde509f9fa189c25cf29671 funds execution and triggers attacker contract 0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3, which borrows 120000000000 raw USDC from Uniswap V3 and swaps it into Curve yPool for 71076010673 raw USDT.ExchangeBetweenPools.doExchange(119023523157), causing the ASResearch USDC bank to issue its entire balance to the trusted rebalancer. The rebalancer approves Curve, swaps the full amount with min_dy = 0, and transfers the resulting 47391213 raw USDT to the ASResearch USDT bank.71076010673 raw USDT back through Curve for 231512389841 raw USDC, repays the flash loan plus fee, and keeps 111500389841 raw USDC. The native balance diff shows the sender EOA later receiving 58470713463528725006 wei and secondary recipient 0xfeebabe6b0418ec13b30aadf129f5dcdd4f70cea receiving 1036355512292965848 wei from exploit proceeds.The adversary-related accounts identified by the auditor are supported by the evidence:
0xc0ffeebabe5d496b2dde509f9fa189c25cf29671: transaction sender and final ETH recipient.0x7c28e0977f72c5d08d5e1ac7d52a34db378282b3: attacker contract that receives the flash loan, executes both Curve swaps, and calls doExchange.0xfeebabe6b0418ec13b30aadf129f5dcdd4f70cea: downstream ETH payout recipient.The direct victim loss is the full depletion of the ASResearch USDC bank:
USDC119023523157 raw units6119,023.523157 USDCThe bank's USDC balance went from 119023523157 to 0 in the incident transaction. The paired ASResearch USDT bank received only 47391213 raw USDT (47.391213 USDT) from the forced rebalance. The attacker retained 111500389841 raw USDC (111,500.389841 USDC) after repaying the flash loan and flash fee, then converted proceeds into ETH. This outcome is consistent with the collector balance diff and the transaction trace.
0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/workspace/session/artifacts/collector/seed/1/0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/metadata.json/workspace/session/artifacts/collector/seed/1/0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/trace.cast.log/workspace/session/artifacts/collector/seed/1/0x578a195e05f04b19fd8af6358dc6407aa1add87c3167f053beb990d6b4735f26/balance_diff.jsonExchangeBetweenPools source: https://etherscan.io/address/0x765b8d7Cd8FF304f796f4B6fb1BCf78698333f6D#codeERC20TokenBank source template: https://etherscan.io/address/0x21A3dbeE594a3419D6037D6D8Cee0B1E10Bf345C#codefrom_bank(), to_bank(), curve(), minimum_amount(), list(), token(), is_trusted(address), and matching bank codehashes for 0x9Ab872... and 0x21A3db...