Blocksism Labs Logo

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 PM

Introduction

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.

How a verdict releases the escrow

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 the sha256 precompile 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 request object 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.

  1. SHA-256, not keccak256. An ES256 signer hashes its input with SHA-256 before signing. If the contract hashes the same bytes with the sha256 precompile, the service can keep calling its normal ES256 sign function, or a KMS key with ECDSA_SHA_256, on abi.encode(VERDICT_TYPEHASH, verdict). Nobody writes custom crypto.
  2. 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.
  3. The fields mirror the JWT claims. kid picks the key, issuedAt and expiresAt are iat and exp, and allow is the verdict. audience, chainId, token, recipient and amount are 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 kid never points at a different key. Rotation means a new kid, 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:

  1. Sign a compact copy of each verdict next to the JWT: the same key, ES256, over abi.encode(VERDICT_TYPEHASH, verdict).
  2. Put the escrow's address and the chain ID in it. Without an audience, a verdict works like a bearer token.
  3. Normalize s to the lower half before you return the signature.
  4. Publish a new key at least an hour before you sign with it, and never reuse a kid.
  5. Keep exp short, 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:
Development

Michał Mirończuk

Smart Contract Engineer

Smart contract engineer. Previously at LI.FI (cross-chain bridge and DEX aggregation) and Instadapp (Fluid).

Member since Mar 15, 2019

Latest Posts

03 Jan 20213 minutes read
Solidity: Upgradable Contracts, Tokens
Michał Mirończuk
Michał Mirończuk
Michał Mirończuk
View All

Get Free
Quote

hire@blocksism.com

Sign Up For Newsletter

Receive 50% discount on first project