12 minutes read
Verifying ES256 approvals on-chain: a P-256 escrow on Base
How a smart contract on Base can check an approval signed with ES256, the P-256 signature behind many JWTs and passkeys, and release escrowed USDC only when the approval holds.
Michał Mirończuk
15/09/2026 7:43 PMIntroduction
In 2020 I wrote about logging in with a wallet: the user signs a message, the server checks the signature and hands back a JWT. This post runs the same idea backwards. A server signs a JWT-style approval, and a smart contract has to decide whether to trust it.
Here is the situation. Atlas is a procurement agent, a piece of software that buys things for a five-person analytics team. Its mandate allows up to 2,000 USDC a month with any single data vendor. Today it wants a 30-day market data feed from Kestrel Data for 1,200 USDC.
If Atlas paid through x402, the purchase would take one HTTP round trip. The client signs an EIP-3009 transferWithAuthorization, a facilitator settles it, and the USDC lands in Kestrel's wallet. That fits a one-cent API call well. For 1,200 USDC and a feed that might never arrive, I want two more things: the money should wait until delivery, and a policy service should confirm that this particular payment fits the mandate.
So the money goes into an escrow contract on Base, and the policy service signs a verdict. The contract has to read that verdict. How hard can that be?
Diagram
Here is the whole flow. Steps 1 to 7 are the happy path, and the box at the bottom is what happens when no verdict comes.
Two things in this picture are easy to miss. The policy service never touches the money: it says yes or no, and the escrow does the rest. And step 7 has only one possible destination, Kestrel's address, which was fixed when the escrow was created.
What I did before at LI.FI
I had built this kind of check before, on the easier curve. At LI.FI, one of our bridge integrations sent user funds to deposit addresses that came from a partner's API. The contract sent funds only to a deposit address that our backend had signed, together with the amount, the receiver, the destination chain and a deadline. It used EIP-712, so the check was the usual one: rebuild the typed-data hash, ecrecover the signer, compare it with the backend's address.
Two details from that code matter here. The contract ran behind a diamond proxy, through delegatecall, so we computed the EIP-712 domain separator at call time: address(this) had to be the diamond, not the facet. And the same signing key covered many transfers, so the contract stored every transaction ID it had seen and refused to process one twice.
That setup is comfortable because the backend signs with secp256k1, the curve Ethereum already speaks. A typical web service signs with something else.
Why ES256 needs a different tool
ES256 is ECDSA on the P-256 curve (also called secp256r1) with SHA-256. It's one of the standard JWT algorithms. Keys are published in a JWKS document, each with its own kid, and the major cloud KMS services can hold them. Most passkeys sign with the same curve. Ethereum's ecrecover knows only secp256k1, so it's no help here.
For years the only option was to verify P-256 in Solidity, which is expensive. Now there's a precompile. RIP-7212 put P256VERIFY at address 0x100 on rollups, Base included, and EIP-7951 brought the same interface to Ethereum mainnet with the Fusaka upgrade. OpenZeppelin's P256 library tries the precompile first and falls back to Solidity when it isn't there.
I measured both paths in Foundry, on the same signature:
| Path | Gas |
|---|---|
Precompile, OP Stack rules (forge test --network optimism) |
about 5,200 |
| Precompile, Ethereum Osaka rules | about 8,700 |
| Solidity fallback | about 246,000 |
The numbers include the call into a small test contract and OpenZeppelin's input checks. On Base the signature check is cheap. Without the precompile it would cost more than everything else in the escrow put together.
Verify the JWT, or ask for a second signature?
The policy service already signs its verdicts as JWTs. The obvious move is to verify that JWT in the contract, so I tried to picture what that takes:
- The signed bytes are
base64url(header) + "." + base64url(payload). Hashing them with thesha256precompile is cheap. - Then the contract has to decode base64url and parse JSON. Field order, whitespace and string escapes are all up to the signer's library. Numbers are worse: JSON has no integer type, and some libraries write large amounts in exponent notation.
- Every byte of the token is calldata, and a JWT with a nested
requestobject runs to several hundred bytes of text.
None of that is impossible. But it means a JSON parser inside a contract that holds money, and I'd rather not audit one. The alternative is to ask the signing side for one extra signature per verdict, with the same key and the same ES256 algorithm, over a format the contract can read without parsing anything:
struct Verdict {
bytes32 kid;
uint256 chainId;
address audience;
bytes32 nonce;
uint64 issuedAt;
uint64 expiresAt;
bool allow;
address token;
address recipient;
uint256 amount;
}
function verdictDigest(Verdict calldata v) public pure returns (bytes32) {
return sha256(abi.encode(VERDICT_TYPEHASH, v));
}
Three decisions hide in those few lines.
- SHA-256, not keccak256. An ES256 signer hashes its input with SHA-256 before signing. If the contract hashes the same bytes with the
sha256precompile, the service can keep calling its normal ES256 sign function, or a KMS key withECDSA_SHA_256, onabi.encode(VERDICT_TYPEHASH, verdict). Nobody writes custom crypto. - A typehash in front. The same key now signs two formats. A JWT's signed bytes are base64url text. These bytes start with a 32-byte hash and contain zero bytes, so neither format can pass for the other.
- The fields mirror the JWT claims.
kidpicks the key,issuedAtandexpiresAtareiatandexp, andallowis the verdict.audience,chainId,token,recipientandamountare what the escrow compares with its own terms.
The checks, in order
This is the function that decides whether a verdict counts:
function _checkVerdict(Verdict calldata v, bytes32 r, bytes32 s) private view {
if (v.chainId != block.chainid) revert WrongChain();
if (v.audience != address(this)) revert WrongAudience();
if (!v.allow) revert VerdictNotAllow();
if (block.timestamp > v.expiresAt) revert VerdictExpired();
if (v.issuedAt > block.timestamp + MAX_CLOCK_SKEW) revert IssuedInFuture();
if (v.token != address(token) || v.recipient != seller || v.amount != amount) {
revert TermsMismatch();
}
(bytes32 qx, bytes32 qy) = registry.keyFor(v.kid);
if (!P256.verify(verdictDigest(v), r, s, qx, qy)) revert BadSignature();
}
The cheap comparisons run first, so a stale or misdirected verdict fails before anyone pays for elliptic curve math. Four of them deserve a closer look.
Audience. audience is the escrow's own address. A verdict for Atlas's deal with Kestrel can't release any other escrow, even one with the same price and the same seller. Together with chainId it does the job the EIP-712 domain separator did at LI.FI.
Time. The verdict expires five minutes after it's issued. Base produces a block every two seconds, so getting the transaction in isn't the risk. Asking too early is. Atlas should request the verdict when the feed arrives, not when the deal starts. The escrow's own 7-day deadline covers the deal, and the verdict covers the moment of payment. I also let issuedAt sit up to 60 seconds in the future, because the signer's clock and the block timestamp never agree exactly.
No nonce bookkeeping. At LI.FI the contract had to remember used transaction IDs. This escrow doesn't. The verdict names this escrow as its audience, and the escrow pays out once, so a replayed verdict meets a closed escrow. The nonce stays in the signed data so the off-chain log can tell verdicts apart. So when does a contract need its own nonce map? When it accepts many payments under the same audience, which is exactly what the LI.FI contract did.
The s value. This one is easy to miss. OpenZeppelin's P256.verify rejects any signature whose s is above half the curve order, which blocks signature malleability. Most ES256 libraries don't normalize s, so about half of the signatures they produce come out high. The fix is one line on the signing side:
if (uint256(s) > HALF_N) s = bytes32(N - uint256(s));
That line comes from my test helper. The policy service needs the same step before it returns (r, s). Without it, about half of its verdicts would fail with BadSignature, and the logs wouldn't say why.
Keys that change
A JWKS rotates, and the contract needs the same keys. At the same time, a stolen admin key shouldn't be able to add a key and use it a minute later. The registry handles both:
function addKey(bytes32 kid, bytes32 qx, bytes32 qy) external onlyOwner {
if (_keys[kid].activeFrom != 0) revert KidTaken(kid);
if (!P256.isValidPublicKey(qx, qy)) revert InvalidPublicKey();
uint64 activeFrom = uint64(block.timestamp + ACTIVATION_DELAY);
_keys[kid] = Key({qx: qx, qy: qy, activeFrom: activeFrom, revokedAt: 0});
emit KeyAdded(kid, qx, qy, activeFrom);
}
- A new key works only one hour after it's added (
ACTIVATION_DELAY). That leaves time to notice a key nobody expected. - Revocation takes effect immediately.
- A
kidnever points at a different key. Rotation means a newkid, so every verdict maps to exactly one key. - The key has to be a point on the curve. A bad key would only ever fail, but I'd rather find out when it's added.
The owner should be a multisig behind a timelock. That's how we ran upgrades at LI.FI, and a key registry deserves the same care.
The escrow itself
Each deal gets its own contract, so the contract's address is the deal's identity. That's what makes the audience check work.
function release(Verdict calldata v, bytes32 r, bytes32 s) external {
if (state != State.Funded) revert WrongState();
if (block.timestamp > deadline) revert DeadlinePassed();
_checkVerdict(v, r, s);
state = State.Released;
token.safeTransfer(seller, amount);
emit Released(v.kid, v.nonce);
}
function refund() external {
if (state != State.Funded) revert WrongState();
if (block.timestamp <= deadline) revert DeadlineNotReached();
state = State.Refunded;
token.safeTransfer(buyer, amount);
emit Refunded();
}
A separate "delivered" state looks natural here: one call records the verdict, a second one pays. Think about USDC's blacklist, though. If Circle blocks Kestrel's address after delivery, the payout reverts and the escrow sits in "delivered" forever. A refund would be wrong too, since the feed did arrive.
So release does both in one transaction. If the transfer fails, the whole call reverts, the escrow stays Funded, and after the deadline anyone can send the money back to Atlas. One of the tests covers that exact case with a blacklisting mock token.
No one can send the money anywhere else. The policy service can only approve the payment to the seller, and the escrow has no admin functions at all. If I add a pause switch, it will stop new escrows from being created and leave existing ones alone, because a pause that blocks refunds is a freeze.
Where session keys fit
Atlas pays from a smart account, and the team doesn't want Atlas's key to have full control of it. That's what session keys are for: a temporary key with limits that the account itself enforces, such as a spending cap, a list of contracts and functions it may call, and an expiry.
Session keys and verdicts answer different questions. A session key answers "may this key call this function at all?", with rules that live on-chain and change slowly. A verdict answers "should this particular payment happen?", with a policy that lives off-chain and can see things the chain can't, like who the vendor is or how much Atlas already spent this month.
The two work well together. Atlas's session key could be limited to creating and funding escrows, up to 2,000 USDC a month. The money then leaves an escrow only with a verdict for a vendor the mandate allows. Even if the session key leaks, the worst case is money parked in escrows until their deadlines.
You could go further and check the verdict inside the account's validation step, so the session key can't send a transaction without one. ERC-4337 adds two constraints there. Validation code may not read block.timestamp, because ERC-7562 bans the TIMESTAMP opcode during validation. Instead, the validator returns the verdict's exp as validUntil in validationData, and the EntryPoint enforces it. validUntil sits in bits 160 to 207 and validAfter in bits 208 to 255:
return (uint256(validUntil) << 160) | (uint256(validAfter) << 208);
The precompile also has to be allowed during validation, and ERC-7562 lists the EIP-7951 P256VERIFY precompile among the allowed ones. This part is a design sketch, not code I've shipped. I haven't built a session-key module yet.
What it costs
From the Foundry gas report, with OP Stack precompiles enabled:
| Call | Gas |
|---|---|
fund() |
about 78,000 |
release() with a valid verdict |
about 82,000 |
refund() after the deadline |
about 60,000 |
These run against a plain ERC-20 mock. Real USDC sits behind a proxy and checks its blacklist on every transfer, so expect a bit more.
Conclusion
If you run the service that signs these verdicts, this is what I'd ask you for:
- Sign a compact copy of each verdict next to the JWT: the same key, ES256, over
abi.encode(VERDICT_TYPEHASH, verdict). - Put the escrow's address and the chain ID in it. Without an audience, a verdict works like a bearer token.
- Normalize
sto the lower half before you return the signature. - Publish a new key at least an hour before you sign with it, and never reuse a
kid. - Keep
expshort, and have the agent ask for the verdict when it's ready to pay.
All of the code above comes from a small Foundry project, and every revert in this post has a test that triggers it. If you build agent payments and see a hole in this design, I'd like to hear about it.
Tags:
Get Free
Quote
Sign Up For Newsletter
Receive 50% discount on first project
