Account audit
What is read from Horizon, how the merge verdict is computed, and which conditions block a close.
auditAccount(publicKey, network) in src/lib/stellar/account-audit.ts produces the read-only
snapshot the plan is built from. It is uncached: every call reads Horizon again.
What it reads
| Call | Notes |
|---|---|
loadAccount(publicKey) | Balances, signers, thresholds, flags, data entries, sponsorship counts |
| The latest ledger | Read after the account, so the clock is never older than the state |
offers().forAccount(publicKey).limit(200) | Pages until a page holds fewer than 200 |
claimableBalances().claimant(publicKey).limit(200) | The same 200-per-page loop |
liquidityPools().liquidityPoolId(poolId) | One per pool-share trustline, in parallel |
loadAccount(issuer) | One per distinct issuer of a claimable-now credit balance the account has no trustline for |
Offers, claimable balances and pools load concurrently. A pool read that fails fails the whole audit with one error naming every failed pool, because the pool reserves size the withdraw floors. Issuer lookups run eight at a time, each retried on a rate limit or server error. A missing issuer leaves its balances untagged; any other failure fails the audit.
The ledger clock (LedgerClock: sequence and close time) travels with the audit. Claimable-balance
predicates, transaction time bounds and the merge sequence ceiling are judged against it, never
against the browser's clock.
Details of the parse
The master key is the signer whose key equals the account id, not the first ed25519 signer.
masterWeight is its weight, or 0 when no such signer exists.
A claimable balance is marked claimableNow only when this account's predicate holds at the
ledger close time and stays true for the whole signing window (300 seconds by default).
An untrusted credit claimable balance carries issuerAuthRequired, the issuer's
auth_required flag. false means the close can open the trustline and then claim. A balance in
the account's own asset is tagged issuedByAccount instead: the claim burns it and needs no
trustline.
Account not found
A Horizon 404 raises AccountNotFoundError with the message
Stellar account not found on this network: <publicKey>. Any other error is rethrown unchanged,
so a server error is never mistaken for a missing account.
Coverable sponsorships
An account cannot sponsor its own trustlines, offers, signers or data entries, so the only entries
in num_sponsoring that the close releases on its own are claimable balances the account created
with itself as a claimant. computeCoverableSponsorships(accountId, claimableBalances, claimedBalanceIds) counts those that are claimable now and in the given claim list, once per
claimant, because the network counts a sponsored claimable balance once per claimant.
The stored audit value, sponsorship.coverable, assumes every balance the audit sees as
collectable is claimed: claimable now and, for a credit asset, the account's own asset, an
authorized trustline with room left (after what the pool withdraws credit to it), or no trustline
and an issuer that does not require authorization. Everything in num_sponsoring beyond
coverable is sponsorship for another account.
The merge verdict
The audit stores a first verdict, computeMergeability(flags, sponsorship):
flags.authImmutablegivesAUTH_IMMUTABLE.numSponsoring - coverable > 0givesIS_SPONSOR, with a detail naming the count.- Otherwise mergeable.
The full check is assessMergeability(audit, { ledger, claimedBalanceIds, transactionsBeforeMerge }). The preview runs it against the claims the plan really makes and every
transaction the account still sources before the merge, and the executor runs it again on a fresh
audit before every classic phase. It returns the first blocker in the order a user resolves them:
| Reason | Condition |
|---|---|
AUTH_IMMUTABLE | The auth_immutable flag is set |
FROZEN_TRUSTLINE | A positive credit balance on a trustline the issuer deauthorized |
IS_SPONSOR | num_sponsoring exceeds the self-sponsored claimable balances the plan claims |
SEQNUM_TOO_FAR | The sequence number after the transactions before the merge is at or above (next ledger) << 32, which the network refuses as ACCOUNT_MERGE_SEQNUM_TOO_FAR |
The SEQNUM_TOO_FAR detail names the first ledger the merge can land in and an estimate of the wait.
Multisig detection
computeRequiresMultisig is masterWeight === 0 || masterWeight < max(low, medium, high). The
close touches all three thresholds: the merge and set_options are high, path payments, payments,
change_trust and offers are medium, and claims are low. The three are independent, so medium or
low can sit above high.
What blocks the page
Two conditions take over the page before any plan is built:
| Block | Condition |
|---|---|
| Auth immutable | flags.authImmutable |
| Sponsoring for others | numSponsoring > coverable on the stored audit |
Everything else that keeps a close from starting is a review blocker: the plan-specific mergeability verdict, a balance with no disposal, a refused conversion, a failed required step, and the rest listed in Clear a blocked closure.
Execution and recovery covers the checks the executor runs before the merge.