Account Demolisher
How it works

Position discovery

How each protocol's positions are found and how the exit transactions are built.

Discovery runs in the browser against the live network. DirectContractProvider in src/lib/adapters/positions/direct.ts probes Blend, Aquarius and Soroswap in parallel, each on its own 25-second budget, and collects every per-protocol, per-pool or per-source failure into positions.errors. A failure is never read as "no position": the review lists it as a blocker, and the executor refuses to merge while the re-probe before the merge records one.

openPositionCount counts every Blend pool, backstop, Aquarius pool, Aquarius concentrated position and Soroswap pair, and is what the merge check uses. Backstop emissions left over with nothing deposited or queued are reported for information only.

Blend

Discovery (src/lib/adapters/blend/client.ts) loads each pinned pool through the Blend SDK, trying the v2 layout first and then v1, and reads the account's liabilities, collateral and supply in underlying units plus the reserve indexes with accrued emissions. Mainnet pins seven pools (the blend-utils registry plus the pools in the v1 backstop's reward zone), testnet one. An unmapped reserve index is a load failure, never an empty position. The SDK's reads fail over to the next RPC endpoint on a rate limit, a server error or a network error.

The backstop is a separate contract, and each pool names its own in its metadata (v1 pools the v1 backstop). For every pool the backstop is read for active shares, queued shares, the part of the queue already unlocked, when the still-locked part unlocks, and the BLND emissions accrued to the user. The queue lock is 17 days on the v2 backstop and 21 on v1.

Exits (src/lib/adapters/blend/exit.ts) re-read the pool and backstop on chain at every build, so amounts, the pool version and the backstop come from current state:

  1. Repay each liability. The request is the debt plus 10 basis points for accrual, capped at what the account can spend; the pool refunds the excess in the same call.
  2. Withdraw collateral and supply. A v2 request is sized so the pool caps it at the position without overflowing its bToken conversion; a v1 request is the buffered underlying amount.
  3. Claim pool emissions, for the reserve indexes that carry them. BLND arrives through a SAC transfer, so the plan opens a BLND trustline first when the account has none, and refuses when the existing one is not authorized.
  4. Claim backstop emissions. The backstop deposits the BLND into the BLND-USDC comet pool and credits new backstop shares. The claim is simulated first; on v2 the minimum LP output is that simulation less the slippage tolerance. When the claim traps in simulation, the plan offers to forfeit the BLND and queue without it.
  5. Queue the withdrawal of every active share.
  6. Withdraw the part of the queue already unlocked. It pays BLND-USDC comet LP tokens.

Repaying needs the borrowed token on hand. blendRepayShortfallWarnings compares each liability with what the account holds and returns one warning when any is short.

When a repay asset is locked in open DEX offers or classic pool shares, the plan adds a ClassicUnwind step ahead of the repays that cancels exactly those offers and withdraws exactly those pools.

Aquarius

Standard pools are found by two sources whose results are combined: AquariusAPIPoolProvider reads the Aquarius REST API (amm-api.aqua.network, or the testnet host), and AquariusEventScanPoolProvider scans the router's deposit and withdraw events over the RPC's retention window, about seven days. Both always run. With a Lumeus key for the network, the scan also asks Lumeus, a hosted Stellar history index, for every deposit event the router emitted with the account as topic 2, over the whole history, and adds the pools those events name to the ones it observed. Every pool, from either list, is resolved through the router's get_pools and its share balance read on chain. Pools are keyed by their sorted token set plus pool index, because a pool index is unique only within a token set. When the REST source fails, what the scan found is kept and an error is recorded. Without Lumeus, the error says the scan cannot rule out an older position. When Lumeus answered, it says router deposits were checked over the whole history but a pool share received by transfer could not be ruled out. When Lumeus was asked and failed, its reason joins the error. When the scan fails, what REST found is kept and an error is recorded too, because a REST answer that left out a recent pool cannot be checked against the chain. Either error warns at review and makes the merge-time check refuse the merge. Discovery fails only when both sources fail. Share balances are read on chain.

Concentrated pools hold tick-range positions in the pool contract itself, which neither source sees. Discovery reads each pinned concentrated pool's per-user storage with batched getLedgerEntries and confirms each hit with the pool's own read functions: the live ranges, the unpaid swap fees and the accrued reward. Mainnet pins 43 pools and testnet 37. When the REST API lists a concentrated pool that is not pinned, discovery records an error, so the merge stays blocked instead of leaving the position behind.

Gauge rewards are paid by reward gauges a pool keeps, not by the router's claim. Discovery reads what each pool's gauges owe for every pool the account still holds a share or a position in. Rewards owed in a pool the account already left are not found: no RPC data source lists them.

gauges_claim runs every gauge the pool keeps in its RewardGaugesMap, and each one transfers what it owes, zero included. A transfer of a classic asset into an account without a trustline for it traps the whole claim (Error(Contract, #13)). So the plan opens a payout trustline for every gauge token of the pool before the claim, whether that gauge owes anything or not. When one of those lines cannot be opened (the issuer authorizes every new line, the issuer account is gone, or the existing line is not authorized), the claim is left out, and the Check the plan step names the pool and the rewards that closing the account forfeits. A gauge never blocks the close.

Exits (src/lib/adapters/aquarius/):

  • Standard pool: gauges_claim on the pool, then the router's withdraw with per-token minimums, then the router's claim for pool rewards.
  • Concentrated pool: the reward claim and claim_all_position_fees, then withdraw_position for each range, with per-token minimums from the pool's own estimate.
  • A gauge claim calls the pool directly. Pools are permissionless and not on the allow-list, so the builder asks the pinned router which pool it lists for the tokens and index and refuses any other address. See Contract allow-list.

Soroswap

Discovery is on chain. The factory's pair list and each pair's balance of the account are read with batched getLedgerEntries, and anything the bulk read cannot resolve goes to a balance() simulation.

The exit is the router's remove_liquidity, with both minimums from a probe simulation less the slippage tolerance.

Held-token conversion (src/lib/adapters/soroswap/swap.ts) swaps a standalone token to XLM with the router's swap_exact_tokens_for_tokens. It searches the direct pair and one-hop routes through a token paired with both sides, quotes each with router_get_amounts_out, and takes the best. A token with no route raises NoSoroswapRouteError. The swap is held to the 2% price-impact cap against a quote for one thousandth of the amount.

Discovery, the exit and the conversion all call the Soroswap contracts directly. No Soroswap API and no aggregator take part in a close.

Standalone tokens

discoverHeldTokens in src/lib/soroban/held-tokens.ts scans the RPC event log for SEP-41 transfer and mint events crediting the account, then reads balance() on each emitting contract and keeps the positive ones. Stellar asset contract events carry an extra topic and never match, on purpose: those balances are classic trustlines the classic close handles. The window is the RPC's retention, about seven days, and the account owner can add an older token by contract address.

With a Lumeus key for the network, partnerHeldTokens in src/lib/lumeus/client.ts reads the account's contract-storage token holdings from Lumeus, alongside the RPC scan. Those contract ids join the scanned ones as candidates, and each candidate gets the same balance() read. The holdings stand for every token received up to the ledger they were read at, so an RPC scan that stopped early counts as complete when that ledger reaches the first ledger the scan covered. A Lumeus failure leaves the RPC result as it was and adds a held-token partner history entry to the discovery diagnostics. The Standalone tokens card says which coverage the scan had.

An Aquarius or Soroswap LP share is a SEP-41 token too, so the scan finds the shares of pools the plan already exits. withoutPositionShares in src/lib/orchestrator/actors/discover.ts removes them from the held and unreadable lists, using share ids read from the Aquarius router (share_id) and the Soroswap factory (get_pair) by positionShareTokens (src/lib/adapters/positions/share-tokens.ts), never from the token itself. When one of those reads fails, every token stays listed and the failure goes to the discovery diagnostics.

Common properties of the exit builders

  • The inclusion fee is SOROBAN_INCLUSION_FEE, 100,000 stroops.
  • The transaction is assembled from its simulation, and a fee-bump result is refused.
  • assertTransactionAllowed runs before the transaction is returned.
  • Every step is rebuilt from fresh state right before it is signed, and the executor runs the allow-list, the authorization-tree check and the fee cap again on the rebuilt transaction.

Unsupported protocols

The close unwinds Blend, Aquarius and Soroswap (SUPPORTED_DEFI_PROTOCOLS in src/lib/safety/manual-close.ts). FxDAO was removed after the protocol shut down on chain. When the account holds standalone tokens, discovery adds a notice that any of them can be a position in an unsupported app, to be unwound there first.

On this page