-
One merchant integration, two payer types. A printed QR and an HTTP 402 are two encodings of the same on-chain payment intent.
-
The payer's side. One signature, no ETH and no Singapore bank account, paying in euros. The relayer submits it and pays the gas.
-
One shop page shows a person the price and hands an AI agent an HTTP 402 it can settle itself, priced in both currencies.
-
The policy the agent runs inside: a daily cap, the categories it may use and the ones it will be turned away from, all on-chain.
-
The payer picks what they pay in. USDC or EURC, both Circle's own tokens. The shop is paid in Singapore dollars either way.
-
The merchant's live feed. Agent and human payments land in one contract, paid dollars and euros, settled to the shop in Singapore dollars.
-
There is no payout schedule. Each payment settles to the merchant's address in the same transaction, with the 0.5% fee skimmed on-chain.
-
The shop's own record. Stored on-chain and still self-attested: this deployment runs no KYC and verifies nobody.
-
An unmodified x402 client finds a shop and pays. Refusal is re-derived from public chain state. Pay link answers a machine with 402.
Inspiration
I go to school in Singapore, where paying for things is already a solved problem. PayNow is instant, it's free, and there's a QR sticker taped to the glass of almost every stall in Maxwell Food Centre. Nobody here thinks about it.
It has two customers it can't serve.
The first lands at Changi every morning. Around 16 million international visitors arrive each year. None of them can pay a hawker with PayNow, because PayNow runs on a Singapore bank account, and they arrive holding something other than Singapore dollars. So they pay cash, or they pay by card and the stall gives up about 2.8%.
The second is newer. x402 turned up in May 2025 and revived HTTP's dead 402 Payment Required status code so that software could pay for things without a human in the loop. It went to the Linux Foundation, and by July 2026 the foundation was operational with 40 founding members including Google, Visa and Cloudflare. The client libraries work today. An AI agent still can't open a bank account, so PayNow is shut to it as well, and not because of any policy decision. There's just no form for software to fill in.
What struck me reading through the x402 ecosystem was that nearly everyone building on it was building a second rail. Agent-only endpoints, agent-native marketplaces. Ah Hock does not want a second QR code and a second reconciliation problem. He wants the money to arrive.
So Gantry starts from the assumption that the merchant shouldn't have to know. One registration, one settlement contract, and two encodings of the same payment request: one a phone camera can read, one an HTTP client can.
What it does
A merchant registers once and walks away with a printed QR standee. There is no application and nobody to approve it, because registerMerchant is permissionless, so the onboarding form is open to anyone and a shop is printing its code about two minutes after deciding to.
A human pays through the QR. It opens a mobile page showing the price in Singapore dollars. The payer signs an EIP-3009 transferWithAuthorization, which is a signature rather than a transaction, and the relayer submits it and pays the gas. A tourist needs no ETH and no local bank account, and they choose what they pay in: USDC or EURC, both Circle's own testnet tokens.
An agent pays the same link. Put an amount on it, GET /pay/<handle>?sgd=4.50, and that one URL answers a browser with the payment page above and answers everything else with 402 Payment Required and an x402 v2 challenge. A QR code and a 402 really are two encodings of one payment request, so one link can be both.
The challenge offers two schemes, each priced in both currencies. First is the standard exact scheme, so an unmodified @x402/fetch client can pay with no knowledge of Gantry at all. Second is gantry-pbm, my own scheme, where the agent signs an EIP-712 spend authorization with a session key and the payment executes through an on-chain policy wallet that the human owns.
Both land in the same function. GantryCore._settle() pulls the funds, swaps to XSGD against a minimum-out guard, pays the merchant and emits IntentSettled. Whatever arrived, the shop is paid in Singapore dollars, because the settlement token is immutable on the contract and cannot vary by payer or by screen. The dashboard shows both rows in one feed, tagged Human or Agent.
The policy wallet is where the agent side gets interesting. The owner sets a Policy holding a daily cap, a per-transaction cap, an expiry and a category bitmap. When the agent tries to buy a S$4 phone cable from an electronics shop on a food-and-beverage-only policy, the wallet reverts with CategoryNotAllowed(2).
That's a contract revert, not a check in my backend. And the caps aren't mine to move either: setPolicy and revoke are onlyOwner, and Gantry holds no key that can call them.
You don't have to take my word for the refusal. verify:denial takes the cancellation's transaction hash, reads the policy, the day's spend, the wallet balance and the merchant's category at that block, recomputes what the wallet would have decided, and compares it against the reason I published. Every input is a public getter or a log, so anyone with an RPC URL gets the same answer, including contradicted if I ever publish a reason the chain doesn't support.
The last piece is finding the shop at all. Every other surface assumes a human already chose it, by scanning a code or tapping a list, which is a step software cannot take. GET /discovery/resources lists every registered merchant as a payable resource carrying its own real price, so an agent can list the shops, pick one and pay it without a human anywhere in the loop.
How I built it
Four contracts in Solidity, deployed and verified on Base Sepolia. GantryCore holds the merchant registry and the intent lifecycle, and it's the only thing that moves money. AgentPBMWallet and a permissionless factory hold the policy layer. FixedRateSwap sits behind an IGantrySwap interface. MockXSGD is a hand-written EIP-3009 token.
The backend is Node and Express with viem. It runs an x402 facilitator implementing /verify and /settle, a relayer that owns the only gas key and serialises its nonce through a FIFO queue, and an indexer that streams settlements to the dashboard over SSE. SQLite caches whatever the indexer sweeps, and every table it holds can be rebuilt from the chain. There is no merchant login, and I didn't build one: both merchant writes are authenticated by the shop's payout address instead, the contract checking msg.sender for a payout rotation and the backend checking an EIP-712 signature before it relays a profile edit, since that call is onlyRelayer and the merchant cannot send it themselves.
The web app is Next.js 15 and wagmi, in two surfaces: a merchant back-office and a payer app, plus the printable standee. The agent is a terminal CLI on the Vercel AI SDK, which narrates while deterministic tools do the signing and the HTTP. It runs on AIsa's gateway on qwen-flash, chosen by benchmarking the candidates against the real tool schemas rather than by reputation; Gemini is the alternative provider behind the same SDK, and with no key at all it runs a scripted path that sends identical wire traffic.
Quotes are pinned when an intent is created, and they always round up. This is fuzz-tested, because a quote rounded down means the settlement underpays and reverts:
$$\text{amountIn} = \left\lceil \frac{\text{sgd} \times 10^{6}}{\text{rate}} \right\rceil$$
On what's real and what isn't: the contracts are live, payments settle in Circle's real testnet stablecoins in both USDC and EURC, the x402 wire format is the actual spec, and the denials are real reverts. MockXSGD is the one mocked token, and it's unavoidable, because XSGD exists on no testnet anywhere. The FX rate is owner-set rather than a market. One relayer key is trusted, and it takes brief custody on the exact path. Merchant categories are self-attested and there's no KYC anywhere in the system. I'd rather list those than have a judge find them.
Challenges we ran into
A vanilla x402 client cannot pay my contract, and I worked this out late. createNonce() in @x402/evm randomly generates the EIP-3009 nonce and offers no way to pin it. GantryCore requires nonce == intentId, which is what binds a signature to one specific intent so it can't be replayed against another. Those two facts don't reconcile. An unmodified client can never sign what my contract settles. I could have forked the client and quietly kept claiming standards compliance. Instead I built a facilitator bridge: the challenge stays 100% spec-standard with payTo set to the relayer, and at settle time the bridge collects the agent's authorization, self-signs a second one carrying the right nonce, and settles. It costs one custodial hop, which is what a PSP does anyway, and it's on the honest-labels list.
A denied payment leaves no reverted transaction to point at. Simulate-before-send catches the policy revert, so it never gets broadcast, so there's nothing on chain. The refusal had to ride on the cancellation instead, which does land, carrying the wallet's verbatim revert bytes so the indexer decodes them with the same decoder the backend used when it caught them.
Free-tier RPC shaped the architecture more than any design decision I made. Alchemy caps eth_getLogs at ten blocks, so every one of my 1,999-block sweep chunks gets rejected by the paid provider and served by the free public node. The rate-limited endpoint carries the correctness path. It gets worse: a rate limit belongs to the egress IP, not the endpoint, so a scan that completes cleanly from my laptop dies on Render's shared address against that same endpoint. Every concurrency constant I tuned locally was a throughput choice and never a safety one.
viem's waitForTransactionReceipt will hand you somebody else's receipt. Unless you pass onReplaced, it scans for a transaction with the same sender and nonce and resolves with whatever it finds. The deployed build signs every visitor's payment with one shared demo key, which makes that routine rather than exotic. It would grade a stranger's transaction as my success, and createWallet would parse the stranger's logs and hand their wallet address back to my user. It also resolves a reverted receipt instead of throwing.
An agent that talks but never pays was the worst failure I found. If the gateway answers a tool call as prose with tool_calls left empty, text streams so the timeout never fires, nothing executes so nothing throws, and the agent narrates a purchase that never happened. My own timeout fallback made it dangerous rather than safe, because the scripted path actually pays, so a model that had only talked would have produced a real payment nobody asked for. A run that ends having called no tool now refuses and exits non-zero.
Accomplishments that we're proud of
An unmodified @x402/fetch client pays a Gantry merchant end to end, in dollars or in euros. That was the bar I set for whether I'd implemented a standard or just cited one.
The refusal is a contract revert. CategoryNotAllowed comes out of the wallet, surfaces verbatim as the x402 errorReason, and lands on the payer's receipt under the same name. One vocabulary from the contract to the screen, and a stranger can re-derive that refusal from public chain state without asking me for anything.
Both doors share a single settlement function, so the merchant genuinely can't tell them apart except by a badge. One link serves both of them, and an agent that was never told a handle can find the shop, pay it, and be turned down by it.
550+ tests across four suites. Some of the Foundry tests need a fork RPC and skip themselves in CI, which I'd rather say here than let someone discover.
What we learned
Most of the hard parts of payments engineering are about not knowing what happened.
A receipt timeout doesn't mean the transaction failed. It may still mine. So the rule became: never infer an outcome from a helper that threw, prove it on chain before compensating, and write a critical log with manual-recovery data instead of issuing a blind refund.
Reading back your own write turned out to be a distributed systems problem. The browser confirms a transaction against one RPC provider, then the next screen reads through the backend on a different one, and a read issued milliseconds later is routinely served by a replica that doesn't have the block yet. The payer arrives back on a screen still showing the rules they just changed, which looks exactly like the write doing nothing. The fix was to record a fingerprint of what we expect and poll until the read agrees, and to render nothing at all rather than render something known to be stale. A frame that states the wrong caps is a claim about the chain.
The last thing that surprised me: honest labelling is an engineering discipline, not a copywriting one. Writing "self-attested" instead of "verified" forced me to notice that nothing in the system reviews a merchant. That's a fact about the architecture. The word was just where it surfaced.
Its corollary took longer to see. Every honest label I wrote made a screen longer, and past a certain length nobody reads any of it, the labels included. Cutting the app's copy back to a sentence or two per screen wasn't a design pass. It was the only way the disclosures got read at all.
What's next for Gantry
A hawker should never have to see a hex string, and that part is already in: the onboarding form can mint a passkey smart account from whatever unlocks the merchant's phone, with no seed phrase and no app to install, and Gantry never holds the key. What I won't do is generate and hold that key myself, which would make Gantry the custodian of hawker revenue and turn a non-custodial rail into an unlicensed money service.
Tap to pay is next. The standee already encodes a URL, and an NFC tag carries the same one, so a tourist taps the counter instead of opening a camera. It is the same payment request underneath, in a third encoding.
Real FX comes after that, behind the interface that's already there. Settlement talks to IGantrySwap rather than to a pool, and the contract enforces its own minimum-out by measuring its own balance delta, so production can swap in real XSGD liquidity through an aggregator or an RFQ quote without the settlement contract changing at all.
Then a licensed PSP in front. Gantry is the rail and the policy layer, not the regulated entity. MAS already has the framework: the Payment Services Act, and Project Orchid, which explored this exact purpose-bound money idea. StraitsX issues XSGD under that regime today.
After that, the institutions door for payroll and disbursements, SGQR+ interoperability, and mainnet XSGD. The rail works and the doors are open. What's left is the institutional plumbing that turns a prototype into something a hawker can rely on.
Built With
- aisa
- base
- eip-3009
- eip-712
- ethereum
- express.js
- foundry
- next.js
- node.js
- openzeppelin
- qwen-flash
- rainbowkit
- render
- solidity
- sqlite
- stablecoin
- tailwindcss
- typescript
- vercel
- vercel-ai-sdk
- viem
- wagmi
- x402

Log in or sign up for Devpost to join the conversation.