Skip to content

feat: agentic payments batch - #315

Open
Ghadi8 wants to merge 75 commits into
mainfrom
mariano/agentic-payments-batch
Open

Ghadi8 wants to merge 75 commits into
mainfrom
mariano/agentic-payments-batch

Conversation

@Ghadi8

@Ghadi8 Ghadi8 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Intent

A release train for the agentic payments work. Every change here was reviewed and merged into this branch on its own, and they go to main as one so the release cuts once instead of twenty-four times, and so the pieces that lean on each other never sit on main half-applied.

It merges rather than squashes, deliberately: nx release reads the subjects underneath, and collapsing them would turn a feat(core) minor, a feat(ui,keys) minor and twenty fix(cli) patches into one bump on whichever package the squashed subject happened to name.

What it delivers, in one line: the CLI can be handed to somebody who has no API key, it pays x402 challenges under caps that are counted the way the chain counts them, and its ledger and its locks survive two processes and a bad file.

What

The changes that landed on this branch one at a time, each with its own review, going to main together.

  #304  test(keys)     drop storageArea from the dispatched storage events
  #305  chore          update the permissions contracts submodule
  #306  fix(cli)       bound the x402 rpc reads with a timeout
  #307  feat(core)     type the capabilities response per chain
  #308  fix(cli)       stop offering amoy, where no permission manager exists
  #309  test(ui)       cover the prefundSpender wiring in the grant dialog
  #310  fix(cli)       heartbeat the payment lock while the work runs
  #311  fix(cli)       count x402 spend per permission, not per payer
  #312  fix(cli)       measure the payer balance after a refill before signing
  #313  feat(ui,keys)  show what a grant can spend over its whole life
  #316  docs(cli)      drop the change-history commentary from the payment modules
  #317  perf(cli)      read the x402 ledger once per payment
  #319  fix(cli)       one rule for the tightest spend limit
  #322  perf(cli)      fold old x402 ledger rows into a checkpoint
  #325  fix(cli)       check the settled amount against the transaction on chain
  #326  fix(cli)       warn when the registry eip712 domain and the token disagree
  #327  docs(cli)      bring the readme up to what the cli actually does
  #328  fix(core)      price the spender prefund off JAW's paymaster only
  #329  fix(core)      refuse the prefund when the permission cannot cover an operation
  #330  feat(core)     export one builder for the jaw paymaster url
  #332  feat(core)     let an account be built without an api key
  #333  feat(keys)     serve the cli its own workspace key on connect
  #334  feat(cli)      keep the api key the bridge filled in
  #339  fix(cli)       let the session file say it does not know

Twenty-six commits sit here without a PR of their own. One is a test in #329's area that goes through the url builder #330 introduced; the rest are what #334 left open and what two review passes over this branch turned up, and they are described under Why:

  fix(cli)  resolve the injected key on the paying path too
  fix(cli)  reconnect once when the proxy rejects the workspace key
  fix(cli)  resolve the injected key everywhere it decides a call
  fix(cli)  keep the ledger honest when a row or a lock goes bad
  fix(cli)  never trade a working key for no key at all
  fix(cli)  stop asking about a row the chain never answers
  docs(cli) drop amoy from the x402 supported networks
  fix(core) refuse a grant that cannot reach the relay
  fix(cli)  retire the rows no reconciliation can ever reach
  fix(cli)  keep the browser pairing when a payment cannot reach it
  docs(cli) say what a dry run does write
  refactor  assemble the x402 payment window in one place
  refactor  take the float off config in the payment window
  test(cli) cover the x402 pay command's payment wiring
  fix(cli)  swap the config file into place instead of truncating it
  fix(cli)  report an expiry session status cannot read
  docs(keys) say that nothing bounds the cli key yet
  test(core) use the paymaster url builder in the prefund test
  fix(keys,cli) say why the bridge refused instead of timing out
  fix(cli)  keep a fold and a bridge from outliving what owns them
  test(cli) pin the warning that the payment lock was lost
  fix(cli)  read the api key off disk after the bridge fills it
  fix(cli)  stop a ledger fold from failing the payment it follows
  test(cli) stop the settlement fixture from aging into a sweep
  fix(cli)  ask the token about a failed exact attempt
  fix(cli)  reconcile the ledger before x402 log prints it

Why

Most of it is one pass over the x402 payment path in the CLI, and the pieces lean on each other.

#311 gives the ledger's two spend meters the scope each one measures. The per-period figures mirror an on-chain counter, and the chain meters per permission, so they now count over permissionId. The session total stays per payer, because that ceiling is the user's own. A re-grant landing mid-window is where the two disagreed.

#306 bounds the four RPC reads a payment makes while holding the payment lock. They ran on viem's defaults, up to 41 seconds each, and the lock serializes every payment on the machine, so a slow node stalled more than its own payment. #310 is the other half of that lock: its holder rewrites its timestamp every 30s while the work runs, so the staleness threshold could drop from 300s to 90s without a long payment being treated as abandoned. #317 takes the file reads out of the same window, reading the ledger once per payment inside the lock and handing that snapshot to the four functions that sum it. #322 stops that file growing without bound: past two megabytes a payment folds its oldest rows into a checkpoint carrying their totals and moves the originals to an archive, and a fold only covers rows every live cap either counts in full or ignores in full, so every enforced figure comes out the same afterwards.

Three review passes over the whole branch found the rest, and the fixes are worth naming because each one is a number the caps enforce. Compaction folded rows the chain had not answered yet, and a correction finds its row by nonce while a checkpoint carries none, so the ceiling it was holding became permanent. The reconcile batch took its eight slots from the oldest end, so rows that are askable and never answer held every slot forever and the rows a live cap was still counting were never reached; rows now come from both ends, and a row the chain has had a week to answer is given up on, which also lets compaction fold it. An unreadable checkpoint stopped x402 status outright, which is the command a user runs to find out what is wrong; its figures are now a floor with the trouble named in the verdict. A checkpoint folded into a later one reported itself as standing in for one payment rather than three thousand, and that figure is what an agent reads over MCP. The compaction temp file existed world-readable between its write and its chmod. And the payment lock could be lost by a process that was paused rather than dead, which it now says out loud rather than leaving two payers to count the same budget in silence.

#325 is what brings an upto payment's figure back down to reality. A settled amount arrives as the server's own claim, and until this it was believed: the row is now checked against the transaction the receipt names, reading the transfer in it, so a receipt claiming less than moved cannot talk a cap down. #319 removes the last place where the rule for "which limit binds" was written twice, and #326 warns when the EIP-712 domain in the registry and the token on chain disagree, which is the shape a wrong-chain USDC would take.

#308 drops Polygon Amoy from the x402 asset registry, where the permission manager has no code, and derives the other chain lists from the registry instead of keeping four of them in step by hand. The docs commit finishes that: the supported networks table still advertised Amoy, so a reader was being pointed at a chain the CLI refuses, and the table now says what makes a chain listable rather than only which ones are. #305 is what makes the finding trustworthy: the submodule pin was from March and had started answering "not deployed" for chains where the manager has been live for months, Polygon mainnet among them.

#312 reads the payer balance back after a refill confirms and refuses before signing when it is short. The payer is charged the gas for its own refill, so a refill that pulls exactly the shortfall leaves the payment that follows signed for more than the payer holds.

#328, #329 and #330 are the fix for the wall a new session hits. The grant is supposed to leave the spender enough to pay the fee on its first operation, and it was pricing that transfer through a paymaster nothing on the CLI path configures, so the quote came back empty and the transfer was dropped in silence. #328 prices it off JAW's own paymaster, #330 puts the url for it behind one builder rather than three spellings, and #329 refuses the prefund outright when the permission cannot cover a single operation, instead of moving an allowance that buys nothing. This affects every session, not only the ones that pay.

#339 makes the session file say when it does not know. expiry was typed number while the value is read off a file a person can edit, and expiry <= now is false for a NaN, so an unreadable field turned the expiry check off rather than failing it. Widening it to number | null put the compiler to work: it listed seventeen sites, and each answers the unknown case for its own question, because the safe answer differs by who is asking. A payment must not go out under an expiry nobody can read, and a revoke must not skip a permission that may still be live, since skipping the chain there also deletes the local record. It carries one fix that reaches sessions nobody edited: session add restamped createdAt when the field was absent, which counts the session total from the present and hands the spend cap a clean slate, so starting a session and replacing one are now separate writes.

#332, #333 and #334 take the API key out of the first step. #332 makes apiKey optional in core so a connect can happen without one, #333 has keys hand the CLI the key of a workspace created for it, and #334 keeps that key in its own config field, apart from the user's, so a rotation on the deployment reaches installs that already have one.

Four commits finish that. The key lands in workspaceApiKey, and every site that decides whether a call can happen was reading apiKey: the four the paying path goes through, then session setup, session status, the MCP session handler, and the transport every x402 chain read is made on. session setup was the worst of them, and it is the first run of the product: the browser connected, keys handed a key over, and the command then read the field the key had not landed in and aborted with "Connected, but no API key came back". They all go through one resolver now.

The other two are about the rotation. The deployment can change that key at any time, so a refusal from the proxy is how an install learns: the session bridge asks a browser that is already paired for the key it is handing out now, rebuilds the account under it, and sends again. Nothing is dropped on the way in, which the first attempt got wrong. Clearing the stored key before connecting, and then not reaching a browser, which is the normal case for an agent, left the install with no key at all and nothing short of jaw session setup to get one back. And the refresh may never open a browser: it runs inside the payment lock, so a fresh session would sit on an approval nobody is watching while every other payer on the machine waits it out.

The last of them is not about the key, and it rides here because a PR to main would release core ahead of the batch it belongs with. grantPermissions sends its approval on chain and stores it through the relay second, and neither leg was guarded against a missing key, so a relay call refused for want of one leaves a permission approved on chain and absent from the record every reader goes through. It is unreachable today, since a caller with no key cannot reach the proxy at all and the first leg fails first. It becomes reachable the moment requests are served without one, which is what #321 does, so it lands here, ahead of that. revokePermission needed nothing: it reads from the relay before it touches the chain, so it already fails in the safe direction.

A third review pass over the branch added the last six. Two are one refactor: both x402 front ends assembled the same payment window by hand, seven pieces in the same order written twice, and they had already drifted once when the grant-seeded policy reached the agent and not the terminal, so one session refused at the granted cap in one place and paid under the defaults in the other. They now open one window, and the x402 pay command got its first test along with it, since the MCP half was covered and the terminal half was not.

The other four are corrections the same pass found. saveConfig truncated the real file in place, and the bridge turned it into something written on every connect that is handed a rotated key, so the MCP server writing beside a terminal could hand the reader a half-written file and lose the api key, the paymasters and the x402 caps; it goes through a temp file and a rename now, which is what writeSessionConfig already did for the session file and for the same reason. session status asserted an expiry it had just allowed to be null, so a hand-edited 99999999999999 crashed the command someone runs precisely because the file is suspect. And the route that serves the CLI its workspace key claimed a bound that does not exist, which is corrected below.

The last three on the list come from driving the payment path end to end against the staging API, which turned up money the ledger could not account for. A failed exact attempt carries a nonce and no transaction, and reconciliation read that as a question the chain cannot answer, so the row kept its ceiling for a week and nothing ever checked whether it had settled. The token answers it: authorizationState says whether the authorization was ever consumed, which is the move the upto path already makes against the Permit2 bitmap. One of three failures on staging came back consumed, with AuthorizationUsed and a 5000-unit transfer in a transaction mined one second after the endpoint answered 400, so that payment settled while the caller got an error and no resource, and the row says so now instead of reading as an attempt that moved nothing. The two that were never consumed stop costing their ceiling, which was 0.01 USDC the caps were holding for no reason. x402 log reconciles too, since it printed figures only status had brought up to date. The fixture change beside them is unrelated to any of that: underReported carried a literal at, and a row is given up on a week after it, so the suite turned red on its own once the clock passed that date.

The rest sit elsewhere. #307 replaces Record<hex, Record<string, unknown>> with a declared per-chain shape and deletes the seven casts that restated it. #313 states on each spend row of the grant screen what approving it can cost by the time the permission expires, rather than showing a rate and leaving the multiplication to the user. #309 covers the prefundSpender capability reaching Account.grantPermissions as its seventh positional, which is how it got dropped once with the compiler unable to notice. #304 unblocks the pre-commit hook for anyone on Node 25 or later. #316 and #327 are documentation: the payment modules lose the commentary that narrated their own edit history, and the README describes session setup and x402, which it did not mention at all.

How to test

bun install
bunx prettier --check .
bunx nx run-many -t lint test typecheck build api-check --projects=@jaw.id/core,@jaw.id/cli,@jaw.id/ui,@jaw-mono/keys-jaw-id

Everything here was reviewed and verified in its own PR, so the check that matters at this level is the integration: the whole graph builds and every suite passes with all of them together, which is what the commands above answer, and api-check carries the new ChainCapabilities.

By hand, the x402 payment path is where the pieces meet. A jaw x402 pay --pay against a server quoting an asset in the registry should pay, refill and append a ledger row, with the caps counted per permission and the payer balance checked after the refill. On an install whose only key is the one the browser filled in, the same run should still refill rather than warn that it cannot, and jaw session setup on a machine with no key at all should reach the grant rather than stopping on the key it was just handed. A payment on Polygon Amoy is refused before anything is signed, and an upto challenge outside Base and Base Sepolia is refused too.

Checklist

  • The title is a conventional commit. This one merges rather than squashes, so it is the subjects underneath that nx release reads, which is the point of the section below
  • bunx prettier --check . and the run above pass; validate is green on the branch
  • @jaw.id/core's public API changed and the report is part of this branch: etc/core.api.md carries ChainCapabilities, added in feat(core): type the capabilities response per chain #307
  • Docs updated: the CLI x402 page listed Polygon Amoy as supported, which fix(cli): stop offering amoy, where no permission manager exists #308 removed from the registry, and the row is gone along with the stale half of a comment in permit2.ts that still named it

Merging

This one needs a merge commit. A squash collapses the subjects into one, and nx release reads only that, so the feat(core) and feat(ui,keys) minors and the fix(cli) patches would come out as a single bump on whichever package the subject happens to name.

Also at merge

Merging this turns the injected key on. The workspace exists, it holds one key, and JAW_CLI_API_KEY is already set on the keys deployment. None of that does anything today, because the route that serves it lives on this branch and not on main. From the moment this merges and keys redeploys, every CLI that carries no key of its own is handed that one.

That is deliberate rather than an oversight, and the reasoning is worth stating for whoever reads this later. Writes pay for themselves: every userOp is charged to its sender in USDC through the ERC-20 paymaster, always, so abuse of the write path is funded by whoever abuses it, out of an account they do not control. Reads are the asymmetry, since they cost the caller nothing. The decision is to ship without a limit on them and to watch instead, rather than hold the product behind a question about quotas and plans that is bigger than this branch.

Watching is the part that has to be real. The proxy already emits ASSETS_FETCHED, ACTIVITY_FETCHED and PERMISSIONS_FETCHED, carrying the api-key id and the workspace. Those two fields are the same for every install once the key is shared, so a graph of them separates nothing: heavy use by many and abuse by one look identical. Whatever alerting is built needs one dimension that tells callers inside a shared workspace apart, and the source address is the one that needs no change to the published package.

Clearing the environment variable is the way to ship this with the key off, if that is ever wanted: the route then answers 503, the bridge refuses a CLI that sent no key exactly as main does today, and a CLI carrying its own key is unaffected either way.

That key cannot be held secretly on either side. Anything the browser app carries is inlined into its bundle at build time, and anything the published package carries is public from the day it ships. So the key attributes nobody.

What it authorises is everything its workspace can do, and that is worth stating plainly because an earlier draft of this description said a read limit bounds it. None exists. An api key carries no scope and no quota, and the only check there is, the allowed-domain list, is matched against an Origin the CLI does not send, so this key's workspace has to be allowlisted with *, which is the value that turns that check off. Anyone narrowing that list will find the CLI stops working. The route's own comment now says this rather than the opposite.

Recorded here rather than carried in a conversation, because the step it unblocks is the one that takes the API key out of the CLI onboarding entirely.

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
demo Ready Ready Preview Sep 23, 2026 3:39am UTC
jaw-docs Ready Ready Preview Sep 23, 2026 3:39am UTC
keys-jaw-id Ready Ready Preview Sep 23, 2026 3:39am UTC
playground Ready Ready Preview Sep 23, 2026 3:39am UTC

Request Review

@justalab-agents justalab-agents Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings outside the diff

These sit on lines this PR did not change, but this PR is what makes them wrong. They cannot be posted as inline comments.

@AngeloAyranji

Copy link
Copy Markdown
Contributor

Thanks Marianno! Most of the changes here address the feedback from the last PR #254 . Approved no comments

This branch was successfully deployed

4 active deployments
Preview – jaw-docs b19df04a Deployed Sep 23, 2026 by vercel[bot]
Preview – demo b19df04a Deployed Sep 23, 2026 by vercel[bot]
Preview – playground b19df04a Deployed Sep 23, 2026 by vercel[bot]
Preview – keys-jaw-id b19df04a Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants