SEP-41 allowances
How allowances are enumerated from on-chain events, confirmed against the token contract, and revoked.
SEP-41 is Stellar's Soroban token interface. An allowance is a standing approval letting 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 enumerates them, confirms them, and builds revokes.
The scan window
DEFAULT_SCAN_WINDOW_LEDGERS = 120_960Soroban RPC providers retain events for roughly seven days. The constant is derived
directly from that: 7 * 24 * 60 * 60 / 5 = 120,960 ledgers, at five seconds per ledger.
A companion constant, SCAN_WINDOW_DESCRIPTION ("about the last 7 days"), sits beside it
so the copy and the behaviour cannot drift apart.
Two other windows exist for related scans: held-token discovery uses 120,000 ledgers, and the demolish flow carries its own copy of the 120,960 figure.
Paging
| Constant | Value |
|---|---|
PAGE_LIMIT | 10,000 events per page |
MAX_PAGES | 100 |
The first request supplies startLedger; every later request supplies only the cursor.
The loop exits only when there is no forward cursor, or when the cursor stops advancing. An empty page that still carries a cursor means "no matches in this slice, keep paging" and must not break the loop.
Retention clamping
When the RPC rejects the requested start ledger as older than it retains, the scan re-clamps and retries.
parseRetentionFloor concatenates the error message and its cause, then matches
/ledger range:\s*(\d+)\s*-\s*(\d+)/, taking the first number as the floor.
- Clamping is attempted only on the first page. On any later page the floor is forced to null and the error rethrows.
- A non-range error, or a floor that would not advance the start ledger, rethrows unchanged.
- The new start is
min(currentLedger, floor + 60). That 60-ledger margin (RETENTION_RETRY_MARGIN_LEDGERS) exists because the floor advances about one ledger every five seconds, so clamping to the exact floor races a ledger closing between the error and the retry. - At most three retries (
MAX_RETENTION_RETRIES), each re-clamping on a fresh floor.
Deduplication
Records accumulate into a map keyed by `${contractId}|${spender}`.
A record replaces the previous one only when its event ledger is strictly higher. Ledgers are compared explicitly rather than relying on arrival order, because pagination can deliver events out of order. A tie does not replace.
The result is one record per token-and-spender pair, carrying the highest-ledger approve event, so you see current state rather than a stale one.
On-chain confirmation
An approve event is only evidence that an event was emitted. A malicious contract can emit a fabricated one.
confirmAllowancesOnChain reads the token's own allowance(from, spender) for every
record, concurrently. The record's onChainAmount then carries one of three states:
| Value | Meaning |
|---|---|
undefined | Not confirmed |
null | The confirm read failed |
bigint | The real current allowance |
A failed read never drops the row. It is shown as unconfirmed instead.
The simulation source is any valid G address. The read moves no funds and the source does
not need to exist on chain.
Mismatch
allowanceAmountMismatch is true only when a successful read returned a value differing
from the event-derived amount. Neither undefined nor null is ever a mismatch.
A mismatch means the event was fabricated or stale, so the displayed limit is not the real one. The viewer surfaces the on-chain figure alongside it and points you at that value.
Revoking
A revoke is an ordinary approve set to zero:
approve(from, spender, 0, currentLedger)Arguments are [address(from), address(spender), i128(amount), u32(liveUntilLedger)].
The expiration ledger argument is the current ledger sequence, not zero and not a future ledger.
Every invocation is built with the Soroban inclusion fee of 100,000 stroops and a 300-second timeout, assembled through simulation, and a returned fee-bump is rejected.
The ownership check
SEP-41 requires the transaction source to be the approval's owner. A wallet extension signs with whatever account it currently holds, so a user switching accounts inside the wallet after connecting would otherwise sign as the wrong key and fail opaquely at submit.
The revoke path therefore re-reads the connector's public key and refuses when it does not match:
Your wallet's active account changed. Switch back to the account that owns this address, or reconnect it, to revoke.
At the page level, revoking additionally requires a connected wallet whose key equals the address being viewed, and a live connector.
The auth-smuggling guard
Before signing, assertSafeRevokeInvocation holds the transaction to an exact shape and
throws Refusing to sign revoke: <reason> otherwise. It refuses when the transaction is not
exactly one operation, when that operation is not invokeHostFunction, when the host
function is not invokeContract, and on the further shape checks that pin the call to the
expected contract, owner, and spender.
This is why RevokeAllowance is exempt from the DeFi contract allow-list: it is constrained
to a safe zero-approval on the user's own token contract, chosen from their own allowance
scan.
Held tokens
A parallel scan discovers standalone SEP-41 balances the account holds. Those are moved with
TransferAsIs or ConvertSorobanToXLM, and assertSafeTransferInvocation holds the
transfer to an exact shape the same way the revoke guard does.
Related
- The plan graph for how revokes enter a plan
- Contract allow-list for the signing-time gate