KeeperHub / KeeperHub/keeperhub

An agent cannot discover which token approvals a wallet has granted

Open
#2,331 3 comments 0 reactions 0 assignees View on GitHub
enhancement needs-discussion
Dominant language
TypeScript
Stars
24
Forks
93
Avg merge
1d 8h
Merged PRs (30d)
266

Description

### Before filing

- [x] I searched open and closed issues for this proposal.
- [x] I checked the docs and the current behaviour on `staging`.
- [x] This is one change, not several. (Several means several issues.)

### Reason: what you cannot do today

Almost every piece I need already exists. approve-token with an amount of 0 is the revoke. check-allowance confirms it landed. batch-read-contract checks many at once. assess-risk already reasons about approval risk — it flags
setApprovalForAll(true) grants full NFT access to operator.

The one thing missing is the spender set. check-allowance takes ownerAddress and spenderAddress, both required, so it answers "how much can this spender take" and not "who can take anything". Nothing answers the second question, and the second
question is the one a revoke workflow has to answer first.

The surfaces I checked and found unable to close the gap:

- query-events is the closest. But it uses contractAddressField(), which is required: true, so it cannot scan across tokens — I would have to already know every token contract the wallet has ever touched. And query-events-core.ts builds its filter
as contract.filters[eventName]?.() with no arguments, so there is no indexed-owner topic filter: querying Approval on one token returns every user's approvals in that range, to be filtered client-side. The default lookback is 6500 blocks, roughly
a day on Ethereum, against approvals that are routinely years old.
- query-transactions is explorer-backed and filters by function calls to a named contract, so it has the same "which contract" problem.
- The Blockscout plugin exposes get-address-balance, get-address-info, get-address-counters, get-transaction, and get-token-info. None cover approvals.
- I went through all 67 action slugs across plugins/*/index.ts on staging. None enumerate approvals.

What told me to expect otherwise: check-balance needs only an address, and check-token-balance only an address and a token. Approval exposure is the thing that actually drains wallets, and it is the one asset-side question on the Web3 surface that
requires knowing the answer before you can ask it.

What I do instead: scan Approval and ApprovalForAll logs outside the platform, dedupe by (token, spender), drop the ones already at zero, and feed the survivors back into KeeperHub as literal arguments.

### Reason: what the workaround costs

The workaround runs. What it costs is that the part of the decision that matters happens outside the product.

In work: approval enumeration is not a call, it is a subsystem. revoke.cash, the project I am integrating with, maintains a dedicated indexer service for exactly this — apps/indexer/src/allowances/allowances.worker.ts plus separate queues for
events, token metadata, spender metadata, timestamps and transfer details under packages/backend/lib/indexer/queues — and their auto-revoke covers 11 chains. Reproducing even a thin version of that, per chain, and keeping it warm, is the entry price
for every revoke workflow anyone builds on KeeperHub, and each builder pays it again.

In reliability: a log scan over a long history is slow and RPC-heavy, and the answer is stale the moment it is computed. During an incident staleness is the failure mode, because the approval granted five minutes ago is exactly the one that needs
catching. The 6500-block default on query-events is well chosen for its own use case and is about a day; approval history is measured in years.

In an invariant I cannot hold: KeeperHub records the revoke but not the exposure the workflow saw when it decided to revoke. The audit trail ends up holding a transaction with no recoverable reason for it. For a workflow whose entire purpose is
acting on someone's behalf during an incident, "why did it touch this token" is the question asked afterwards, and today it cannot be answered from KeeperHub's own record — the inputs to that decision never entered the platform.

There is no workaround at all for the last one.

Scope: what this touches, and what it does not

Touches: the Web3 read action surface in plugins/web3, and the action schema reported for it.

Does not touch: signing, the executor, or the delegate. Not the database schema — the result is computed on demand from chain state and nothing needs persisting. Not pricing, plan limits, or spend limits. Not any existing response shape, since this
lands as a new action rather than a change to an existing one. If triage prefers reusing the query-events machinery, relaxing its required contractAddress would change an existing action's contract, and I would treat that as a separate issue rather
than fold it in here.

Confirming this is one change: this is the read only. Revoking already works through approve-token at amount 0 and needs nothing from this issue. Matching a wallet's approvals against a known-exploit list is a separate change that could ship later,
or never, with this one still correct and useful on its own; I will file it separately if this is accepted. Deciding automatically what is safe to revoke is a third thing and is not proposed here.

### Scope: what this touches, and what it does not

Does not touch: signing, the executor, or the delegate. Not the database schema — the result is computed on demand from chain state and nothing needs persisting. Not pricing, plan limits, or spend limits. Not any existing response shape, since this
lands as a new action rather than a change to an existing one. If triage prefers reusing the query-events machinery, relaxing its required contractAddress would change an existing action's contract, and I would treat that as a separate issue rather
than fold it in here.

Confirming this is one change: this is the read only. Revoking already works through approve-token at amount 0 and needs nothing from this issue. Matching a wallet's approvals against a known-exploit list is a separate change that could ship later,
or never, with this one still correct and useful on its own; I will file it separately if this is accepted. Deciding automatically what is safe to revoke is a third thing and is not proposed here.

### Plan: what you propose

A list-approvals action in plugins/web3, shaped like check-allowance with the spenderAddress requirement removed.

configFields: evmNetworkField(), ownerAddress (required), an optional tokenAddresses list to narrow the scan, an optional block-range group matching the one on query-events (blockCount, fromBlock, toBlock), and readFailOnErrorField().

outputFields: approvals, an array of { token, tokenSymbol, tokenDecimals, spender, allowance, allowanceRaw, isUnlimited, lastUpdatedBlock } filtered to non-zero, plus approvalCount, fromBlock, toBlock, and the standard success and error pair used by
the other read actions.

New action. No existing callers affected, nothing renamed or removed. It composes with what is already there: list-approvals to discover, assess-risk to rank, approve-token at amount 0 to revoke, check-allowance to verify.

The one decision I would rather have from triage than guess at is where the data comes from. Extending the existing event machinery — allowing the contract address to be omitted and threading indexed-argument topics through resolveEventFilter — is
dependency-free and reuses block-range-helpers.ts, but a full-history scan is expensive and the 6500-block default exists for a reason. An indexer API is fast and gives spender labels for free, but it adds an outbound dependency and a key to manage,
against the raw-fetch-no-SDK rule in plugins/AGENTS.md. I lean toward the first with a bounded lookback and an explicit fromBlock, but you own the RPC budget and I do not, so I would rather be told than assume.

Plan: alternatives you considered

Doing nothing. The scan stays outside KeeperHub and my integration still works, because the revoke half already does. What is lost is that the exposure data never enters the audit trail, and every builder who wants a revoke workflow rebuilds the
same indexer. That is survivable, which is why this is an enhancement and not a bug.

A narrower revoke.cash plugin instead: fetch-only, reading the public MIT approval-exploit-list (https://github.com/RevokeCash/approval-exploit-list) to answer "is this wallet exposed to a known exploit". Smaller, zero new dependencies, fits the
existing plugin conventions exactly. I rejected it as the primary proposal because it answers a narrower question than the one blocking me — it covers only spenders already known to be compromised, and says nothing about the stale unlimited approval
that has not been exploited yet. It may still be worth doing, and I would file it separately.

Looping check-allowance over candidate spenders, or query-events per token. Both work once the token and spender are known, which is the thing that is missing. batch-read-contract makes the loop cheap, but only after something else has produced the
list.

Folding this into #2110. #2110 (accepted) concerns workflows re-sending approvals the chain has already granted, which needs allowance(owner, spender) for a spender the workflow already names. Same primitive, different question: that one is about a
spender you chose, this one is about spenders you did not. I do not think either blocks or subsumes the other, but flagging it so triage can say if they should land together.

Leave all five compatibility checkboxes unticked. And if you haven't actually run the log scan yet, change that last line of the first field to what you're about to do rather than what you do.

### Plan: alternatives you considered

_No response_

### Scope: compatibility

- [ ] Changes an existing response shape, status code, CLI flag, or default.
- [ ] Adds, removes, or upgrades a dependency.
- [ ] Changes database schema or requires a migration.
- [ ] Touches authentication, permissions, validation, or spend limits.
- [ ] Changes pricing, plan limits, or anything a user is charged.

Contributor guide

Open the contributing guide

Research direction

Start in plugins/web3 and compare the existing read actions with query-events-core.ts, block-range-helpers.ts, and the action registrations under plugins/*/index.ts. Read plugins/AGENTS.md before evaluating the raw-fetch constraint, then resolve whether approval data should use the event machinery or an indexer. Done means a new list-approvals action exposes the specified non-zero approval fields without changing existing actions or persistence.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, blockchain
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.