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_960Soroban 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:
| Value | Meaning |
|---|---|
undefined | Not confirmed |
null | The confirm read failed |
bigint | The 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 oneinvokeHostFunctionoperation callingapproveon 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 throwsRefusing 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-tokenclass insrc/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.
Related
- The plan graph for how revokes enter a plan
- Contract allow-list for the signing-time gate
Signing relay API
The four routes that collect signatures for a shared-account close, their guards, and how signatures are verified, merged and trimmed.
Exchange integration
For exchange operators. What a close delivers to your deposit address, how the registry and memo rules work, and how to add an entry.