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,
useMediatoris set withoutmediatorPublicKey,- 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_MALFORMEDon chain), - a credit balance is frozen by its issuer (
FROZEN_TRUSTLINE), claimableBalanceIdsis 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:
| Order | Disposal | When |
|---|---|---|
| 1 | Payment to the destination | The account owner chose it |
| 2 | Payment back to the issuer | The account owner chose it |
| 3 | path_payment_strict_send to XLM | A 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 |
| 4 | Nothing | Anything 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:
- On a direct close with a memo, the payments to the destination and the removal of their trustlines, so they travel with the memo.
- A threshold reset to low 0, medium 0, high 0 and master weight 1, when the account is not there already.
- One
set_optionsper non-master signer with a positive weight. - The close marker: a
manage_datathat setsaccount-demolisher.closeand 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. - On an exchange close,
create_accountfunding the forwarding account with 2 XLM, unless an earlier phase already funded it. account_mergeinto 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
| Item | Value |
|---|---|
| Starting bid per operation | From Horizon fee_stats: fee_charged.mode below 50% ledger capacity, p90 above |
| Ceiling per operation | 1,000,000 stroops (0.1 XLM), and never more than the spendable XLM covers |
| Total fee guard | The total must fit in a u32 (4,294,967,295 stroops) |
| Time bound | 300 seconds from the latest ledger's close time |
| Multisig bundle | 100 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.