Approvals
Every swap, bridge and lending deposit leaves an approval behind: a contract that may move the user’s tokens later. The approvals module finds them, flags the risky ones and revokes them through the account, so the user can answer “who can spend my USDC?” in your app.
| Standard | What it is | How it is revoked |
|---|---|---|
erc20 |
Token allowance (approve) |
approve(spender, 0) |
erc721-token |
One NFT approved to one address | approve(0x0, tokenId) |
erc721, erc1155 |
Operator over a whole collection (setApprovalForAll) |
setApprovalForAll(operator, false) |
permit2 |
Uniswap Permit2 allowance | Permit2 lockdown, several in one transaction |
eip7702 |
The account’s EIP-7702 delegation | A type-4 transaction (revokeDelegation) |
The module is EVM only for now. Snippets come from examples/approvals, which our CI
typechecks and tests.
npm install @anywalletkit/protocol-approvals-evmSet up
Section titled “Set up”import { Wallet, type Policies, type WriteIntent } from '@anywalletkit/kit'import { WalletManagerEvm, type EvmConfig } from '@anywalletkit/wallet-evm'import ApprovalsProtocolEvm, { BlockscoutSource, type Approval, type ApprovalsCache, type CacheEntry, type RevokeResult,} from '@anywalletkit/protocol-approvals-evm'
export interface ApprovalsSetup { /** An RPC that answers eth_getLogs WITHOUT an address filter (your node, most paid plans). */ logsRpc: NonNullable<EvmConfig['provider']> cache: ApprovalsCache policies?: Policies /** The account's RPC (quotes, sends). Default: a public endpoint — pass your own in production. */ ethereumRpc?: EvmConfig['provider']}
export function walletWithApprovals(seedPhrase: string, { logsRpc, cache, policies, ethereumRpc = 'https://ethereum-rpc.publicnode.com' }: ApprovalsSetup): Wallet { const wallet = new Wallet(seedPhrase, policies ? { policies } : {}) wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: ethereumRpc, chainId: 1 }) wallet.registerProtocol('approvals', ApprovalsProtocolEvm, { chain: 'eip155:1', config: { provider: logsRpc, // discovery only; revokes are sent through the account's own provider fromBlock: 23_000_000, // the block your app created this wallet in: no approval can be older blockRange: 10_000, // your RPC's eth_getLogs window cache, // rescans read only the blocks since the last one }, }) return wallet}Discovery asks for every approval event whose owner is the account, across all contracts.
That is an eth_getLogs call with no address filter, and most free public RPCs refuse
it (publicnode, for one, answers “Please specify an address”). Use your own node or a paid
plan for logsRpc, or switch discovery to an indexer (see below).
Revokes don’t use logsRpc: they go out through the account’s own provider.
Two settings decide how long a scan takes:
fromBlock. A scan starts here. Pass the block your app created the wallet in: no approval can be older. An imported wallet has no such block; use an indexer for its first scan.blockRange. The largesteth_getLogswindow your RPC accepts (QuickNode: 10 000). A range the node refuses is halved and retried. A block that still fails becomes a gap incoverageinstead of an error.
Give the module a cache, and the next scan reads only the blocks since the last one:
/** Scan state per `chainId:owner`. Plain JSON: persist it as-is (file, MMKV, IndexedDB). */export function memoryCache(store = new Map<string, string>()): ApprovalsCache { return { get: (key) => { const json = store.get(key) return json ? (JSON.parse(json) as CacheEntry) : null }, set: (key, entry) => { store.set(key, JSON.stringify(entry)) }, }}An entry is plain JSON keyed by chainId:owner. Store it wherever your app keeps state.
Only complete scans are written.
List approvals
Section titled “List approvals”export async function listApprovals(wallet: Wallet) { const approvals = await wallet.getProtocol<ApprovalsProtocolEvm>('approvals') const { approvals: list, coverage } = await approvals.getApprovals()
// complete: false = some block range could not be scanned; the list may be missing entries. if (!coverage.complete) console.warn('partial scan, gaps:', coverage.gaps)
return { // Unlimited allowances, spenders without code, live EIP-7702 delegations. risky: list.filter((a) => a.risks.length > 0), all: list, }}The events only say which pairs to check. The values come from the chain as it is now:
allowance, isApprovedForAll, getApproved and the Permit2 allowance, read in one
Multicall3 call. Anything already used up, expired or revoked is dropped (pass
includeZero: true to keep it).
| Field | Meaning |
|---|---|
id |
Stable key: chainId:standard:token:spender[:tokenId] |
token, spender |
The contract approved, and who may spend from it |
amount |
Allowance in base units; null for operators and delegations |
unlimited |
2²⁵⁵ or more, or the Permit2 maximum |
risks |
unlimited; eoa-spender (the spender has no code, a classic drainer sign); expired (Permit2, with includeZero) |
expiration |
Unix seconds, Permit2 only |
spenderLabel |
Verified contract name, when a labels resolver is set |
getApprovals filters by standards, tokens and spenders, and takes fromBlock /
toBlock for one-off ranges. An explicit fromBlock bypasses the cache.
Spender names
Section titled “Spender names”A raw spender address means nothing to the user. EtherscanLabels turns verified contracts
into their verified contract names:
import { EtherscanLabels } from '@anywalletkit/protocol-approvals-evm'
config: { labels: new EtherscanLabels({ chainId: 1, baseUrl: 'https://your-proxy/etherscan' }) }Keep the Etherscan key on your server: point baseUrl at a proxy that adds it. A label
lookup that fails leaves spenderLabel empty and never fails the scan.
A faster first scan
Section titled “A faster first scan”An indexer returns the whole history in one request instead of thousands of block windows.
On a real Ethereum wallet with 11 approval events over 4 M blocks, the RPC walk took 398
requests and 121 s at a 10 000-block window. EtherscanSource and BlockscoutSource took
1 request and about 1 s.
The indexer source also works without a seed. A read-only account needs only getAddress(),
so you can audit any address:
/** Any address, no key and no seed: a read-only account only needs getAddress(). */export async function auditAddress(address: string): Promise<Approval[]> { const approvals = new ApprovalsProtocolEvm( { getAddress: async () => address }, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1, // The whole history in one request instead of a block-by-block eth_getLogs walk. source: new BlockscoutSource({ chainId: 1 }), }, ) const { approvals: list } = await approvals.getApprovals({ standards: ['erc20', 'permit2'] }) return list}| Source | Key | Limits |
|---|---|---|
BlockscoutSource({ chainId }) |
None; an apiKey from dev.blockscout.com raises the limit |
Keyless instances allow about 10 requests per window. Built-in hosts: Ethereum, Optimism, Gnosis, Polygon, Base, Arbitrum, Scroll, Linea, Sepolia; others take baseUrl |
EtherscanSource({ chainId, apiKey }) |
One Etherscan V2 key for every chain | Free tier: 5 requests/s, no Base, Optimism, BNB or Avalanche. Proxy the key through baseUrl |
RpcLogsSource (default) |
Your RPC | Address-less eth_getLogs, see Set up |
A common pattern: an indexer for an imported wallet’s first scan, then the default RPC source with a cache for every scan after.
Revoke
Section titled “Revoke”export async function revokeOne(wallet: Wallet, approval: Approval) { const approvals = await wallet.getProtocol<ApprovalsProtocolEvm>('approvals') const { fee } = await approvals.quoteRevoke(approval) // gas in wei, nothing is signed const { hash } = await approvals.revoke(approval) return { hash, fee }}
export async function revokeRisky(wallet: Wallet): Promise<RevokeResult[]> { const approvals = await wallet.getProtocol<ApprovalsProtocolEvm>('approvals') const { approvals: list } = await approvals.getApprovals() // Permit2 entries fold into one `lockdown`; every other approval is its own transaction. const results = await approvals.revokeAll(list.filter((a) => a.risks.length > 0)) for (const r of results) { // revokeAll never throws for one item: a failure rides on its result. if (r.error) console.warn('not revoked:', r.ids, r.error.message) } return results}quoteRevokereturns the gas fee in wei without signing. Show it before the user confirms.revokesends one transaction and resolves to{ hash, fee }. An ERC-20 revoke goes through the account’sapprove({ amount: 0 }). An EIP-7702 delegation is revoked withrevokeDelegation().revokeAllfolds every Permit2 entry into onelockdownand sends the rest one by one. On an account that takes a list insendTransaction(ERC-4337, gasless 7702), setbatch: trueand everything goes in one user operation.buildRevokeTransaction(approval)returns the raw{ to, data, value }without sending, for apps that build their own batches.
A revoke costs gas. An account with no ETH cannot revoke anything. Check the balance against
quoteRevoke first.
With policies
Section titled “With policies”A revoke is a signature like any other, so policies see it. confirm
gets kind: 'revoke', with the spender in to and the full list in revokes. New
approvals arrive as kind: 'approval': approve, signPermit, and EIP-2612 or Permit2
typed data. That makes confirm the place to warn about unlimited allowances before they are
granted:
/** `ask` is your confirmation sheet: resolve true when the user taps Confirm. */export function approvalPolicies(ask: (intent: WriteIntent, warning?: string) => Promise<boolean>): Policies { return { confirm: (intent) => { // A revoke: intent.revokes lists each { token, spender } being taken back. if (intent.kind === 'revoke') return ask(intent) // A new approval: approve, signPermit, EIP-2612 or Permit2 typed data; intent.to is the spender. if (intent.kind === 'approval' && intent.unlimited) { return ask(intent, `${intent.to} could spend all of this token, now and later.`) } return ask(intent) }, }}A revoke is not checked against the allowlist or the spender’s blocklist entry, so the user can always take an allowance back from a spender you have blocklisted. See Revokes.
Activity
Section titled “Activity”describeActivity recognises approve and revoke transactions in the account’s history:
“Approved USDC for Permit2”, “Revoked USDC for 0x1231…”. Pass tokens (lowercase address → symbol and
decimals) in the config for readable titles. It runs offline on any EVM chain.
Errors
Section titled “Errors”Failures throw ApprovalsError with a reason:
reason |
When |
|---|---|
provider-required |
No provider in the config and none on the account |
account-required |
revoke on a read-only account |
unsupported-standard |
A standard this chain cannot revoke, or an EIP-7702 revoke on an account without revokeDelegation |
invalid-approval |
A malformed approval object |
source-failed |
The indexer refused or rate-limited the request. The message says when it resets |
Policy refusals arrive as UserRejectedError and PolicyViolationError from kit, as on
every other write.