RippleD 3.1.3 Fork-Inheritance Audit: Live Findings And Remediation Inventory
The contention. As it stands today, Post Fiat inherits the XRP Ledger. Our controlled testnet runs on a maintained XRPLF/rippled fork, and prior to this work the operating assumption was simple: we are live on that fork and we inherit its package updates as they ship. This article exists because we are now testing that assumption directly. We are in the midst of a full-scale security audit of the upstream codebase to settle two questions: (1) which XRPL features Post Fiat actually needs to support, and (2) whether the inherited implementation is sound enough to build on — or whether Post Fiat needs its own chain. That is the context for everything below.
Audience and intent. This report is written for Post Fiat validators; it is the evidence base for those two decisions. Over the coming weeks we will also put forth best-effort pull requests upstream to XRPLF/rippled for the findings that warrant one.
Baseline. The audit targets upstream XRPLF/rippled, baseline 3.1.3, commit 46b241ace8b30d9c9775d60ffba7d24b21903896.
What the packet contains. The packet reproduces 22 individual behaviors as local jtx cases, but they collapse to a much smaller set of distinct findings, and we count them that way. Unfixed in the checked 3.2.0-b7 / origin/develop refs: five distinct findings — three core safety/accounting defects (a baseline IOU reserve/owner-count asymmetry, an MPT lock-state deletion, an MPT transfer-rate overflow), one policy-enforcement pattern reproduced across thirteen transaction-family paths (DisallowIncoming / DepositAuth enforced on direct paths but not on indirect settlement — one architectural issue of genuinely contested severity, counted once; see Severity Calibration), and one minor issuer self-exemption. Separately, five findings were remediated after 3.1.3. A later continuation sweep adds overlay-layer, invariant-layer, and consensus-trust-model findings in their own sections below.
Proof model: each packet finding is reproduced in a clean local upstream jtx harness, bound to a named marker, checked against live amendment state from direct XRPL JSON-RPC, and backed by a static packet verifier.
Executive Summary
This is a fork-inheritance audit. The practical question is whether a new chain should inherit this code path directly, support it with local patches, or treat the underlying implementation style as too expensive to carry.
A finding only enters the inventory if it has a local reproduction wrapper, an expected marker in the OpenP0Repro proof log, a risk label, a live-amendment dependency, and remediation status checked against 3.2.0-b7 and origin/develop.
The publisher’s incentives are stated plainly. Post Fiat is building in the same authority-validator settlement lineage that made XRPL important, and this audit exists to decide whether direct RippleD inheritance is engineering leverage or inherited risk. Weigh the conclusions against what reproduces: the manifest, the live-amendment receipt, the local jtx proof markers, the remediation refs, and the verifier hash. Every claim here reduces to one of those.
The main pattern is straightforward: direct paths often enforce account policy, while indirect settlement paths sometimes reach the same ledger effect without the same checks. That shows up in offers, NFT settlement, broker fees, checks, token escrows, AMMs, AMM clawback, AMM bid refunds, MPT lock state, reserve accounting, arithmetic, and permissioned DEX invariants.
Publication Posture
This report is public because fork-inheritance claims need to be falsifiable. A private assertion that “RippleD is too risky to inherit” would be useless to downstream engineers; a packet with a manifest, exact baseline, live-surface gate, local reproducer, and verifier can be checked or refuted.
The reproduction scripts are local jtx harness wrappers that run the upstream OpenP0Repro unit-test target and assert named proof markers. They require no mainnet wallet, submit nothing to XRPL mainnet, and read no explorer state. The live-chain component is the amendment/runtime receipt used to decide whether a locally reproduced surface is relevant to current mainnet semantics.
Public disclosure still carries risk, but the report’s blast radius is materially different from a live exploit runbook. The point is to let maintainers, fork authors, auditors, and infrastructure operators distinguish three things that are often blurred together: reproducible state-transition behavior, live amendment relevance, and the policy question of whether upstream considers the behavior intended.
Severity Calibration
The scores in this report are internal fork-inheritance risk labels for our own prioritization, calibrated to one engineering question: “how dangerous or expensive is this behavior for a chain deciding whether to inherit rippled 3.1.3 semantics?” They carry no CVSS or CVE weight.
MPT-LOCK-UNAUTH-001, TRUSTLINE-POSITIVE-BALANCE-RESERVE-001, and MPT-TRANSFER-RATE-OVERFLOW-001 touch lock durability, reserve accounting, or bounded consensus arithmetic. These do not depend on a philosophical reading of issuer opt-out policy.
The DisallowIncoming and DepositAuth cases are enforcement-consistency failures: a policy the direct path enforces is bypassed by an indirect settlement path that reaches the same ledger effect. If upstream intends those flags as soft preferences, that should be specified. If they are hard account policies, enforcement belongs at the shared ledger-effect boundary, where one check covers every settlement path.
Five findings are being fixed in beta/develop. We use those landed fixes to calibrate inheritance risk: where upstream is already moving, a fork can track it; where it is not, the fork carries the surface itself.
The policy cluster remains high fork-risk even when it is not direct fund loss. A ledger-visible flag named DisallowIncomingTrustline or DepositAuth is a contract with wallets, issuers, auditors, and downstream protocol authors. If that contract only applies to some transaction families, the boundary must be explicit in the protocol specification and centralized in implementation. If it is not explicit, every new settlement path becomes a semantic trap: the direct path says “rejected,” while an indirect path reaches the same ledger effect.
That is why the count matters. Eleven similar policy findings are eleven instances of one invariant going unenforced — rediscovered across offers, NFTs, checks, escrows, AMMs, AMM clawback, and AMM auction refunds. For Post Fiat, that is the exact kind of inherited maintenance hazard a new codebase is supposed to eliminate.
How To Read The Packet
flowchart LR
A[Direct XRPL JSON-RPC<br/>server_info + feature + Amendments object]
B[Live-surface filter<br/>enabled amendments only]
C[Local upstream jtx repro<br/>rippled 3.1.3 target]
D[Marker assertion<br/>OpenP0Repro proof log]
E[Remediation check<br/>3.2.0-b7 + origin/develop]
F[Public packet<br/>manifest + script + hash]
A --> B --> C --> D --> E --> F
The packet deliberately separates three questions:
Checked through direct XRPL public JSON-RPC and the raw on-ledger Amendments object, not an explorer page.
Each finding has a wrapper under repros/ and an expected marker in the proof log.
Fix status is checked against the latest beta/ref set available in this packet, not inferred from PR titles alone.
Current Mainnet State
Direct XRPL public JSON-RPC checks were refreshed through 2026-05-28T10:28:36Z and produced the current-state packet:
- Runtime receipt:
direct_xrpl_mainnet_runtime_status_20260527.json - Amendment receipt:
direct_xrpl_amendment_status_20260527.json - Remediation receipt:
upstream_remediation_status_20260527.json - Canonical manifest:
repro_manifest.json
| Check | Result |
|---|---|
| Target release | rippled 3.1.3, commit 46b241ace8b30d9c9775d60ffba7d24b21903896 |
| Public server versions checked | s1.ripple.com and s2.ripple.com reported rippled_version=3.1.3 |
| Latest checked beta | 3.2.0-b7 |
| Live enabled surfaces used here | AMM, AMMClawback, Checks, CheckCashMakesTrustLine, Credentials, DepositAuth, DisallowIncoming, fixDisallowIncomingV1, MPTokensV1, NonFungibleTokensV1_1, PermissionedDomains, PermissionedDEX, TokenEscrow, fixMPTDeliveredAmount, fixAMMv1_3, fixTokenEscrowV1, fixAMMClawbackRounding, fixCleanup3_1_3 |
| Disabled surfaces excluded | LendingProtocol, SingleAssetVault, PermissionDelegation, Batch, fixDelegateV1_1, fixDisallowIncomingV1_1 |
| Cleanup-era gate | fixCleanup3_1_3 was enabled by raw on-ledger amendment hash, so cleanup-era-only candidates are excluded |
The Dominant Failure Pattern
flowchart TB
subgraph Direct["Direct path: policy is checked"]
A[Issuer or receiver sets policy<br/>DisallowIncomingTrustline or DepositAuth]
B[Direct transaction<br/>TrustSet or Payment]
C[Rejected<br/>policy honored]
A --> B --> C
end
subgraph Indirect["Indirect settlement path: same ledger effect through another family"]
D[Same policy already set]
E[Offer, NFT, CheckCash,<br/>TokenEscrow, AMM, AMMBid]
F[Shared balance or accountSend effect]
G[Trustline or balance changes anyway]
D --> E --> F --> G
end
C -. expected invariant .-> G
The system-level lesson is that receiver/issuer policy should live at the shared ledger-effect boundary. If each transaction family has to remember every policy check independently, every new settlement path becomes another bypass candidate. The policy cluster therefore stands as evidence that the implementation distributes policy across too many call sites.
DisallowIncomingTrustline bypassed through offers, NFTs, broker fees, checks, escrows, AMM create/deposit/withdraw, and AMM clawback paired returns.
DepositAuth bypassed through AMM clawback paired returns and AMMBid LP-token refunds.
MPT lock-state deletion, positive-balance trustline reserve drift, and MPT transfer-rate overflow.
Escrow cancellation, stale AMM authorization, MPT amount canonicalization, and permissioned DEX metadata/invariant fixes.
On The Strongest Unfixed Finding
TRUSTLINE-POSITIVE-BALANCE-RESERVE-001 is qualitatively different from the other thirteen unfixed findings in this packet, and the difference matters for fork-inheritance work. The other thirteen are all gated by recent amendments — a fork that holds back the relevant amendment surface can scope or defer the exposure. The trustline reserve drift is not gated by any amendment in the live receipt: it lives in the baseline IOU credit primitive rippleCreditIOU in src/libxrpl/ledger/View.cpp, which every IOU settlement that flows through accountSendIOU / rippleSendIOU eventually calls. A fork inheriting rippled 3.1.3 inherits this behavior whether or not it activates any surrounding amendment.
Three corroborating facts, each independently checkable in a single command (Appendix A.9 records the exact recipes):
-
Site is in the core credit primitive at 3.1.3. At commit
46b241ace8b30d9c9775d60ffba7d24b21903896,rippleCreditIOUhas a sender-side reserve-clear branch atView.cpplines 2047–2086 and no symmetric receiver-side branch. A second site,updateTrustLinelines 2881–2932, has the same defect shape but no production caller at this commit — its consumers (AMMBidLP burn,AMMWithdrawLP redeem) only drive holder balances toward zero, which is the sender-side path that is already handled. Active exposure is thereforerippleCreditIOUalone. -
Fix exists, has an open upstream PR, has not landed. PR #5867 (“Fix: positive balance trustline not incrementing owners count in some cases”, author
vvysokikh1) was opened on 2025-10-08 againstdevelop. It introduces an amendmentfixTrustLineOwnerCountand adds the missing receiver-side block. As of 2026-05-28 the PR is open and non-draft but not mergeable due to conflicts on a develop-side identifier-naming refactor; the most recent maintainer comment, dated 2026-05-07 (“what’s the status of this PR?”), has no response on the thread. The fix commitb4a45f1f0f49d3caf56d2c790960380b5e648a60is not an ancestor of the3.1.3tag, the3.2.0-b7tag, ororigin/developat the snapshot date. -
Invariant layer at 3.1.3 does not catch the resulting state.
src/xrpld/app/tx/detail/InvariantCheck.hdefines twenty-five invariant classes. The onlysfOwnerCountchecks inInvariantCheck.cppareAccountRootsDeletedClean::finalize(fires only when an account is being deleted, requiresOwnerCount == 0) andValidLoanBroker::finalize(LoanBroker-specific). There is no general invariant for “an account’sOwnerCountequals the count of its owner-directory entries” or “a trustline’s positive-balance side has its reserve flag set.” A transaction that leaves either consistency property broken therefore commits without an invariant violation.
This is a foundational accounting-integrity defect. Each occurrence leaks one trustline owner reserve that should have been charged and leaves a RippleState object with no reserve backing. It is the most consequential single item in the packet for a precise reason: it sits in the baseline IOU credit primitive, is gated by no amendment, has no landed fix, and is invisible to the invariant pass. It costs accounting integrity at a foundational layer that every fork inherits, and an uncharged-reserve drift in the core credit primitive is an expensive kind of defect to carry.
Reachability is concrete. The solidly reachable route is CashCheck settlement against a trustline that was previously cleared — the path the PR author’s own description in #5867 emphasizes. Several of this finding’s twelve markers (AMMWithdraw, AMMClawback, AMM paired-asset returns) reach rippleCreditIOU primarily along the already-handled toward-zero path and exercise the unhandled positive-transition edge only under narrower conditions. The defect at the helper level is unconditional, and the invariant layer does not catch the resulting state.
The introducing commit is datable. The sender-side reserve-clear branch in what is now rippleCreditIOU was added by commit 96733c287476b7279289e8884a357a1c827a7bf7 on 2013-03-31, subject “Add trust auto clear. Fixes #28”, author Arthur Britto. That commit added the clear-on-balance-falling-to-zero half of trust auto-clear; the matching re-acquire-on-balance-rising-into-positive half on an existing line was never added in the same change and has not been added since. The asymmetry has been carried forward through every restructuring of the IOU credit primitive — present at the introducing commit, at 46b241a (3.1.3), and at upstream/develop HEAD at the snapshot date, where rippleCreditIOU still contains a single saBefore > beast::zero && saBalance <= beast::zero sender-side branch and no symmetric receiver-side branch. Between 2013-03-31 and 2026-05-28 is approximately thirteen years and two months. The packet’s binary repros on tags 1.5.0, 2.0.0, 2.5.0, and 3.1.3 and the source-lineage report in verify_trustline_positive_balance_lineage.py (sampled refs 0.12.0 through 3.1.3) corroborate that the same shape has been live across that span.
Inventory
Read the Risk column as internal fork-inheritance risk. For the policy cluster, the score reflects repeated cross-path enforcement drift and downstream audit burden; for the core safety/accounting findings, it reflects direct state-safety impact. The near-uniform 8.0 across the policy cluster is a deliberate scoring choice: these are facets of one architectural concern — the same receive-policy invariant rediscovered across transaction families — scored once and listed across thirteen paths only to show the pattern’s breadth. Whether upstream intends those ledger flags as hard invariants or soft preferences is a specification question the protocol should answer; for a chain deciding what to inherit, an unspecified policy boundary is itself the risk.
| ID | Risk | Status | Surface | Exploit class | Repro |
|---|---|---|---|---|---|
| MPT-LOCK-UNAUTH-001 | 8.2 | No confirmed fix | MPTokensV1 |
Lock-state deletion | sh |
| TRUSTLINE-POSITIVE-BALANCE-RESERVE-001 | 8.1 | No confirmed fix | Baseline IOU trustlines | Reserve/owner-count bypass | sh |
| TRUSTLINE-DISALLOW-INCOMING-OFFER-001 | 8.0 | No confirmed fix | DisallowIncoming + offers |
Issuer policy bypass | sh |
| NFTOKEN-DISALLOW-INCOMING-ACCEPT-001 | 8.0 | No confirmed fix | NFT settlement | Issuer policy bypass | sh |
| NFTOKEN-BROKER-FEE-DISALLOW-INCOMING-TRUSTLINE-001 | 8.0 | No confirmed fix | NFT broker fee | Issuer policy bypass | sh |
| CHECKCASH-DISALLOW-INCOMING-TRUSTLINE-001 | 8.0 | No confirmed fix | Checks | Issuer policy bypass | sh |
| TOKENESCROW-DISALLOW-INCOMING-FINISH-001 | 8.0 | No confirmed fix | TokenEscrow | Issuer policy bypass | sh |
| AMMWITHDRAW-DISALLOW-INCOMING-TRUSTLINE-001 | 8.0 | No confirmed fix | AMM withdraw | Issuer policy bypass | sh |
| AMMCREATE-DISALLOW-INCOMING-TRUSTLINE-001 | 8.0 | No confirmed fix | AMM create | Issuer policy bypass | sh |
| AMMDEPOSIT-EMPTY-DISALLOW-INCOMING-TRUSTLINE-001 | 8.0 | No confirmed fix | AMM empty-pool deposit | Issuer policy bypass | sh |
| AMMCLAWBACK-DISALLOW-INCOMING-PAIRED-ASSET-001 | 8.0 | No confirmed fix | AMMClawback | Issuer policy bypass | sh |
| AMMCLAWBACK-DEPOSITAUTH-PAIRED-ASSET-001 | 8.0 | No confirmed fix | AMMClawback | Holder receive-policy bypass | sh |
| AMMBID-DEPOSITAUTH-REFUND-001 | 8.0 | No confirmed fix | AMMBid | Holder receive-policy bypass | sh |
| MPT-TRANSFER-RATE-OVERFLOW-001 | 7.4 | No confirmed fix | MPTokensV1 |
Arithmetic overflow | sh |
| ESCROW-CANCEL-IOU-001 | 8.1 | Remediated after 3.1.3 | TokenEscrow | Deterministic exception | sh |
| AMM-STALE-AUTH-001 | 8.0 | Remediated after 3.1.3 | AMM | Stale authorization state | sh |
| MPT-NONCANONICAL-AMOUNT-001 | 7.6 | Fixed in develop, not confirmed in 3.2.0-b7 |
MPTokensV1 |
Non-canonical amount validation | sh |
| PDEX-HYBRID-QUALITY-001 | 7.7 | Remediated after 3.1.3 | PermissionedDEX | Order-book metadata corruption | sh |
| PDEX-CANCEL-INVARIANT-001 | 7.5 | Remediated after 3.1.3 | PermissionedDEX | Valid transaction invariant failure | sh |
| AMMBID-DISALLOW-INCOMING-REFUND-001 ⓦ | 8.0 | No confirmed fix | AMMBid | Issuer policy bypass | sh |
| NFTOKEN-ACCEPT-DEPOSITAUTH-001 ⓦ | 8.0 | No confirmed fix | NFT settlement | Holder receive-policy bypass | sh |
| NFTOKEN-OFFER-ISSUER-SELF-FREEZE-001 ⓦ | 6.0 | No confirmed fix | NFT offers | Issuer self-exemption gap | sh |
(ⓦ marks Wave 3 supplementary findings added 2026-05-28; see Appendix A.6 for verification details.)
Finding Cards
Unfixed In Checked 3.2.0-b7 / origin-develop
MPT-LOCK-UNAUTH-001
MPT locked holder lock-state deletion
- What is this?
- MPTokens are XRPL multi-purpose tokens. Issuers can authorize holders and mark holder token objects as locked.
- Why it matters
- A lock should be durable issuer control, not state a holder can erase by deleting and recreating its token object.
- Bug
- A holder can
tfMPTUnauthorizea locked zero-balance MPToken, deleting the lock state, then re-authorize withoutlsfMPTLocked. - Intended behavior
- Locked-token deletion should preserve the issuer lock or reject deletion while locked.
- Actual behavior
- The reproduced path deletes the locked holder object and recreates it unlocked.
- Remediation
- Enforce locked MPToken deletion checks in the MPT authorization path.
TRUSTLINE-POSITIVE-BALANCE-RESERVE-001
Positive IOU balance without receiver owner reserve
- What is this?
- XRPL IOUs live on trustlines; a positive holder balance normally creates owned ledger state and consumes owner reserve.
- Why it matters
- Reserve accounting is XRPL's anti-state-spam mechanism. Positive balance without owner reserve means durable ledger state exists without the normal cost.
- Bug
- Offer crossing can give a holder a positive IOU balance while
OwnerCountstays zero and the receiver reserve flag stays unset. - Intended behavior
- A receiver crossing from non-positive to positive balance should pay owner reserve or the transaction should fail.
- Actual behavior
- The reproduced path creates the positive balance without the reserve-side accounting.
- Remediation
- Charge receiver owner reserve on the balance transition, or fail if reserve is unavailable.
TRUSTLINE-DISALLOW-INCOMING-OFFER-001
OfferCreate bypasses issuer DisallowIncomingTrustline
- What is this?
asfDisallowIncomingTrustlineis an issuer flag intended to block new incoming trustlines.OfferCreateis the DEX path for crossing IOU offers.- Why it matters
- If direct
TrustSetis blocked but DEX settlement creates the same trustline, the issuer policy is not actually enforced. - Bug
- An issuer can block direct trustline creation, but a taker without a trustline can still cross an offer and receive the issuer IOU.
- Intended behavior
OfferCreateshould apply the same incoming-trustline opt-out check before creating the trustline.- Actual behavior
- Direct
TrustSetis rejected, then offer crossing creates the trustline anyway. - Remediation
- Reject offer acceptance that would create a blocked issuer trustline.
NFTOKEN-DISALLOW-INCOMING-ACCEPT-001
NFTokenAcceptOffer bypasses issuer DisallowIncomingTrustline
- What is this?
NFTokenAcceptOffersettles NFT sales and can pay the seller in an issued IOU.- Why it matters
- NFT settlement should not be a second route to create a trustline that direct issuer policy forbids.
- Bug
- The seller can receive an issuer IOU through NFT settlement despite the issuer setting
asfDisallowIncomingTrustline. - Intended behavior
- NFT IOU settlement should enforce the issuer's incoming-trustline opt-out.
- Actual behavior
- Direct
TrustSetis rejected, butNFTokenAcceptOffercreates the seller trustline. - Remediation
- Add the same issuer-policy check to NFT IOU settlement.
NFTOKEN-BROKER-FEE-DISALLOW-INCOMING-TRUSTLINE-001
NFToken broker fee bypasses issuer DisallowIncomingTrustline
- What is this?
- Brokered NFT settlement can pay a broker fee in an issuer IOU.
- Why it matters
- Broker fees are easy to miss because the broker is neither buyer nor seller; this tests whether receive-policy enforcement is centralized.
- Bug
- The broker can receive an issuer IOU fee and get a new trustline despite the issuer opt-out.
- Intended behavior
- Broker-fee payment should enforce
asfDisallowIncomingTrustlinebefore creating a broker trustline. - Actual behavior
- The broker-fee path creates the trustline through settlement.
- Remediation
- Apply issuer-policy checks to NFT broker-fee IOU payment.
CHECKCASH-DISALLOW-INCOMING-TRUSTLINE-001
CheckCash bypasses issuer DisallowIncomingTrustline
- What is this?
- Checks allow delayed settlement; with
CheckCashMakesTrustLine, cashing an IOU check can create the receiver trustline. - Why it matters
- Delayed settlement should not bypass the same issuer policy that direct trustline creation must obey.
- Bug
CheckCashcan create an incoming trustline to an issuer that has opted out of new incoming trustlines.- Intended behavior
CheckCashshould reject IOU cashing when it would create a blocked trustline.- Actual behavior
- Direct
TrustSetis blocked, then the check-cash path creates the trustline. - Remediation
- Add issuer-policy checks to automatic trustline creation during
CheckCash.
TOKENESCROW-DISALLOW-INCOMING-FINISH-001
EscrowFinish bypasses issuer DisallowIncomingTrustline
- What is this?
- TokenEscrow releases issued assets when
EscrowFinishcompletes. - Why it matters
- Escrow completion is non-interactive for the destination; it should not force a policy-blocked trustline onto the account.
- Bug
EscrowFinishcan deliver an IOU and create a destination trustline despite issuerDisallowIncomingTrustline.- Intended behavior
- Finishing an IOU escrow should enforce the issuer's incoming-trustline opt-out.
- Actual behavior
- Direct
TrustSetis rejected, then escrow completion creates the trustline. - Remediation
- Add issuer-policy checks to TokenEscrow finish settlement.
AMMWITHDRAW-DISALLOW-INCOMING-TRUSTLINE-001
AMMWithdraw bypasses issuer DisallowIncomingTrustline
- What is this?
AMMWithdrawreturns pooled assets to a liquidity provider.- Why it matters
- AMMs are a major indirect settlement surface. If withdrawal skips issuer policy, liquidity mechanics can create blocked trustlines.
- Bug
- A withdrawal can send an issuer IOU to an account with no trustline even after the issuer has opted out.
- Intended behavior
- AMM withdrawal should enforce issuer trustline policy before creating a receiver trustline.
- Actual behavior
- The AMM withdrawal path creates the trustline through
accountSend. - Remediation
- Apply issuer-policy checks to AMM withdrawal sends.
AMMCREATE-DISALLOW-INCOMING-TRUSTLINE-001
AMMCreate bypasses issuer DisallowIncomingTrustline
- What is this?
AMMCreatecreates the special AMM account and the initial pool.- Why it matters
- Pool creation creates durable ledger state. It should not create an AMM-account trustline to an issuer that opted out.
- Bug
- A pool can be created for an issuer IOU despite issuer
DisallowIncomingTrustline. - Intended behavior
- AMM creation should reject pool creation when it would create a blocked issuer trustline.
- Actual behavior
- The AMM account trustline is created through the pool creation path.
- Remediation
- Apply issuer-policy checks to AMM account trustline creation.
AMMDEPOSIT-EMPTY-DISALLOW-INCOMING-TRUSTLINE-001
AMMDeposit empty-pool bypass
- What is this?
AMMDepositwithtfTwoAssetIfEmptycan reinitialize an empty pool.- Why it matters
- Reinitialization is a lifecycle edge case where old state is recreated; those paths must re-run the same policy checks as first creation.
- Bug
- Empty-pool reinitialization can recreate an AMM trustline to an issuer that has opted out.
- Intended behavior
- Empty-pool deposit should enforce issuer policy before recreating the AMM account trustline.
- Actual behavior
- The reinit path recreates the trustline despite
DisallowIncomingTrustline. - Remediation
- Apply issuer-policy checks to empty-pool reinitialization.
AMMCLAWBACK-DISALLOW-INCOMING-PAIRED-ASSET-001
AMMClawback paired-asset DisallowIncoming bypass
- What is this?
AMMClawbacklets issuer A claw back its asset from a two-asset AMM pool, which can return issuer B's paired asset to a holder.- Why it matters
- Cross-issuer AMM operations must respect both issuers' policies, not only the issuer initiating the clawback.
- Bug
- Issuer A's clawback can force-return issuer B's IOU to a holder after issuer B opted out of incoming trustlines.
- Intended behavior
- Returning the paired asset should enforce issuer B's trustline policy.
- Actual behavior
- The paired asset is returned and the issuer B trustline is recreated.
- Remediation
- Apply issuer-policy checks to paired-asset returns in AMM clawback.
AMMCLAWBACK-DEPOSITAUTH-PAIRED-ASSET-001
AMMClawback paired-asset DepositAuth bypass
- What is this?
DepositAuthis a receiver-side flag that requires authorization before unsolicited funds can be delivered.- Why it matters
- A protocol-generated AMM return is still a delivery to the receiver; it should not bypass the receiver's no-unsolicited-deposits policy.
- Bug
- AMM clawback can force-return a paired IOU to a holder that rejects direct payment under
DepositAuth. - Intended behavior
- AMM clawback should enforce the holder's receive authorization before delivering paired assets.
- Actual behavior
- Direct payment is rejected, but the AMM clawback return delivers the asset.
- Remediation
- Apply
DepositAuthchecks to paired-asset returns.
AMMBID-DEPOSITAUTH-REFUND-001
AMMBid auction refund bypasses DepositAuth
- What is this?
AMMBidreplaces the current AMM auction-slot owner and refunds LP tokens to the previous owner.- Why it matters
- The previous owner is not signing the later bid. Protocol-generated refunds still need to obey receiver policy.
- Bug
- The previous owner can set
DepositAuth, reject direct LP-token payment, and still receive an LP-token refund through a laterAMMBid. - Intended behavior
- AMM bid refunds should respect the previous owner's
DepositAuthstate. - Actual behavior
- The refund path delivers LP tokens despite the receiver policy.
- Remediation
- Apply
DepositAuthchecks to AMM bid refunds.
MPT-TRANSFER-RATE-OVERFLOW-001
MPT transfer-rate scaling overflow
- What is this?
- MPT transfer rates scale token movements to account for issuer transfer fees.
- Why it matters
- Consensus transaction code should not throw arithmetic exceptions on transaction amounts; it should compute deterministically or reject cleanly.
- Bug
- A large integral MPT amount with a 1.5 transfer rate reaches a scaled-mantissa overflow path.
- Intended behavior
- Transfer-rate math should be bounded and deterministic, or fail before application.
- Actual behavior
- The reproduced path hits an
overflow_error. - Remediation
- Route MPT transfer-rate math through bounded consensus arithmetic.
Remediated Or Remediating After 3.1.3
ESCROW-CANCEL-IOU-001
EscrowCancel deleted IOU trustline exception
- What is this?
- TokenEscrow cancellation should unwind escrow accounting after normal trustline lifecycle changes.
- Why it matters
- Cancellation should not strand state or throw a deterministic exception because a related trustline was deleted.
- Bug
- Canceling an IOU escrow after sender trustline deletion returns
tefEXCEPTION/ owner-count template-field failure. - Intended behavior
- Escrow cancellation should account from durable account state, not require the old trustline to still exist.
- Actual behavior
- The cancellation path depends on deleted trustline state and throws.
- Remediation
- Patched after 3.1.3 by using the account ledger entry for cancellation accounting; confirmed in
3.2.0-b7andorigin/develop.
AMM-STALE-AUTH-001
AMM stale AuthAccounts after empty reinit
- What is this?
- AMM auction authorization state controls the current discounted trading slot.
- Why it matters
- Empty-pool reinitialization should not inherit privilege metadata from a prior pool lifecycle.
- Bug
- Reinitializing an empty AMM leaves stale
sfAuthAccountsfrom the previous auction slot. - Intended behavior
- Empty-pool reinit should clear stale auction authorization state.
- Actual behavior
- The old authorization list survives into the new pool lifecycle.
- Remediation
- Patched after 3.1.3 by clearing
AuthAccounts; confirmed in3.2.0-b7andorigin/develop.
MPT-NONCANONICAL-AMOUNT-001
Non-canonical MPT amount reaches ledger engine
- What is this?
- XRPL amount encodings are supposed to be canonical before ledger application.
- Why it matters
- Malformed values should fail preflight, not reach fee-burning application paths.
- Bug
- A non-canonical MPT amount reaches transaction application and returns
tecPATH_PARTIALinstead oftemBAD_AMOUNT. - Intended behavior
- Non-canonical MPT amounts should be rejected before application.
- Actual behavior
- The malformed amount reaches the ledger engine and burns a fee.
- Remediation
- Patched in
origin/develop; not confirmed in checked3.2.0-b7.
PDEX-HYBRID-QUALITY-001
Permissioned-DEX hybrid-offer quality mismatch
- What is this?
- Permissioned DEX hybrid offers are indexed by quality for matching and settlement metadata.
- Why it matters
- Offer quality is not cosmetic; mismatched quality changes order-book interpretation and can corrupt market metadata.
- Bug
- A partially crossed hybrid offer leaves its open-book directory key at one quality while
sfExchangeRaterecords another. - Intended behavior
- Directory key quality and
sfExchangeRateshould agree after partial crossing. - Actual behavior
- The reproduced path leaves those values inconsistent.
- Remediation
- Patched after 3.1.3 by fixing hybrid offer placement and metadata repair; confirmed in
3.2.0-b7andorigin/develop.
PDEX-CANCEL-INVARIANT-001
Permissioned-DEX regular-offer cancel invariant failure
- What is this?
- Permissioned DEX offers can cancel or interact with regular offers from the same account.
- Why it matters
- Invariants should catch impossible ledger mutation, not reject a valid transaction because two offer families interact.
- Bug
- A valid domain
OfferCreatethat cancels a regular offer fails withtecINVARIANT_FAILED. - Intended behavior
- The invariant should permit the valid deletion caused by the domain offer path.
- Actual behavior
- The invariant treats the deleted regular offer as forbidden mutation.
- Remediation
- Patched after 3.1.3 by updating the permissioned-DEX invariant; confirmed in
3.2.0-b7andorigin/develop.
AMMBID-DISALLOW-INCOMING-REFUND-001
AMMBid auction refund bypasses recipient DisallowIncomingTrustline
- What is this?
asfDisallowIncomingTrustlineis an account flag intended to block creation of new trustlines on this account. AMMBid refunds LP tokens to the previous auction-slot holder viaaccountSend.- Why it matters
- If a holder closes their LP trustline and sets DisallowIncoming, the AMM auction refund forces a new LP trustline despite the explicit opt-out.
- Bug
- The refund routes through
accountSend→rippleCreditIOU→trustCreate;trustCreateatView.cpp:1733-1782has zero references tolsfDisallowIncomingTrustline. - Intended behavior
- AMMBid should respect the previous holder's incoming-trustline policy when refunding.
- Actual behavior
- A new LP trustline is created on the unwilling recipient, with the refund balance.
- Remediation
- Gate the refund through a wrapper that checks
lsfDisallowIncomingon the destination, or fold into afixDisallowIncomingV1_1amendment.
NFTOKEN-ACCEPT-DEPOSITAUTH-001
NFTokenAcceptOffer bypasses recipient lsfDepositAuth
- What is this?
asfDepositAuthis an account flag intended to block incoming payments.NFTokenAcceptOffer::paydelivers the buyer's IOU payment to the seller viaaccountSend.- Why it matters
- A seller who set DepositAuth still receives the buyer's payment as a side effect of NFT settlement — the receive-policy opt-out is silently bypassed.
- Bug
- Exhaustive grep of
NFTokenAcceptOffer.cppandNFTokenUtils.cppfinds zeroverifyDepositPreauthcalls.checkTrustlineAuthorized(RequireAuth) andcheckTrustlineDeepFrozenexist;DepositAuthdoes not. - Intended behavior
NFTokenAcceptOffer::payshould callverifyDepositPreauthbeforeaccountSend, returningtecNO_PERMISSIONif the destination haslsfDepositAuthand the sender is not preauthorized.- Actual behavior
- The seller's
asfDepositAuthflag is ignored. - Remediation
- Add the
verifyDepositPreauthcheck, gated behind a fix amendment.
NFTOKEN-OFFER-ISSUER-SELF-FREEZE-001
Issuer NFTokenCreateOffer in own currency blocked by own GlobalFreeze
- What is this?
- The XRPL freeze documentation states issuers can transact with their own currency even during their own global freeze (cf.
CashCheck.cpp:176"an issuer can always accept their own currency";DirectStep.cpp:906"pure issue/redeem can't be frozen"). - Why it matters
- An issuer that mints NFTs and issues an IOU cannot create an NFT offer in their own currency while their own GlobalFreeze is active — violating the documented issuer-exemption.
- Bug
NFTokenUtils.cpp:941callsisFrozen(acctID, currency, amount.getIssuer())with no issuer-exemption guard above it. When all three arguments resolve to the same account, the GlobalFreeze early return inisFrozenfires before theissuer != accountcheck is reached.- Intended behavior
- Skip the freeze check when
acctID == amount.getIssuer(), mirroring the pattern atCashCheck.cpp:176. - Actual behavior
- The offer is rejected with
tecFROZENeven though the protocol exempts issuers from their own freeze. - Remediation
- Add an issuer-exemption guard at the line-941 and line-927 sites in
tokenOfferCreatePreclaim, gated under a new fix amendment.
Evidence Packet
| Evidence object | Link |
|---|---|
| Packet index | AUDIT_PACKET.md |
| Canonical manifest | repro_manifest.json |
| Direct XRPL amendment receipt | direct_xrpl_amendment_status_20260527.json |
| Direct XRPL runtime receipt | direct_xrpl_mainnet_runtime_status_20260527.json |
| Upstream remediation receipt | upstream_remediation_status_20260527.json |
| Live-only triage | live_p0_hunt_v2_triage.md |
| Static packet verifier | verify_packet.py |
| Common repro runner | run_repro.sh |
| Proof extract | live_mainnet_enabled_proof_extract_20260527_v23.log |
Proof extract hash:
3ad276376bc6a04b7ddc335144d57d9297fedb9a09022af57095967efa939769
Reproduction Model
Run the static packet verifier:
cd /home/postfiat/repos/agtico.github.io/assets/research/xrpl-rippled-p0-audit
python3 verify_packet.py
Run one finding:
cd /home/postfiat/repos/agtico.github.io/assets/research/xrpl-rippled-p0-audit
./repros/TRUSTLINE-POSITIVE-BALANCE-RESERVE-001.sh
Expected proof footer:
ripple.tx.OpenP0Repro had 0 failures.
70 cases, 16752 tests total, 0 failures
ripple.tx.OpenP0ReproCrash had 0 failures.
1 case, 12 tests total, 0 failures
The per-finding wrapper reads repro_manifest.json, runs the upstream local jtx proof suite, asserts the targeted marker, and requires the zero-failure proof footer.
Overlay-Layer And Invariant Findings (Continuation Sweep)
The packet above is transaction-apply-layer work. A continuation pass extended the same four-trap method to two surfaces a fork inherits wholesale, independent of amendment state: the peer/overlay network layer and the invariant layer. The distinction that matters here is structural. The transaction-apply path is wrapped in three layers of try/catch (applySteps.cpp, apply.cpp, BuildLedger.cpp), so a thrown exception during transaction processing is caught and the transaction simply fails. The overlay layer has no such backstop: an unhandled exception on a peer-read strand reaches std::terminate(). The findings below are stated at their real severity.
Two memory-safety bugs in the [ledger_replay] surface — opt-in, off by default. The ledger-replay feature (Config::LEDGER_REPLAY, experimental since rippled 1.7.0, default off) processes peer-supplied TMProofPathResponse / TMReplayDeltaResponse messages. LedgerReplayMsgHandler::processProofPathResponse (LedgerReplayMsgHandler.cpp:99) calls deserializeHeader on a present-but-truncated ledgerheader with no try/catch on the peer strand; SerialIter throws on buffer underrun and the throw propagates to std::terminate(). This is binary-reproduced (LedgerReplay_test::testShortHeaderCrash): a connected peer, unauthenticated, crashes any node running [ledger_replay], and can crash-loop it on reconnect. A second, lower-severity bug in the same path (verifyProofPath, a depth-64 off-by-one) performs a one-byte out-of-bounds read whose result is then masked. Both require the operator to have enabled [ledger_replay]; a default node never processes these messages. They are a P0 and a hygiene defect for replay-enabled operators, not network-default. The fix is a try/catch around deserializeHeader, mirroring the guard InboundLedger::processData already has on the default-config sibling path.
One overlay concurrency defect — default config, low impact. PeerImp::doAccept makes an inbound peer broadcast-visible (overlay_.activate(), PeerImp.cpp:784) before issuing the handshake-response async_write (:803), and that write bypasses the send_queue_ machinery that elsewhere guarantees a single outstanding write per stream. A relayed validation/proposal/transaction landing in that window starts a second concurrent async_write on the same TLS stream — an Asio single-writer violation. The overlap reproduces at runtime (OverlayConcurrentWrite_test), but it does not crash the node: both writes share the peer strand, so the underlying SSL_write calls serialize in time and never access the OpenSSL object concurrently. The realistic effect is out-of-order ciphertext on that one connection, which drops the affected peer link while the node keeps running. The fix is to defer activate() until the handshake write completes. We flag this at its corrected severity (≈P2) precisely because the first-pass theory of a network-wide crash did not survive its own reproduction.
Invariant-layer gaps. Consistent with the trustline-reserve finding, the invariant pass has coverage holes. InvariantCheck.cpp overwrites its violation accumulator (bad_ =) instead of OR-ing it (bad_ |=) in the MPT amount check, so a violation flagged by one ledger entry can be masked by a later in-range entry in the same transaction. There is no invariant enforcing MPT OutstandingAmount <= MaximumAmount or MPT balance conservation. And the AMM invariant does not verify the constant-product relation on Payment / OfferCreate — only on AMM-management transactions. No exploit is demonstrated through these; they are the same architectural pattern as the reserve finding — the invariant layer trailing the transaction layer — and they are what a fork inherits as latent debt.
The fork-inheritance reading of this section is narrow and concrete: the only memory-safety crashes are in opt-in code, but that code is materially less hardened than the default paths, and the invariant layer that should be the last line of defense has documented gaps. A chain that enables ledger replay, or that builds on this overlay and invariant code, inherits exactly those weaknesses.
The Inherited Trust Model: Consensus Safety Is Not Code-Enforced
Everything above is implementation. This is the architecture — and for a fork-inheritance decision it is the finding that matters most. XRPL’s fork-freedom guarantee has two halves: each node requires a local quorum (≥80% of its own trusted validators), and every pair of correctly-functioning nodes must run trusted-validator lists (UNLs) that overlap by ≥~90%. rippled enforces the first in code and never checks the second.
ValidatorList::calculateQuorum (src/xrpld/app/misc/detail/ValidatorList.cpp:1718) computes quorum entirely from the local node’s own UNL — max(0.8 × effectiveUNL, 0.6 × unlSize). There is no term for, and no runtime access to, any other node’s UNL. The overlap precondition — the actual fork-freedom requirement — is never evaluated. There is no minimum UNL size (a UNL of one yields quorum one), and the --quorum override only logs “potentially unsafe” and obeys. Two nodes, or two publisher lists, whose UNLs drift apart each compute a valid local quorum, each mark their own ledger validated (LedgerMaster::checkAccept), and the network forks — no error, no halt.
Reproduced in rippled’s own simulator
This is not an external model. rippled ships a consensus simulation framework (src/test/csf, the same one upstream Consensus_test::testFork uses). We drive it across decreasing UNL overlap between two validator cliques and let rippled’s own algorithm decide the outcome (src/test/consensus/UNLOverlapImplosion_test.cpp, run with --unittest=UNLOverlapImplosion; output is deterministic and byte-identical across runs):
overlap |UNL_A| |UNL_B| shared synced? branches verdict
100% 20 20 20 yes 1 SAFE (one ledger)
...
20% 12 12 4 yes 1 SAFE (one ledger)
10% 11 11 2 no 2 *** FORKED *** (split brain)
0% 10 10 0 no 2 *** FORKED *** (split brain)
In a benign two-clique partition with no adversary and no latency the visible split appears only at extreme overlap loss — which is precisely the point: the degradation is gradual and silent, and nothing in the code rejects the unsafe trust topology. Under real adversarial conditions (Byzantine validators, network delay) the unsafe zone widens toward the ~90% margin; the benign case is the conservative floor.
The same mechanism, described by the people who built it
This is well-documented. XRPL’s own documentation cites the research that competing UNLs “may need 90% overlap in the worst case to prevent a fork,” and co-creator David Schwartz describes consensus legitimacy as flowing through trust lists and validator coordination, with UNL alignment and economic adoption determining which ledger survives a split. Schwartz frames it as a strength: because servers default to the same publisher-curated UNL, manufacturing a rival chain requires five separate layers — old-rule validators, a rival UNL, an old-rule code distribution, infrastructure support, and market recognition.
We agree with that description completely. The difference is the lens. The property that makes XRPL fork-resistant is a hard dependency on a trust-and-coordination layer anchored in a handful of centrally-published validator lists (vl.ripple.com, vl.xrplf.org). For an operator inside that convention it is stability. For an entity deciding whether to inherit it, it is the central question: the ledger’s safety is not self-enforcing — it rests on a UNL you do not control, with no code backstop if overlap ever degrades.
The real-world record
The mechanism is not hypothetical. In February 2025 the network halted for ~64 minutes at ledger 93,927,173 — consensus kept running but validations stopped publishing, the network fragmented (“drift”), and recovery required validator operators to manually select a restart point and resume. No funds were lost, but the episode sparked debate over consensus tradeoffs and the centralization “between a relatively small number of trusted validators.” In March 2026, Common Prefix disclosed two consensus liveness bugs in transaction-set handling (a SHAMap-node crash and a malformed-transaction relay crash) that a compromised UNL validator could use to stall forward progress; both were fixed in rippled 3.0.0 (December 2025) — before our 3.1.3 baseline, consistent with our finding that the default-config deserialization and SHAMap paths are now hardened.
The pattern across both: XRPL deliberately trades liveness for safety — it halts rather than forks — and recovery depends on the trusted validator operators coordinating by hand. That is the centralization in operation.
What this means for the decision
- Inherit the canonical chain and ride the amendment treadmill. Servers that fall behind an activation (e.g.
fixCleanup3_1_3, May 27) become amendment-blocked: unable to determine ledger validity, process transactions, or participate in consensus until upgraded. “We inherit package updates” is not passive — missing one makes the node a non-participant, and the ledger’s safety remains a function of a UNL Post Fiat does not publish. - Run an independent chain, which — by Schwartz’s own five layers — means standing up Post Fiat validators, a Post Fiat UNL, a code distribution defaulting to it, and the surrounding infrastructure. The cost is real, but it converts an inherited, uncontrolled trust dependency into one Post Fiat governs.
The C++ is largely sound. What the decision turns on is whose validator list the ledger’s safety depends on.
Upstream And Disclosure Boundary
This report does not claim to speak for Ripple, XRPLF, or upstream maintainers. It records our reproducibility packet and the upstream source state we checked.
Upstream may classify these variously — some as bugs, some as amendment-semantics changes. We classify them by inheritance risk: the DisallowIncoming and DepositAuth cluster is an architectural critique of distributed policy enforcement, and the lock-state, reserve-accounting, and overflow findings are standalone safety/accounting defects. Whichever label upstream eventually applies does not change what a downstream chain inherits today.
The five remediating findings are explicitly labeled as such because public beta/develop evidence shows fixes landing after 3.1.3. For the seventeen “no confirmed fix” findings (the original fourteen plus the three Wave 3 additions), the claim is only that our checked 3.2.0-b7 / origin/develop refs did not contain a confirmed remediation at the time of the packet.
Post Fiat’s immediate use of this report is internal engineering due diligence: whether to inherit a RippleD-derived path, support it with local hardening, or avoid the inherited surface. Any downstream production decision should also consider upstream’s later response, amendment policy, and any coordinated-disclosure outcome after this packet.
Excluded Boundary
MPT-DOMAIN-AUTH-001 is excluded from the live packet. The reproduced MPT DomainID path requires SingleAssetVault in the current MPTokenIssuanceCreate / MPTokenIssuanceSet feature gate, and direct XRPL mainnet status shows SingleAssetVault=false.
Cleanup-era candidates are also excluded unless they reproduce with fixCleanup3_1_3 enabled. The raw on-ledger Amendments object contains the fixCleanup3_1_3 hash, so old pre-cleanup reproduction alone is not enough for this public live inventory.
Implications For Post Fiat
- The implementation is largely sound; the inherited surface is not free. The packet shows repeated gaps between direct policy checks and indirect settlement paths. Receive-policy enforcement (
DisallowIncomingTrustline,DepositAuth, freeze, authorization, reserve, owner-count) is spread across transaction families instead of centralized at the ledger-effect boundary, so the surface grows faster than review coverage. Inheriting these paths means inheriting that maintenance burden. - The decisive issue is architectural. Consensus safety is not code-enforced — it rests on a centrally-published UNL and an overlap assumption the code never checks (see “The Inherited Trust Model,” reproduced in rippled’s own simulator). Inheriting
3.1.3means inheriting that trust dependency wholesale and riding the amendment treadmill on a validator list Post Fiat does not publish; running an independent chain means standing up Post Fiat’s own validators, UNL, and code defaults. What the decision turns on is whose validator list the ledger’s safety depends on. - Treat
3.1.3as unsafe to inherit blindly — without this packet’s implementation fixes and a deliberate, Post-Fiat-controlled UNL/trust posture.
Appendix A: Independent Verification
This appendix exists so that a reader without access to our build harness can confirm the packet’s claims from first principles using only public sources. Every claim below decomposes into four independent evidence chains, each verifiable on its own.
A.1 Upstream codebase pin (GitHub)
The packet pins XRPLF/rippled tag 3.1.3, which is a GPG-signed annotated tag that dereferences to commit 46b241ace8b30d9c9775d60ffba7d24b21903896 (“Set version to 3.1.3”, committer date 2026-05-07T17:31:08Z).
# tag → commit (dereferences annotated tag to commit)
gh api 'repos/XRPLF/rippled/git/refs/tags/3.1.3' --jq '.object.sha'
# → 7645ce97240e0662774902be39e8eaa3c638c89b (the annotated tag object)
gh api 'repos/XRPLF/rippled/git/tags/7645ce97240e0662774902be39e8eaa3c638c89b' --jq '.object.sha'
# → 46b241ace8b30d9c9775d60ffba7d24b21903896 (the pinned commit)
The tag object’s payload contains a PGP signature; the commit subject is Set version to 3.1.3. Any reviewer can git clone https://github.com/XRPLF/rippled.git && git checkout 46b241ace8b30d9c9775d60ffba7d24b21903896 to read the exact source the packet claims it is reproducing against.
A.2 Live mainnet amendment state (direct XRPL JSON-RPC)
The live filter is based on a direct read of the on-ledger Amendments ledger object from https://s1.ripple.com:51234/. A single curl re-fetches the validated Amendments object and lets a reviewer cross-check every name the packet says must be enabled (or disabled). At the live state checked, the on-ledger object carries 92 enabled amendments. All 24 amendments the packet requires to be enabled are present; all 6 amendments the packet requires to be disabled are absent.
curl -s -X POST -H 'Content-Type: application/json' https://s1.ripple.com:51234/ \
-d '{"method":"ledger_entry","params":[{"index":"7DB0788C020F02780A673DC74757F23823FA3014C1866E72CC4CD8B226CD6EF4","ledger_index":"validated"}]}' \
| python3 -c 'import sys,json,hashlib; \
H = lambda n: hashlib.sha512(n.encode()).hexdigest()[:64].upper(); \
amends = set(json.load(sys.stdin)["result"]["node"]["Amendments"]); \
must_on = ["AMM","AMMClawback","Checks","CheckCashMakesTrustLine","DepositAuth","DisallowIncoming","fixDisallowIncomingV1","MPTokensV1","NonFungibleTokensV1_1","fixEnforceNFTokenTrustline","fixEnforceNFTokenTrustlineV2","fixRemoveNFTokenAutoTrustLine","fixNFTokenReserve","fixNFTokenRemint","NFTokenMintOffer","PermissionedDomains","PermissionedDEX","TokenEscrow","Credentials","fixMPTDeliveredAmount","fixAMMv1_3","fixTokenEscrowV1","fixAMMClawbackRounding","fixCleanup3_1_3"]; \
must_off = ["LendingProtocol","SingleAssetVault","PermissionDelegation","Batch","fixDelegateV1_1","fixDisallowIncomingV1_1"]; \
print("enabled_count =", len(amends)); \
print("on_present =", all(H(n) in amends for n in must_on)); \
print("off_absent =", all(H(n) not in amends for n in must_off))'
# expected:
# enabled_count = 92 (or higher, if more amendments enable later)
# on_present = True
# off_absent = True
Note that public Clio servers do not expose fixCleanup3_1_3 through the feature RPC by name — the receipt records feature_rpc_visible: false for it. The packet works around this exactly the way the snippet above does: by reading the raw Amendments ledger object and matching the SHA-512-half-of-name hash. That is why both checks (raw object + name derivation) appear in the packet rather than only a feature RPC lookup.
A.3 Amendment ID derivation (pure math, no network)
Every XRPL amendment ID equals the first 32 bytes (upper-half) of SHA-512(amendment_name_ascii). The packet’s amendment IDs are therefore mathematically derivable from the names — no trust in any party is required.
import hashlib
def amendment_id(name): return hashlib.sha512(name.encode()).hexdigest()[:64].upper()
assert amendment_id("fixCleanup3_1_3") == \
"303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC"
assert amendment_id("AMM") == \
"8CC0774A3BF66D1D22E76BBDA8E8A232E6B6313834301B3B23E8601196AE6455"
assert amendment_id("MPTokensV1") == \
"950AE2EA4654E47F04AA8739C0B214E242097E802FD372D24047A89AB1F5EC38"
All 30 amendment names cited by the live filter (24 must-enabled + 6 must-disabled) derive to IDs that exactly match the receipt and the on-ledger Amendments object. Together with A.2 this binds amendment names to live ledger state without anyone having to trust our receipt JSON.
A.4 Packet content integrity (single root hash)
The packet directory now ships a canonical SHA256SUMS.txt covering every packet file (legacy/ snapshots and Python caches excluded; the sums file does not list itself). The single packet root is the SHA-256 of SHA256SUMS.txt.
Packet root:
66175a5059113ccfcce3b99dcd2aada6d70a3037d51af4a84aa6426f70c6c7ff
cd assets/research/xrpl-rippled-p0-audit
sha256sum SHA256SUMS.txt
# expected: 66175a5059113ccfcce3b99dcd2aada6d70a3037d51af4a84aa6426f70c6c7ff SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt | grep -v ': OK$'
# expected: (no output — every file matches)
If a reviewer regenerates SHA256SUMS.txt from scratch, the canonical form is:
find . -type f -not -path './legacy/*' -not -path '*/__pycache__/*' -not -name 'SHA256SUMS.txt' \
-printf '%P\n' | LC_ALL=C sort | xargs sha256sum > SHA256SUMS.txt
A.5 Headline artifact hashes
The hashes below pin the highest-leverage artifacts. They are also covered by SHA256SUMS.txt, but are listed inline so a reader can grep them without downloading the packet.
| Artifact | SHA-256 |
|---|---|
OpenP0Repro_test.cpp (full jtx proof source) |
fd8b7b7935c196cfe268fc7b9041f010793b22ebdacf8b3da62ea81cd90e3821 |
repro_manifest.json (canonical 22-finding manifest, Wave 3 added 3 on 2026-05-28) |
92f470d0856ef115a0fa4618d0428ddd2f7f3dfb7f5f315877e1acb2643d1e68 |
verify_packet.py (static verifier) |
125fa52925bfd61ba7df9358cdbb0cbf98c0ab82926e724c80a6730d23d05925 |
run_definitive_proof.sh (proof runner) |
2b6f48f830169f02a6b62c6d7fb467d9808810aee501f948ba8a4de9f47fdda0 |
runs/20260527-p0-hunt/live_mainnet_enabled_proof_extract_20260527_v23.log (proof log) |
3ad276376bc6a04b7ddc335144d57d9297fedb9a09022af57095967efa939769 |
direct_xrpl_amendment_status_20260527.json (live amendment receipt) |
a253335ef4bace451ae98f942d31539d544694a75f8c5e62975a9694cc571079 |
direct_xrpl_mainnet_runtime_status_20260527.json (live runtime receipt) |
150bcf249021d57293276de238e5bfddb90f5b79f5a13cc3ba4b338ac746af96 |
direct_xrpl_did_feature_status_20260528.json (DID feature receipt) |
e97e39ecd9ebf7e83a144887c65e330c664e70230b823ac2dbbe6e0ad8bace4c |
upstream_remediation_status_20260527.json (git-ancestry remediation receipt) |
a64906012ccddfe86883a4711d464dfba385548e5d2eba1c1910b4de44c3dfac |
runs/20260527-p0-hunt/live_state_snapshot_20260528_moby_dick.json (continuation-slice live snapshot) |
e4f756f7ae60087a90ff7e4eaf15292fe092caecfab69ff3c22116e6a546c972 |
The proof-log SHA matches what repro_manifest.json declares (proof.sha256) and what verify_packet.py enforces. The proof log itself includes the canonical footers ripple.tx.OpenP0Repro had 0 failures. / 70 cases, 16752 tests total, 0 failures and ripple.tx.OpenP0ReproCrash had 0 failures. / 1 case, 12 tests total, 0 failures.
A.6 Per-finding hashes and live anchors
Each row binds a finding to (1) the repro shell wrapper a reviewer can read, (2) the marker text the proof log must contain, (3) the live amendment surface required for the reproduction to count, and (4) whether the upstream remediation receipt found a fix in 3.2.0-b7 or origin/develop.
| ID | Repro script SHA-256 | Marker (proof-log string) | Required surface | Fixed in 3.2.0-b7 / develop? |
|---|---|---|---|---|
MPT-LOCK-UNAUTH-001 |
85248096331872fc4b884d6b02ac653573722348f9491bdd4df1e4ba1c712cc7 |
MPT current — locked holder can delete lock state without SAV |
MPTokensV1 ✓, SingleAssetVault ✗ |
no |
TRUSTLINE-POSITIVE-BALANCE-RESERVE-001 |
b1f13fadca2c2e6a28e9eff911a20a3f2a66f5209e4788b53caa082b9c27ed9e |
12 markers (offer-crossing, transfer-rate, CheckCash, TokenEscrow, NFToken accept, NFToken broker fee, AMMWithdraw, AMMClawback + boundary controls) | baseline trustline path | no |
TRUSTLINE-DISALLOW-INCOMING-OFFER-001 |
153a72ea2e809f8d61fc27af23f99708337e7a0d86294befc0e5742c81d9598b |
TrustLine current — OfferCreate bypasses DisallowIncomingTrustline |
DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixDisallowIncomingV1_1 ✗ |
no |
NFTOKEN-DISALLOW-INCOMING-ACCEPT-001 |
e5a2ea5c7c6f1246b7732be9935925190c0a40dd8b727a91c412ab54e3d5647b |
NFToken current — AcceptOffer bypasses DisallowIncomingTrustline |
NonFungibleTokensV1_1 ✓, fixEnforceNFTokenTrustlineV2 ✓, DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixDisallowIncomingV1_1 ✗ |
no |
NFTOKEN-BROKER-FEE-DISALLOW-INCOMING-TRUSTLINE-001 |
f5c7bf606ca36a54cff0de51abf0a8c78717a40020106990fd9dac2559628f62 |
NFToken current — broker fee bypasses DisallowIncomingTrustline |
same as NFTOKEN-DISALLOW-INCOMING-ACCEPT-001 |
no |
CHECKCASH-DISALLOW-INCOMING-TRUSTLINE-001 |
2ee5defde6f7b339eadf2f39e49bbd2afc0c03da562663b03d94946f6f562d70 |
CheckCash current — bypasses DisallowIncomingTrustline |
Checks ✓, CheckCashMakesTrustLine ✓, DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixDisallowIncomingV1_1 ✗ |
no |
TOKENESCROW-DISALLOW-INCOMING-FINISH-001 |
c082da6a3f132f8db4a2375a33d2fcb2ef41ff5d2a73e209ab52a880dfeb342d |
TokenEscrow current — Finish bypasses DisallowIncomingTrustline |
TokenEscrow ✓, fixTokenEscrowV1 ✓, DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixDisallowIncomingV1_1 ✗ |
no |
AMMWITHDRAW-DISALLOW-INCOMING-TRUSTLINE-001 |
867e87bf1b2e91aaa229fb4f619c4755f95e0856dfda2de2d527c6ec92779154 |
AMM current — Withdraw bypasses DisallowIncomingTrustline |
AMM ✓, DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixDisallowIncomingV1_1 ✗ |
no |
AMMCREATE-DISALLOW-INCOMING-TRUSTLINE-001 |
8dfacdaa442c6b67e7c3baae367190e6d80590770ece090b0e9807e4fad87dca |
AMM current — Create bypasses DisallowIncomingTrustline |
same as AMMWITHDRAW-DISALLOW-INCOMING-TRUSTLINE-001 |
no |
AMMDEPOSIT-EMPTY-DISALLOW-INCOMING-TRUSTLINE-001 |
687c2675c425aa34029e379582f708c86886222e6a2b1d56dd96ccd94d22bbc3 |
AMM current — Empty deposit bypasses DisallowIncomingTrustline |
same as AMMWITHDRAW-DISALLOW-INCOMING-TRUSTLINE-001 |
no |
AMMCLAWBACK-DISALLOW-INCOMING-PAIRED-ASSET-001 |
7e3cc5841b38baec081c7ddbc1826f021cc108689fa71a5adfd49c6a85e1b612 |
AMM current — Clawback returns paired asset through DisallowIncomingTrustline |
AMM ✓, AMMClawback ✓, DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixAMMClawbackRounding ✓, fixDisallowIncomingV1_1 ✗ |
no |
AMMCLAWBACK-DEPOSITAUTH-PAIRED-ASSET-001 |
d3710f57f30a8bf20b85c609d9743d3f9033d545d8f95ac4b9cc96f374f08751 |
AMM current — Clawback bypasses DepositAuth paired asset |
AMM ✓, AMMClawback ✓, DepositAuth ✓, fixAMMClawbackRounding ✓ |
no |
AMMBID-DEPOSITAUTH-REFUND-001 |
44983a1a1bcd79146e925642fddb98701a5217dee84e0e45b9dcb58332dc6d6d |
AMM current — Bid refund bypasses DepositAuth |
AMM ✓, DepositAuth ✓, fixAMMv1_3 ✓ |
no |
ESCROW-CANCEL-IOU-001 |
656fe3162b4d5b1af03a7b5a4857295b6aa9bcf6b98a907a6a6f088a381d0e9c |
EscrowCancel current — deleted IOU trustline returns tefEXCEPTION |
TokenEscrow ✓, fixTokenEscrowV1 ✓ |
yes |
AMM-STALE-AUTH-001 |
8ff1177d17835ab8a73fb0cf391c624e65251d18006d49450b95504f15b8db0a |
AMM current — stale AuthAccounts survive empty reinit |
AMM ✓, fixAMMv1_3 ✓ |
yes |
MPT-NONCANONICAL-AMOUNT-001 |
21fb8e7b4e5402bade2272048648fe7d30caabb841cf812e1afeae596b5df86c |
MPT current — non-canonical amount reaches ledger engine |
MPTokensV1 ✓, fixMPTDeliveredAmount ✓ |
yes |
MPT-TRANSFER-RATE-OVERFLOW-001 |
f8038fbfb26bbf8c0bd81bd22947224c6bbc91533777515f15bc6a77b10f646f |
MPT current — transfer-rate scaling overflows large integral amount |
MPTokensV1 ✓ |
no |
PDEX-HYBRID-QUALITY-001 |
d2222db17ac0059055820627ccc378896ac2325b45fd005d0f974c05fb8e5b28 |
Permissioned DEX current — hybrid offer open-book quality mismatch |
PermissionedDEX ✓, PermissionedDomains ✓, Credentials ✓ |
yes |
PDEX-CANCEL-INVARIANT-001 |
eb272ddc63f9fef6821662d5edf41608853970ac88df833497880c3105ca770f |
Permissioned DEX current — cancel regular offer via domain offer invariant |
same as PDEX-HYBRID-QUALITY-001 |
yes |
NFTOKEN-OFFER-ISSUER-SELF-FREEZE-001 ⓦ |
c03f6d4448fb708e17d439fa0ea12c9a6312086d9e1334ae1bb13b322d632767 |
NFToken current — issuer NFTokenCreateOffer blocked by own GlobalFreeze |
NonFungibleTokensV1_1 ✓, NFTokenMintOffer ✓ |
no |
AMMBID-DISALLOW-INCOMING-REFUND-001 ⓦ |
e3708b1f241c5907f798514f3d09b46fd19d171d8f9afcc7f771aa8c0ee4a873 |
AMM current — Bid refund bypasses DisallowIncomingTrustline |
AMM ✓, DisallowIncoming ✓, fixDisallowIncomingV1 ✓, fixDisallowIncomingV1_1 ✗ |
no |
NFTOKEN-ACCEPT-DEPOSITAUTH-001 ⓦ |
e1f69aa2374fb7c6fead26a363a7422e4bcfbcf95a047699c1e6a65ddf393bce |
NFToken current — AcceptOffer bypasses DepositAuth |
NonFungibleTokensV1_1 ✓, DepositAuth ✓ |
no |
ⓦ = Wave 3 supplementary additions (2026-05-28). Their jtx run was executed on a separate rippled build (commit 7a1d3e3abbd4f34aec7e822d3fd0f6f63e0f3d28, branch internal/bug-hunt-plan, version 3.2.0-b0) and appended to the proof log; the supplementary footer 3 cases, 166 tests total, 0 failures is enforced by verify_packet.py alongside the original 70 cases, 16752 tests total, 0 failures footer.
Tally: 17 of 22 are not fixed in the checked 3.2.0-b7 or origin/develop refs (the original 14/19 plus the 3 Wave 3 additions; none have a confirmed fix in the upstream remediation receipt at the snapshot date). The verify_packet.py script enforces the exact same not_confirmed_fixed set against upstream_remediation_status_20260527.json.
A.7 One-shot verification recipe (no rippled build)
A reviewer can confirm the packet’s external claims in under a minute, with no C++ toolchain:
# 1. Pull the article repo at the packet snapshot
git clone https://github.com/agtico/agtico.github.io.git
cd agtico.github.io/assets/research/xrpl-rippled-p0-audit
# 2. Verify the packet content root
sha256sum SHA256SUMS.txt
# → 66175a5059113ccfcce3b99dcd2aada6d70a3037d51af4a84aa6426f70c6c7ff
sha256sum -c SHA256SUMS.txt | grep -v ': OK$' # → no output
# 3. Verify the upstream pin
gh api 'repos/XRPLF/rippled/git/refs/tags/3.1.3' --jq '.object.sha'
gh api 'repos/XRPLF/rippled/git/tags/<tag-sha-from-step-3>' --jq '.object.sha'
# → 46b241ace8b30d9c9775d60ffba7d24b21903896
# 4. Verify live amendment state matches the receipt (snippet from A.2)
# 5. Run the static packet verifier (manifest ↔ scripts ↔ markers ↔ receipts)
python3 verify_packet.py
# → packet-ok
# → records=22 markers=33 proof_sha256=3ad276376bc6a04b7ddc335144d57d9297fedb9a09022af57095967efa939769
Steps 2 through 5 are sufficient to establish: the packet is the file set the author intended; the live-amendment claim is independently true on mainnet; the codebase pin resolves to a GPG-signed upstream tag; every finding maps to a per-ID repro wrapper and a unique marker in the proof log; and the remediation set declared in the report matches the receipt.
A.8 Full harness verification (optional, slow)
The recipe above does not rebuild rippled or rerun jtx. To regenerate the proof log itself, README.md in the packet directory contains the full conan install / cmake / --unittest OpenP0Repro invocation. A successful run produces a log whose SHA-256 matches the value in repro_manifest.json (3ad27637...), and whose footers match the zero-failure lines listed in A.5.
A.9 Verification trail for TRUSTLINE-POSITIVE-BALANCE-RESERVE-001
This sub-appendix maps every concrete claim in “On The Strongest Unfixed Finding” to a one-step external check. None of the commands below require the AGTI packet, our harness, or trust in our infrastructure — only git, gh, and access to https://github.com/XRPLF/rippled.
Claim 1 — bug site at 46b241a, sender-side branch only, no receiver-side branch.
git clone https://github.com/XRPLF/rippled.git && cd rippled
git checkout 46b241ace8b30d9c9775d60ffba7d24b21903896
# rippleCreditIOU function header and sender-side reserve-clear branch
sed -n '1991,2105p' src/libxrpl/ledger/View.cpp
# the only saBefore-based condition in the function is the sender-side one
grep -nE 'saBefore' src/libxrpl/ledger/View.cpp
# expected: lines 2035 and 2047 (declaration and the > zero comparison), no
# receiver-side "saBefore <= zero && saBalance > zero" line
GitHub blob URL pinned to the commit: https://github.com/XRPLF/rippled/blob/46b241ace8b30d9c9775d60ffba7d24b21903896/src/libxrpl/ledger/View.cpp#L2047-L2086
Claim 2 — updateTrustLine has the same defect shape but no live production caller.
# updateTrustLine repeats the sender-side-only shape
sed -n '2881,2932p' src/libxrpl/ledger/View.cpp
# every caller of issueIOU / redeemIOU at this commit, outside View.cpp
git grep -nE 'issueIOU|redeemIOU' 46b241ace8b30d9c9775d60ffba7d24b21903896 -- \
':!**/View.cpp' ':!**/View.h'
# expected: src/test/ledger/PaymentSandbox_test.cpp (tests only)
# src/xrpld/app/tx/detail/AMMBid.cpp:258 redeemIOU (LP burn)
# src/xrpld/app/tx/detail/AMMWithdraw.cpp:653 redeemIOU (LP redeem)
# Both AMM sites burn LP tokens — they reduce holder balance toward zero,
# which is the sender-side path already handled. issueIOU has no production caller.
Claim 3 — open PR #5867, not mergeable, fix commit absent from named refs.
gh api 'repos/XRPLF/rippled/pulls/5867' \
--jq '{state, draft, mergeable, base: .base.ref, head: .head.label,
created_at, updated_at, commits, review_comments}'
# expected at snapshot date 2026-05-28:
# state=open, draft=false, mergeable=false (changes over time as branch is rebased),
# base=develop, head=XRPLF:vvysokikh1/fix-positive-balance-trustline-pay-no-reserve,
# created_at=2025-10-08T15:44:04Z, updated_at=2026-05-07T15:06:53Z
gh api 'repos/XRPLF/rippled/issues/5867/comments' \
--jq '.[-1] | {user: .user.login, created_at, body: (.body | .[0:80])}'
# expected last comment: {"user":"bthomee", ..., "body":"@vvysokikh1 what's the status of this PR?"}
Ancestry of the fix commit (run inside the cloned repo with PR refs fetched):
git fetch origin '+refs/pull/5867/head:refs/pull/5867/head'
FIX=b4a45f1f0f49d3caf56d2c790960380b5e648a60
for ref in 46b241ace8b30d9c9775d60ffba7d24b21903896 origin/release-3.2 origin/develop; do
if git merge-base --is-ancestor "$FIX" "$ref"; then echo "IN $ref"; else echo "NOT_IN $ref"; fi
done
# expected: NOT_IN 46b241a... (3.1.3), NOT_IN origin/release-3.2, NOT_IN origin/develop
git tag --contains "$FIX" # expected: (empty)
The fix commit b4a45f1f0f49d3caf56d2c790960380b5e648a60 is dated 2025-10-08 with subject “fixed positive balance trustline not incrementing owners count in some cases”. It introduces XRPL_FIX(TrustLineOwnerCount, Supported::no, VoteBehavior::DefaultNo) in include/xrpl/protocol/detail/features.macro and the missing receiver-side block in rippleCreditIOU.
Claim 4 — invariant layer at 3.1.3 does not enforce owner-count or reserve-flag consistency.
git show 46b241a:src/xrpld/app/tx/detail/InvariantCheck.h \
| grep -cE '^class '
# expected: 25 invariant classes
git show 46b241a:src/xrpld/app/tx/detail/InvariantCheck.cpp \
| grep -nE 'sfOwnerCount'
# expected: only two hits —
# line ~578 inside AccountRootsDeletedClean::finalize (fires on deletion only)
# line ~2586 inside ValidLoanBroker::finalize (LoanBroker-specific)
# No general consistency invariant for OwnerCount vs. owner-directory size, and
# no invariant tying a trustline's positive-balance side to its reserve flag.
The negative claim (“there is no general invariant for these properties”) is structural and can also be checked by reading the class list in InvariantCheck.h, which is short and lists every invariant name.
Claim 5 — binary repro across older release tags. The packet’s verify_packet.py::check_old_tag_trustline_repros() enforces SHA-256s for the 1.5.0, 2.0.0, and 2.5.0 test patches and logs, and for the lineage report covering refs 0.12.0, 0.20.0, 0.30.0, 0.50.0, 0.80.0, 1.0.0, 1.5.0, 2.0.0, 2.5.0, 3.1.3. The hashes are in SHA256SUMS.txt (covered by the packet root in A.4) and are reproduced inline in the verifier script. Each old-tag log carries the marker Legacy <tag> -- offer crossing creates positive balance without reserve and a zero-failure footer.
Claim 6 — Introducing commit anchor and continuity through develop.
The sender-side reserve-clear branch in what is now rippleCreditIOU was added on 2013-03-31 in commit 96733c287476b7279289e8884a357a1c827a7bf7 (“Add trust auto clear. Fixes #28”, Arthur Britto). The clear-on-down half was added; the symmetric set-on-up half on an existing line was not added in that commit and has not been added since.
git log -1 --format='%H%n%an <%ae>%n%ad%n%s' --date=iso \
96733c287476b7279289e8884a357a1c827a7bf7
# expected:
# 96733c287476b7279289e8884a357a1c827a7bf7
# Arthur Britto <ahbritto@gmail.com>
# 2013-03-31 16:15:45 -0700
# Add trust auto clear. Fixes #28
# the diff at the introducing commit, restricted to LedgerEntrySet.cpp
git show 96733c287 -- src/cpp/ripple/LedgerEntrySet.cpp \
| grep -E '^\+.*saBefore\.isPositive|^\+.*ownerCountAdjust|^\+.*lsfLowReserve|^\+.*lsfHighReserve' \
| head -10
# expected: added lines include `saBefore.isPositive()`, `!saBalance.isPositive()`,
# `ownerCountAdjust(uSenderID, -1, sleSender)`, and `clear (!bSenderHigh ? lsfLowReserve : lsfHighReserve)`.
# No added line tests for the receiver-side transition (`!saBefore.isPositive() && saBalance.isPositive()`).
Continuity check — same asymmetry visible at the snapshot’s upstream/develop:
git fetch origin # if not already fetched
git show upstream/develop:src/libxrpl/ledger/View.cpp \
| awk '/^rippleCreditIOU/,/^}/' \
| grep -nE 'saBefore'
# expected:
# STAmount const saBefore = saBalance;
# ... log line referencing saBefore ...
# if (saBefore > beast::zero <-- the only conditional on saBefore
# && saBalance <= beast::zero <-- sender-side branch; no receiver-side branch
Date arithmetic from the introducing commit to the snapshot:
python3 -c "from datetime import date; \
print((date(2026,5,28) - date(2013,3,31)).days, 'days')"
# expected: 4807 days ≈ 13 years 2 months
Together, claims 1–6 establish: the bug exists at the cited file and lines at a verifiable upstream commit; the asymmetry is datable to a specific 2013 commit and survives to today’s upstream/develop unchanged; the proposed fix sits at a named commit on an open public PR that has not landed in any release we can name; the invariant pass at 3.1.3 does not stop the resulting state from being committed; and the same root has been reproduced on binaries spanning 1.5.0 through 3.1.3, with source-lineage evidence reaching the 0.12.0-era core code. Each of those is a check, not an assertion of intent or quality on anyone’s part.