ADR-0013: Bridge security posture after the 2026 bridge losses

On 2026-04-18 the KelpDAO rsETH bridge on LayerZero lost 116,500 rsETH (about $292M), the largest loss of the year, attributed by LayerZero and Chainalysis to North Korea's Lazarus Group (TraderTraitor). The…

Status: accepted (Decisions here are binding on the mainnet design). Dated 2026-09-03.

2026-09-03. Status: accepted. Decisions here are binding on the mainnet design; the status column in docs/notes/security-hardening-program.md says what is built today and what is not. Nothing in this document claims the system cannot be hacked. It says which attacks each control defeats, which it merely makes expensive, and which it does not address.

Context

On 2026-04-18 the KelpDAO rsETH bridge on LayerZero lost 116,500 rsETH (about $292M), the largest loss of the year, attributed by LayerZero and Chainalysis to North Korea's Lazarus Group (TraderTraitor). The facts that matter for a bridge designer, from LayerZero's own incident report and the independent write-ups:

  • No smart contract was exploited. Every contract behaved as designed.
  • The bridge ran a 1-of-1 verifier configuration — a single verifier network, LayerZero Labs' own, had to attest to a cross-chain message for the destination to mint. It had been downgraded from 2-of-2 to 1-of-1 before the attack.
  • That verifier learned about the source chain through RPC nodes. The attackers compromised two of them and replaced the running binaries so they returned tampered responses to the verifier while returning correct ones to the operator's monitoring, then DDoS'd the honest RPC nodes so failover routed the verifier to the poisoned ones.
  • The verifier therefore attested that 116,500 rsETH had been locked on the source chain when nothing had been locked, and the destination minted against the forged attestation. Chainalysis's summary: supply entered circulation without backing — an accounting invariant broken by infrastructure, not by code.
  • Two follow-up attempts carrying the same packet were blocked, i.e. the protocol had no automatic limit on a first-time anomaly; humans caught the second and third.

The February 2025 Bybit loss (about $1.5B, also Lazarus) belongs to the same family from the other side: no contract bug, a compromised signing front-end showed the signers one transaction and had them sign another.

Two lessons, both about the layer around the contracts:

  1. A bridge is only as strong as what its minting path believes about the other chain. If that belief comes from an off-chain component fed by an RPC, the RPC is the security boundary, whatever the contracts say. One verifier, one RPC path, one failover policy — one point of compromise.
  2. Keys and the humans holding them are the target. Contract audits do not cover a poisoned developer laptop, a fake job offer, or a signing UI that lies.

Where Solieum already stands, and where it does not

Solieum's bridge does not have a verifier set. Value leaves the vault only against a proof: a state-tree inclusion proof of a withdrawal leaf, verified by the portal program against a root the settlement program holds as FINAL after its challenge window (ADR-0010). There is no attester to poison and no configuration that can be downgraded to "one signer". A forged withdrawal needs a forged final root, which is what the fraud-proof system (ADR-0002, ADR-0008, ADR-0011) exists to prevent.

But the deposit direction is the KelpDAO shape, and it must be said plainly. A deposit is a forced-inclusion entry on Solana (ADR-0009); the L2 node learns of it by reading the inbox through an RPC and credits the recipient on the L2. A poisoned RPC could show the node an entry that does not exist, the node would mint L2 balance with no vault backing, and the holder could later withdraw other users' locked funds against a perfectly valid proof of that fake balance. Two things stop this today, and only two: the withdrawal cannot be paid until the root carrying the fake balance is FINAL — seven days in which an honest verifier re-deriving from Solana sees a credit for an entry the inbox never held, which the derivation rule treats as a fault and the dispute game can remove; and the vault pays only what it holds. Neither is a reason to let the node be fooled in the first place.

Decisions

D1 — The minting path trusts no single RPC. Every L1 fact the node acts on for value — inbox entries and deposit records, the settlement head, the vault balance — is read from at least two independent RPC providers (different operators, different infrastructure), and the node credits a deposit only when all agree on the entry's bytes and its included state. Disagreement is not resolved by majority; it halts deposit crediting and raises the alarm. A single-endpoint configuration is refused at startup on any cluster except a local validator. Defeats: one poisoned or DDoS-failed-over RPC. Does not defeat: all configured providers compromised together, or a compromise of Solana itself.

D2 — Supply is checked against the vault before every credit, on-chain truth first. The node maintains L2_minted (the sum of credited deposits) and refuses any credit that would make L2_minted exceed the vault's lamports as read under D1. The same invariant is asserted by the verifier loop from published data, and a breach is a RootDivergence class alarm, not a log line. Defeats: the KelpDAO outcome — a mint without backing cannot become a payable withdrawal beyond what the vault holds. Does not defeat: a fake credit that stays within the vault's balance; that is caught by D1 and by the derivation fault.

D3 — Monitoring never shares infrastructure with the path it monitors. Independent verifiers use RPC providers disjoint from the sequencer's, so a poisoned endpoint that lies to the operator cannot also lie to the watcher. The sequencer's own alarms are not the security signal; a verifier's divergence alarm is. This is the exact failure at KelpDAO: the tampered node answered the monitoring honestly.

D4 — No single key over user funds, and the timelock outlasts the exit. Mainnet upgrade authority for every program is a multisig with a timelock longer than the challenge window plus the payout delay — which since ADR-0016 and ADR-0015 means longer than 48 h + 24 h = 3 days, not the seven days plus one hour this ADR was written against; ≥ 7 days is the recommendation in DEPLOYMENT.md. The rule is unchanged and it is the exit TOTAL the timelock must outlast, never the window alone, so that any upgrade can be seen and exited before it takes effect. Signers use hardware wallets with display-and-confirm; a signing front-end is never trusted to describe what is being signed (the Bybit lesson). The sequencer's receipt key, the settlement payer, and the upgrade authority are three different keys on three different machines; production keys never exist on a developer machine. Defeats: one compromised signer or laptop; a lying signing UI. Does not defeat: a quorum of signers acting together, which is why the quorum is published.

D5 — Limits that a first-time anomaly cannot exceed. Before mainnet the portal gains per-asset caps on deposits and on withdrawal velocity per window, and a large-withdrawal tier that lengthens the payout delay. The existing one-hour delay between proof and payout stays. Caps defer, they do not lose (the bridge core already implements the defer-not-lose rate limit). Defeats: a single-shot drain of the vault. Does not defeat: slow extraction under the cap, which is what D1–D3 are for.

Done in the program, 2026-09-12. Per-asset Limits record at [b"limits", mint] (lamports use the all-zero mint), admin-initialized and admin-set, holding a window, a deposit cap, a payout cap, a large-withdrawal threshold and that tier's delay. deposit and deposit_token count against the window before a lamport or token moves, so a refusal moves nothing; finalize_withdrawal and finalize_token_withdrawal count against the payout cap before paying, so a refused payout stays proven and pays next window; a withdrawal at or above the threshold waits the tier's delay, which may not be shorter than the base delay. The window rolls exactly as the bridge library's does. Zero on a cap means that axis is unlimited.

The record's address is pinned by seeds in every instruction that reads it, so a caller cannot omit it to skip the cap or aim it at another asset — and it may not exist yet, which reads as no limits. That is deliberate for an upgrade with live chains behind it, and it is the state a mainnet portal must not be left in: initializing limits for SOL and every mint is a deploy step beside installing the guardian. Harness-tested on the real binary: a payout past the cap is deferred and pays next window, a deposit past the cap moves nothing and enqueues nothing, a large withdrawal waits the longer delay, and a tier faster than the base delay is refused at configuration. Not yet deployed to any cluster; the upgrade needs the owner's buffer.

D6 — The pause can stop new value in, never proven value out. Kept exactly as built: the withdrawal path cannot read the pause flag. An emergency guardian (2-of-3, separate from the upgrade authority) may pause deposits and new roots; it cannot touch a proven withdrawal and its pause auto-expires. Defeats: a bad batch or a discovered exploit continuing. Does not defeat: an exploit already proven against a final root — by construction, nothing can, and that is the property users are paying for.

D7 — Reproducible builds and a clean-clone gate. Every on-chain program's deployed bytes must be reproducible from source with the verification command published; the gate runs on a clean checkout. This week's finding — the node's tests silently depended on git-ignored key files — is the small version of the same rot. Defeats: an unnoticed difference between audited source and deployed bytes. Does not defeat: a malicious change that is committed and audited as such.

D8 — People are in the threat model. Two-person rule for production changes; no standing production access; dependency additions reviewed by a human (cargo-deny/cargo-vet/cargo-audit in CI); developer machines that never hold production keys; a written playbook for the recruiter and "technical interview" approaches TraderTraitor uses. Defeats: the most common initial access of the last three years of large losses. Does not defeat: a determined insider, which is what D4's quorum and D3's independence are for.

D2 guards credits, and a withdrawal is not a credit (found 2026-09-11)

D2 above says it defeats "the KelpDAO outcome — a mint without backing cannot become a payable withdrawal beyond what the vault holds." That claim does not hold, and the reason is a path the rule never sees.

supply_allows is called on exactly one path: crediting an L1 deposit. Its ledger is bridged = credited − withdrawn, where credited is the sum of credited deposits. Genesis allocations are not deposits. Node::new mints them straight into the store, so they never enter credited, and nothing on L1 ever received them.

The withdrawal path then asks two questions — is this id fresh, and does the sender's L2 balance cover the amount — and never reads the vault at all. So a genesis-minted lamport is freely withdrawable, and the leaf it commits is one the portal will pay from a vault that never received it. That is the outcome D2 names, reached by a route D2 does not watch.

It happened. Beta committed 6,000,000 lamports of withdrawal leaves against a vault that could pay 5,000,000. Topped up the same afternoon and both exits covered, so no user was harmed, and the only reason the margin was small is that beta's genesis allocations were small.

This is not behind us. Gamma's genesis mints 1,000,000,000,000 lamports — a single allocation, to the sequencer's own key — against 2,000,000 ever bridged in. Five hundred thousand times more L2 balance than L1 backing, on the chain that is not being retired. Nothing is wrong with that as a test chain's funding; what is wrong is that those lamports are indistinguishable from bridged ones at the moment a withdrawal is sized, so the trap beta walked into is still open and is larger.

The accounting does not merely miss it; it mis-reads it

bridged is credited.saturating_sub(withdrawn). Withdraw genesis funds and withdrawn exceeds credited, so bridged reads zero — and the credit check then admits deposits up to the whole vault while outstanding leaves already claim part of it. The saturation is deliberate and has a test, whose comment names this very case: "withdrawals of genesis funds (more withdrawn than credited) saturate at zero". The arithmetic was understood. What went unrecorded is that saturating here fails open, because it discards the obligation rather than the surplus.

Beta's own numbers make it concrete. Credited 13,000,000 against 14,000,000 committed out, so bridged read zero against a vault of 5,695,960: a fresh deposit of 5,695,960 would have been admitted and 5,695,961 refused, while 6,000,000 was already owed against that same vault. The chain was not merely short. While it stood, it would also have credited a new deposit of everything the vault held.

The ceiling is the wrong number too

supply_allows is handed vault_lamports, which is the vault PDA's raw balance. The portal cannot pay that out: pay_withdrawal subtracts Rent::minimum_balance(vault_info.data_len()) first, and the vault must stay rent-exempt. On beta that floor was 695,960 — five million payable against a balance reading 5,695,960.

So even where D2 is bounding rather than saturated, its ceiling includes lamports L1 is forbidden to release, and it will admit a mint up to a figure the bridge could never honour.

Gamma shows it to the lamport. Its vault holds 2,695,960 against the same 695,960 floor, so 2,000,000 is payable — and 2,000,000 is exactly what has ever been bridged in. The single deposit fills the honest ceiling precisely. The guard as written would admit another 695,960; a guard that subtracted the floor would admit nothing. Gamma reads bounding, and is still measuring against the wrong number.

This is the same choice the solvency instrument makes deliberately, and the difference is worth stating because the two look identical. An instrument may be optimistic if it says so: covered ignores the rent floor, declares that in its own note, and fires only on a certain shortfall, which is right for a flag, since one that cries wolf gets turned off. A guard may not. Optimistic in an instrument means under-reported; optimistic in a guard means open.

What would actually close it

The invariant D2 wants is not credits against the vault; it is outstanding obligations against the vault, and it has to be asked on the path that creates an obligation. Two honest ways, both consensus changes; the first is now taken (see the end of this section):

  • Refuse a withdrawal the vault cannot honour. Correct, and it changes L2 semantics: a withdrawal can now fail for a reason that has nothing to do with the user's balance, which needs saying in the user-facing contract before it is true in code.
  • Keep genesis mints out of the withdrawable set. Genesis lamports are gas for a test chain, not bridged value, and treating them as unbridgeable makes the ledger honest without touching the withdrawal rule. It needs a rule for which lamports are which, which is state the chain does not currently carry.

Subtracting the rent floor from the ceiling is a third and smaller change, and it is not the one-line clamp it looks like: the node holds a balance it already fetched, not the floor, so computing it needs the vault's data length and the rent parameters — a new input on the credit path, which is a question about what that check is allowed to cost as well as a change to the transition. Worth noting for whoever takes it, because the cost turns out to be small. The vault figure is already bound once before the entry loop, so it is per drain and not per credit, and the floor can ride along with it. PortalClient::view reads the portal account and then calls get_balance on the vault; get_account on that same pubkey returns the lamports AND the data length in one call, which l1.rs already does elsewhere. So the length arrives for free and the only genuinely new request is getMinimumBalanceForRentExemption, once per drain. The credit path costs what it costs today. That answers the cost question without answering the consensus one.

Ask the cluster for the floor; do not recompute it. Deriving it from the rent parameters means reimplementing minimum_balance in the node and keeping that copy in step with the runtime's. The entire defect in this section is the guard and the portal disagreeing about one quantity, so the fix should not introduce a third opinion of it: finalize_withdrawal enforces what the cluster says, and a guard that asks the same authority cannot drift from what will actually be enforced. This is the same rule as the account leaf and the input-region layout — two implementations of one consensus value will differ eventually, and the only reliable remedy is to have one.

Read it on that cadence, do not cache it once. A floor read at startup and kept fails in the UNSAFE direction, which is the opposite of what it looks like. The ceiling is vault − floor, so a floor that is too small makes the ceiling too large. That is what happens when the floor RISES after it was read — cluster rent going up, or a portal upgrade growing the vault account — and the guard then admits a mint the bridge cannot honour, which is the failure this whole section is about. A floor that has gone too large is merely strict. Staleness here is only safe in the direction that costs nothing to be wrong about.

Done, 2026-09-12: the first route, and the ceiling with it. Taken on the owner's instruction after the migration was over, not during it. The withdrawal path now asks withdrawal_allows: what L1 still owes — leaves committed on this chain and not yet paid — plus this exit must fit inside what the vault can pay, which is its balance less the rent floor computed by the SDK's own minimum_balance on the cluster's own rent sysvar, the same function on the same input the portal enforces with. A chain with a portal and no reading yet refuses exits rather than guessing; a chain with no portal has nothing to be solvent against and records the leaf as it always did.

The credit path asks credit_allows: everything ever minted through the bridge plus this credit must fit inside everything ever deposited, which is what the vault can pay plus what it has already paid out. Nothing saturates in either rule; more paid than committed is refused as a ledger fault rather than kept as room.

The paid-out figure comes from each withdrawal's own record on L1, read once per drain for the withdrawals not yet known to be paid, beside the vault reading and at the same instant — which is what keeps the two consistent: a payout between them moves both by the same amount, and a deposit only widens the margin. The verifier asserts both rules at the head through its own readers, never the operator's.

Beta's history replays unchanged, since every credit it made was backed and both its exits were initiated before this rule existed; gamma's one credit passes the new rule, and its trillion genesis lamports are now withdrawable up to exactly what the vault can pay and not one lamport more. The characterization test that pinned the fail-open is replaced by its opposite, as its own doc comment said it should be, and a harness test walks the refusal on the embedded portal: a genesis-funded payer is admitted up to payable and refused at payable plus one, with nothing debited.

What the user-facing contract now says, because it is now true in code: a withdrawal can be refused for a reason unrelated to the sender's balance — the bridge cannot currently honour it — and the refusal costs a retry, not funds.

The second route — keeping genesis mints out of the withdrawable set — is not taken and is no longer needed for solvency; it would only matter if the chain wanted genesis lamports to be unbridgeable as a matter of policy, which is a different decision.

The instruments that preceded the enforcement stay. getBridge carries a solvency block comparing outstanding against the vault, with covered left absent rather than true when the vault could not be read (885bc03) — the distinction between an instrument and a liar. Beside it, supplyGuard reads saturated or bounding, naming the state in which D2 has stopped guarding at all (eb05c79); beta read saturated, gamma reads bounding. Both report the condition. Nothing refuses on either.

What this does not claim

  • It does not make Solieum unhackable. It moves every single point of failure the 2026 incidents exploited into a quorum, a proof, or a delay long enough to see.
  • It does not replace audits (ADR programme: two sequential audits plus a proof-system specialist before mainnet) or a bounty sized to TVL.
  • It does not address Solana-level failures (a deep reorg, a runtime bug); those are inherited, and the site says so.

Reversal trigger

If a bridge design emerges whose minting path needs no off-chain reader at all (for example, deposits credited by the L2 execution itself from L1 data carried in the batch and verified by the fraud proof), D1–D3 collapse into the proof system and this ADR is superseded. Until then the reader exists and must be treated as the security boundary it is.