Execution and recovery
How the executor walks the plan, what it retries, and the checks that stand between a close and its merge.
executePlanTreeOnChain in src/lib/orchestrator/executor.ts walks a hydrated plan in
topological order and signs and submits each node. The execute actor
(src/lib/orchestrator/actors/execute.ts) wires it to Horizon, Soroban RPC, the connector and the
mediator, and turns its outcome into a result and a failure kind.
Before the walk
- One run per account. A second run for the same account and network in the same tab is
refused with
close_running. Across tabs the run holds a Web Lock named after the account; when another tab holds it, the run does not start and reportsclose_running. - Retry resets the tree. On entering
executing,prepareTreeForExecutionsets every node that has no receipt and isfailed,signed,submitted, or skipped because of a dependency back topending. Nodes with a receipt are skipped by the walk; best-effort steps skipped on purpose stay skipped.
The walk
For each node in order:
- A node with a receipt is marked
confirmedand skipped. - A
failedorskippednode is left alone. - Unless the node is the forward, a stop request ends the run here, before anything else is signed. The forward runs regardless, because it moves the balance out of the forwarding account and asks the account owner to sign nothing.
- A dependency that is not
confirmed(a best-effort step skipped on purpose excepted) skips the node withSkipped: dependency "<id>" did not complete. The forward requires a confirmed merge. - The node runs by kind:
| Kind | How it runs | On failure |
|---|---|---|
| Required Soroban steps and trustlines | Rebuilt from fresh state, allow-list, authorization tree and fee cap checked, signed, submitted | Fails the node and stops the close |
RevokeAllowance | Rebuilt, held to exactly approve(user, spender, 0, ledger), signed, submitted | Skipped with a note: the allowance stays on the token |
TransferAsIs | Rebuilt for the live balance, held to exactly transfer(user, destination, amount) | Skipped with a note, and the token is reported as left behind. A token the account no longer holds is skipped as nothing to send |
ConvertSorobanToXLM | Like a required step | A price refusal or missing route stops the close with conversion_refused; any other failure sends the token as-is on a direct close, and stops the close if that fails too |
ClassicUnwind | Classic batches planned from a fresh audit, touching only the reviewed offers and pools | Fails the node and stops the close |
FinalClassicTx | The close loop below | Fails the node and stops the close |
MediatorForward | Renews the token when needed, then submitMediatorForward | Stops the close with forward_pending |
Before every Soroban signature the progress line names the step and the network fee its signature allows.
Submitting and unknown outcomes
Every submission is journaled per node (src/lib/orchestrator/journal.ts) with its hash and
maxTime. When the network does not confirm either way, the error is AmbiguousSubmitError and the
hash stays in the journal. The next run looks the hash up first: a landed transaction counts as the
step's receipt, a failed or expired one is forgotten, and one that can still land stops the run
with ambiguous until it settles. Nothing is signed again while an earlier submission of the same
step can still land.
Soroban submissions go through sendSoroban and pollSoroban in src/lib/soroban/submit.ts. Only
PENDING and DUPLICATE count as queued. A TRY_AGAIN_LATER answer (a full RPC queue) is resent
as the identical envelope up to five times, after 1, 2, 4, 8 and 16 seconds; when the RPC still
answers TRY_AGAIN_LATER, nothing landed and the step fails as a retryable network failure. A
result is polled for about a minute.
Soroban retries
A Soroban step is attempted up to three times. Before each attempt it is rebuilt (fresh sequence, fresh simulation, fresh time bound) and checked again. A failure is retried only when a rebuild can clear it:
| Failure | Why a rebuild clears it |
|---|---|
txBadSeq | The rebuild reads a fresh sequence |
txInsufficientFee, txSorobanInvalid | Re-simulation prices the resource fee |
| A changed, archived or expired footprint entry, or an exceeded limit from a write the simulation did not see | Re-simulation declares the footprint again |
Any other failure stops the close, and Failure reference lists the kind it gets. A refused step is never retried, because an unsafe tree does not become safe on a rebuild.
The close loop
runFinalClassic runs one classic batch per phase, up to 30 phases, and audits the account again
after each lands. For each phase it:
- Audits the account. A missing account means a merge already landed, even if its reply was lost:
findAccountMergereads the account's history, and a merge into the expected account (the destination, or the forwarding account) finishes the close. - Quotes the conversions for the balances plus what the close claims into them.
- Discovers DeFi positions again and refuses while any is open (
account_merge blocked: N Soroban DeFi position(s) still open) or a protocol could not be read (network). - Refuses when a balance can no longer be disposed of as reviewed:
conversion_refusedfor a price refusal,not_mergeablefor a balance with no path. - Reads the ledger clock, leaves out claims that would not stay claimable for the signing window, and quotes again when that changed the claims.
- Reads the destination again and refuses with
not_mergeablewhen it can no longer receive a balance the account owner chose to send there, for the balance plus what this batch claims into it. A balance with a market path is checked too, because an explicit send choice wins over the sale. - On an exchange close, probes the forwarding account (see below).
- Re-batches and runs
assessMergeabilityagainst the claims the batch makes and the transactions still to come. - When the batch funds the forwarding account, checks the account can spare its 2 XLM.
- Before the merge batch, reads every token the DeFi exits paid out that is not a classic balance and refuses while any is still on the account.
- Refuses conversions that just failed against the same quotes.
- Caps the fee to the spendable XLM, builds, signs and submits the batch.
The receipt of FinalClassicTx is set only when the batch carrying the merge lands.
Classic rejections
The whole close loop is attempted up to three times. classicRejectionKind sorts a rejection
Horizon answers, and a rejection the next attempt can clear is retried:
| Rejection | What the next attempt changes |
|---|---|
tx_insufficient_fee | The per-op bid triples, up to 1,000,000 stroops |
tx_insufficient_balance | The bid drops to the 100-stroop minimum; a rejection already at the minimum stops |
tx_bad_seq | A fresh audit and build |
tx_too_late | A fresh audit and build with a new time bound |
op_under_dest_min, op_too_few_offers, op_over_source_max | Fresh quotes; the same quotes failing again stops the close |
Ledger drift: op_invalid_limit, op_underfunded, op_has_sub_entries, op_src_no_trust, op_offer_not_found, op_does_not_exist, op_cannot_claim, op_cannot_delete, op_under_minimum, op_already_exists | A fresh audit and build, so every amount is recomputed from current state |
Any other rejection, and any rejection on the third attempt, stops the close. The classic unwind ahead of the repays sorts its rejections the same way: it raises or lowers the bid as above, plans again from a fresh audit for the rest, and stops at the third rejection.
The starting bid
surgeFeeBase reads Horizon fee_stats. Below 50% ledger capacity it bids fee_charged.mode,
above it fee_charged.p90, clamped between 100 and 1,000,000 stroops per operation. An unreadable
answer throws a network failure, The network fees could not be read, instead of bidding the
minimum, which never lands while fees surge.
Horizon reads
Reads that prepare a phase (the account, the latest ledger, fee stats, the audit) are tried up to
three times on a server error, a rate limit or no response, with a delay that doubles from 300 ms.
A 429 backs off on that same schedule: the SDK drops the response headers, so retry-after is not
read. A 404 or another 4xx is an answer and is not retried.
The forwarding account on an exchange close
The forward needs a flow token that is still valid at the merge and through the forward. The executor tracks when the token on the plan was issued:
- Probe. When the forwarding account on the plan exists, or the account's last recorded flow on
this network names one that exists, that account already holds funds and is adopted, never
replaced. Only a 404 from Horizon counts as "not funded". Any other failure to load the account,
after three tries with a 429 or a 5xx retried, stops the close before anything is signed, as a
retryable
networkfailure that names the account. A recorded flow for a different destination or memo is refused, and the account owner resumes that forward first. When fewer than eight minutes (the merge's 300-second validity plus the forward's 180) remain on the token, it is renewed through/api/mediator/reissue; when it still cannot cover that, the merge is refused withmediator_expired. - Funding. A new flow is minted only right before the batch that funds the forwarding account,
and its recovery record is written to browser storage before that batch is signed. A close whose
record cannot be stored does not start. A later phase of the same close leaves the funding
operation out. When the funding
create_accountis refused withop_already_existsanyway, the rejection counts as ledger drift: the close audits again, the probe finds and adopts the funded account, and the rebuilt batch carries no funding operation. - Forward. When the forwarding account is gone after the merge, a forward already landed:
findForwardreads it, and an emptying into any other address is reported as such. Otherwise the token is renewed when fewer than 180 seconds remain, andsubmitMediatorForwardruns. A failure stops the close withforward_pending, and Retry runs the forward again.
The mediator forward protocol covers the forward itself.
What is never reported as success
- A close whose merge batch did not land: the result is a failure, whatever ran before it.
- An exchange close whose forward did not land:
forward_pending, with the merge hash. - A prepare-only run where a required step did not complete:
The close was not prepared. - A step without its own receipt.
A close whose merge landed while a held token the account owner chose to send stayed behind is reported as closed, not as a clean close: the heading reads Account closed, tokens left behind, and each token is listed with its contract id, the amount and why it was not sent, or the hash of a transfer whose outcome the network has not confirmed.
A run that only prepares the close ends in the prepared state with the unlock date, read from the
queue transaction that was signed.
Multisig bundles
A collected multisig close is submitted as-is (submitBundle). Before submitting, the actor checks
the bundle has not already landed, refuses when the plan holds any step other than the classic close,
discovers DeFi positions again, compares the account with the signer snapshot the relay checked
(sequence, balances, offers, signers), and trims the signatures to what the current signers need.
A tx_insufficient_fee refusal is answered with a fee bump paid by the connected wallet.
See Failure reference for every failure kind and what Retry does with it.