Skip to main content

On 9 October 2026 the XRP Ledger Foundation published a vulnerability disclosure report for xrpld 3.4.1. It covers two bugs. The second one is the reason you heard about it: an integer overflow in the payment engine that let a crafted set of offers plus a single payment mint XRP out of nothing, spendable like any other XRP, present in the code since the current payment engine was written in 2015. A researcher found it through the bug bounty programme and reported it on 22 September. There is no evidence anyone exploited it.

That is the headline, and the headline is not the interesting part. This is the interesting part, from the report itself: “The XRP Ledger has a built-in invariant (safety check) that confirms no transaction creates XRP. However, that check summed balance changes the same way, so it wrapped around identically and detected nothing.”

The ledger had a guard against exactly this. The guard was present, it was switched on, it ran on every one of those transactions, and it passed. It passed because it was built out of the same arithmetic as the bug it was supposed to catch. That is the ninety-first unasked half of fairness, and it is a question nobody asks about any verification system, including ours.

TL;DR

  • xrpld 3.4.1 fixes a 64-bit overflow in the XRP Ledger payment engine that could mint spendable XRP from nothing. Reported 22 September 2026 via the bug bounty, in the code since 2015, no evidence of exploitation.
  • The XRPNotCreated invariant, added in February 2017 specifically to catch XRP being created, used the same unchecked 64-bit accumulator. It wrapped identically and reported a normal transaction.
  • I read the fix. The invariant’s counter changes from std::int64_t to boost::multiprecision::int128_t: one line, in a file whose entire job is to be the thing that cannot be fooled.
  • A 64-bit drop counter holds 92.2 times the whole XRP supply, which I measured live at 99,985,592,917.391374 XRP. That cushion is exactly why nobody found this for a decade.
  • Satoshie runs on Base. The Chainlink VRF 2.5 coordinator it draws from has 30 source files and exactly six unchecked blocks, every one of them inside the proof verifier, none of them anywhere near a balance.
  • I ran the coordinator’s own balance invariant myself at block 52,415,984 and it held to the wei and to the juel. The point is not that it held. The point is that I could run it.

What actually went wrong

A payment on the XRP Ledger can route through the order book, consuming many offers at once. The engine sums what the buyer owes across those offers. That sum used plain 64-bit integer addition with no overflow check.

Each individual offer was valid. A few hundred of them, each asking for an enormous amount of XRP, add up to more than a signed 64-bit integer can hold. The total wrapped round to a tiny value. The engine then did the two halves of the trade inconsistently: it credited each offer owner the full amount of their offer, and it charged the buyer the wrapped total. The gap between those two numbers was new XRP that had never existed. It could then be spent from any of the receiving accounts, all of which could belong to the attacker.

The cost of mounting this was a few hundred XRP in account and offer reserves, refundable, plus ordinary fees. The large numbers in the attack are what the offer owners receive, not what the attacker has to post.

Two safety checks should have stopped it. The disclosure is unusually clear about why neither did. The per-account balance check only fires when a single account holds more than the total supply, so spreading the proceeds across hundreds of accounts walked straight past it. And the no-XRP-created invariant summed the net change across the whole transaction using the same kind of 64-bit counter, wrapped the same way, and concluded that the net change was the transaction fee. Which is what a healthy transaction looks like.

The half that gets asked

When something like this surfaces, the question everyone asks is whether a check existed. It is a reasonable question and it has a satisfying shape, because the answer is a yes or a no and you can usually find it in a repository.

Here the answer is an emphatic yes. The XRP Ledger’s invariant framework landed in commit 026a249173 on 23 February 2017, under the title “Implement transaction invariant checks (RIPD-1425)”. It is gated by an amendment called EnforceInvariants, and I confirmed it is live on mainnet by reading the ledger rather than the documentation: the Amendments object at validated ledger 107,557,556 lists 97 enabled amendment IDs, and the SHA-512-half of the string EnforceInvariants is one of them. I ran a control probe at the same time, because an absence proves nothing without one. An invented amendment name is absent, and LendingProtocolV1_1, which is currently open for voting, is also absent, while fixBatchV1_2 and BatchV1_1 are both present as of their activation on 9 October.

So: the check existed, it was enabled by validator vote, it ran, and it is one of the better-designed pieces of defensive engineering in the business. Everybody’s yes-or-no question gets a yes.

The half that does not

Nobody asks what the check is made of.

A safety check is only worth the probability that it fails differently from the thing it is checking. If the auditor and the audited share a failure mode, you do not have two layers. You have one layer and a comforting label. The payment engine’s adder overflowed silently, and the invariant that was supposed to notice was built on an adder that overflowed silently in precisely the same way, at precisely the same threshold, for precisely the same reason. The invariant was written two years after the engine, by people trying hard to be paranoid, and it inherited the one assumption that mattered.

This is a sharper version of a failure I have written about before. In the Coldcard entropy case, the good component was in the codebase but off the execution path, and auditors confirmed presence rather than invocation. This is worse, because the invariant was invoked. It did its job. It computed an answer and the answer was wrong in the direction of “everything is fine”, which is the only direction that matters.

You can state the general rule without any reference to the XRP Ledger. The value of a verifier is not in whether it runs. It is in whether its assumptions are independent of the assumptions of the thing it verifies. Nobody measures that, because it is not a yes or a no and there is no field in any audit report for it.

The one-line fix, read first-hand

I pulled the diff between tags 3.4.0 and 3.4.1 from the repository rather than taking the report’s word for it. Four commits, twelve files. The arithmetic work is one commit, 578224f2e6, dated 24 September, titled “Assorted integer-arithmetic hardening in the payment engine and ledger helpers”.

In include/xrpl/tx/invariants/InvariantCheck.h, the XRPNotCreated class changes by a single line:

-    std::int64_t drops_ = 0;
+    boost::multiprecision::int128_t drops_ = 0;

That is the whole repair to the invariant. The counter gets wider than anything the engine below it can produce, so the auditor can no longer be fooled by a number the audited code can reach. The accompanying comment in the implementation file says it plainly: “drops_ is a full-width running total, so a value that would have wrapped a 64-bit accumulator is caught here.”

The engine itself got a different treatment. A new header, MathUtilities.h, gains checkedAdd and checkedSub, which return std::nullopt when the exact mathematical result is not representable, along with about twenty compile-time assertions pinning down the boundary cases. In StrandFlow.h and BookStep.cpp, the summations that caused this were written as bare std::accumulate calls. They are now explicit loops that bail out on overflow, and a payment that would overflow ends as a clean tecPATH_DRY instead of a mint.

Two different fixes for two different roles. The engine is taught to fail loudly. The invariant is given a counter that cannot be reached from below. Both are correct, and the second one is the one I care about, because it is the only one that restores independence.

Ninety-two times the entire supply

Why did this survive a decade in the most-scrutinised payment engine outside Bitcoin?

Because the cushion looked enormous. XRP is stored in drops, a millionth of an XRP. I read the validated ledger at 08:32:31 UTC on 10 October 2026, sequence 107,557,556, and its total_coins field says 99,985,592,917,391,374 drops. A signed 64-bit integer tops out at 9,223,372,036,854,775,807 drops. The counter holds 92.247 times every XRP in existence.

No honest payment gets near that. The report says so, and the report is right. But the accumulator was not summing balances. It was summing amounts offers asked for, and what an offer asks for is not bounded by what exists. A balance is capped by supply. A request is capped by the field width and nothing else. That single distinction is the whole bug, and it generalises further than XRP: anywhere your code sums declared or requested quantities rather than held ones, the economics of your asset are not protecting you. Only the arithmetic is.

The total_coins field is worth dwelling on, because it is the external version of the same invariant and it is readable by anyone. XRP is burn-only, so the number falls and never rises. At ledger 50,000,000 in September 2019 it was 99,991,346,321.080101 XRP. Today it is 99,985,592,917.391374, so 5,753,403.69 XRP has been destroyed in fees over seven years. Across the last million ledgers alone, 26 August to today, it fell by 31,008.313614 XRP.

Here is the uncomfortable bit. Minted XRP would not have shown up in that field, because total_coins only ever moves down. The exploit’s fingerprint would have been that the sum of every XRP-holding entry on the ledger no longer equals total_coins. That check is available to anyone. It requires walking the full ledger state, which I did not do and which nothing in the normal client path does either. Reading total_coins costs one request. Checking whether it is still true costs a full-state walk, and so the ceiling gets quoted constantly and verified never.

The fix that has no amendment

One more structural detail, and it matters for anybody trying to verify this after the fact. Protocol changes on the XRP Ledger normally go through amendments: the code ships switched off, validators vote, it needs more than 80 percent support held for two weeks, then every server switches at the same ledger. That is how the ledger records its own rule changes.

This fix did not go through that process, deliberately, and the Foundation says it is the first time a transaction-processing change has shipped this way in the amendment system’s ten-year history. The reasoning is sound and they published it: xrpld is open source, so the release that carries the fix also shows where the bug is, and the amendment path would have left a critical, cheap, publicly-visible exploit live on mainnet for weeks while validators voted.

I checked this against the source rather than the announcement. The 3.4.1 diff touches features.macro, the file that defines amendments, with exactly one added line, and that line is XRPL_FIX(BatchV1_2, ...), for the other bug. There is no amendment for the overflow fix. There is no ninety-eighth entry in the on-ledger Amendments object for it and there never will be.

Which means the ledger, the thing whose entire purpose is to record which rules were in force when, does not record this one. If you want to know whether the server that validated your transaction had a correct adder, the ledger cannot tell you. You have to ask the operator what binary they were running.

What any of this has to do with a draw

Satoshie settles a coinflip as randomWords[0] % 2 and a raffle as keccak256(VRF word + prior blockhash) % ticketsMinted, with the random word arriving from Chainlink VRF and its proof verified on chain before the callback can touch the game. I have argued many times that this makes the draw a closed question. It does. It also only covers the draw.

The XRPL bug is a clean reminder that the arithmetic underneath a fair draw is a separate guarantee with a separate failure mode. A perfect random word divided by a corrupted ticket count is a corrupted raffle, which is the same reason a prize pool is a function call rather than a number, and no VRF proof anywhere in the world will tell you about it.

So I went and looked at what the coordinator we depend on is actually made of, because that is the question this disclosure teaches you to ask. The VRF 2.5 coordinator on Base at 0xd5D517aBE5cF79B7e95eC98dB0f0277788aFF634 is verified on Sourcify as a full match against both the runtime and creation bytecode: VRFCoordinatorV2_5_Optimism, solc 0.8.19, thirty source files.

Across all thirty files there are exactly six unchecked blocks. Every single one of them is in VRF.sol, the proof verifier, operating on field elements modulo the secp256k1 prime, and every one carries a comment stating the assumption it relies on. One says “Note that 0 <= p[1] < FIELD_SIZE so this cannot wrap”. Another says “Note we are relying on the wrap around here.” The accounting files, SubscriptionAPI.sol and VRFCoordinatorV2_5.sol, which hold every line that moves a balance, contain zero.

That is the difference, and it is not about word size. s_totalBalance and s_totalNativeBalance are declared uint96, which is narrow by Ethereum standards. Narrowness was never the problem. Silence was. In Solidity 0.8 an overflow in checked arithmetic reverts the transaction; on the XRP Ledger it returned a wrong number and carried on. An unchecked block is not a defect either. It is a declared assumption, written down next to the code that depends on it. What failed in 2015 was an undeclared one: a bare std::accumulate that nobody had thought of as a trust boundary.

The invariant I could actually run

Chainlink has the same kind of guard the XRP Ledger had. SubscriptionAPI.sol declares error BalanceInvariantViolated(uint256 internalBalance, uint256 externalBalance), with the comment “Should never happen”, and checks the contract’s tracked total against its real holdings.

Unlike the XRPL one, I can evaluate it from outside, so I did. At Base block 52,415,984 the coordinator reports s_totalNativeBalance of 17,799,473,318,973,886,854 wei, and eth_getBalance on the same contract at the same block returns 17,799,473,318,973,886,854 wei. The difference is zero, not approximately zero. On the LINK side, s_totalBalance is 2,308,814,578,912,558,542,442 juels and balanceOf returns the identical integer. Exact to the juel.

Four RPC calls. No permission, no operator, no binary version to take on trust. The reason that matters is not the result, which is a pass and was always likely to be a pass. It is that both operands of the comparison are public state, so the invariant is not something I have to believe ran correctly inside somebody’s process. It is arithmetic I did myself. XRPL users had no equivalent. The accumulator that failed lived in server memory, produced no artefact, and left a ledger entry byte-identical to a healthy one.

Honest limits

Nobody exploited this. No funds moved, no chain halted, and the bug bounty did exactly what a bug bounty is for. The response was fast, the amendment skip was reasoned in public, and the disclosure explains its own failures with more candour than most incident reports manage. If your takeaway is that the XRP Ledger is badly engineered, you have read the wrong post.

Satoshie is not exempt from any of this. Solidity’s checked arithmetic protects us up to the first unchecked block or the first line of assembly, and every one we write is a promise we are making on our own authority. The coordinator’s six are documented; ours have to be too, or we are running the 2015 payment engine with better syntax.

And I measured what was cheap. I read total_coins; I did not sum every account on the XRP Ledger against it. Sourcify proves the coordinator’s source matches its deployed bytecode, which is not the same as proving solc 0.8.19 has no codegen bug of its own. That regress does not end. The question is only ever whether your next layer up fails for different reasons than the layer below.

Three questions worth asking

  • For every safety check in a system you rely on, what arithmetic is the check itself made of, and can the thing it checks reach a value that breaks it?
  • Which of your sums add up quantities that someone else declared, rather than quantities that exist? Those are the ones supply does not bound.
  • Can you evaluate the invariant yourself from public state, or are you trusting that it ran correctly somewhere you cannot see?

The closer

A decade is a long time for a bug to live in a payment engine that processes billions in value, but it is not the surprising number here. The surprising number is two: the two independent safety checks that both looked at this transaction and both said it was fine, because one of them shared an adder with the bug and the other was measuring the wrong thing.

Provable fairness is the same shape of claim. It says a specific question has a specific answer that anyone can recompute. That is worth a great deal, and it is worth exactly nothing about the arithmetic standing underneath it. The useful version of the claim is not “we have a check”. It is “here are the two operands, go and subtract them yourself”. On the XRP Ledger that was impossible for a decade. On Base it took me four RPC calls this morning.

Ask what your verifier is made of. If the answer is the same thing as the code it verifies, you have one check wearing two badges.

📷 Photo by Nick Hillier on Unsplash

Valentina Ní Críonna

Author Valentina Ní Críonna

More posts by Valentina Ní Críonna