Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions examples/nextjs-agentic-payments-x402/.env.example
Original file line number Diff line number Diff line change
@@ -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 → <your-url>/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 <accountCode>`)
# 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
38 changes: 38 additions & 0 deletions examples/nextjs-agentic-payments-x402/.gitignore
Original file line number Diff line number Diff line change
@@ -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/
4 changes: 4 additions & 0 deletions examples/nextjs-agentic-payments-x402/.vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
.env.*
node_modules
.next
143 changes: 143 additions & 0 deletions examples/nextjs-agentic-payments-x402/README.md
Original file line number Diff line number Diff line change
@@ -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 <accountCode> ──────────────────────────────────────┘
│ 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 <code>`) — 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://<your-domain>/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 <accountCode> # 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://<your-vercel-domain>/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.
83 changes: 83 additions & 0 deletions examples/nextjs-agentic-payments-x402/agent/import-delegation.ts
Original file line number Diff line number Diff line change
@@ -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 <path-to-payload.json>
*
* 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 <payload.json>");

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;
});
Loading