Open source · Dart, with a WASM build for TypeScript

KNOW WHAT YOUR
PAYMENT WILL DO
BEFORE YOU SIGN.

An XRPL payment aimed at Flare is one shot and final. Every byte that decides the outcome is committed before you sign, and every way it can go wrong resolves afterwards, on a different chain, with the money already gone. Plimsoll computes the outcome first, from live chain state, and refuses the ones that lose money.

plimsoll forecast

Unsafe · do not sign
$ plimsoll forecast \
   --xrp 0.15 --to 0x38d5…8e83

# reads live Coston2, forecasts a
# payment that does not exist yet

verdict     unsafe
reason      ConsumedAsFeeZeroMint
you send    0.15 XRP
fee taken   0.10 XRP  (minting)
executor    0.05 XRP  (whatever is left)
delivered   0.00 XRP
warning     paymentTooSmall: false
Verified by
tx 0x8aa1437f…19b8df

A LOAD LINE FOR
A PAYMENT.

A Plimsoll line is the mark painted on a ship's hull showing how deep it may be loaded before it stops being safe. Samuel Plimsoll got it written into law in 1876, after too many overloaded ships sank with crews who had no way to know. It is now on every hull in the world.

It is the right name because our first finding is a load line, and it sits well above where the documentation puts it.

0.1 TO 0.2 XRP DELIVERS
EXACTLY ZERO.

Silently. No event, no revert, the transaction succeeds. On Coston2 today, FAssets direct minting takes a minimum fee of 0.1 XRP and an executor fee of 0.1 XRP, but the protocol's own paymentTooSmall flag only checks against the first.

Every row forecast before the payment was sent, then paid for
You sendProtocol warns?You receive
0.05 XRPyes0
0.10 XRPno0
0.15 XRPno0
0.20 XRPno0
0.200001 XRPno0.000001 XRP

The real waterline is 0.2 XRP plus one drop, the sum of two independent settings, which moves whenever governance changes either.

What the documentation already says, and where it stops. Flare's FAssets minting docs do say that a payment below the minimum minting fee mints nothing and that the whole payment goes to the fee receiver. The bottom of this range is documented and we are not claiming to have found an unknown bug. Three things are not documented, and each of them is what actually costs money.

Undocumented 01

The dead zone does not end where the docs end.

It ends at the sum of the minting fee and the executor fee, and that sum appears in no single protocol setting.

Undocumented 02

The warning flag is silent exactly where it is needed.

paymentTooSmall is computed against the minimum fee alone. Across the whole 0.1 to 0.2 XRP band it reads false while delivery is zero.

Undocumented 03

Both fees are governance settable.

So a value hardcoded from today's docs is a latent bug rather than a wrong constant. The number has to be read from the chain at the moment of use.

Flow chart. An XRPL payment is checked for a registered destination tag; if one exists the tag wins and the memo is ignored. Otherwise the memo decides whether the executor fee is subtracted, and either path then meets the waterline test, below which delivery is zero.
Where the money actually goes. Four things decide it and only one of them is the amount. A registered destination tag overrides the memo entirely; the recipient path subtracts the executor fee and the smart account path does not; and the smart account branch mints to the controller, which forwards what remains. Only then does the waterline apply.

Anyone who reads "minimum 0.1 XRP" and hardcodes it ships this.

This was not inferred from source. It was paid for, on chain, on purpose.

CHECK IT WITHOUT
TRUSTING US.

Three ways, shortest first. None of them needs anything from us, and the third one needs no chain either.

1. Read the two fees yourself, 30 seconds

export RPC=https://coston2-api.flare.network/ext/C/rpc
AM=0xc1Ca88b937d0b528842F95d5731ffB586f4fbDFA

cast call $AM "getDirectMintingMinimumFeeUBA()(uint256)"  --rpc-url $RPC   # 100000 UBA = 0.1 XRP
cast call $AM "getDirectMintingExecutorFeeUBA()(uint256)" --rpc-url $RPC   # 100000 UBA = 0.1 XRP

2. Look at a payment that lost the money, 1 minute

cast receipt 0x8aa1437fc1ce9cb6d08c191ed34dd92ac9774f3e48d3a0bbe65804773f19b8df --rpc-url $RPC

The transaction succeeded. Its logs contain exactly two Transfer events, 100,000 UBA to the fee receiver and 50,000 to the executor, and a DirectMintingExecuted carrying mintedAmountUBA: 0. The intended recipient appears nowhere and no warning event fired. Note the executor took 50,000 rather than its configured 100,000: it is paid whatever remains. Every party except the recipient was paid in full.

3. Run the forecaster, then verify it without a chain, 3 minutes

cd packages/plimsoll_cli && dart pub get

# Reads live Coston2 and forecasts a payment that does not exist yet.
dart run bin/plimsoll.dart forecast \
  --from rDE4JUm2jaue31VwidRXWuWzf5dQkUxcsB \
  --xrp 0.15 --to 0x38d58d1bea8ff21fd8397494f17f64a99bcf8e83 \
  -o /tmp/deadzone.json
# UNSAFE, do not sign this payment ... delivered 0 XRP   (exit 1)

# Re-derives that forecast from the record alone. No chain, no clock, no network.
dart run bin/plimsoll.dart reproduce /tmp/deadzone.json      # exit 0

Now break it, and watch the check fail.

python3 -c "
import json; d=json.load(open('/tmp/deadzone.json'))
d['forecast']['verdict']='info'; d['forecast']['safeToSign']=True
json.dump(d, open('/tmp/tampered.json','w'))"

dart run bin/plimsoll.dart reproduce /tmp/tampered.json
# DOES NOT REPRODUCE
#   forecast.verdict: recorded info, re-derived unsafe
#   forecast.safeToSign: recorded true, re-derived false      (exit 1)

That asymmetry is the point of the tool. forecast reads the chain. reproduce reads nothing. A forecast is only worth something if the person relying on it can check it without also having to rely on the machine that produced it.

Flow chart. Forecast reads live Coston2 and writes a record holding the inputs, the parameters and the verdict. Reproduce re-derives the verdict from that record with no chain, no clock and no network, and exits non-zero if it does not match.
The asymmetry. One half of the tool reads everything. The other half reads nothing. That is what makes a forecast checkable by the person relying on it rather than only by the machine that produced it.

GIVEN A PAYMENT YOU ARE
ABOUT TO SIGN.

Seven answers, before the signature rather than after it.

Route

Which of the four branches you will actually hit.

FAssets routes on memo length and destination tag, in a specific precedence order, and a registered destination tag silently overrides the memo entirely. Plimsoll tells you who really receives the funds.

Amounts

Exact, with the branch-dependent formula.

Integer arithmetic against live parameters. The recipient path subtracts the executor fee and the smart-account path does not. One formula for both is wrong for one of them.

Delays

Hourly limiter, daily limiter, large-mint threshold.

And the allowedAt each one implies, so a payment that will sit for hours says so before it is sent.

Dry run

Your user operation, executed against live state.

For Smart Accounts 0xFE and 0xFF instructions: nonce currency, executor pinning, hash commitment, and every inner call run through eth_call, so a batch that would revert is caught before the XRP is gone.

Verdict

Safe, degraded, or unsafe. One of three.

With a reason code and a plain explanation. There is no boolean success flag anywhere in the model, because a successful transaction that minted nothing is not a success.

Recovery

For a payment already stranded.

The exact 0xE0 sequence, with the bytes and the ordering constraints, and a refusal to propose one where recovery does not apply.

What it is not. No signing. No custody. No new bridge, wrapped asset, token, or vault.

It does not reimplement Smart Accounts. Flare shipped those; Plimsoll makes them safe to aim at. Our being offline must never stop someone completing or recovering a payment through the canonical path, and that is demonstrated by test rather than asserted.

FOUND BY MEASURING
RATHER THAN READING.

Each of these contradicted the documentation, and each of them would have cost money.

Every entry traces to docs/GROUND-TRUTH.md in the repository
FindingSection
The dead zone. 0.1 to 0.2 XRP delivers zero, silently§1, §2.2
PackedUserOperation is the nine field EIP-4337 struct, not the ten field one in the docs' own example§5
0xE2 memos are 50 bytes, 0xD0 is 30 and 0xD1 is 10, not the documented "same 42-byte shape"§8.1
A recovery payment needs received − systemFee ≥ executorFee, not "a positive net mint amount"§8.3
DirectMintingExecuted is two different events on two contracts, with fields in different places§6.2
The smart-account branch mints to the controller, which forwards what is left, often nothing§6.1
Direct-minting events index nothing, so payments are located via PaymentConfirmed instead§7.2
Coston2 runs a public executor that mints in about two minutes§10
Flare's own published preflight is off by one on all three delay bounds. The contracts compare inclusively, the tool exclusively, and the amount it calls "the largest that avoids delay" is delayed§19

THE EVIDENCE,
ON CHAIN.

Four transactions, each sent to provoke one specific failure.

ClaimTransaction
0.15 XRP delivered zero, no warning0x8aa1437f…19b8df
The recipient branch has no callback. A contract whose entire runtime is 0xfd received 800,000 UBA0x4504b9d4…da409e
1.8 XRP redirected to a stranger by a destination tag, silently. The memo's target received nothing0x063a0028…fb260707
A stranded payment recovered: IgnoreMemoSet, then the mint with no UserOperationExecuted0x54e81978…3c8f121c

480 TESTS.
71 AGAINST THE CHAIN.

Built and exercised on live Coston2. Every suite re-run and green on 5 August 2026.

PackageTestsCovering
plimsoll_core, pure, zero dependencies332forecasting, classification, recovery planning
plimsoll_flare, the chain-reading shell20 + 67 livesnapshots, dry runs, lifecycle
plimsoll_cli18 + 4 livethe artefact and its verification
plimsoll_js, WASM bindings7the same engine, callable from npm
plimsoll_mcp, the agent boundary25protocol, and the read-only guarantee enforced
plimsoll_app, Flutter reference client7an unsafe verdict cannot be signed
Preflight latency

p50 833 ms, p95 1.59 s

Over the public Coston2 RPC, of which the engine itself is 1 ms. The rest is twenty contract reads collapsed into one multicall.

Settlement latency

p50 131 s, p95 170 s

XRPL validation to FXRP minted, over 30 payments, all of which were picked up. Dominated by the FDC voting round.

Loss conditions

All six provoked live

The dead zone, the 0.05 XRP warning, the one-drop boundary, both ends of the zone, a stranded payment recovered via 0xE0, and the tag override.

What is not proven, stated here rather than left for someone to discover.

Dry-run fidelity is partial. eth_simulateV1 and debug_traceCall are unavailable on Coston2. eth_call with state overrides reproduces the batch atomically under the real caller, but not the reentrancy-re-enabled context, the advanced nonce, or a first-ever mint where the account is not yet deployed. It predicts most reverts, not all, every result carries a fidelity record naming its gaps, and a test asserts that none ever claims to be complete.

Delays cannot be provoked on testnet. Both thresholds sit at 100,000 XRP against usage of about ten. Delay prediction is verified against pinned source and live limiter state, never by provocation. Reaching the threshold needs roughly 1,000 faucet top-ups; that was attempted and stopped at 59% after two hours, because the faucet throttles and three more hours of load on a shared resource was not worth an event the source already predicts.

The executor-fee fallback is unconfirmed. A skipped memo is claimed to fall back to the asset manager's default fee. Our run cannot tell, because the memo asked for 100,000 and the default is also 100,000.