Account Demolisher
Protocol

SEP-41 allowances

How allowances are found from on-chain events, confirmed against the token contract, capped, and revoked under an exact-shape guard.

SEP-41 is Stellar's Soroban token interface. An allowance is a standing approval that lets a spender move a holder's tokens up to a limit until an expiry ledger passes or the holder revokes it.

src/lib/soroban/allowances.ts finds them and builds revokes. Two places use it: the allowance viewer at /allowances, and the discovery step of a close, which plans one RevokeAllowance per allowance it finds.

The scan window

DEFAULT_SCAN_WINDOW_LEDGERS = EVENT_RETENTION_WINDOW_LEDGERS = 120_960

Soroban RPC keeps events for about seven days by default: 7 × 24 × 60 × 60 / 5 = 120,960 ledgers at five seconds a ledger. Every RPC event scan in the app looks back this far at most, and an allowance whose last approve is older than the window cannot be found by an RPC scan alone.

Full history through Lumeus

When the build carries a Lumeus publishable key for the network (NEXT_PUBLIC_LUMEUS_TESTNET_KEY or NEXT_PUBLIC_LUMEUS_MAINNET_KEY), partnerApprovals in src/lib/lumeus/client.ts passes scanAllowances a partner read. Lumeus is a hosted Stellar history index. The read asks it for every approve event whose first address topic is the owner, over the whole history, and fails unless the answer is complete, so a partial answer is never taken for a complete one.

The RPC scan runs alongside it. mergePartnerEvents in src/lib/soroban/event-scan.ts unions the two event lists, deduplicated and ordered by the RPC event id, so the folding below sees one ordered stream. Lumeus answers through its snapshot ledger, returned as partnerThroughLedger. An RPC scan that stopped early counts as complete only when that ledger reaches the first ledger the RPC scan did cover; otherwise its incomplete report stands.

A Lumeus failure leaves the RPC result as it was and sets partnerError. The viewer adds it to The scan didn't finish notice, and a close records it in the discovery diagnostics. Without a key for the network, partner is absent and the scan runs on the RPC window alone.

The allowance viewer, the Review step and the status panel state which coverage the scan had: the whole approve history through Lumeus, or the seven-day RPC window.

Scanning

The scan asks for contract events whose topics are approve and your address, with three topics or four (["approve", from, *] and ["approve", from, *, *]). The window is cut into slices of 10,000 ledgers, the most one getEvents request covers, and four slices run at once. A slice the RPC refuses as too costly is split down to 625 ledgers. The scan stops at a 20-second budget, inside the 30-second discovery timeout.

When the RPC rejects a start ledger older than it retains, the scan reads the floor from the ledger range: N - M error, moves the start to the floor plus 60 ledgers, and retries, up to three times.

A scan that does not cover the whole window reports what it covered and why it stopped (the budget, the processing limit, an RPC error, or the page cap). The viewer shows the partial list with that note. A close carries the partial list into the plan and warns that an older permission can be missing, pointing you at the Allowances page.

Folding events into allowances

Events arrive in on-chain order (ledger, transaction, operation, event), and the last approve seen for a token-and-spender pair is the one in force, even when two land in the same ledger. Each pair becomes one record with the amount and expiry ledger of that approve.

By default the scan leaves out:

  • records whose expiry ledger has passed, and
  • records whose latest approve set the amount to zero, which is how a revoke looks.

The approve data decodes from the SEP-41 tuple (amount: i128, expiration_ledger: u32), or from a map with amount and live_until_ledger or expiration_ledger keys. An event that does not decode cleanly, or that names a different owner, is ignored.

The per-contract cap

Any contract can emit approve events naming you, for spenders it chooses, and its allowance() is its own code too. A contract with more than 20 spenders in the scan is cut to its 20 most recent, and the cut is reported with a note that the token is hostile. A Stellar Asset Contract is never cut: it runs no token code, so each of its approve events is an approval you signed. The cap bounds the confirm reads, the revokes a close plans, and the rows the viewer renders.

On-chain confirmation

An approve event is evidence that an event was emitted, and a malicious contract can emit a fabricated one. confirmAllowancesOnChain reads the token's own allowance(from, spender) for every record, six at a time. The record's onChainAmount then holds one of three states:

ValueMeaning
undefinedNot confirmed
nullThe confirm read failed
bigintThe current allowance

A failed read keeps the row and marks it unconfirmed. allowanceAmountMismatch is true only when a successful read differs from the event amount, and the viewer then shows the on-chain figure.

Both paths confirm. The viewer shows every confirmed row. Discovery for a close drops rows whose on-chain read is zero (withoutConfirmedZero), because an allowance spent through transfer_from or already revoked leaves nothing to revoke. When the confirm step fails as a whole, the close keeps the event-derived list and records the failure in the discovery diagnostics, which the run status shows when the close is prepared or finished. It is not a review warning.

A hostile token's allowance() can answer non-zero for spenders it invented, so confirmation does not filter fabricated rows on its own. The cap above and the revoke guard below bound what such a token can make you sign.

Revoking

A revoke is an approve set to zero:

approve(from, spender, 0, currentLedger)

The arguments are [address(from), address(spender), i128(0), u32(currentLedger)]. The expiry argument is the current ledger sequence.

The transaction carries the Soroban inclusion fee of 100,000 stroops and a 300-second time bound, and is assembled from simulation. An assembled fee bump is refused.

Checks before signing

  • The signer is the owner. SEP-41 requires the transaction source to be the approval's owner. The revoke re-reads the connector's public key and refuses when it changed: Your wallet's active account changed. Switch back to the account that owns this address, or reconnect it, to revoke. The viewer also requires a connected wallet whose key equals the address being viewed.
  • The call has exactly one shape. assertSafeRevokeInvocation (src/lib/soroban/revoke-guard.ts) requires one invokeHostFunction operation calling approve on the expected contract with four arguments: the expected owner, the expected spender, an amount of exactly 0, and a ledger. Every authorization entry must use source-account credentials, have no sub-invocations, and authorize that same call. Anything else throws Refusing to sign revoke: <reason>. This blocks a hostile token from adding a nested transfer of your other balances to the authorization your signature covers.
  • The fee has a ceiling. The transaction is held to the untrusted-token class in src/lib/soroban/fee-cap.ts: a resource fee of at most 2,000,000 stroops, at most 8,192 declared write bytes, and at most 16 read-write entries.

These checks are why RevokeAllowance is not held to the DeFi contract allow-list: the token contract comes from your own allowance scan, and the call is pinned to a zero approval on it.

On the allowance viewer, building and signing are two steps (prepareRevoke and submitPreparedRevoke in src/lib/soroban/revoke.ts), so the fee the signature authorizes is on screen before anything is signed. The token's own code sizes a revoke's resources, and a pasted-key connector signs without a prompt of its own, so for that connector the page is the only place the fee appears.

Revokes for one account are signed one at a time: the next waits until the previous one is included, or at most 90 seconds (REVOKE_GATE_RELEASE_MS) when its confirmation never settles, and reads the sequence number from the same RPC. When the reviewed transaction can no longer land, because its sequence number is stale or its time bound passed, it is rebuilt and checked again, and the rebuilt one is signed only when its fee is no higher than the reviewed one. Otherwise RevokeFeeChangedError puts the rebuilt revoke and its fee up for review, and nothing is signed. A row shows Revoked only after the ledger confirms it.

In a close, a revoke that fails is skipped with a note, and the allowance stays on the token contract. A merge does not remove allowances: they live in the token's storage.

Held tokens

A separate scan in src/lib/soroban/held-tokens.ts finds standalone SEP-41 balances: it reads transfer and mint events crediting the account in the same seven-day window and, with a Lumeus key, adds the account's contract-token holdings from Lumeus, then probes balance(address) on each candidate contract. See Position discovery. The close moves those balances with TransferAsIs, or sells them with ConvertSorobanToXLM. assertSafeTransferInvocation holds a transfer to exactly transfer(user, destination, amount) the same way the revoke guard holds an approve, under the untrusted-transfer fee class: a resource fee of at most 7,500,000 stroops, at most 16,384 declared write bytes, and at most 24 read-write entries. A transfer that cannot be sent is skipped, and the close result names the token as left behind on the closed account.

On this page