|
| 1 | +--- |
| 2 | +title: Demo 1 — Agentic joke buyer |
| 3 | +description: >- |
| 4 | + A headless Node client that pays the Mezo x402 joke paywall |
| 5 | + autonomously using @x402/fetch and a funded testnet wallet — no |
| 6 | + browser, no Connect wallet button, no human click. |
| 7 | +topic: developers |
| 8 | +--- |
| 9 | + |
| 10 | +import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components'; |
| 11 | + |
| 12 | +The [Quickstart](./x402-quickstart/) walked through a **buyer-clicks-the-button** |
| 13 | +flow: the paywall renders in a browser, the reader signs with MetaMask, |
| 14 | +the joke appears. |
| 15 | + |
| 16 | +Demo 1 flips that around: a **headless agent** pays the same paywall, |
| 17 | +with no UI and no human in the loop. The agent loads a private key, |
| 18 | +wraps `fetch()` with x402 payment handling, makes one GET request, |
| 19 | +and the `@x402/fetch` helper does the rest — detect `402`, sign a |
| 20 | +permit2 authorization, retry, read the receipt header, print the joke. |
| 21 | + |
| 22 | +Same protocol, same facilitator, same `0.001 MUSD` charge. Different |
| 23 | +end user — code instead of a person. |
| 24 | + |
| 25 | +## What you will build |
| 26 | + |
| 27 | +A single-file Node client (`client.ts`) that: |
| 28 | + |
| 29 | +1. Loads **Account A**'s private key (the account funded with MUSD in |
| 30 | + the [Quickstart](./x402-quickstart/#step-2-get-testnet-musd-to-pay-with)). |
| 31 | +2. Wraps global `fetch` with `wrapFetchWithPayment` from `@x402/fetch`. |
| 32 | +3. Calls a paywalled URL — either the live `humor.vativ.io/joke` or |
| 33 | + your own seller from Step 7 of the Quickstart. |
| 34 | +4. Prints the joke to the terminal plus the on-chain settlement |
| 35 | + transaction hash it paid. |
| 36 | + |
| 37 | +No browser, no wallet extension, no Connect-wallet click. |
| 38 | + |
| 39 | +<Aside type="caution" title="You will paste a private key into an env var"> |
| 40 | +The agent signs with a raw private key loaded from `.env`. Use a |
| 41 | +**testnet-only, throwaway key** — never an address that holds real |
| 42 | +funds on any mainnet. For production agentic flows you'd replace the |
| 43 | +private key with a hardware signer, an MPC wallet, or a KMS-backed |
| 44 | +signer (see the x402 specs for the signer interface). |
| 45 | +</Aside> |
| 46 | + |
| 47 | +## Prerequisites |
| 48 | + |
| 49 | +- **Node.js 20+** and **pnpm 9+**. Same as the Quickstart. |
| 50 | +- **Account A (Buyer) from the Quickstart** — already funded with ≥ |
| 51 | + 1,800 MUSD and on Mezo Testnet. If you haven't done the Quickstart, |
| 52 | + do Steps 1 and 2 of it first, then come back here. |
| 53 | +- **Account A's private key.** In MetaMask: |
| 54 | + account menu → *"Account details"* → *"Show private key"* → type |
| 55 | + your MetaMask password. Copy the `0x…` private key somewhere you |
| 56 | + will delete after this demo. |
| 57 | +- A running x402 seller to call. Either: |
| 58 | + - Your local seller from Quickstart Step 7 (`localhost:3000/paid`), |
| 59 | + or |
| 60 | + - The live demo at `https://humor.vativ.io/joke` (no setup needed). |
| 61 | + |
| 62 | +## Step 1: One-time Permit2 approval |
| 63 | + |
| 64 | +The `@x402/fetch` EVM signer moves MUSD via |
| 65 | +[Uniswap's Permit2](https://github.com/Uniswap/permit2) — one allowance |
| 66 | +grant from your wallet to the Permit2 contract, then every subsequent |
| 67 | +payment is a signature, not a new on-chain approval. You only do this |
| 68 | +once per account. |
| 69 | + |
| 70 | +Check whether Account A has already approved Permit2 for MUSD: |
| 71 | + |
| 72 | +```bash |
| 73 | +cast call 0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503 \ |
| 74 | + "allowance(address,address)(uint256)" \ |
| 75 | + <account-A-address> \ |
| 76 | + 0x000000000022D473030F116dDEE9F6B43aC78BA3 \ |
| 77 | + --rpc-url https://rpc.test.mezo.org |
| 78 | +``` |
| 79 | + |
| 80 | +(`0x0000…BA3` is the canonical Permit2 contract address, same on every |
| 81 | +EVM chain.) |
| 82 | + |
| 83 | +If the returned allowance is `0`, approve it: |
| 84 | + |
| 85 | +```bash |
| 86 | +cast send 0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503 \ |
| 87 | + "approve(address,uint256)" \ |
| 88 | + 0x000000000022D473030F116dDEE9F6B43aC78BA3 \ |
| 89 | + 115792089237316195423570985008687907853269984665640564039457584007913129639935 \ |
| 90 | + --rpc-url https://rpc.test.mezo.org \ |
| 91 | + --private-key $CLIENT_PRIVATE_KEY |
| 92 | +``` |
| 93 | + |
| 94 | +(The big number is `type(uint256).max` — grant unlimited approval so |
| 95 | +you never have to re-approve.) |
| 96 | + |
| 97 | +<Aside type="note" title="No cast? Use your preferred tool"> |
| 98 | +`cast` is part of [Foundry](https://book.getfoundry.sh/). If you |
| 99 | +prefer Node, you can make the same `approve` call with `viem`'s |
| 100 | +`writeContract`, or run it through any EVM wallet extension by |
| 101 | +sending a token approval transaction to Permit2. |
| 102 | +</Aside> |
| 103 | + |
| 104 | +## Step 2: Install client packages |
| 105 | + |
| 106 | +```bash |
| 107 | +mkdir mezo-x402-joke-buyer && cd mezo-x402-joke-buyer |
| 108 | +pnpm init |
| 109 | +pnpm add @x402/fetch @x402/evm viem dotenv |
| 110 | +pnpm add -D typescript tsx @types/node |
| 111 | +``` |
| 112 | + |
| 113 | +The same Mezo-preview override reasoning from Quickstart Step 6 |
| 114 | +applies here too: if `pnpm list @x402/fetch` shows `2.10.x` and not |
| 115 | +`2.11.0+`, add the overrides block to `package.json`: |
| 116 | + |
| 117 | +```json |
| 118 | +"pnpm": { |
| 119 | + "overrides": { |
| 120 | + "@x402/evm": "https://github.com/vativ/x402-mezo-preview/releases/download/v2.10.0-mezo.6/x402-evm-2.10.0-mezo.6.tgz", |
| 121 | + "@x402/fetch": "https://github.com/vativ/x402-mezo-preview/releases/download/v2.10.0-mezo.6/x402-fetch-2.10.0-mezo.6.tgz" |
| 122 | + } |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +Then `pnpm install` again. |
| 127 | + |
| 128 | +## Step 3: Write the agent |
| 129 | + |
| 130 | +Create `client.ts`: |
| 131 | + |
| 132 | +```typescript |
| 133 | +import 'dotenv/config'; |
| 134 | +import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from '@x402/fetch'; |
| 135 | +import { ExactEvmScheme } from '@x402/evm/exact/client'; |
| 136 | +import { toClientEvmSigner, PERMIT2_ADDRESS } from '@x402/evm'; |
| 137 | +import { createPublicClient, http, erc20Abi } from 'viem'; |
| 138 | +import { privateKeyToAccount } from 'viem/accounts'; |
| 139 | +import { mezoTestnet } from 'viem/chains'; |
| 140 | + |
| 141 | +const RESOURCE_URL = process.env.RESOURCE_URL || 'http://localhost:3000/paid'; |
| 142 | +const RPC_URL = process.env.RPC_URL || 'https://rpc.test.mezo.org'; |
| 143 | +const MUSD_ADDRESS = '0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503' as `0x${string}`; |
| 144 | + |
| 145 | +const CLIENT_PRIVATE_KEY = process.env.CLIENT_PRIVATE_KEY as `0x${string}` | undefined; |
| 146 | +if (!CLIENT_PRIVATE_KEY || !/^0x[0-9a-fA-F]{64}$/.test(CLIENT_PRIVATE_KEY)) { |
| 147 | + throw new Error('CLIENT_PRIVATE_KEY must be set to a 0x-prefixed 64-hex-character private key.'); |
| 148 | +} |
| 149 | + |
| 150 | +async function main() { |
| 151 | + const account = privateKeyToAccount(CLIENT_PRIVATE_KEY); |
| 152 | + const publicClient = createPublicClient({ chain: mezoTestnet, transport: http(RPC_URL) }); |
| 153 | + |
| 154 | + // Sanity-check MUSD balance and Permit2 allowance before trying to pay. |
| 155 | + const balanceBefore = await publicClient.readContract({ |
| 156 | + address: MUSD_ADDRESS, abi: erc20Abi, functionName: 'balanceOf', args: [account.address], |
| 157 | + }); |
| 158 | + const allowance = await publicClient.readContract({ |
| 159 | + address: MUSD_ADDRESS, abi: erc20Abi, functionName: 'allowance', |
| 160 | + args: [account.address, PERMIT2_ADDRESS as `0x${string}`], |
| 161 | + }); |
| 162 | + console.log(`Buyer wallet: ${account.address}`); |
| 163 | + console.log(`MUSD balance: ${(Number(balanceBefore) / 1e18).toFixed(6)} MUSD`); |
| 164 | + console.log(`Permit2 allowance: ${allowance === 0n ? 'MISSING — run Step 1 first' : 'OK'}`); |
| 165 | + |
| 166 | + // Wrap fetch with x402 payment handling: detects 402, signs, retries. |
| 167 | + const signer = toClientEvmSigner(account, publicClient); |
| 168 | + const client = new x402Client(); |
| 169 | + client.register('eip155:*', new ExactEvmScheme(signer)); |
| 170 | + const fetchWithPay = wrapFetchWithPayment(fetch, client); |
| 171 | + |
| 172 | + console.log(`\nRequesting ${RESOURCE_URL} …`); |
| 173 | + const response = await fetchWithPay(RESOURCE_URL); |
| 174 | + if (!response.ok) { |
| 175 | + console.error(`Request failed: ${response.status} ${response.statusText}`); |
| 176 | + console.error(await response.text()); |
| 177 | + return; |
| 178 | + } |
| 179 | + |
| 180 | + const data = await response.json(); |
| 181 | + console.log('\n=== Payment Successful ==='); |
| 182 | + console.log(JSON.stringify(data, null, 2)); |
| 183 | + |
| 184 | + // Read the settlement receipt the server attached to the 200 response. |
| 185 | + const rxHeader = response.headers.get('PAYMENT-RESPONSE') || response.headers.get('X-PAYMENT-RESPONSE'); |
| 186 | + if (rxHeader) { |
| 187 | + const rx = decodePaymentResponseHeader(rxHeader); |
| 188 | + console.log('\nOn-chain settlement:'); |
| 189 | + console.log(` tx: ${rx.transaction}`); |
| 190 | + console.log(` network: ${rx.network}`); |
| 191 | + console.log(` success: ${rx.success}`); |
| 192 | + } |
| 193 | + |
| 194 | + const balanceAfter = await publicClient.readContract({ |
| 195 | + address: MUSD_ADDRESS, abi: erc20Abi, functionName: 'balanceOf', args: [account.address], |
| 196 | + }); |
| 197 | + const delta = balanceBefore - balanceAfter; |
| 198 | + console.log(`\nMUSD spent: ${(Number(delta) / 1e18).toFixed(6)} MUSD`); |
| 199 | +} |
| 200 | + |
| 201 | +main().catch((err) => { console.error(err); process.exit(1); }); |
| 202 | +``` |
| 203 | + |
| 204 | +Create `.env`: |
| 205 | + |
| 206 | +```bash |
| 207 | +# Account A's private key — use a testnet-only wallet. Never mainnet. |
| 208 | +CLIENT_PRIVATE_KEY=0xYOUR_ACCOUNT_A_PRIVATE_KEY_HERE |
| 209 | + |
| 210 | +# Target URL. Pick one: |
| 211 | +# RESOURCE_URL=http://localhost:3000/paid # your local seller from the Quickstart |
| 212 | +# RESOURCE_URL=https://humor.vativ.io/joke # the live demo |
| 213 | +RESOURCE_URL=https://humor.vativ.io/joke |
| 214 | +``` |
| 215 | + |
| 216 | +Add `.env` to `.gitignore` immediately. Private key leakage is how |
| 217 | +test wallets drain. |
| 218 | + |
| 219 | +## Step 4: Run the agent |
| 220 | + |
| 221 | +```bash |
| 222 | +pnpm exec tsx client.ts |
| 223 | +``` |
| 224 | + |
| 225 | +Expected output: |
| 226 | + |
| 227 | +``` |
| 228 | +Buyer wallet: 0x<account-A> |
| 229 | +MUSD balance: 1800.000000 MUSD |
| 230 | +Permit2 allowance: OK |
| 231 | +
|
| 232 | +Requesting https://humor.vativ.io/joke … |
| 233 | +
|
| 234 | +=== Payment Successful === |
| 235 | +{ |
| 236 | + "setup": "Why did the Bitcoin go to therapy?", |
| 237 | + "punchline": "It had too many forks in its past." |
| 238 | +} |
| 239 | +
|
| 240 | +On-chain settlement: |
| 241 | + tx: 0x<tx-hash> |
| 242 | + network: eip155:31611 |
| 243 | + success: true |
| 244 | +
|
| 245 | +MUSD spent: 0.001000 MUSD |
| 246 | +``` |
| 247 | + |
| 248 | +The `tx` hash is a real Mezo Testnet transaction — look it up on |
| 249 | +[`explorer.test.mezo.org`](https://explorer.test.mezo.org) to see the |
| 250 | +MUSD flow from Account A (the buyer) to the seller's `payTo` address, |
| 251 | +with gas paid by the facilitator. |
| 252 | + |
| 253 | +## How the auto-payment works |
| 254 | + |
| 255 | +`wrapFetchWithPayment(fetch, client)` returns a drop-in replacement for |
| 256 | +the global `fetch` that runs this sequence automatically on every call: |
| 257 | + |
| 258 | +<Steps> |
| 259 | + |
| 260 | +1. **First GET (unpaid).** The agent calls |
| 261 | + `fetchWithPay(RESOURCE_URL)`. The server responds `402 Payment |
| 262 | + Required` with a `PAYMENT-REQUIRED` header carrying a base64 JSON |
| 263 | + body describing the `accepts` list: scheme `exact`, asset |
| 264 | + (`0x1189…Ac503` = MUSD), amount (`1000000000000000` wei = `0.001 |
| 265 | + MUSD`), `payTo` address, network (`eip155:31611`), and a |
| 266 | + `maxTimeoutSeconds` window. |
| 267 | + |
| 268 | +2. **Select and sign.** The wrapped fetcher picks the `accepts` entry |
| 269 | + it can satisfy (EVM + MUSD), constructs a permit2 |
| 270 | + `SignatureTransferDetails` for the amount and `payTo`, and the |
| 271 | + signer (`toClientEvmSigner`) signs it with Account A's private key. |
| 272 | + No on-chain transaction yet — just an off-chain signature. |
| 273 | + |
| 274 | +3. **Retry with X-PAYMENT.** The fetcher resends the original GET with |
| 275 | + an `X-PAYMENT` header carrying the signed permit. |
| 276 | + |
| 277 | +4. **Facilitator settles.** The server's `paymentMiddleware` forwards |
| 278 | + the signed permit to the facilitator at `facilitator.vativ.io`. The |
| 279 | + facilitator submits the on-chain `permitTransferFrom` to Permit2, |
| 280 | + which moves `0.001 MUSD` from Account A to the seller's `payTo` |
| 281 | + in a single transaction. The facilitator also pays gas — Account A |
| 282 | + never needs BTC for this. |
| 283 | + |
| 284 | +5. **200 OK with receipt.** The server waits for settlement, then |
| 285 | + sends the real response body (the joke) with a `PAYMENT-RESPONSE` |
| 286 | + header containing the tx hash. `decodePaymentResponseHeader` |
| 287 | + decodes it for the agent's logs. |
| 288 | + |
| 289 | +</Steps> |
| 290 | + |
| 291 | +From the agent's perspective this is one `await fetchWithPay(url)`. |
| 292 | +Everything else is inside the library. |
| 293 | + |
| 294 | +## Troubleshooting |
| 295 | + |
| 296 | +| Symptom | Cause | Fix | |
| 297 | +|---|---|---| |
| 298 | +| `Permit2 allowance: MISSING` in the log | Account A has not approved Permit2 to spend its MUSD | Run the `cast send approve` command in Step 1 | |
| 299 | +| Request fails with `402` forever (never retries) | The agent is using plain `fetch` instead of `fetchWithPay` | Double-check the import; every call that should auto-pay must go through `fetchWithPay` | |
| 300 | +| `CLIENT_PRIVATE_KEY must be set…` | `.env` not loaded, wrong format, or key is `0x`-less | Confirm the key is `0x` + 64 hex characters; confirm `dotenv/config` is imported first | |
| 301 | +| Settlement succeeds but no joke body | Server responded `200` with an empty body, or the server is not the x402 demo | `curl -s -i <URL>` to confirm the server is up and returning JSON | |
| 302 | +| `MUSD balance: 0.000000` | Account A never received MUSD, or you pasted the wrong key | Check Quickstart Step 2; run `cast call balanceOf` to confirm balance on chain | |
| 303 | +| `insufficient allowance` revert on chain | Permit2 approval was less than the permit amount | Re-run Step 1 with the `type(uint256).max` argument shown | |
| 304 | + |
| 305 | +## Security |
| 306 | + |
| 307 | +- **Never put a real-funds private key in `.env`.** A testnet key that |
| 308 | + holds only borrowed MUSD and faucet BTC is fine for this demo. |
| 309 | + Anything else goes in a hardware signer or MPC wallet. Rotate the |
| 310 | + testnet key after the demo if you shared the repo anywhere. |
| 311 | +- **`.env` must be in `.gitignore`.** The default `pnpm init` does |
| 312 | + not create one — add it: |
| 313 | + ```bash |
| 314 | + echo ".env" >> .gitignore |
| 315 | + ``` |
| 316 | +- The `toClientEvmSigner` interface is swappable — production agentic |
| 317 | + flows replace it with a signer backed by a KMS, HSM, or threshold |
| 318 | + wallet. The rest of the pipeline (`wrapFetchWithPayment`, the x402 |
| 319 | + handshake, the facilitator) doesn't change. |
| 320 | + |
| 321 | +## See also |
| 322 | + |
| 323 | +- [Quickstart](./x402-quickstart/). Build the seller side of this flow |
| 324 | + and pay it with a browser wallet before pointing the agent at it. |
| 325 | +- [`vativ/mezo-hack/apps/humor`](https://github.com/vativ/mezo-hack/tree/main/apps/humor). |
| 326 | + Full source for the `humor.vativ.io` live demo — server + client |
| 327 | + both. This page's client is a trimmed version of `apps/humor/client/client.ts`. |
| 328 | +- [Uniswap Permit2](https://github.com/Uniswap/permit2). The allowance |
| 329 | + contract the EVM x402 scheme uses for token transfers. |
0 commit comments