From 94646779907b222d0ffbf7f39d7b9b1828708b4f Mon Sep 17 00:00:00 2001 From: Avneesh Agarwal Date: Wed, 10 Jun 2026 15:20:20 +1000 Subject: [PATCH] feat(nextjs-agentic-payments-x402): crypto-abstracted agentic payments (Dynamic + x402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Next.js + Dynamic JS SDK example where a user signs in with email, gets an embedded MPC wallet, authorizes an agent (delegated access), funds in USD, and an autonomous agent pays for services via gasless x402 (EIP-3009 USDC) — signed inside Dynamic's MPC, so no private keys touch the agent. - Web: email login -> embedded EVM wallet (guarded) -> delegateWaasKeyShares -> USD funding page. - Webhook: HMAC-verified, decrypts the delegated share (RSA-OAEP + AES-GCM). - Encrypted store: re-encrypts shares (AES-256-GCM) into Supabase; short account code per wallet (RLS on). - Paid service + x402 gate (Coinbase facilitator on mainnet, public on testnet). - Agent: resolves a user's wallet by account code, pays via x402; bridges Dynamic MPC into the x402 signer. Note: the two hex strings the secret scanner flags in lib/shared/constants.ts are public USDC token contract addresses on Base / Base Sepolia, not secrets. Co-Authored-By: Claude Fable 5 (1M context) --- .../nextjs-agentic-payments-x402/.env.example | 53 + .../nextjs-agentic-payments-x402/.gitignore | 38 + .../.vercelignore | 4 + .../nextjs-agentic-payments-x402/README.md | 143 + .../agent/import-delegation.ts | 83 + .../agent/pay-for-service.ts | 122 + .../app/api/account/route.ts | 31 + .../app/api/balance/route.ts | 46 + .../app/api/services/azure-compute/route.ts | 24 + .../app/api/webhooks/dynamic/handler.ts | 83 + .../app/api/webhooks/dynamic/route.ts | 21 + .../app/globals.css | 124 + .../app/layout.tsx | 35 + .../nextjs-agentic-payments-x402/app/page.tsx | 15 + .../components.json | 21 + .../components/dynamic/login-form.tsx | 115 + .../components/dynamic/logo.tsx | 61 + .../components/dynamic/logout-button.tsx | 25 + .../components/flow/agent-funding-flow.tsx | 297 + .../components/footer.tsx | 50 + .../components/header.tsx | 19 + .../components/ui/button.tsx | 76 + .../components/ui/card.tsx | 92 + .../components/ui/skeleton.tsx | 13 + examples/nextjs-agentic-payments-x402/env.ts | 68 + .../lib/dynamic-client.ts | 18 + .../lib/dynamic/delegation/decrypt.ts | 96 + .../lib/dynamic/delegation/index.ts | 2 + .../lib/dynamic/delegation/storage.ts | 7 + .../lib/dynamic/webhooks/handlers.ts | 102 + .../lib/dynamic/webhooks/index.ts | 3 + .../lib/dynamic/webhooks/schemas.ts | 164 + .../lib/dynamic/webhooks/verify-signature.ts | 101 + .../lib/providers.tsx | 173 + .../lib/shared/constants.ts | 71 + .../lib/shared/delegation-store.ts | 250 + .../lib/shared/x402-account.ts | 74 + .../nextjs-agentic-payments-x402/lib/utils.ts | 6 + .../middleware.ts | 46 + .../next.config.ts | 40 + .../nextjs-agentic-payments-x402/package.json | 55 + .../pnpm-lock.yaml | 9809 +++++++++++++++++ .../postcss.config.mjs | 5 + .../public/favicon.ico | Bin 0 -> 10117 bytes .../supabase/migrations/0001_delegations.sql | 29 + .../supabase/migrations/0002_account_code.sql | 7 + .../tsconfig.json | 27 + 47 files changed, 12744 insertions(+) create mode 100644 examples/nextjs-agentic-payments-x402/.env.example create mode 100644 examples/nextjs-agentic-payments-x402/.gitignore create mode 100644 examples/nextjs-agentic-payments-x402/.vercelignore create mode 100644 examples/nextjs-agentic-payments-x402/README.md create mode 100644 examples/nextjs-agentic-payments-x402/agent/import-delegation.ts create mode 100644 examples/nextjs-agentic-payments-x402/agent/pay-for-service.ts create mode 100644 examples/nextjs-agentic-payments-x402/app/api/account/route.ts create mode 100644 examples/nextjs-agentic-payments-x402/app/api/balance/route.ts create mode 100644 examples/nextjs-agentic-payments-x402/app/api/services/azure-compute/route.ts create mode 100644 examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/handler.ts create mode 100644 examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/route.ts create mode 100644 examples/nextjs-agentic-payments-x402/app/globals.css create mode 100644 examples/nextjs-agentic-payments-x402/app/layout.tsx create mode 100644 examples/nextjs-agentic-payments-x402/app/page.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components.json create mode 100644 examples/nextjs-agentic-payments-x402/components/dynamic/login-form.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/dynamic/logo.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/dynamic/logout-button.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/flow/agent-funding-flow.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/footer.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/header.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/ui/button.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/ui/card.tsx create mode 100644 examples/nextjs-agentic-payments-x402/components/ui/skeleton.tsx create mode 100644 examples/nextjs-agentic-payments-x402/env.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic-client.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/delegation/decrypt.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/delegation/index.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/delegation/storage.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/webhooks/handlers.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/webhooks/index.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/webhooks/schemas.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/dynamic/webhooks/verify-signature.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/providers.tsx create mode 100644 examples/nextjs-agentic-payments-x402/lib/shared/constants.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/shared/delegation-store.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/shared/x402-account.ts create mode 100644 examples/nextjs-agentic-payments-x402/lib/utils.ts create mode 100644 examples/nextjs-agentic-payments-x402/middleware.ts create mode 100644 examples/nextjs-agentic-payments-x402/next.config.ts create mode 100644 examples/nextjs-agentic-payments-x402/package.json create mode 100644 examples/nextjs-agentic-payments-x402/pnpm-lock.yaml create mode 100644 examples/nextjs-agentic-payments-x402/postcss.config.mjs create mode 100644 examples/nextjs-agentic-payments-x402/public/favicon.ico create mode 100644 examples/nextjs-agentic-payments-x402/supabase/migrations/0001_delegations.sql create mode 100644 examples/nextjs-agentic-payments-x402/supabase/migrations/0002_account_code.sql create mode 100644 examples/nextjs-agentic-payments-x402/tsconfig.json diff --git a/examples/nextjs-agentic-payments-x402/.env.example b/examples/nextjs-agentic-payments-x402/.env.example new file mode 100644 index 0000000..7b723b9 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/.env.example @@ -0,0 +1,53 @@ +# ─── Dynamic ──────────────────────────────────────────────────────────────── +# Environment ID from https://app.dynamic.xyz (public). Use a LIVE env for production. +NEXT_PUBLIC_DYNAMIC_ENV_ID= +# Server API token (Settings → API tokens) +DYNAMIC_API_TOKEN= +# Webhook signing secret (Dashboard → Webhooks). Subscribe to +# wallet.delegation.created + wallet.delegation.revoked → /api/webhooks/dynamic +DYNAMIC_WEBHOOK_SECRET= +# RSA private key that decrypts delegation shares from the webhook. +# openssl genrsa -out private-key.pem 3072 +# openssl rsa -in private-key.pem -pubout -out public-key.pem # upload the PUBLIC key +# Paste the PRIVATE key here (actual newlines or \n). +# Production: prefer decrypting via AWS/GCP KMS or HashiCorp Vault instead of an env var. +DYNAMIC_DELEGATION_PRIVATE_KEY= + +# ─── Supabase (stores encrypted delegated shares) ─────────────────────────── +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +# AES-256-GCM key for encryption at rest: openssl rand -hex 32 +DELEGATION_ENCRYPTION_KEY= +# DB connection string — used only to run migrations with the supabase CLI +# (`supabase db push --db-url "$SUPABASE_DB_URL"`). Use the session pooler URI. +SUPABASE_DB_URL= + +# ─── x402 payments ────────────────────────────────────────────────────────── +# Network: "base" (mainnet, default) or "base-sepolia" (testnet dev). +X402_NETWORK=base +NEXT_PUBLIC_X402_NETWORK=base +# Address that receives service payments (your merchant / treasury wallet). +X402_PAY_TO=0x +# Optional RPC override for balance reads. +BASE_RPC_URL= +# REQUIRED ON MAINNET: Coinbase CDP keys for the x402 facilitator that settles on +# Base mainnet (https://portal.cdp.coinbase.com). Not needed on base-sepolia +# (the public facilitator https://x402.org/facilitator settles testnet for free). +CDP_API_KEY_ID= +CDP_API_KEY_SECRET= + +# ─── On-ramp / funding ────────────────────────────────────────────────────── +# Hosted on-ramp widget URL (MoonPay / Coinbase / Crypto.com). The funding page +# appends ?walletAddress=...&baseCurrencyAmount=... e.g. https://buy.moonpay.com +NEXT_PUBLIC_ONRAMP_URL= +# Testnet faucet used to "add funds" on base-sepolia, e.g. https://faucet.circle.com +NEXT_PUBLIC_FAUCET_URL= + +# ─── Agent (optional) ───────────────────────────────────────────────────────── +# Which account the agent acts for. Pass on the CLI (`pnpm agent `) +# or set here. Accepts the short account code (shown on the funding page) or a 0x address. +AGENT_ACCOUNT= +# Defaults to http://localhost:3000/api/services/azure-compute +X402_SERVICE_URL= +# Funding page the agent points to when the wallet is empty. +FUNDING_URL=http://localhost:3000 diff --git a/examples/nextjs-agentic-payments-x402/.gitignore b/examples/nextjs-agentic-payments-x402/.gitignore new file mode 100644 index 0000000..079c757 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/.gitignore @@ -0,0 +1,38 @@ +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# supabase CLI temp +supabase/.temp/ diff --git a/examples/nextjs-agentic-payments-x402/.vercelignore b/examples/nextjs-agentic-payments-x402/.vercelignore new file mode 100644 index 0000000..132d0d1 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/.vercelignore @@ -0,0 +1,4 @@ +.env +.env.* +node_modules +.next diff --git a/examples/nextjs-agentic-payments-x402/README.md b/examples/nextjs-agentic-payments-x402/README.md new file mode 100644 index 0000000..3ffaa41 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/README.md @@ -0,0 +1,143 @@ +# Agentic payments with Dynamic + x402 (crypto, abstracted) + +A production-shaped demo of **agentic payments that hide all the crypto**. A user +signs in, "adds funds" in USD (via a MoonPay card top-up), and authorizes an +agent. The agent then **pays for services on the user's behalf** using +[x402](https://x402.org) — gasless USDC payments signed inside Dynamic's MPC, so +no private keys ever touch the agent and the user never sees a seed phrase, gas +fee, or token symbol. + +> "Stablecoins between agents, on existing rails": the user experience is a +> dollar balance and a card top-up; the settlement layer is stablecoins over HTTP 402. + +Built on the **Dynamic JS SDK** (`@dynamic-labs-sdk/*` + react-hooks), like +`nextjs-stablecoin-yield-aave`. + +## What's in the box + +| Piece | Path | Role | +| --- | --- | --- | +| **Website** | `app/`, `components/flow/`, `lib/providers.tsx` | Email sign-in → embedded wallet (guarded) → authorize → fund. USD-framed, light theme. | +| **Delegation webhook** | `app/api/webhooks/dynamic/`, `lib/dynamic/` | Verifies + receives `wallet.delegation.created`, decrypts the share (RSA-OAEP + AES-GCM). | +| **Encrypted store** | `lib/shared/delegation-store.ts`, `supabase/` | Re-encrypts shares (AES-256-GCM) into Supabase; derives a short **account code** per wallet. | +| **Account API** | `app/api/account/`, `app/api/balance/` | Account code + USD balance for the UI. | +| **Paid service** | `middleware.ts`, `app/api/services/azure-compute/` | An "Azure-style" resource gated behind x402 (priced in USD). | +| **Agent** | `agent/pay-for-service.ts` | Resolves a user's wallet **by account code**, pays the service via x402 (gasless). | + +## How it works + +``` + Website (Dynamic JS SDK) + user ──email sign-in──▶ embedded EVM wallet ──authorize──▶ delegateWaasKeyShares + │ + Dynamic webhook (wallet.delegation.created) + │ RSA-decrypt + ▼ AES-256-GCM encrypt + Supabase (encrypted share + code) + │ + agent ──────────────────────────────────────┘ + │ check USD balance ──(empty)──▶ point user to funding page + │ pay x402 service ──sign EIP-3009 via Dynamic MPC (gasless)──▶ facilitator settles USDC + ▼ + "Azure compute unit provisioned. Charged $0.01." +``` + +The agent never holds a key. x402's `exact` scheme only needs an EIP-712 +signature, which `lib/shared/x402-account.ts` produces by routing viem's +`signTypedData` to Dynamic's `delegatedSignTypedData`. + +**User ↔ wallet mapping.** Each delegation gets a short, stable **account code** +(derived from the wallet address, stored in Supabase). The funding page shows it; +the agent is told which user to act for by that code (`pnpm agent `) — no +hardcoded addresses. + +## Network + +Defaults to **Base mainnet** (`X402_NETWORK=base`), settled by the **Coinbase +facilitator** (`@coinbase/x402`, needs `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET`). +Set `X402_NETWORK=base-sepolia` for testnet dev — that uses the public facilitator +(`https://x402.org/facilitator`, no keys). USDC address/chain switch automatically +(`lib/shared/constants.ts`). + +## Setup + +1. **Install**: `pnpm install` + +2. **Dynamic** ([dashboard](https://app.dynamic.xyz), use a **live** env for prod): + - Enable embedded wallets + delegated access + email login. + - Create an API token. + - Generate the delegation keypair, upload the **public** key (delegated-access encryption key): + ```bash + openssl genrsa -out private-key.pem 3072 + openssl rsa -in private-key.pem -pubout -out public-key.pem + ``` + - Add a webhook → `https:///api/webhooks/dynamic`, events + `wallet.delegation.created` + `wallet.delegation.revoked`; copy the signing secret. + +3. **Supabase**: create a project, run migrations, grab the URL + service-role key: + ```bash + supabase db push --db-url "$SUPABASE_DB_URL" # applies supabase/migrations/* + ``` + +4. **Coinbase CDP** (mainnet only): create API keys at + [portal.cdp.coinbase.com](https://portal.cdp.coinbase.com) → `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET`. + +5. **Env**: `cp .env.example .env` and fill it in. + ```bash + openssl rand -hex 32 # → DELEGATION_ENCRYPTION_KEY + ``` + +6. **Run**: `pnpm dev` → http://localhost:3000 + +## Demo flow + +1. **Sign in** with email (embedded wallet created silently, guarded so it's never duplicated). +2. **Authorize your agent** — delegated access; the webhook stores the encrypted share in Supabase. +3. **Add funds** — hosted on-ramp card top-up on mainnet (`NEXT_PUBLIC_ONRAMP_URL`, e.g. MoonPay/Coinbase/Crypto.com), faucet on testnet. Balance shows in USD. Note your **account code**. +4. **Run the agent** for that account: + ```bash + pnpm agent # or a 0x address, or set AGENT_ACCOUNT + ``` + ``` + Account EA8CD66A → wallet 0x… + Balance: $25.00 + 💳 Paying for service: …/api/services/azure-compute + ✅ Service delivered (paid $0.01): { status: "provisioned", … } + ``` + Empty balance → the agent prints the funding URL instead. + +## Deploy (Vercel) + +```bash +vercel link # link the project +# set the env vars from .env in the Vercel dashboard (or `vercel env add` each) +vercel --prod +``` +Then: +- **Disable Deployment Protection** for production (Project Settings → Deployment + Protection → Vercel Authentication → off) so the site is public and the webhook + endpoint is reachable. +- Point the Dynamic webhook at `https:///api/webhooks/dynamic`. +- Set `FUNDING_URL` / `X402_SERVICE_URL` to the deployed domain. Run the agent + from any server with the same env (it talks to Supabase + the deployed service). + +> **npm registry:** this example pins the public npm registry via `.npmrc`. The +> Dynamic JS SDK is pinned to **1.2.1** (`react-hooks` 0.26.5) — the versions fully +> published to public npm (newer 1.8.x deps are only on Dynamic's internal registry, +> which a CI/Vercel build can't reach). + +## Security notes + +- **No plaintext key material at rest.** Shares arrive RSA-encrypted from Dynamic, + are decrypted server-side, then re-encrypted with AES-256-GCM + (`DELEGATION_ENCRYPTION_KEY`) before Supabase. The table has RLS on with no + public policies — only the service-role key reads it. +- **No raw keys in the agent.** All signing is inside Dynamic's MPC. +- **Gasless.** x402 payments are EIP-3009 `transferWithAuthorization` — the + facilitator pays gas; the user pays only the stablecoin amount. +- **Webhook auth.** Incoming webhooks are signature-verified (`DYNAMIC_WEBHOOK_SECRET`). +- **Secrets via env only.** `.env*` and `*.pem` are gitignored. For production, + prefer a KMS/HSM for the RSA + at-rest keys and decrypt on demand; rotate keys; + use the Dynamic **live** env and least-privilege Supabase access. +- Revocation: `wallet.delegation.revoked` deletes the stored share, so the agent + can no longer act. diff --git a/examples/nextjs-agentic-payments-x402/agent/import-delegation.ts b/examples/nextjs-agentic-payments-x402/agent/import-delegation.ts new file mode 100644 index 0000000..d89e420 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/agent/import-delegation.ts @@ -0,0 +1,83 @@ +/** + * One-off importer: takes a `wallet.delegation.created` webhook payload (e.g. + * relayed from webhook.site), decrypts the delegated share + wallet API key with + * the RSA private key, and stores them (AES-256-GCM encrypted) in Supabase — the + * same thing the live webhook handler does, but driven by a payload you paste in. + * + * Usage: pnpm tsx agent/import-delegation.ts + * + * The payload must contain data.encryptedDelegatedShare + data.encryptedWalletApiKey + * (each { ek, iv, ct, tag }), walletId, chain, publicKey, userId. + */ +import "dotenv/config"; +import { readFileSync } from "fs"; +import crypto from "crypto"; +import { storeDelegation } from "../lib/shared/delegation-store"; + +interface Enc { + ek: string; + iv: string; + ct: string; + tag: string; +} + +function rsaOaepDecryptEk(privateKeyPem: string, ekB64: string): Buffer { + return crypto.privateDecrypt( + { + key: privateKeyPem, + oaepHash: "sha256", + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + }, + Buffer.from(ekB64, "base64url") + ); +} + +function aesGcmDecrypt(key: Buffer, e: Enc): Buffer { + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(e.iv, "base64url") + ); + decipher.setAuthTag(Buffer.from(e.tag, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(e.ct, "base64url")), + decipher.final(), + ]); +} + +function decrypt(e: Enc, pem: string): Buffer { + return aesGcmDecrypt(rsaOaepDecryptEk(pem, e.ek), e); +} + +async function main() { + const path = process.argv[2]; + if (!path) throw new Error("Usage: tsx agent/import-delegation.ts "); + + const payload = JSON.parse(readFileSync(path, "utf8")); + const data = payload.data ?? payload; // accept the full webhook body or just `data` + + const pem = (process.env.DYNAMIC_DELEGATION_PRIVATE_KEY ?? "").replace(/\\n/g, "\n"); + if (!pem) throw new Error("DYNAMIC_DELEGATION_PRIVATE_KEY not set"); + + const delegatedShare = JSON.parse( + decrypt(data.encryptedDelegatedShare as Enc, pem).toString("utf8") + ); + const walletApiKey = decrypt(data.encryptedWalletApiKey as Enc, pem).toString("utf8"); + + await storeDelegation({ + userId: data.userId, + chain: data.chain, // typically "EVM" + walletId: data.walletId, + address: data.publicKey, + delegatedShare, + walletApiKey, + }); + + console.log(`✅ Imported + stored delegation for ${data.publicKey} (${data.chain})`); + console.log(" Run `pnpm agent` to spend from it."); +} + +main().catch((err) => { + console.error("❌ Import failed:", err instanceof Error ? err.message : err); + process.exitCode = 1; +}); diff --git a/examples/nextjs-agentic-payments-x402/agent/pay-for-service.ts b/examples/nextjs-agentic-payments-x402/agent/pay-for-service.ts new file mode 100644 index 0000000..307b03b --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/agent/pay-for-service.ts @@ -0,0 +1,122 @@ +/** + * The agent — runs server-side, no UI, no human in the loop. + * + * Given a user who has funded and authorized their account on the website, the + * agent: + * 1. loads the user's delegated wallet credentials from Supabase (decrypting them), + * 2. checks the spendable USD balance, + * 3. if empty, points the user to the funding page and stops, + * 4. otherwise pays an x402-protected "cloud service" — a gasless USDC payment + * signed inside Dynamic's MPC — and uses the result. + * + * Everything is logged in USD. Run with: pnpm agent + */ +import "dotenv/config"; +import { createPublicClient, http } from "viem"; +import { wrapFetchWithPayment } from "x402-fetch"; +import { + ERC20_BALANCE_ABI, + USDC_ADDRESS, + VIEM_CHAIN, + RPC_URL, + DELEGATION_CHAIN, + formatUsd, +} from "../lib/shared/constants"; +import { + getDelegationByAddress, + getDelegationByCode, + type DelegationRecord, +} from "../lib/shared/delegation-store"; +import { createDynamicX402Account } from "../lib/shared/x402-account"; + +const SERVICE_URL = + process.env.X402_SERVICE_URL ?? + "http://localhost:3000/api/services/azure-compute"; +const FUNDING_URL = process.env.FUNDING_URL ?? "http://localhost:3000"; +const PRICE_USD_BASE_UNITS = BigInt(10_000); // $0.01 in USDC (6 decimals) + +async function loadDelegation(): Promise { + // Production: the agent acts on a specific user's wallet, identified by the + // short account code (or address) — resolved from the webhook-populated store. + // pnpm agent (e.g. the code shown on the funding page) + // pnpm agent <0xWalletAddress> (or set AGENT_ACCOUNT) + const selector = + process.env.AGENT_ACCOUNT ?? process.argv[2] ?? process.env.AGENT_WALLET_ADDRESS; + if (!selector) { + throw new Error( + "Specify which account to act for: `pnpm agent ` " + + "(or set AGENT_ACCOUNT). The agent resolves it to the user's delegated " + + "wallet from the store the webhook populates." + ); + } + + const delegation = selector.startsWith("0x") + ? await getDelegationByAddress(selector, DELEGATION_CHAIN) + : await getDelegationByCode(selector, DELEGATION_CHAIN); + + if (!delegation) { + throw new Error( + `No delegation found for "${selector}". Has the user authorized the agent ` + + "(and did the Dynamic webhook store it)?" + ); + } + return delegation; +} + +async function getBalanceBaseUnits(address: string): Promise { + const client = createPublicClient({ + chain: VIEM_CHAIN, + transport: http(RPC_URL), + }); + return client.readContract({ + address: USDC_ADDRESS, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: [address as `0x${string}`], + }); +} + +async function main() { + console.log("🤖 Agent starting…\n"); + + const delegation = await loadDelegation(); + console.log(`Account ${delegation.code} → wallet ${delegation.address}`); + + // 1. Check funds + const balance = await getBalanceBaseUnits(delegation.address); + console.log(`Balance: $${formatUsd(balance)}`); + + if (balance < PRICE_USD_BASE_UNITS) { + console.log( + `\n⚠️ Not enough funds to pay for the service ($${formatUsd( + PRICE_USD_BASE_UNITS + )}).` + ); + console.log(`👉 Ask the user to add funds at: ${FUNDING_URL}\n`); + return; + } + + // 2. Build an x402 signer backed by the delegated MPC wallet (gasless EIP-3009). + const account = createDynamicX402Account(delegation); + const fetchWithPayment = wrapFetchWithPayment(fetch, account); + + // 3. Pay for the service. x402 handles the 402 → sign → retry handshake. + console.log(`\n💳 Paying for service: ${SERVICE_URL}`); + const res = await fetchWithPayment(SERVICE_URL, { method: "GET" }); + + if (!res.ok) { + console.error(`Service call failed: HTTP ${res.status}`); + console.error(await res.text()); + process.exitCode = 1; + return; + } + + const result = await res.json(); + console.log("\n✅ Service delivered (paid $0.01):"); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((err) => { + console.error("\n❌ Agent error:", err instanceof Error ? err.message : err); + process.exitCode = 1; +}); diff --git a/examples/nextjs-agentic-payments-x402/app/api/account/route.ts b/examples/nextjs-agentic-payments-x402/app/api/account/route.ts new file mode 100644 index 0000000..6c13d54 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/api/account/route.ts @@ -0,0 +1,31 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { deriveAccountCode } from "@/lib/shared/delegation-store"; +import { DELEGATION_CHAIN } from "@/lib/shared/constants"; +import { getDelegationByAddress } from "@/lib/shared/delegation-store"; + +/** + * Returns the short account code for a wallet address — shown to the user so an + * operator/system can tell the agent which account to act for (`pnpm agent `). + * + * The code is deterministic from the address, so we can return it immediately; + * `delegated` reflects whether the authorize step has stored credentials yet. + * The code is not a secret (it doesn't grant access to funds — signing creds + * stay encrypted server-side), so a public address lookup is acceptable here. + */ +export async function GET(request: NextRequest) { + const address = request.nextUrl.searchParams.get("address"); + if (!address || !isAddress(address)) { + return NextResponse.json( + { error: "A valid `address` query param is required" }, + { status: 400 } + ); + } + + const delegation = await getDelegationByAddress(address, DELEGATION_CHAIN); + return NextResponse.json({ + address, + code: deriveAccountCode(address), + delegated: Boolean(delegation), + }); +} diff --git a/examples/nextjs-agentic-payments-x402/app/api/balance/route.ts b/examples/nextjs-agentic-payments-x402/app/api/balance/route.ts new file mode 100644 index 0000000..bcebb42 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/api/balance/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createPublicClient, http, isAddress } from "viem"; +import { + ERC20_BALANCE_ABI, + RPC_URL, + USDC_ADDRESS, + VIEM_CHAIN, + formatUsd, +} from "@/lib/shared/constants"; + +/** + * Returns a wallet's spendable balance as plain USD. + * + * Reads the on-chain USDC balance on the configured network (Base mainnet by + * default) and formats it as dollars — the UI never mentions tokens or chains. + * On-chain balances are public, so no auth is required. + */ +export async function GET(request: NextRequest) { + const address = request.nextUrl.searchParams.get("address"); + if (!address || !isAddress(address)) { + return NextResponse.json( + { error: "A valid `address` query param is required" }, + { status: 400 } + ); + } + + const client = createPublicClient({ + chain: VIEM_CHAIN, + transport: http(RPC_URL), + }); + + try { + const balance = await client.readContract({ + address: USDC_ADDRESS, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: [address as `0x${string}`], + }); + return NextResponse.json({ address, usd: formatUsd(balance) }); + } catch (err) { + // Log details server-side; return a generic message so RPC/infra details + // aren't leaked to the client. + console.error("Balance read failed:", err); + return NextResponse.json({ error: "Failed to read balance" }, { status: 502 }); + } +} diff --git a/examples/nextjs-agentic-payments-x402/app/api/services/azure-compute/route.ts b/examples/nextjs-agentic-payments-x402/app/api/services/azure-compute/route.ts new file mode 100644 index 0000000..37cb511 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/api/services/azure-compute/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +/** + * Sample paid "cloud service" — stands in for an Azure service the agent buys. + * + * The x402 middleware (see /middleware.ts) gates this route: a request without a + * valid payment gets HTTP 402 + payment requirements; once the caller attaches a + * settled USDC payment, the request reaches this handler and we return the + * "provisioned" resource. The handler itself contains no crypto — it just serves + * the product the user paid for, framed in plain USD. + */ +export async function GET() { + const provisionedAt = new Date().toISOString(); + return NextResponse.json({ + status: "provisioned", + service: "Azure-style compute unit", + resourceId: `vm-${Math.random().toString(36).slice(2, 10)}`, + region: "eastus", + spec: { vcpus: 2, memoryGb: 8 }, + priceUsd: "0.01", + provisionedAt, + message: "Compute unit provisioned. Charged $0.01 to your account.", + }); +} diff --git a/examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/handler.ts b/examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/handler.ts new file mode 100644 index 0000000..f53e781 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/handler.ts @@ -0,0 +1,83 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + handleDelegationCreated, + handleDelegationRevoked, + handlePing, + verifyWebhookSignature, + WebhookPayloadSchema, +} from "@/lib/dynamic/webhooks"; + +/** + * Webhook endpoint for Dynamic events + * + * This endpoint receives webhooks from Dynamic and processes them securely: + * 1. Verifies the webhook signature to ensure authenticity + * 2. Validates the payload structure using Zod schemas + * 3. Routes to appropriate handlers based on event type + * + * Configure this URL in your Dynamic dashboard: + * https://your-domain.com/api/webhooks/dynamic + * + * Supported events: + * - ping: Health check event + * - wallet.delegation.created: Fired when a delegation is created + * - wallet.delegation.revoked: Fired when a delegation is revoked + */ +export async function handleWebhookRequest(request: NextRequest) { + // Step 1: Verify the signature and extract payload + // This ensures the webhook is authentic and from Dynamic + const verificationResult = await verifyWebhookSignature(request); + + if (!verificationResult.success) { + return NextResponse.json( + { error: verificationResult.error }, + { status: verificationResult.status } + ); + } + + // Step 2: Validate payload structure with Zod + // This ensures type safety and catches malformed payloads early + const validationResult = WebhookPayloadSchema.safeParse( + verificationResult.payload + ); + + if (!validationResult.success) { + console.error("Invalid payload structure:", validationResult.error.issues); + return NextResponse.json( + { + error: "Invalid payload structure", + details: validationResult.error.issues, + }, + { status: 400 } + ); + } + + // Step 3: Route to appropriate handler based on event type + // Add new event handlers here as you support more webhook events + let result: { success: boolean; message: string }; + + const payload = validationResult.data; + switch (payload.eventName) { + case "ping": + result = await handlePing(payload); + break; + case "wallet.delegation.created": + result = await handleDelegationCreated(payload); + break; + case "wallet.delegation.revoked": + result = await handleDelegationRevoked(payload); + break; + + default: + // TypeScript ensures this switch is exhaustive + // If you add a new event type to the schema, TypeScript will error here + // until you add a corresponding case + return NextResponse.json( + { error: "Unexpected event type" }, + { status: 400 } + ); + } + + return NextResponse.json(result, { status: 200 }); +} diff --git a/examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/route.ts b/examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/route.ts new file mode 100644 index 0000000..70727c1 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/api/webhooks/dynamic/route.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { handleWebhookRequest } from "./handler"; + +/** + * POST handler for Dynamic webhooks + * + * Wraps the webhook handler in error handling to ensure + * all errors are caught and returned as proper HTTP responses + */ +export async function POST(request: NextRequest) { + try { + return await handleWebhookRequest(request); + } catch (error) { + console.error("Error processing webhook:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/examples/nextjs-agentic-payments-x402/app/globals.css b/examples/nextjs-agentic-payments-x402/app/globals.css new file mode 100644 index 0000000..93d5119 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/globals.css @@ -0,0 +1,124 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-dynamic: var(--dynamic); + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --dynamic: #4679fe; + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/examples/nextjs-agentic-payments-x402/app/layout.tsx b/examples/nextjs-agentic-payments-x402/app/layout.tsx new file mode 100644 index 0000000..cff79e0 --- /dev/null +++ b/examples/nextjs-agentic-payments-x402/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import Footer from "@/components/footer"; +import Header from "@/components/header"; +import Providers from "@/lib/providers"; + +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Agent Wallet — Fund & Pay", + description: + "Create an agent account, add funds, and let your agent pay for services automatically. Powered by Dynamic + x402.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+
+ {children} +
+