Failure and compensation
Payments fail. What matters is where the money is afterwards, and whether the books say so.
Two failures, two answers
Section titled “Two failures, two answers”| When it fails | What the saga does | Why |
|---|---|---|
| Before money leaves Corridor’s control | Compensates every completed step, last to first, and ends compensated |
Each earlier state can be truthfully undone |
| After the anchor was funded | Throws ManualReviewError: no retries, no automatic compensation, status manual_review |
The money is at the anchor. Reversing Corridor’s books before its refund is confirmed would describe a sequence that never happened |
The common path barely has a middle
Section titled “The common path barely has a middle”On a route with an anchor on the hub — ledger.reserve → partner.payout — there is only one place a failure can land before money moves: the anchor refusing the order. The hold is released, and the customer ends exactly where they started.
-- after a refused ordersaga_steps: ledger.reserve = compensated, partner.payout = failedsagas: status = compensatedThat is a consequence of settling where the anchors are: fewer legs means fewer intermediate states to unwind.
A route with a leg off the hub
Section titled “A route with a leg off the hub”The naira corridor leaves the hub, so it has a middle. On testnet that leg is bridge.simulated, whose Stellar side is real:
| Step | Forward | Compensation |
|---|---|---|
ledger.reserve |
Available → hold | Hold → available |
bridge.simulated |
Treasury → escrow on Stellar, reference as MEMO_HASH; the far side booked simulated: true |
Escrow → treasury on Stellar with the same reference; books bridge.unfill and bridge.refund |
partner.payout |
Failed | Nothing to undo |
Both Stellar movements carry the payout’s reference, so reconciliation matches the forward entry and the refund to the same payout.
Why reverse order
Section titled “Why reverse order”Compensations run from the last completed step to the first. Unwinding the hold before the refund would let the customer’s balance show funds that are still in the escrow, and the ledger would balance while describing a sequence that never happened. In reverse order, every intermediate state is one that really existed.
A test asserts exactly this, with a three-step saga and a forced failure:
expect(await engine.run(id)).toBe("compensated");expect(view.steps.map((s) => s.status)).toEqual(["compensated", "compensated", "failed"]);Retries that do not pay twice
Section titled “Retries that do not pay twice”Every leg records its external side effect before it waits on it, and a leg that moves money asks Horizon first whether an identical movement — same memo, parties, amount and asset — already landed. A crash between “sent” and “recorded” therefore resumes rather than repeats.
Separately, every commit checks the saga’s fencing token. A worker that stalls inside a leg, loses its lease and wakes up later is refused at its next commit with StaleLeaseError, writes nothing, and returns the saga’s real status. A test runs that race — a stalled worker while a second worker completes the saga — and checks that only one booking exists; with fencing disabled the same test finds two.
The result
Section titled “The result”| Before | After a pre-funding failure | |
|---|---|---|
| Customer available | X | X |
| Customer hold | 0 | 0 |
| Treasury vault on Stellar | Y | Y (refund received, where a leg had moved value) |
| Saga status | compensated |