Repository navigation
payment-router: classify failed settlement and add a dead-letter path - #87
Merged
joelpeace48-cell merged 1 commit intoAug 24, 2026
Conversation
Settlement can fail for reasons the contract cannot prevent and cannot observe - a destination that no longer exists, a frozen or clawed-back asset, a trustline removed after the escrow was opened. Until now there was no defined recovery: the payout simply reverted, and the value sat claimable against a destination that would keep rejecting it. Classification, not swallowing: - report_failure records an attempt that did not land, classified as NoDestination, NoTrustline, AssetRestricted, or Other. A reverted transfer takes the whole transaction with it, so there is no in-band way for the contract to observe a failure and continue - the payee reports it, signing for their own escrow. - The distinction matters to whoever is on call. A removed trustline is fixed by the payee in one transaction; a clawed-back asset is not something they can do anything about. An indexer that only sees "failed" cannot tell those apart. Failed state preserves the claim: - After MAX_SETTLEMENT_FAILURES (3) reports the escrow moves to Failed and records the ledger. The funds stay claimable throughout - Failed is a signal to operators, not a confiscation. - clear_failure resets the record once the account works again, so a later failure counts from zero rather than from a stale total. Recovery is the existing pull: - No retry mechanism is added, deliberately. The retry is claim, called again by the payee. Push-based retry against a broken destination burns fees on every attempt and can be griefed by anyone able to keep the destination failing. Dead-letter policy, stated rather than implied: - Opens only after 3 failures AND DEAD_LETTER_DELAY (120,960 ledgers, about seven days) from entering Failed. The week is for the payee's benefit; value should not be swept from under someone fixing their trustline. dead_letter_at reports the deadline. - The value returns to the payer. It is not burned and does not accrue to the contract - returning it to the party who put it in is the only destination needing no new trust assumption. - The arbiter triggers it, not the payer. A payer who could both trigger the sweep and receive the proceeds would have an incentive to grief a payee into the failed state and take the value back. - The sweep advances claimed to released before transferring, the same accounting a successful pull performs, so the payee cannot afterwards pull value that has already been returned. Events at each step (SettlementFailed carrying the reason and count, SettlementRecovered, DeadLettered) so a backend can alert rather than discovering a stuck escrow by inspection. Recovery state is a DataKey::Settlement(id) side-car, not fields on Escrow: most escrows never fail, and a side-car means they pay nothing for the possibility. Same persistent TTL policy as the escrow itself - state outliving its escrow is useless, state expiring first would silently reset the failure count. Thirteen tests cover classification, the threshold, claim preservation, recovery, both dead-letter refusals (too early, already swept), the authorization split, and that failure handling leaves the dispute path alone. Policy is written up in docs/payment-router-settlement-recovery.md. Closes FinesseStudioLab#59
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #59
Settlement can fail for reasons the contract cannot prevent and cannot observe — a destination that no longer exists, a frozen or clawed-back asset, a trustline removed after the escrow was opened. Until now there was no defined recovery: the payout simply reverted, and the value sat claimable against a destination that would keep rejecting it.
Pull, not push — no retry mechanism, deliberately
Release and payout were already separate:
release_partialmakes funds claimable and calls no token, and the payee pulls withclaim. Recovery keeps that shape. The retry isclaim, called again by the payee once their account works.I did not add a push-based retry. It burns fees on every attempt, and anyone able to keep a destination failing can make the contract pay for the attempts indefinitely.
Classification, not swallowing
A reverted
transfertakes the whole transaction with it, so there is no in-band way for the contract to observe a failure and continue. The payee reports it viareport_failure, signing for their own escrow, classified asNoDestination,NoTrustline,AssetRestricted, orOther.The distinction matters to whoever is on call: a removed trustline is fixed by the payee in one transaction, a clawed-back asset is not something they can do anything about. An indexer that only sees "it failed" cannot tell those apart.
States
Failedpreserves the claim. It is a signal to operators, not a confiscation.clear_failureresets the record once the account works again, so a later failure counts from zero rather than from a stale total, and a recovered escrow stops looking broken on a dashboard.Dead-letter policy
Three questions answered explicitly, since an under-specified sweep is a liability:
When.
MAX_SETTLEMENT_FAILURES(3) reported failures, and thenDEAD_LETTER_DELAY(120,960 ledgers, ~7 days) after enteringFailed. Three attempts distinguishes transient from broken. The week exists for the payee's benefit — a removed trustline is fixable, and value should not be swept from under someone who is fixing it.dead_letter_atreports the deadline.Where the value goes. Back to the payer. The payee has demonstrably been unable to receive it across three attempts and a week; returning it to the party who put it in is the only destination requiring no new trust assumption. Not burned, does not accrue to the contract or a treasury. A return, not a forfeiture.
Who can trigger it. The arbiter — deliberately not the payer. A payer who could both trigger the sweep and receive the proceeds would have an incentive to grief a payee into the failed state and take the value back. Not the payee either, for whom it does nothing. There is a test asserting the payer cannot.
The sweep advances
claimedtoreleasedbefore transferring — the same accounting a successful pull performs — so the payee cannot afterwards pull value that has already been returned, and the balance invariant stays intact.Events
SettlementFailed(carrying the reason, the running count, and anexhaustedflag on the attempt that tips intoFailed),SettlementRecovered, andDeadLettered, so a backend can alert an operator rather than discovering a stuck escrow by inspection.Storage
Recovery state is a
DataKey::Settlement(id)side-car, not fields onEscrow. Recovery is the exception — most escrows never have one — and a side-car means those escrows pay nothing for the possibility. Same persistent TTL policy as the escrow it describes: state outliving its escrow would be useless, state expiring first would silently reset the failure count.Tests
Thirteen, covering classification, the threshold, claim preservation through
Failed, recovery via the ordinary pull, both dead-letter refusals (too early / already swept), the authorization split, and that failure handling leaves the dispute path alone.Policy is written up in
docs/payment-router-settlement-recovery.md.Acceptance criteria
Failedstate with a documented recovery pathVerification
cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace, andcargo build --release --target wasm32v1-noneall pass.This PR and #86 (for #58) are conflict-free in either merge order. I merged them both ways locally against
main: zero conflicts each way, both orders produce an identical tree, and the merged result passes all four CI gates. Whichever you merge first, the other needs no rebase.