Data access · 11 min read
The KelpDAO rsETH bridge exploit, reproduced from onchain data
116,500 rsETH, about $292 million, left KelpDAO's bridge on 18 April 2026 in a single successful transaction that cost 94,456 gas. It is the largest DeFi theft of the year, and it has been written up by LayerZero, Chainalysis, OpenZeppelin and Blockaid. Not one of those reports prints a transaction hash, a contract address, a block number or a timestamp. This page supplies them, and then does the thing the reports describe but do not show: it puts the delivery on Ethereum next to the send on the source chain and reads the difference.
1. What the incident reports leave out
The narrative is well documented and consistent across sources. LayerZero's incident report dates the initial compromise to 6 March 2026, when a developer was socially engineered out of session keys. The attacker eventually tampered with responses from the internal RPC nodes the LayerZero Labs DVN relied on, while a denial-of-service pushed the verifier onto exactly those nodes. Kelp's rsETH pathway used a 1-of-1 verifier configuration, so a single compromised attestation was sufficient. Chainalysis attributes the theft to the Lazarus Group, and OpenZeppelin makes the point that an audit would not have caught it, because there was no bug in the contracts to find.
All of that is offchain narrative. The onchain record is public, and it is more specific than any of the writeups. Everything below comes from two queries against SQD's Portal, one on ethereum-mainnet and one on unichain-mainnet.
- Block24,908,285
- Timestamp1776533735 (2026-04-18 17:35:35 UTC)
- Transaction index12
- Gas used94,456
- Status1 (success)
- Amount116,500.0000 rsETH
2. The theft, in four calls
Event logs alone would show you a single Transfer and tell you almost nothing about how it was authorised. The execution trace shows the whole path. Requesting traces for block 24,908,285 and keeping transaction index 12 returns exactly four frames:
Read top to bottom, that is the entire exploit. An ordinary externally owned account at 0x4966…575e calls 0x1a44…728c, which is the canonical LayerZero V2 endpoint on Ethereum, with selector 0x0c0c389e. That address is worth checking rather than trusting: it matches the endpointV2 entry for endpoint ID 30101 in LayerZero's own metadata.
The endpoint then calls the Kelp bridge at 0x85d4…8ef3 with 0x13137d65. The two selectors are both lzReceive, with different signatures: 0x0c0c389e is the endpoint's, and 0x13137d65 is the one an OApp implements. Finally the bridge calls rsETH at 0xa129…e5a7 with 0xa9059cbb, a plain transfer, which delegates through to the implementation.
The last frame is the detail worth pausing on. The bridge did not mint. It called transfer, which means it handed over rsETH it was already holding in custody on behalf of everyone who had bridged out of Ethereum. No contract in this tree did anything it was not written to do.
3. What the message actually said
The root call's calldata is 357 bytes and decodes against lzReceive((uint32,bytes32,uint64) origin, address receiver, bytes32 guid, bytes message, bytes extraData). Hashing that signature gives 0x0c0c389e, which is how you confirm you have the right shape before trusting a single field:
- origin.srcEid30320
- origin.sender0xc3eacf0612346366db554c991d7858716db09f58
- origin.nonce308
- receiver0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3
- guid0x3f4510d855cf3a805fec59daafae640d290749b7bf1e5450f91b5fb0018b3b4e
- message40 bytes
Endpoint ID 30320 resolves to Unichain in LayerZero's metadata. The payload that moved $292 million is 40 bytes long: a 32-byte recipient followed by an 8-byte amount, which is the standard OFT message body.
OFT transfers carry amounts in shared decimals, six by default, so that tokens with different local precision can agree on a wire format. 116,500,000,000 divided by 106 is 116,500.000000, a round number to the last digit, addressed to 0x8b1b…0d3b.
4. The GUID commits to routing, not to money
A LayerZero V2 GUID looks like a message fingerprint, and it is easy to assume it identifies the message contents. It does not. It is keccak256 over the packed routing tuple only:
That is a byte-for-byte match with the GUID in the calldata. Notice what is absent from the preimage: the recipient inside the payload, and the amount. Two messages with completely different payloads on the same pathway and nonce produce the same GUID. Notice also that every input is predictable. A pathway's next nonce is public, the endpoint IDs are constants, and both contract addresses are fixed. The identifier for a message that has not been sent yet can be computed at any time by anyone.
That is not a flaw on its own. Integrity of the payload is supposed to come from the verifier attesting to a payload hash. With one verifier, and that verifier reading a poisoned view of the source chain, the only thing standing between a forged payload and the vault is gone.
5. The same GUID on the source chain
Here is where having both chains in one place changes what you can see. rsETH's OApp on Unichain is 0xc3ea…9f58, and it emits OFTSent on every outbound transfer. Scanning the full history of that contract on unichain-mainnet, through block 57,170,136, returns 320 sends, every one of them addressed to Ethereum. Exactly one carries the exploit GUID.
- Block45,830,866
- Timestamp1776579225 (2026-04-19 06:13:45 UTC)
- fromAddress0x8e60b7b64b63cd56b18ebcecadcb79b04919286e
- dstEid30101 (Ethereum)
- amountSentLD0.001 rsETH
- amountReceivedLD0.001 rsETH
Two things fall out of that row at once.
The first is the amount. The genuine message on nonce 308 carried 0.001 rsETH. The delivery on Ethereum released 116,500. Same pathway, same nonce, same identifier, and the payload differs by a factor of 116.5 million.
The second is the clock. The send is timestamped 1776579225. The delivery is timestamped 1776533735. Subtract them and the delivery happened 45,490 seconds, 12 hours 38 minutes and 10 seconds, before the message it claimed to deliver was sent. The attacker did not intercept and rewrite a message in flight. They computed the identifier for a nonce the source chain had not reached yet, had it attested, and spent it. Half a day later an ordinary user bridged 0.001 rsETH and unknowingly produced the message that was supposed to have justified the transfer.
Delivered 12 h 38 m before it was sent
The two nonce-308 events on the Unichain to Ethereum rsETH pathway, 2026-04-18/19 UTC
For scale: across its entire history that Unichain contract has sent 30,479.3061 rsETH to Ethereum in total, and its single largest send ever was 1,000 rsETH. The forged message claimed 3.8 times everything the pathway had ever legitimately carried, cumulatively, since deployment.
- Delivered on Ethereum under nonce 308116,500.0000
- Actually sent from Unichain under nonce 3080.0010
- Largest legitimate send in contract history1,000.0000
- All sends to Ethereum, cumulative, all time30,479.3061
6. The message that never arrived
There is one more query worth running, and it closes the loop. If the fraudulent delivery consumed nonce 308 on Ethereum, what happened to the real 0.001 rsETH when it turned up 12 hours later?
Scanning every OFTReceived event on the bridge from the exploit block through the pinned head, blocks 24,908,285 to 25,853,665, which is 945,381 blocks, returns 601 deliveries. Exactly one carries GUID 0x3f4510d8…8b3b4e, and it is the 116,500 rsETH release itself. The genuine message never landed. Its slot had already been spent.
Somewhere behind 0x8e60…286e is a person who bridged 0.001 rsETH on a Sunday morning and never received it, because the identifier their transfer would be given had been stolen the previous afternoon. That is a small thing next to $292 million, and it is the kind of detail that only appears when you can put two chains side by side over arbitrary historical ranges.
7. Reproduce it with the Pipes SDK
Both halves of the reconciliation fit in one script. The Ethereum side asks for raw traces; the Unichain side decodes OFTSent against an ABI. Two datasets, two streams, no node and no trace backfill.
That prints the four-frame tree and then the single line that undoes the story:
8. Four things that will trip you up
Every one of these cost time while writing this page, so they are worth stating plainly.
- Trace fields come back as requestedYou request callFrom and callTo; they arrive nested as action.from and action.to
- evmQuery({ from, to, fields }) configures the queryevmQuery() takes no arguments and silently ignores a config object. Chain .addFields() and .addTraceRequest({ range, request }) instead
- defineAbi accepts signature stringsIt takes JSON ABI items. A human-readable signature throws "items is not iterable"
- Decoded events are flat objectsArguments are under .event, the untouched log under .rawEvent
One more, on the Portal HTTP API rather than the SDK: a stream response covers as much of the requested range as fits, with no error and no cursor. If the last block returned is below your toBlock, resume from the block after it. Treating a short response as a complete one is the single easiest way to under-count a historical scan.
9. Why this is harder without a data layer
Nothing here needed privileged access. It needed three capabilities at once, and that combination is where the friction usually is.
It needed traces, which are the most expensive EVM data type to get from a node: they require an archive node with tracing enabled, and debug_traceBlock is either disabled or rate-limited on most commercial endpoints. It needed full history on two chains, including a scan of every log a Unichain contract ever emitted and 817,404 blocks of Ethereum, which as eth_getLogs calls against a provider with a block-range cap is thousands of paginated requests. And it needed the same query shape on both, so that a Unichain scan and an Ethereum scan are the same code with a different URL.
Portal serves logs, transactions, traces and state diffs as first-class filterable types over any historical range, with the same request shape across 130+ networks. The reconciliation above is two endpoints and a handful of queries. Assembled from RPC it is an archive node per chain, a tracing backfill, and pagination logic you have to get right before any of the numbers mean anything.
Frequently asked questions
What was the KelpDAO rsETH bridge exploit?
What is the transaction hash for the KelpDAO hack?
How can a cross-chain message be delivered before it is sent?
How do you trace a bridge exploit across two chains?
Related guides
Blockchain data API: a complete guide
RPC, indexed data, decentralized networks, and market data APIs. What each does and when to use them.
RPC vs indexed data: which do you actually need?
When an RPC endpoint is enough, when you need an indexer, and the costs of each.
Stablecoin data: tracking cross-chain flows and peg health
Following stablecoin transfers across every chain they live on with one query model, aligning windows across chains by timestamp, and reading peg health from DEX trades.
Monitoring bridges and cross-chain flows?
See how trace-level and multi-chain data feeds detection work on the risk monitoring solution page.