Skip to content

Instantly share code, notes, and snippets.

@banteg
Last active April 22, 2026 04:37
Show Gist options
  • Select an option

  • Save banteg/705d0284513b74ad20f61d90f5b5de62 to your computer and use it in GitHub Desktop.

Select an option

Save banteg/705d0284513b74ad20f61d90f5b5de62 to your computer and use it in GitHub Desktop.

Kelp rsETH Unichain -> Ethereum Path Investigation

Date: 2026-04-19

Scope

Investigate whether Ethereum delivery tx 0x1ae232da212c45f35c1525f851e4c41d529bf18af862d9ce9fd40bf709db4222 was backed by real source-side funds on Unichain, and whether the LayerZero path configuration explains the failure mode.

Executive Summary

The Ethereum-side 116,500 rsETH release came out of real, pre-funded bridge inventory on the Ethereum adapter, but it does not appear to be backed by any legitimate source-side debit on Unichain.

The strongest evidence:

  • The Unichain -> Ethereum route is configured as a 1-of-1 DVN path with no optional DVNs.
  • The Ethereum adapter held 116,723.5206355 rsETH one block before the exploit and only 223.5206355 rsETH immediately after, so the exploit drained existing inventory rather than minting new rsETH on Ethereum.
  • Nonce 308 was PayloadVerified at 2026-04-18 17:33:35 UTC, committed at 17:35:11 UTC, and successfully delivered at 17:35:35 UTC.
  • Ethereum accepted inbound nonce 308, but the matching Unichain source endpoint still reports outbound nonce 307.
  • The Unichain rsETH source token did not show a matching burn or supply reduction around the message window.
  • A second packet, nonce 309, was also verified on Ethereum by the same single DVN, but only after Kelp had already frozen the original recipient.
  • The first failed retry for nonce 309 reverted with TransfersBlocked(0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b, 2026-04-19 18:23:11 UTC), so that failure was not caused by insufficient adapter inventory.
  • The recipient and its splitter wallets were freshly Tornado-funded and dispersed the full 116,500 rsETH into prepared branches within about five minutes.

My current conclusion is that the observed behavior is most consistent with a single-DVN verification failure or equivalent verification-path compromise on the Unichain -> Ethereum Kelp route. The onchain evidence does not identify the exact root cause beyond that.

Entities

Finding 1: The route is single-DVN

Onchain config reads for the exact Kelp path show one required DVN and zero optional DVNs on both sides.

Source side

  • getSendLibrary(oapp=0xc3eacf0612346366db554c991d7858716db09f58, dstEid=30101) returned 0xC39161c743D0307EB9BCc9FEF03eeb9Dc4802de7
  • getConfig(oapp=0xc3eacf0612346366db554c991d7858716db09f58, lib=0xC39161c743D0307EB9BCc9FEF03eeb9Dc4802de7, eid=30101, configType=2) returned data that decodes to:
    • confirmations = 42
    • requiredDVNCount = 1
    • optionalDVNCount = 0
    • optionalDVNThreshold = 0
    • requiredDVNs = [[0x282b3386571f7f794450d5789911a9804fa346b4](https://uniscan.xyz/address/0x282b3386571f7f794450d5789911a9804fa346b4)]
    • optionalDVNs = []

Destination side

  • getReceiveLibrary(oapp=0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3, srcEid=30320) returned 0xc02Ab410f0734EFa3F14628780e6e695156024C2
  • getConfig(oapp=0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3, lib=0xc02Ab410f0734EFa3F14628780e6e695156024C2, eid=30320, configType=2) returned data that decodes to:
    • confirmations = 42
    • requiredDVNCount = 1
    • optionalDVNCount = 0
    • optionalDVNThreshold = 0
    • requiredDVNs = [[0x589dedbd617e0cbcb916a9223f4d1300c294236b](https://etherscan.io/address/0x589dedbd617e0cbcb916a9223f4d1300c294236b)]
    • optionalDVNs = []

This route is therefore effectively 1-of-1.

The required DVN on the Ethereum side, 0x589dedbd617e0cbcb916a9223f4d1300c294236b, is labeled LayerZero : DVN on Etherscan. Separately, LayerZero's DVN provider docs and Simple Config Generator present LayerZero Labs as a standard DVN provider and use [['LayerZero Labs'], []] as the minimal single-DVN configuration example. That does not by itself prove the exact provider identity for this address from docs alone, but it does show that a 1-of-1 configuration with LayerZero as the sole DVN provider is part of the intended configuration model.

That matters for fault attribution. A 1-of-1 route whose sole verifier is explorer-labeled as LayerZero-associated is materially different from a 1-of-1 route secured by an unrelated third-party verifier. If the path failed at verification, the likely fault domain shifts toward a LayerZero-associated worker or its trust inputs, rather than an arbitrary external DVN provider.

Verification and execution were distinct steps

For nonce 308, the verification chain on Ethereum was:

For nonce 309, the chain was separate and later:

This matters because nonce 309 was not already sitting verified before the freeze. The Kelp Safe freeze tx 0xa1495d12abdd7369bb92ecfc0cbdbdc2a7f631604f783abc1e985f3e682e25fc landed at 2026-04-18 18:23:11 UTC, before 309 was PayloadVerified, committed, or executed.

The actors were also distinct:

So the onchain path was: DVN-side proof submission -> manual commit by 0x4966260619701a80637cDbdAc6A6cE0131f8575E -> manual execution by 0x4966260619701a80637cDbdAc6A6cE0131f8575E.

The DVN-side txs were not generic wallet sends. They were calls to DVN 0x589dedbd617e0cbcb916a9223f4d1300c294236b using execute((uint32,address,bytes,uint256,bytes)[]), with the embedded call targeting receive lib 0xc02Ab410f0734EFa3F14628780e6e695156024C2 and verify(bytes,bytes32,uint64).

Configured executor vs. actual executor behavior

On the Unichain source side, getConfig(..., configType=1) for send lib 0xC39161c743D0307EB9BCc9FEF03eeb9Dc4802de7 returned executor config bytes that decode as:

  • maxMessageSize = 99
  • executor = [0x4208D6E27538189bB48E603D6123A94b8Abe0A0b](https://etherscan.io/address/0x4208D6E27538189bB48E603D6123A94b8Abe0A0b)

LayerZero's executor docs describe this config as the fee-paying executor for destination delivery, but execution is still permissionless after verification. That is consistent with what happened here: the configured executor address was not the gas-paying EOA. Instead, 0x4966260619701a80637cDbdAc6A6cE0131f8575E manually committed and executed the already-verified packets.

Visible Ethereum history makes 0x4966260619701a80637cDbdAc6A6cE0131f8575E look like a fresh throwaway caller rather than a standing executor wallet:

  • it has exactly five outgoing Ethereum txs, all five on this single Kelp LayerZero route
  • it has no visible ERC-20 history and no non-LayerZero protocol interactions
  • it was funded hours earlier by an internal transfer of 0.99392252389025 ETH from TornadoCash contract 0x47ce0c6ed5b0ce3d3a51fdb1c52dc66a7c3c2936

By contrast, the configured executor address 0x4208D6E27538189bB48E603D6123A94b8Abe0A0b currently looks dormant on Ethereum: no code, nonce 0, no outgoing txs, and no token activity.

So the evidence supports a narrower description than "the LayerZero executor did this." The visible executor path is: a configured executor role exists in the send-lib config, but the exploit-path commit and delivery calls were made by a fresh, mixer-funded EOA exploiting LayerZero's permissionless post-verification execution model.

The DVN signer committee was stable

DVN 0x589dedbd617e0cbcb916a9223f4d1300c294236b is not a proxy:

The constructor committee was:

Current state matches the state from about 30 days before the incident:

  • quorum() = 2
  • signerSize() = 3
  • all three constructor signers were active both now and at block 24,693,386

I found no UpdateSigner(address,bool) events and no UpdateQuorum(uint64) events. So the signer committee and threshold do not appear to have been changed around the exploit.

What did change was admin surface:

So the suspicious verification submitters were not fresh attacker-controlled signers added right before the incident. They were long-standing authorized DVN admins operating against a stable signer committee.

They also do not look isolated to this packet pair. In a narrow receive-lib sweep over the surrounding window, both submitters were verifying many other packets on 0xc02Ab410f0734EFa3F14628780e6e695156024C2, which makes them look like routine DVN operator/admin EOAs rather than one-off attacker wallets.

Finding 2: Nonce 308 was accepted on Ethereum, but the source endpoint never advanced past 307

Ethereum-side delivery

Tx:

  • 0x1ae232da212c45f35c1525f851e4c41d529bf18af862d9ce9fd40bf709db4222

It is a LayerZero EndpointV2 lzReceive call carrying:

  • srcEid = 30320 (Unichain)
  • sender = [0xc3eacf0612346366db554c991d7858716db09f58](https://uniscan.xyz/address/0xc3eacf0612346366db554c991d7858716db09f58)
  • nonce = 308
  • receiver = [0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3](https://etherscan.io/address/0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3)
  • guid = 0x3f4510d855cf3a805fec59daafae640d290749b7bf1e5450f91b5fb0018b3b4e

The payload delivered 116,500 rsETH to:

Endpoint nonce mismatch

  • Ethereum destination endpoint:
    • lazyInboundNonce(0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3, 30320, 0xc3eacf0612346366db554c991d7858716db09f58) = 308
  • Unichain source endpoint:
    • outboundNonce(0xc3eacf0612346366db554c991d7858716db09f58, 30101, 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3) = 307

If nonce 308 had been legitimately sent from Unichain, the source outbound nonce should have advanced to at least 308. It did not.

LayerZero Scan shows the same mismatch for the packet metadata:

  • nonce 308 / GUID 0x3f4510d855cf3a805fec59daafae640d290749b7bf1e5450f91b5fb0018b3b4e
  • destination tx populated on Ethereum
  • source tx still blank in the scan response

This is stronger than a public-indexer omission. If a canonical source-side send had succeeded through the LayerZero endpoint, the source outbound nonce should have advanced. It did not.

Finding 3: No source-side debit exists for the 116,500 rsETH release

The Unichain source contract is the token itself:

  • name() = "KelpDao Restaked ETH"
  • symbol() = "rsETH"
  • token() = [0xc3eacf0612346366db554c991d7858716db09f58](https://uniscan.xyz/address/0xc3eacf0612346366db554c991d7858716db09f58)
  • decimals() = 18
  • sharedDecimals() = 6

Control case: a normal send on the same path burns on Unichain

Nonce 307 is a clean baseline for expected behavior on this exact route.

In the Unichain source receipt for nonce 307, the token emits:

  • Transfer(0x222e68aa6658a8067585f19ecca5440feaa8bb00 -> 0x8e60b7b64b63cd56b18ebcecadcb79b04919286e, 0.006 rsETH)
  • Transfer(0x8e60b7b64b63cd56b18ebcecadcb79b04919286e -> 0x0000000000000000000000000000000000000000, 0.006 rsETH)

That is the expected OFT debit-and-burn pattern on the source chain. The LayerZero message payload for nonce 307 ends with 0x1770, which is 6000 shared-decimal units, and the Ethereum destination tx releases exactly 0.006 rsETH.

This control case matters because it shows what a legitimate send on this route looks like. Nonce 308 does not show the same source-side burn footprint.

Supply check

Unichain source totalSupply() remained unchanged at:

  • block 45,785,275: 49.259532 rsETH
  • block 45,785,276 (LayerZero Scan creation time for nonce 308): 49.259532 rsETH
  • block 45,785,277: 49.259532 rsETH
  • block 45,786,000: 49.259532 rsETH

There is no 116,500 rsETH source-side supply reduction.

Source token event check

Using eth_getLogs against the Unichain RPC for the source token 0xc3eacf0612346366db554c991d7858716db09f58 over the message window around nonce 308:

  • no Transfer logs at all for the token in the matching block range
  • no burn to 0x0000000000000000000000000000000000000000
  • no source-side debit pattern resembling a normal OFT send

Taken together with the nonce mismatch, this strongly indicates the destination-side release was not backed by a real source-side debit.

The 116,500 rsETH came from existing Ethereum-side inventory

The exploit did not mint new rsETH on Ethereum. The canonical rsETH balance of the Ethereum adapter 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 was:

  • block 24,908,284 (one block before the exploit): 116,723.5206355 rsETH
  • block 24,908,285 (the exploit block): 223.5206355 rsETH

So the exploit was a withdrawal from pre-existing Ethereum bridge inventory against what appears to have been an unbacked cross-chain claim.

Packet structure comparison: 307, 308, and 309 look structurally normal

The clean control packet 307 was executed through helper contract 0x173272739Bd7Aa6e4e214714048a9fE699453059 using execute302((address,(uint32,bytes32,uint64),bytes32,bytes,bytes,uint256)) in tx 0xc232af35a6c98c92fdb0b08675e93d678994c2c97d31e133f909e0cb95960211. Its decoded packet fields were:

The suspicious packets 308 and 309 were delivered directly through endpoint lzReceive(...), but their decoded fields have the same shape:

Decoded payloads:

So there is no obvious packet-shape anomaly in 308/309. The suspicious property is not malformed packet encoding. It is that structurally normal-looking packets were accepted without the canonical source-side debit.

Finding 4: A second packet (nonce 309) was also verified

LayerZero Scan for GUID

  • 0x19073f141ef29ea2eb2c52046e60942a928b2106651e622b73c68e27c969cfe6

shows a second message on the same path:

On Ethereum:

  • inboundPayloadHash(0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3, 30320, 0xc3eacf0612346366db554c991d7858716db09f58, 309) = 0xbf86af6f10782715c263b7c76c86e7a965b29f2a0119806ea4eb108d197e0c7e

This matches the LayerZero Scan payload hash for nonce 309, meaning the destination endpoint has stored the verified packet.

Amount in nonce 309

The failed executor lzReceive retries for nonce 309 encode amountSD = 40,000,000,000.

With sharedDecimals = 6, this corresponds to:

  • 40,000 rsETH

Finding 5: Nonce 309 was verified, but execution was blocked on the recipient side

Failed Ethereum executor txs:

  • 0x8509533aed1c9257242b44447daf4fc5d0c562972f366c98cea92dc531783e53
  • 0x48d9b3e8fc30c3780d3345743f5defc84ea57e5214ec9d3c4abd04f90465d792

Both are failed lzReceive attempts for nonce 309.

The first failed attempt can be replayed as an eth_call against the same block and reverts with:

  • TransfersBlocked(0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b, 2026-04-19 18:23:11 UTC)

So nonce 309 did not fail because the adapter lacked inventory. It failed because the destination-side transfer to the same recipient was blocked by token or adapter logic.

For completeness, the adapter inventory at those blocks was still sufficient to cover 40,000 rsETH:

  • block 24,908,539: 40,357.5838335 rsETH
  • block 24,908,547: 40,357.5838335 rsETH

The transfer block was set deliberately on Ethereum

The Ethereum canonical rsETH token at 0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7 contains:

  • mapping(address => uint256) public transfersBlockedUntil
  • error TransfersBlocked(address account, uint256 blockedUntil)
  • event UserTransfersBlocked(address indexed user, uint256 until)

The live transfersBlockedUntil value for recipient 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b matches the revert timestamp:

  • 1776622991 = 2026-04-19 18:23:11 UTC

That block was set in Ethereum block 24,908,522 by tx 0xa1495d12abdd7369bb92ecfc0cbdbdc2a7f631604f783abc1e985f3e682e25fc, which:

0xCbcdd778AA25476F203814214dD3E9b9c46829A1 is a GnosisSafeProxy. Its onchain owner set includes 0x7AAd74b7f0d60D5867B59dbD377a71783425af47, and the Safe threshold is 3.

So the recipient freeze was not incidental. It was an active Ethereum-side intervention through a Kelp-controlled Safe path after nonce 308 had already released funds and before nonce 309 retries.

The recipient was a freshly funded exploit intake wallet

Recipient 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b does not look like a normal operational wallet or a reused Kelp address.

After receipt of 116,500 rsETH, the wallet emptied itself in about five minutes into seven pre-staged branch wallets:

Those branch wallets also look staged. The visible sample shows them funded hours earlier from the same Tornado pool with roughly 0.0978 ETH each. The post-split flows resolve into three concrete buckets:

  • 53,400 rsETH remained on Ethereum as open Aave collateral across two wallets
  • 39,742.795185625676946836 rsETH was re-bridged onward through verified LZMultiCall
  • 23,357.204814374323053164 rsETH was monetized on Ethereum into ETH, WBTC, and wstETH before final ETH consolidation

Visible ETH proceeds then converged on fresh collector 0x5d3919f12bcc35c26eee5f8226a9bee90c257ccc. Direct branch-to-collector transfers now total 75,700.747423535750706684 ETH:

The visible pattern is therefore a purpose-built exploit cluster, not a reused operational wallet.

One branch used Aave V3 to turn rsETH into ETH and left the position open

Branch wallet 0x1F4C1c2e610f089D6914c4448E6F21Cb0db3adeF is the clearest on-Ethereum deployment leg.

It received 53,000 rsETH from the intake wallet in tx 0xe381248517921e5a576fd915e6c7b93f7ab10f3d323ea89730b28fe91d84e70a at 2026-04-18 17:37:23 UTC, then moved into Aave V3 in a tight sequence:

Against that collateral, the same wallet borrowed ETH through gateway 0xd01607c3C5eCABa394D8be377a08590149325722 in four draws:

Total borrowed: 52,440.58266541057456148 ETH.

The next tx from the same wallet, 0xe21c3603917d55641267b12b9a4539e6fa48f2b5b40f7afb50c0e6d46ce4d7f6, sent 52,440.676111351964954319 ETH to collector 0x5d3919f12bcc35c26eee5f8226a9bee90c257ccc at 2026-04-18 17:44:47 UTC. The small excess over the borrowed amount is consistent with forwarding almost the entire wallet ETH balance, not just the borrowed principal.

This was not a deposit-and-close loop. The Aave position remains open. Current Aave reads show:

  • eMode = 3
  • current rsETH aToken balance: 53,000.000000023560640817
  • current WETH variable debt: 52,442.233352651116124510
  • health factor: 1.026937684262118097
  • available borrows: 0

I did not find subsequent Repay, Withdraw, or LiquidationCall events for this wallet on the Aave pool. So this branch used stolen rsETH as Aave collateral, extracted ETH against it, forwarded that ETH to the collector, and left the leveraged position behind.

A second branch repeated the Aave pattern at smaller size

Branch wallet 0x8D11Aeac74267DD5C56d371Bf4AE1aFa174c2D49 ran the same basic strategy with a smaller allocation.

It received 5,000 rsETH from the intake wallet in tx 0x373dfc8aa9c4e4fd365a24bdf36cfb23f1770086d2568b85dcfd05467c890f23 at 2026-04-18 17:40:35 UTC, then:

This position also remains open. Current Aave reads show:

  • eMode = 3
  • current rsETH aToken balance: 400.000000000177392827
  • current WETH variable debt: 393.928786891380672123
  • health factor: 1.031790326609564651

So the wallet used 400 rsETH as still-open collateral, re-bridged 4,600 rsETH, and forwarded the borrowed ETH to the collector.

Three non-Aave branches monetized the stolen rsETH directly on Ethereum

Branch wallet 0xEBA786c9517A4823A5CfD9c72E4E80Bf8168129B split its 30,000 rsETH across two Compound V3 paths and one LayerZero bridge leg:

So this branch monetized 17,426.204814374323053164 rsETH on Ethereum and re-bridged 12,573.795185625676946836 rsETH.

Branch wallet 0xCBB24a6B4dAfaaA1A759a2F413Ea0eb6aE1455cC used Euler swap-style flows, not collateralized borrowing:

So this branch re-bridged 9,299 rsETH and directly swapped the remaining 701 rsETH into WETH, WBTC, and then ETH.

Branch wallet 0xBB6A6006eb71205E977eCEB19FcaD1c8D631C787 used direct aggregator swaps:

So this branch swapped 5,230 rsETH directly into ETH, re-bridged 770 rsETH, and left no exploit-linked rsETH exposure on Ethereum.

Two branches simply re-bridged their full allocations

Branch wallet 0x1B748B680373a1dD70A2319261328CaB2A6f644C received 8,000 rsETH in tx 0xe8748b75e9156de25b5b5225bf0d274bf31af8a3e7bdc7a14e847b42b00ce3b3, approved in tx 0x16a169d54927b5f087a29a90e030b938cc3348d9d2a0321d3097f8e7804fa3c5, and re-bridged the full 8,000 rsETH in tx 0xdde8945d6f5812ce2d68197e24fc1abcce84b6a37f3e65f4e8adc54d20d5b71f. No compensating Ethereum-side asset receipt is visible afterward.

Branch wallet 0xE9E2f48Bb0018276391AEC240ABB46e8C3CAD181 did the same with its full 4,500 rsETH, from receipt tx 0x684ccb28d1a9fd7db9c21f3bdf92691c124c17858edd45ad37e07bfd7c26cd2f through approval tx 0x49c89d3000e5271d567ff2bac8771c5a52cf554c30e9a769717f2581101656e3 into full re-bridge tx 0xb4572affd2f6c85bb5ca2839dfb6e0832568ac2750ebb312d461fd40537d8a00. Again, no later Ethereum-side asset return is visible.

The collector has not moved the extracted ETH on Ethereum

Collector 0x5d3919F12bCc35c26Eee5F8226A9bee90c257Ccc still has nonce 0.

Its visible exploit-linked inflows are the five branch transfers listed above, totaling 75,700.747423535750706684 ETH. Current balances are:

  • 75,700.758694571480554178 ETH
  • 0 rsETH

The small excess over branch inflows appears to be later dust or spam receipts. On Ethereum, the collector has not yet deployed, bridged, or forwarded the extracted ETH.

The re-bridged funds landed on Arbitrum and mostly became a second Aave extraction leg

All six Ethereum re-bridge txs used the same Kelp Ethereum -> Arbitrum pathway:

The six destination deliveries on Arbitrum were:

Five of the six wallets then immediately routed the received rsETH into Aave Arbitrum:

The straightforward Arbitrum leg was:

That pattern is clear for:

0xCBB24a6B4dAfaaA1A759a2F413Ea0eb6aE1455cC used the same base path but then added a second debt leg:

  • current aArbrsETH = 9,299.999999999999999999
  • current variableDebtArbWETH = 4,308.052246442210827272
  • current variableDebtArbwstETH = 8.131122016749303072
  • forwarded 4,317.7 E.๊“”.H to the Arbitrum collector across two txs

0x8D11Aeac74267DD5C56d371Bf4AE1aFa174c2D49 was the most complex Arbitrum path. It deposited the full 4,600 rsETH, unwound most of it, redeposited, opened both WETH and wstETH debt, and distributed wstETH across several addresses before sending 998.897 E.๊“”.H to the same collector. Its current state is:

  • rsETH dust balance: 0.000000081905225048
  • current aArbrsETH = 1,024.428942918094774949
  • current variableDebtArbWETH = 28.681503371652002426
  • current variableDebtArbwstETH = 813.113978360517124871

Across the six re-bridged branches, the currently open Arbitrum Aave exposure is:

  • 36,168.224127918094774944 aArbrsETH
  • 29,787.575856312706095675 variableDebtArbWETH
  • 821.245100377266427943 variableDebtArbwstETH

So the re-bridged funds did not leave the attack surface. They were largely turned into a second live leverage cluster on Arbitrum.

The re-bridged value also converged on a second collector:

Unlike the Ethereum collector, this Arbitrum collector is not idle. Its current nonce is 6. Its visible outbound activity so far is six zero-value txs back to the contributing branch EOAs. I do not yet have a clean explanation for that behavior from onchain data alone.

Finding 6: Only one more verified packet is currently sitting on the exact route

For the exact route

the Ethereum endpoint currently reports:

  • lazyInboundNonce(...) = 308
  • inboundNonce(...) = 309
  • inboundPayloadHash(..., 309) = 0xbf86af6f10782715c263b7c76c86e7a965b29f2a0119806ea4eb108d197e0c7e
  • inboundPayloadHash(..., 310..315) = 0x0

So on this exact route, there is one verified-but-undelivered packet still committed on the destination side: nonce 309. There is no evidence of additional committed packets beyond it.

Finding 7: This was not the only 1-of-1 Kelp inbound route into the Ethereum adapter

Scanning the LayerZero message history for Ethereum adapter 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 shows multiple Kelp inbound pathways from distinct source EIDs.

For the observed source EIDs

  • 30106
  • 30110
  • 30165
  • 30181
  • 30184
  • 30214
  • 30217
  • 30303
  • 30320
  • 30335
  • 30339
  • 30390

the Ethereum endpoint receive-library config decodes as follows:

  • 30106: requiredDVNCount = 1, optionalDVNCount = 0
  • 30110: requiredDVNCount = 1, optionalDVNCount = 0
  • 30165: requiredDVNCount = 1, optionalDVNCount = 0
  • 30181: requiredDVNCount = 1, optionalDVNCount = 0
  • 30184: requiredDVNCount = 1, optionalDVNCount = 0
  • 30214: requiredDVNCount = 1, optionalDVNCount = 0
  • 30217: requiredDVNCount = 2, optionalDVNCount = 0
  • 30303: requiredDVNCount = 1, optionalDVNCount = 0
  • 30320: requiredDVNCount = 1, optionalDVNCount = 0
  • 30335: requiredDVNCount = 1, optionalDVNCount = 0
  • 30339: requiredDVNCount = 1, optionalDVNCount = 0
  • 30390: requiredDVNCount = 1, optionalDVNCount = 0

So the Unichain 30320 -> 30101 path was not uniquely weak at the config layer. Among the Kelp inbound routes observed on this Ethereum adapter, almost all were also configured as 1-of-1 at the receive layer. The only observed exception in this set was source EID 30217, which required two DVNs.

Current nonce-parity check across other observed Kelp routes

Current chain state shows that Unichain is still the only route in this set with a true source/destination nonce mismatch. For the other resolved routes, the source outboundNonce matches Ethereum inboundNonce, and only lazyInboundNonce lags where there is ordinary committed backlog. I resolved 11 of the 12 observed source EIDs in this pass.

  • 30106 Avalanche: source outboundNonce = 77, destination lazyInboundNonce = 77, inboundNonce = 77, no verified backlog
  • 30110 Arbitrum: source outboundNonce = 3245, destination lazyInboundNonce = 3241, inboundNonce = 3245, verified backlog at 3242..3245
  • 30165 zkSync: source outboundNonce = 534, destination lazyInboundNonce = 534, inboundNonce = 534, no verified backlog
  • 30181 Mantle: source outboundNonce = 31, destination lazyInboundNonce = 31, inboundNonce = 31, no verified backlog
  • 30184 Base: source outboundNonce = 828, destination lazyInboundNonce = 824, inboundNonce = 828, verified backlog at 825..828
  • 30214 Scroll: source outboundNonce = 1597, destination lazyInboundNonce = 1597, inboundNonce = 1597, no verified backlog
  • 30217 Manta: source outboundNonce = 40, destination lazyInboundNonce = 40, inboundNonce = 40, no verified backlog
  • 30303 Zircuit: source outboundNonce = 520, destination lazyInboundNonce = 517, inboundNonce = 520, verified backlog at 518..520
  • 30320 Unichain: source outboundNonce = 307, destination lazyInboundNonce = 308, inboundNonce = 309, verified backlog at 309
  • 30335 Swell: source outboundNonce = 111, destination lazyInboundNonce = 111, inboundNonce = 111, no verified backlog
  • 30339 Ink: source outboundNonce = 94, destination lazyInboundNonce = 93, inboundNonce = 94, verified backlog at 94

That distinction matters. The Arbitrum, Base, Zircuit, and Ink routes currently show committed packets waiting to be lazily advanced or executed, but not unbacked source claims. Unichain remains the only observed route in this Kelp set where the source endpoint itself still reports a lower outbound nonce than Ethereum has already accepted.

I also checked inboundPayloadHash beyond the current backlog window on these resolved routes. I did not find extra committed payload hashes beyond the current inboundNonce.

Live Ethereum blast-radius sample for DVN 0x589dedbd617e0cbcb916a9223f4d1300c294236b

A broader live Ethereum sample shows that 0x589dedbd617e0cbcb916a9223f4d1300c294236b is not Kelp-specific.

  • In a sample of the last ~5,000 Ethereum blocks, the endpoint emitted 1,746 recent packet-verification events collapsing to 258 distinct active (receiver, srcEid) routes.
  • Querying current receive config on the top 40 active routes showed 35/40 with 0x589dedbd617e0cbcb916a9223f4d1300c294236b in requiredDVNs.
  • Most of those were not 1-of-1. The common pattern in the active sample was requiredDVNCount = 2, often with 0x589dedbd617e0cbcb916a9223f4d1300c294236b plus one other required DVN.

The important point is that 0x589dedbd617e0cbcb916a9223f4d1300c294236b appears widely as a required verifier, but the Kelp route is still unusual in the sample because it is a confirmed nonce-mismatch case on a 1-of-1 path.

The active sample also produced at least one obvious non-Kelp 1-of-1 route:

Sample nonce-parity checks on other active routes looked normal:

  • RaveDAO BSC -> Ethereum: source outboundNonce = 9346, destination lazyInboundNonce = 9346, inboundNonce = 9346
  • Stargate Base -> Ethereum: source outboundNonce = 121970, destination lazyInboundNonce = 121970, inboundNonce = 121970
  • River BSC -> Ethereum: source outboundNonce = 2834, destination lazyInboundNonce = 2834, inboundNonce = 2834
  • Kelp Unichain -> Ethereum remained the outlier: source outboundNonce = 307, destination lazyInboundNonce = 308, inboundNonce = 309

This is not a full Ethereum-wide census. It is a recent-activity sample. But it is enough to show two things at once:

Finding 8: Route ownership, delegate, and library state look stable across the last 90 days

The Unichain source contract 0xc3eacf0612346366db554c991d7858716db09f58 currently reports:

  • owner() = [0x9Fc47d6A2F5A1EFd8BaF475E1873c76D9b28dDFD](https://uniscan.xyz/address/0x9Fc47d6A2F5A1EFd8BaF475E1873c76D9b28dDFD)
  • endpoint delegate for the OApp is also [0x9Fc47d6A2F5A1EFd8BaF475E1873c76D9b28dDFD](https://uniscan.xyz/address/0x9Fc47d6A2F5A1EFd8BaF475E1873c76D9b28dDFD)
  • the verified ABI exposes no public mint(...) function or role-management surface beyond ownership and peer/config controls

Neither the Unichain source contract nor the Ethereum adapter showed a nonzero EIP-1967 implementation slot, so there is no obvious proxy-upgrade layer at those addresses.

More importantly, the exploited-route state is unchanged between the incident window and approximately 90 days earlier:

  • on Unichain at block 38,032,441 (2026-01-19 00:00:00 UTC), owner(), endpoint delegate, send library for dstEid = 30101, executor config bytes, and DVN config bytes all matched current state exactly
  • on Ethereum at block 24,265,092 (2026-01-19 00:00:11 UTC), owner(), endpoint delegate, receive library for srcEid = 30320, and DVN config bytes all matched current state exactly
  • the destination-side configType = 1 call reverted both now and at the historical block, so I cannot use that call to prove a stable executor-config byte string on the receive side

So I do not see evidence that the exploited route was quietly downgraded, peer-swapped, or re-pointed shortly before the incident. The 1-of-1 setup appears to have been longstanding, not a last-minute config change.

The limit here is event-level completeness. I did not finish a full 90-day tx-by-tx event crawl for every possible PeerSet, SetConfig, SetSendLibrary, SetReceiveLibrary, or ownership event. What I can say confidently is narrower but still useful: the live state relevant to the exploited route matches the state from 90 days earlier on both chains, so this does not look like a last-minute route downgrade, and the source token address does not present an obvious public mint hook or proxy-upgrade surface.

Timeline

Interpretation

The cleanest explanation that fits the evidence is:

  1. The Unichain -> Ethereum Kelp route was secured by a single required DVN.
  2. A structurally normal-looking packet for nonce 308 was PayloadVerified, committed, and released on Ethereum without a real source-side burn or nonce advance.
  3. A second packet for nonce 309 was separately PayloadVerified and committed only after Kelp had already frozen the original recipient, which is why the execution attempts failed.
  4. The attacker side was pre-staged: the intake wallet and its branch wallets were Tornado-funded ahead of time, and the 116,500 rsETH was dispersed in minutes into branches that re-bridged funds out or turned them into ETH.
  5. The Unichain 30320 -> 30101 route between source peer 0xc3eacf0612346366db554c991d7858716db09f58 and Ethereum adapter 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 was not uniquely weak. Among the observed inbound Kelp routes into the same Ethereum adapter, almost all were also configured as 1-of-1 at the receive layer. In a broader active Ethereum sample, 0x589dedbd617e0cbcb916a9223f4d1300c294236b appeared widely as a required DVN, but Kelp remained the only confirmed nonce-mismatch case in the sampled routes.

This rules out both of the benign interpretations:

  • the 116,500 rsETH was simply bridged out from real Unichain funds
  • the destination adapter somehow minted fresh rsETH on Ethereum

The onchain evidence instead supports a narrower description: real Ethereum-side bridge inventory was released against a claim that does not appear to correspond to any canonical source-side send on Unichain.

The remaining uncertainty is narrower than that. The evidence shows that Ethereum accepted an unbacked-looking packet on a 1-of-1 route, but it does not isolate the exact root cause inside the verification path. Because the sole required DVN is Etherscan-labeled LayerZero : DVN rather than an obviously unrelated third-party verifier, the likely fault domain is narrower than "some weak app-chosen verifier failed." The most important unresolved distinction is whether that LayerZero-associated DVN was compromised, had a verification bug, or trusted a bad upstream input on the Unichain side.

@arberx
Copy link
Copy Markdown

arberx commented Apr 18, 2026

ty ๐Ÿ

@dhruvinparikh
Copy link
Copy Markdown

๐Ÿ

@jacksanford1
Copy link
Copy Markdown

Very factual and clear writeup

@kmbarry1
Copy link
Copy Markdown

Great analysis, thanks for sharing! (as always)

@Freemandaily
Copy link
Copy Markdown

very depth analysisi

@Ch-301
Copy link
Copy Markdown

Ch-301 commented Apr 19, 2026

Thanks G

@L-KH
Copy link
Copy Markdown

L-KH commented Apr 19, 2026

๐Ÿ

@0xskr33p
Copy link
Copy Markdown

nice-smack

@davidgao-alt
Copy link
Copy Markdown

goat

@Bruno12221
Copy link
Copy Markdown

here is a live security monitor for LayerZero OFTs
https://l0-web.vercel.app/
theres way more 1-to-1 DVN routes

@anishbuilds
Copy link
Copy Markdown

v cool

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment