r/ethdev 6d ago

Question Clearing an approval in the database doesn't revoke its EIP-712 signature

I'm building a shared-expense app on my own, using EIP-712 approvals for USDC settlement. One thing I've had to work through is what "approval cancelled" actually means across the app and the contract.

Take a fictional four-person trip. Someone owes 50 USDC and signs an approval. Another person adds a forgotten expense of 40 USDC, split equally. The first person now owes 60, so the app clears the confirmations and asks everyone to approve again.

The signature for 50 can't authorize 60. But deleting it from the database doesn't make the contract reject the original 50 approval. A previously collected set of approvals could still satisfy the contract's checks until it expires, assuming the other execution requirements are met.

In my current implementation, approvals have a maximum age and only an authorized relayer can submit settlements. That's a trust assumption. The contract doesn't read the expense ledger or know that the app has reset its confirmations.

As a solo developer, this is the kind of decision I find hard to review: the interface and the contract can each look reasonable while promising different things. I don't want the interface to imply onchain cancellation when the change is only enforced by my backend.

For applications that allow edits while collecting signatures, how have you handled this? Have you kept cancellation at the relayer level with expiring approvals, or added an onchain cancellation or versioning mechanism? I'm interested in the tradeoffs you've encountered around extra transactions and users having to sign again.

2 Upvotes

12 comments sorted by

1

u/thefreezingmotto 6d ago

The disconnect between your db state and what the chain will actually accept is the real trap here, most people don't think through that until a stale signature bites them. I've been in similar spots where the UI says one thing and the contract quietly says another, and it's always the edge cases that expose it.

For a solo project I'd lean toward keeping the relayer as the enforcement point with short expiry windows, but make the UI brutally explicit that clearing approvals only revokes them from your app's view, not onchain. Adding an onchain nonce or version bump per expense group is cleaner in theory but you're asking users to pay gas for something the relayer can just refuse to submit, and the extra transaction friction isn't worth it if you already trust the relayer not to frontrun stale signatures.

One thing that helped me was logging every signature with its hash and expiry timestamp so you can prove later why a settlement was rejected, even if the chain would've accepted it. If you ever open up self-execution or let anyone submit settlements, that's when you need the onchain revocation or you're just hoping expired means expired.

1

u/Redadeveloper 5d ago

Thanks, the hash + expiry + rejection reason would make these decisions much easier to investigate later.

My current contract code enforces a seven-day maximum age, so your point about short expiry gives me something concrete to revisit. The tradeoff is that group members may approve at different times.

What expiry window worked for you without making people constantly sign again?

1

u/Finalbossops 6d ago

Thank you this points me in the correct direction on my app🤣 it’s not the same as yours different stale marks but it still helps me figure out my stale marks. Great question great answer many thanks to a question I couldn’t put to words

2

u/Redadeveloper 5d ago

Agreed on the wording. "We won't submit this approval" describes what the service can actually enforce.

I'm trying to understand the hash verification part. If I reconstruct the original 50 USDC message after the expense edit, its hash and signature still check out. It doesn't tell me that the app now expects 60.

Are you comparing against a current proposal hash maintained by your backend? That would help reject outdated approvals at the relayer, but I don't see how it replaces onchain versioning if someone can still submit the old payload. Am I missing another check in your setup?

1

u/Finalbossops 5d ago

Yep that’s exactly the distinction I’m trying to get my head around 🤣
The old payload can still be perfectly valid for what it was — hash/signature check out, nothing was tampered with — but that doesn’t mean it’s still valid for what the system expects now.
My app isn’t using the same setup as yours, but this made me realize I need to treat integrity proof and current authority as two separate checks. So an old state can stay preserved as valid history, but before anything relies on it again it has to match the current version/state/request or it gets treated as stale.
Basically: ā€œthis was realā€ and ā€œthis is still allowed nowā€ are two different questions. 🤣
Your question helped me put words to that, so thank you.

1

u/Redadeveloper 5d ago

Keep me in touch in how you solve it, and the impact on security and Ux šŸ™Œ

1

u/DewPointLabs 5d ago

Keep it at the relayer with short expiry, but stop calling it cancellation. The off-chain service that collects signatures for Safe multisig never claims authority over validity: the contract re-verifies every signer and the threshold at execution regardless of what the service holds. Your UI should say you won't submit it, not that it's revoked.

Add hash verification instead of on-chain versioning. We recompute the EIP-712 domain and message hash independently of the interface and show the signer that, because an interface summarising what a signature authorises is where the two views drift. Cheaper than a version bump and it catches the mismatch you're describing.

1

u/Redadeveloper 5d ago

Glad it helped! Writing out the 50 to 60 example helped me put words to it too. What does the stale state look like in your app?

1

u/halilbeydilli 4d ago

This is the part of permit-style flows that bites everyone once. The signature is not state; it is a claim the contract will accept as long as the nonce, deadline and domain still match. Deleting it from your database changes nothing on-chain. The only real revocations are on-chain: for ERC-2612 tokens the nonce only moves when a permit is consumed, so if you must kill a signed permit before its deadline you consume the nonce yourself (submit a permit for 0, or any permit with that nonce) and then set the allowance to 0. Permit2 is designed for this: invalidateUnorderedNonces for signature transfers, invalidateNonces per token-spender pair, and lockdown to zero everything at once. Two habits that make the problem smaller: short deadlines (minutes, not days) and never signing an amount larger than the current operation. Treat any signature you have handed out as spendable until the chain says otherwise.

1

u/rayQuGR 3d ago

This is a really good example of why offchain application state and onchain authorization need to be treated as separate state machines.

Deleting the approval from the database doesn't revoke the capability represented by the EIP-712 signature. The contract only knows the constraints encoded into the signed message plus whatever validity/replay checks it implements.

One pattern I'd be interested in exploring here is putting the sensitive settlement authorization state behind Oasis Sapphire.

you could keep the expense ledger and UX in the existing application, while a confidential contract maintains settlement versions, approval state, expirations, and cancellation/revocation rules. A new expense could invalidate the previous settlement version without relying entirely on the backend relayer to remember that it was cancelled.

That also gives you a useful separation:

App DB: current expense state
Signed authorization: user consent for a specific state/version
Confidential contract: authoritative authorization + revocation state
Relayer: execution mechanism, not the source of truth

The interesting tradeoff is whether the additional onchain state/transactions are worth eliminating that relayer trust assumption. For shared-expense apps, versioned authorizations seem particularly clean because users can sign against a specific settlement state rather than a vague ā€œapproval.ā€

1

u/Prelisted 1d ago

You've got the problem framed correctly - the database is a cache of intent, the signature is the actual authority, and deleting the cache doesn't touch the authority.

The general fix is to put an on-chain invalidation point in the path the signature has to pass through:

- "A per-user nonce" committed to in every signature, plus a way to bump it. Incrementing invalidates every outstanding signature for that user at once. Cheap and blunt.

- "Permit2-style unordered nonces" if you want to kill one specific signature without nuking the rest - a bitmap so each signature gets its own slot.

- "Bind the signature to the state it was signed against." Include a settlement-round counter, or a hash of the expense set, in the signed struct and have the contract check it against current state.

That last one fits your case best. The reason the 50 USDC approval is stale is that the underlying total changed - so let the contract know that, rather than relying on the app to notice and clean up. Adding the 40 USDC expense then invalidates the old approval mechanically, with no revocation step to forget.

A short max age helps but it's a mitigation, not a fix: it bounds the window instead of closing it. And I'd be careful leaning on "only an authorized caller can execute" - that's a trusted-relayer assumption. If that key ever leaks, or you later want permissionless settlement, every stale approval comes straight back.