Account Demolisher
How it works

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 reports close_running.
  • Retry resets the tree. On entering executing, prepareTreeForExecution sets every node that has no receipt and is failed, signed, submitted, or skipped because of a dependency back to pending. Nodes with a receipt are skipped by the walk; best-effort steps skipped on purpose stay skipped.

The walk

For each node in order:

  1. A node with a receipt is marked confirmed and skipped.
  2. A failed or skipped node is left alone.
  3. 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.
  4. A dependency that is not confirmed (a best-effort step skipped on purpose excepted) skips the node with Skipped: dependency "<id>" did not complete. The forward requires a confirmed merge.
  5. The node runs by kind:
KindHow it runsOn failure
Required Soroban steps and trustlinesRebuilt from fresh state, allow-list, authorization tree and fee cap checked, signed, submittedFails the node and stops the close
RevokeAllowanceRebuilt, held to exactly approve(user, spender, 0, ledger), signed, submittedSkipped with a note: the allowance stays on the token
TransferAsIsRebuilt 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
ConvertSorobanToXLMLike a required stepA 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
ClassicUnwindClassic batches planned from a fresh audit, touching only the reviewed offers and poolsFails the node and stops the close
FinalClassicTxThe close loop belowFails the node and stops the close
MediatorForwardRenews the token when needed, then submitMediatorForwardStops 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:

FailureWhy a rebuild clears it
txBadSeqThe rebuild reads a fresh sequence
txInsufficientFee, txSorobanInvalidRe-simulation prices the resource fee
A changed, archived or expired footprint entry, or an exceeded limit from a write the simulation did not seeRe-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:

  1. Audits the account. A missing account means a merge already landed, even if its reply was lost: findAccountMerge reads the account's history, and a merge into the expected account (the destination, or the forwarding account) finishes the close.
  2. Quotes the conversions for the balances plus what the close claims into them.
  3. 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).
  4. Refuses when a balance can no longer be disposed of as reviewed: conversion_refused for a price refusal, not_mergeable for a balance with no path.
  5. Reads the ledger clock, leaves out claims that would not stay claimable for the signing window, and quotes again when that changed the claims.
  6. Reads the destination again and refuses with not_mergeable when 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.
  7. On an exchange close, probes the forwarding account (see below).
  8. Re-batches and runs assessMergeability against the claims the batch makes and the transactions still to come.
  9. When the batch funds the forwarding account, checks the account can spare its 2 XLM.
  10. 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.
  11. Refuses conversions that just failed against the same quotes.
  12. 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:

RejectionWhat the next attempt changes
tx_insufficient_feeThe per-op bid triples, up to 1,000,000 stroops
tx_insufficient_balanceThe bid drops to the 100-stroop minimum; a rejection already at the minimum stops
tx_bad_seqA fresh audit and build
tx_too_lateA fresh audit and build with a new time bound
op_under_dest_min, op_too_few_offers, op_over_source_maxFresh 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_existsA 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 network failure 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 with mediator_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_account is refused with op_already_exists anyway, 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: findForward reads it, and an emptying into any other address is reported as such. Otherwise the token is renewed when fewer than 180 seconds remain, and submitMediatorForward runs. A failure stops the close with forward_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.

On this page