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:
- 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.
- 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.
- 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.
- 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.
- Queue the withdrawal of every active share.
- 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_claimon the pool, then the router'swithdrawwith per-token minimums, then the router'sclaimfor pool rewards. - Concentrated pool: the reward claim and
claim_all_position_fees, thenwithdraw_positionfor 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.
assertTransactionAllowedruns 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.