Account Demolisher
How it works

The classic batch

How the classic close is ordered, how it splits across transactions, and how it is rebuilt at execution time.

batchClassicDemolition(audit, options, paths) in src/lib/plan/classic-batcher.ts turns an account audit into an ordered list of classic batches. It is a pure function. The operation order is fixed, so a trustline is never removed before its balance is handled and the merge is always last.

Guards

The batcher throws before emitting anything when:

  • the destination is one of the refused exchange internal wallets,
  • useMediator is set without mediatorPublicKey,
  • an exchange close asks to send a balance to the destination (it would arrive without the memo),
  • a direct close merges into the account itself (ACCOUNT_MERGE_MALFORMED on chain),
  • a credit balance is frozen by its issuer (FROZEN_TRUSTLINE),
  • claimableBalanceIds is neither "every-collectable" nor an array of ids.

Operation order

1. Withdraw liquidity pool shares

One liquidity_pool_withdraw per pool share with a positive balance. Horizon's reserves are the whole pool's, so each minimum is the holder's proportional share, reserve * shares / totalShares, less the slippage tolerance. A pool record without positive total shares throws instead of signing a withdraw with no floor.

When a payout would not fit under its trustline's limit, a change_trust raising that line to the maximum limit runs first. A payout that does not fit even then throws.

2. Cancel offers

One cancel per open offer.

3. Claim claimable balances

planClaimableBalances(audit, claimableBalanceIds) decides which balances the close claims. The list is required: "every-collectable" claims every balance the close can collect, and an array of ids is the reviewed selection ([] claims none). A caller that leaves it out, or passes anything else, makes both planClaimableBalances and batchClassicDemolition throw, so a missing selection never turns into claiming everything. Self-sponsored balances are claimed regardless of the selection, because leaving one keeps the account a sponsor. A balance is left behind, with its reason, when it is not claimable yet, has no trustline the close can open, has an unauthorized or full trustline, or needs a trustline whose reserve the account cannot spare. A missing trustline is opened (change_trust_add) when the issuer does not require authorization; a full one is raised to the maximum limit.

4. Dispose of credit balances

For each credit balance, in this precedence:

OrderDisposalWhen
1Payment to the destinationThe account owner chose it
2Payment back to the issuerThe account owner chose it
3path_payment_strict_send to XLMA path exists, its price impact is within 2% (or the account owner accepted it), and the fresh quote is not worse than the reviewed one beyond the slippage tolerance
4NothingAnything else; the balance and its trustline stay, which blocks the merge

An explicit choice wins over a market path found later. The path payment sends to the account itself, converting in place, and its destMin is the quoted output less the slippage tolerance. The batcher records the quoted path on every path payment, [] for a direct hop, and src/lib/stellar/classic-builder.ts refuses one whose path is missing instead of building a direct hop nobody quoted. See Simulation for how quotes and the price-impact cap are computed.

5. Remove trustlines

Pool-share trustlines first, then credit trustlines. A pool-share removal carries the full liquidity pool asset (both reserves and the fee). A credit line is removed only when its balance is handled.

6. Delete data entries

One manage_data delete per entry.

7. Release sponsorships

No operation. Removing, cancelling, clearing or claiming a self-sponsored entry releases its sponsorship. Sponsorships for other accounts cannot be released from this account and block the merge in assessMergeability.

8. The merge group

These always land together in the final batch, in this order:

  1. On a direct close with a memo, the payments to the destination and the removal of their trustlines, so they travel with the memo.
  2. A threshold reset to low 0, medium 0, high 0 and master weight 1, when the account is not there already.
  3. One set_options per non-master signer with a positive weight.
  4. The close marker: a manage_data that sets account-demolisher.close and one that deletes it. It needs one base reserve free right before the merge and two operation slots; without them the close runs unmarked and the review says it will not be counted.
  5. On an exchange close, create_account funding the forwarding account with 2 XLM, unless an earlier phase already funded it.
  6. account_merge into the destination, or into the forwarding account on an exchange close.

Phases and splitting

A pool withdraw or a credit claim whose asset is then sold credits balances this audit never saw, so converting or removing them in the same transaction would fail. When either exists, they go in a leading phase together with the offer cancels, and the executor audits the account again after it lands and rebuilds the rest. Claims of assets with an explicit disposal stay in the same transaction as that disposal.

Conversions are quoted one by one against the untouched market, so two path payments that trade through the same market cannot share a transaction: the second would miss its floor once the first used up depth its quote counted on. conversionStages groups the conversions so that no two in a group share a market, a market being one directed asset pair along the path (sold asset, each path hop, XLM), order book and pools alike. Each conversion joins the first group it shares no market with. Every group but the last runs in a phase of its own, after the leading phase. When there is no leading phase, the offer cancels move into the first of those phases. The last group stays in the final phase. Because the executor quotes again before every phase, each group is priced after the one before it landed, and a fresh quote worse than the reviewed one by more than the slippage tolerance stops the close with conversion_refused before anything in that phase is signed.

A transaction holds at most 100 operations (MAX_OPS_PER_TX). The leading phase packs front to back. The final phase packs from the end: the merge group takes the last batch whole, the body operations that fit go in front of it, and the rest fill earlier batches. A merge group over 100 operations throws.

The deposit memo goes on the final batch of a direct close only. An exchange close carries the memo on the forward instead.

Fees and time bounds

ItemValue
Starting bid per operationFrom Horizon fee_stats: fee_charged.mode below 50% ledger capacity, p90 above
Ceiling per operation1,000,000 stroops (0.1 XLM), and never more than the spendable XLM covers
Total fee guardThe total must fit in a u32 (4,294,967,295 stroops)
Time bound300 seconds from the latest ledger's close time
Multisig bundle100 stroops per operation, valid for 72 hours

An unreadable fee_stats answer fails the step as a network failure instead of bidding the network minimum. See Execution and recovery for how the bid escalates.

Rebuilt at execution time

The batches attached to the plan are what the review shows, not what gets signed. Before every phase the executor audits the account again, quotes the conversions again for the balances plus what the close claims into them, discovers DeFi positions again, re-batches with the reviewed options (finalBatchOptions), and builds only the first batch of the result. It repeats until the batch it built is the last one, which carries the merge.

The reviewed options it carries over are the claimable-balance list, both disposal lists, the accepted price-impact keys, the reviewed reference and full-size quotes, the memo, the forwarding account and whether it is funded, and the fee bid. The receipt of FinalClassicTx is set only when the merge batch lands, so a stopped close never reads as finished.

On this page