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.

Updated 2026-08-28 · By the SQD team

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.

FieldValue
  • Block24,908,285
  • Timestamp1776533735 (2026-04-18 17:35:35 UTC)
  • Transaction index12
  • Gas used94,456
  • Status1 (success)
  • Amount116,500.0000 rsETH
The release transaction on Ethereum

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:

[root] call 0x4966260619701a80637cdbdac6a6ce0131f8575e -> 0x1a44076050125825900e736c501f859c50fe728c 0x0c0c389e
[0] call 0x1a44076050125825900e736c501f859c50fe728c -> 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 0x13137d65
[0.0] call 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 -> 0xa1290d69c65a6fe4df752f95823fae25cb99e5a7 0xa9059cbb
[0.0.0] delegatecall 0xa1290d69c65a6fe4df752f95823fae25cb99e5a7 -> 0x7159107483e623707c18c6e06cbc095bd0717783 0xa9059cbb

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:

ArgumentValue
  • origin.srcEid30320
  • origin.sender0xc3eacf0612346366db554c991d7858716db09f58
  • origin.nonce308
  • receiver0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3
  • guid0x3f4510d855cf3a805fec59daafae640d290749b7bf1e5450f91b5fb0018b3b4e
  • message40 bytes
Decoded lzReceive arguments, block 24,908,285

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.

sendTo 0x0000000000000000000000008b1b6c9a6db1304000412dd21ae6a70a82d60d3b
amountSD 0x0000001b1ff0ed00 = 116,500,000,000

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:

keccak256( nonce(8) ‖ srcEid(4) ‖ sender(32) ‖ dstEid(4) ‖ receiver(32) )
nonce 308
srcEid 30320 (Unichain)
sender 0x…c3eacf0612346366db554c991d7858716db09f58
dstEid 30101 (Ethereum)
receiver 0x…85d456b2dff1fd8245387c0bfb64dfb700e98ef3
= 0x3f4510d855cf3a805fec59daafae640d290749b7bf1e5450f91b5fb0018b3b4e

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.

FieldValue
  • Block45,830,866
  • Timestamp1776579225 (2026-04-19 06:13:45 UTC)
  • fromAddress0x8e60b7b64b63cd56b18ebcecadcb79b04919286e
  • dstEid30101 (Ethereum)
  • amountSentLD0.001 rsETH
  • amountReceivedLD0.001 rsETH
The OFTSent event carrying GUID 0x3f4510d8…8b3b4e, on Unichain

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

Ethereum Unichain 12 h 38 m 10 s the delivery preceded its own send same GUID 0x3f4510d8…8b3b4e 116,500 rsETH released 17:35:35 UTC · nonce 308 consumed 0.001 rsETH sent 06:13:45 UTC · the genuine nonce 308 17:0019:0021:0023:0001:0003:0005:0007:00 Apr 18 Apr 19
Blocks 24,908,285 / 45,830,866 Source: SQD Portal · ethereum-mainnet + unichain-mainnet · 2026-08-28

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.

MeasurersETH
  • 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
Claimed versus actual, Unichain to Ethereum rsETH pathway

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.

import { evmPortalStream, evmQuery, evmEventDecoder, defineAbi } from '@subsquid/pipes/evm'
const GUID = '0x3f4510d855cf3a805fec59daafae640d290749b7bf1e5450f91b5fb0018b3b4e'
// Side 1: the delivery on Ethereum, as a call tree.
const delivery = evmPortalStream({
id: 'kelp-delivery',
portal: 'https://portal.sqd.dev/datasets/ethereum-mainnet',
outputs: {
traces: evmQuery()
.addFields({
block: { number: true, timestamp: true },
trace: {
transactionIndex: true, traceAddress: true, type: true,
callFrom: true, callTo: true, callInput: true, callType: true,
},
})
.addTraceRequest({
range: { from: 24_908_285, to: 24_908_285 },
request: { type: ['call'] },
}),
},
})
for await (const { data } of delivery) {
for (const block of data.traces) {
const tree = (block.traces ?? [])
.filter((t) => t.transactionIndex === 12)
.sort((x, y) => x.traceAddress.join('.').localeCompare(y.traceAddress.join('.')))
for (const t of tree) {
const a = t.action ?? {}
const path = t.traceAddress.join('.') || 'root'
console.log(`${' '.repeat(t.traceAddress.length)}[${path}] ${a.from} -> ${a.to} ${(a.input ?? '').slice(0, 10)}`)
}
}
}
// Side 2: the send on Unichain, decoded.
const oft = defineAbi([{
type: 'event', name: 'OFTSent', anonymous: false,
inputs: [
{ name: 'guid', type: 'bytes32', indexed: true },
{ name: 'dstEid', type: 'uint32', indexed: false },
{ name: 'fromAddress', type: 'address', indexed: true },
{ name: 'amountSentLD', type: 'uint256', indexed: false },
{ name: 'amountReceivedLD', type: 'uint256', indexed: false },
],
}])
const send = evmPortalStream({
id: 'kelp-send',
portal: 'https://portal.sqd.dev/datasets/unichain-mainnet',
outputs: {
sent: evmEventDecoder({
range: { from: 45_830_866, to: 45_830_866 },
contracts: ['0xc3eacf0612346366db554c991d7858716db09f58'],
events: { OFTSent: oft.events.OFTSent },
}),
},
})
for await (const { data } of send) {
for (const { event } of data.sent.OFTSent) {
if (event.guid.toLowerCase() !== GUID) continue
console.log('sent', Number(event.amountSentLD) / 1e18, 'rsETH')
}
}

That prints the four-frame tree and then the single line that undoes the story:

sent 0.001 rsETH

8. Four things that will trip you up

Every one of these cost time while writing this page, so they are worth stating plainly.

ExpectationReality
  • 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
Corrections applied while reproducing the trace

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?
On 18 April 2026 an attacker moved 116,500 rsETH, roughly $292 million, out of KelpDAO's rsETH bridge on Ethereum. It was not a smart contract bug. Kelp's rsETH pathway was configured with a single verifier, the LayerZero Labs DVN, and LayerZero has said an attacker compromised the internal RPC nodes that DVN read from, so the verifier attested to a message nobody had sent. On Ethereum the release itself is one ordinary successful transaction that used 94,456 gas.
What is the transaction hash for the KelpDAO hack?
The release transaction is 0x1ae232da212c45f35c1525f851e4c41d529bf18af862d9ce9fd40bf709db4222, at Ethereum block 24,908,285, timestamp 1776533735 (2026-04-18 17:35:35 UTC), transaction index 12. It moved exactly 116,500.0000 rsETH from the bridge at 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 to 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b. The published incident reports from LayerZero, Chainalysis and OpenZeppelin describe the attack but none of them print this hash.
How can a cross-chain message be delivered before it is sent?
A LayerZero V2 GUID is keccak256 over the nonce, source endpoint ID, sender, destination endpoint ID and receiver. It commits to routing metadata only, never to the payload or the amount. Every one of those inputs is predictable, so the identifier for a future nonce can be computed before that nonce is ever used. In this case the fraudulent delivery consumed nonce 308 on Ethereum at 17:35:35 UTC on 18 April; the genuine nonce-308 send left Unichain at 06:13:45 UTC on 19 April, 12 hours 38 minutes later, carrying 0.001 rsETH.
How do you trace a bridge exploit across two chains?
You need execution traces on the destination chain to see the call tree inside the release transaction, decoded events on the source chain to see what was actually sent, and historical range access on both. SQD's Portal serves traces and logs as first-class data types over any block range on 130+ networks, so the whole reconciliation is a handful of queries against two endpoints rather than an archive node per chain plus a trace backfill.

Related guides

Monitoring bridges and cross-chain flows?

See how trace-level and multi-chain data feeds detection work on the risk monitoring solution page.