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

Share this project:

Updates

posted an update —

New capabilities

  1. Pay in euros. Circle's real EURC is now a second input token on both doors. No mock was added for it, and MockXSGD is still the only mocked token in the system.
  2. One URL serves both doors. GET /pay/<handle>?sgd= answers a browser with the payment page and answers a machine with HTTP 402 and an x402 challenge.
  3. Machine-readable discovery. GET /discovery/resources lists every registered shop as a payable resource, so an agent can find a merchant rather than be told one. The whole loop now runs with no human in it.
  4. Open onboarding. Registering a shop is live for anyone, bounded by rate ceilings rather than by anyone's approval.
  5. Passkey payout wallets. Onboarding can mint a smart account from whatever unlocks the merchant's phone, so a hawker never has to paste a hex string.
  6. Both merchant writes are authenticated by the shop's payout key. The contract checks a payout rotation directly; a profile edit is checked as an EIP-712 signature, with an ERC-1271 tier for contract accounts.

Proof you don't have to take on trust

  1. verify:denial. Re-derives any refusal from public chain state at that block and compares it to the reason we published, including a contradicted verdict if we ever publish one the chain doesn't support. It proves the wallet would have refused, never that the agent asked, and it prints that limit on every run.
  2. Measured gas and latency, from real receipts rather than projections. The custodial exact hop costs 47% more gas than the direct policy-wallet path, and all three doors settle within about 1% of each other.
  3. Five on-chain artifacts written up with what each one proves and, more usefully, what it does not.

Engineering quality

  1. Policy invariants. Four properties held over any call sequence, with a guard that fails any run where nothing was ever successfully spent. Every one of them is satisfiable by a wallet that refuses everything, which the first version of the handler quietly was.
  2. Flake hunted and killed. Measured at 5 red suites in 20 runs, fixed in the handler's bounds rather than in the guard, re-measured at 0 in 100.
  3. Slither gating CI, with all 25 findings triaged in the repo and every exclusion answered in writing.
  4. The agent refuses rather than pays when it is misconfigured, when the gateway is dead, or when a model talks without ever calling a tool. That last one would otherwise have narrated a purchase it never made.
  5. The model was picked by benchmark, not by reputation. qwen-flash on AIsa's gateway, driven against the real tool schemas. The bench caught one candidate paying S$13.50 for a S$4.50 basket, reproducibly.
  6. 550+ tests across four suites, up from 446.

Surfaces

  1. A refusal now has a way out. A declined receipt re-checks the same payment against today's policy and opens the rule that stopped it, already ticked, for the owner to sign.
  2. Agent policies show what they turn away, not only what they allow.
  3. Merchant totals are summed on the server, so no headline figure depends on how much history the browser happened to have loaded.
  4. App copy cut back hard. When there is a lot to read, nobody reads anything, and the honest labels are the first thing that gets skipped.

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

Submission history