DEV Community

Christian Pichichero
Christian Pichichero

Posted on

What 'Revocable' Actually Means at the Contract Level

If you've ever called approve() on an ERC-20 token and then moved on with your life, you've already brushed up against the thing this post is about: an approval is not a setting inside some app, it's a row in a smart contract's storage, and every system built on top of it is only as honest as its last read of that row.

Most token approvals work the same way. A user signs a transaction granting a spender contract permission to move up to some amount of a token from their wallet. The ERC-20 standard stores this as allowance[owner][spender]. Any contract that wants to move the user's tokens checks that number before doing so, and the check happens inside the same transaction that tries to move funds — so the contract-level enforcement is real. The token contract itself will not let a transfer through if the allowance is insufficient.

Revocation is just another write to that same slot, usually setting it to zero. It's a normal transaction. It has to be signed, broadcast, and mined like any other. That's the part people gloss over: revoking is not a UI toggle, it's a transaction with all the same properties as the transaction that created the approval in the first place — it sits in a mempool, it can be delayed by network congestion, and it isn't final until it's in a confirmed block.

Where the gap shows up

Say you're building a service that executes on a user's behalf using a stored approval — a trading bot, a subscription payment puller, anything with an off-chain component that decides when to spend and an on-chain component that actually moves the funds. The natural design is to keep a local record: "user X has approved up to Y, active." That record is convenient. It's also just an opinion your own server holds about the world, and it can be wrong in both directions.

It can be wrong stale-permissive: the user revokes, your service hasn't seen it yet, and if the on-chain check before spending is missing or weak, you build and sign a transaction anyway. Depending on how you structured the check, this either fails harmlessly at the token contract (wasted gas, a failed tx, an alert) or, if you did something sloppier — like checking your database instead of the chain — you send a transaction that the chain itself will still reject, because the allowance really is zero now. The token contract is the backstop here, which is good, but you don't want your system's normal path to depend on that backstop catching your own mistake.

It can also be wrong stale-restrictive: your database says revoked, but the revoke transaction is still sitting unconfirmed, and the user expects it to be in effect immediately because that's what the button said. This one is more of a UX problem than a safety problem, but it's the same root cause — a record and the chain disagreeing about what's true right now.

Why the check has to happen at the moment of execution

The fix sounds almost too obvious to write down: before doing anything that spends a user's tokens, read the allowance from the chain, right then, not from whatever your database cached the last time you looked.

function attemptExecution(user, requiredAmount) {
 onChainAllowance = readAllowance(user); // the ground truth, right now
 if (onChainAllowance < requiredAmount) {
 skipAndLog(user, "allowance insufficient at execution time");
 } else {
 sendExecutionTx(user, requiredAmount);
 }
}
Enter fullscreen mode Exit fullscreen mode

This has real costs. Every execution now needs an RPC round-trip before it can act, which adds latency and adds load on whatever node provider you're using. If you're executing for many users on a schedule, that's a lot of extra reads for what is, most of the time, a value that hasn't changed since the last check. There's also a subtler question buried in "read from the chain": read at what block? RPC providers don't all agree on the very latest block during a reorg, and a read that lands on a block that later gets replaced is its own small version of the same trust problem, one layer down. Treating the freshest confirmed state as authoritative, and re-checking rather than caching, is the mechanism — it doesn't make the read instantaneous or immune to provider disagreement, it just means you're asking the right question at the right time instead of trusting an answer that might be minutes or days old.

What breaks mid-cycle

The uncomfortable case is a multi-step action — say a swap that routes through two pools, or a strategy that does three on-chain calls in sequence to complete one logical action. If a user revokes between step one and step two, step one already happened. You can't undo it. The system has to be built so a partial completion is a safe, loggable state rather than an unrecoverable one — which mostly means designing each step so it's fine to stop after it, not chaining steps that only make sense together and hoping revocation never lands in the middle. That's a design constraint, not something the revoke mechanism itself solves for you.

None of this makes revocation less real. The user genuinely can remove the authorization, and once that transaction confirms, the contract genuinely will not let the spender move their tokens anymore — that part is enforced by the token contract itself, not by anyone's goodwill. What it means is narrower and more mechanical than "revocable" sounds: it's a state change on a specific chain, subject to that chain's confirmation times, and any executor sitting on top of it is only trustworthy if it treats its own records as a guess and the chain as the check.

Disclosure: I build Tradevo, which executes strategies from a scoped on-chain authorization the user can revoke at any time; the executor re-reads the on-chain allowance before every execution rather than relying on a cached copy of it, for exactly the reasons above.

Top comments (0)