Skip to content

Failure and compensation

Payments fail. What matters is where the money is afterwards, and whether the books say so.

Saga forward steps and reverse compensation
Fig. 07 · Forward legs, a failure at the anchor, and compensation in reverse order with an on-chain refund on Stellar.
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

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 order
saga_steps: ledger.reserve = compensated, partner.payout = failed
sagas: status = compensated

That is a consequence of settling where the anchors are: fewer legs means fewer intermediate states to unwind.

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.

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"]);

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.

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