Skip to content

payment-router: classify failed settlement and add a dead-letter path - #87

Merged
joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
definurse13:feat/59-settlement-retry-dead-letter
Aug 24, 2026
Merged

joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
definurse13:feat/59-settlement-retry-dead-letter

Conversation

@definurse13

Copy link
Copy Markdown
Contributor

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_partial makes funds claimable and calls no token, and the payee pulls with claim. Recovery keeps that shape. The retry is claim, 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 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 via report_failure, signing for their own escrow, classified as NoDestination, NoTrustline, AssetRestricted, or Other.

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

State Meaning Claim
healthy no failures recorded claimable
failed 3 failures reported preserved, still claimable
dead-lettered swept after the delay ended

Failed preserves the claim. It 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, 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 then DEAD_LETTER_DELAY (120,960 ledgers, ~7 days) after entering Failed. 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_at reports 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 claimed to released before 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 an exhausted flag on the attempt that tips into Failed), SettlementRecovered, and DeadLettered, 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 on Escrow. 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

  • Failure classified, not swallowed
  • Failed state with a documented recovery path
  • Pull-based claim implemented
  • Dead-letter policy documented and tested

Verification

cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, and cargo build --release --target wasm32v1-none all 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.

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
@joelpeace48-cell
joelpeace48-cell merged commit 7d9754e into FinesseStudioLab:main Aug 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

payment-router: failed settlement retry and dead-letter handling

2 participants