diff --git a/README.md b/README.md index f2a4a28..8b23702 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Authored by:** Chris Wajule and Simon Goldberg -This solution demonstrates pay-per-use AI content generation using the x402 payment protocol. Users pay with USDC on Base Sepolia to access Amazon Nova 2 Lite for text and Amazon Nova Canvas for image generation. Two payment architectures are included: the serverless flow where users sign payments via browser wallets, and the agentic flow using the Strands agent powered by Amazon Bedrock AgentCore with CDP AgentKit, both utilizing the x402.org facilitator to verify signatures and settle payments on-chain via EIP-3009 transferWithAuthorization. +This solution demonstrates pay-per-use AI content generation using the x402 payment protocol (v2). Users pay with USDC on Base Sepolia to access Amazon Nova 2 Lite for text and Amazon Nova Canvas for image generation. Two payment architectures are included: the serverless flow where users sign payments via browser wallets, and the agentic flow using the Strands agent powered by Amazon Bedrock AgentCore with CDP AgentKit, both utilizing the x402.org facilitator to verify signatures and settle payments on-chain via EIP-3009 transferWithAuthorization. ## Table of Contents @@ -41,7 +41,7 @@ The numbers in the following flow correspond to the serverless stablecoin paymen 5. **Payment Authorization:** The frontend is hosted on AWS Amplify and displays a payment modal with the cost preview. The user confirms and the application generates an `EIP-712` typed data signature using the connected wallet. The wallet prompts the user to sign the message. The signature authorizes the USDC transfer with validity timestamps and a unique nonce. -6. **Payment Submission:** The frontend retries the `/generate` request with the `EIP-712` signature in the `PAYMENT-SIGNATURE` header. The request includes the authorization object (from, to, value, validAfter, validBefore, nonce) and signature. +6. **Payment Submission:** The frontend retries the `/generate` request with the `EIP-712` signature in the `PAYMENT-SIGNATURE` header. The Base64-encoded payload uses the x402 v2 shape `{ x402Version: 2, payload: { signature, authorization }, accepted }`, where the authorization object holds (from, to, value, validAfter, validBefore, nonce) and `accepted` echoes the chosen payment requirements. 7. **Payment Verification:** The AWS Lambda function sends the payment payload to the x402.org facilitator's `/verify` endpoint. The facilitator validates the `EIP-712` signature against the USDC contract domain on Base Sepolia. @@ -373,19 +373,33 @@ Agent: 🎉 Success! Your futuristic city at sunset image has been generated suc } ``` -**Response (402 Payment Required):** +**Response (402 Payment Required):** (x402 v2 wire format) ```json { - "scheme": "exact", - "network": "base-sepolia", - "maxAmountRequired": "192", - "payTo": "0x...", - "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - "description": "AI content generation with nova-llm" + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "192", + "payTo": "0x...", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "maxTimeoutSeconds": 300, + "extra": { "name": "USDC", "version": "2" } + } + ], + "resource": { + "url": "https://.../generate", + "description": "AI content generation with nova-llm", + "mimeType": "application/json" + }, + "error": "Payment required" } ``` +> x402 v2 changes from v1: `x402Version` is `2`, `network` uses the CAIP-2 form (`eip155:84532` for Base Sepolia), the amount field is `amount` (was `maxAmountRequired`), and `resource`/`description`/`mimeType` are hoisted to a top-level `resource` object. The same requirements are also returned Base64-encoded in the `PAYMENT-REQUIRED` response header. + **Response (200 Success):** ```json @@ -544,7 +558,7 @@ rm -rf node_modules/ dist/ serverless/node_modules/ serverless/cdk.out/ serverle - Verify transaction hash on [BaseScan Sepolia](https://sepolia.basescan.org) 10. **Gateway Returns 402 After Payment:** - - Check that the x402 client is using the correct network (base-sepolia) + - Check that the x402 client is using the correct network (CAIP-2 `eip155:84532` for Base Sepolia in x402 v2) - Verify the USDC contract address matches - Ensure the payment amount matches the required amount diff --git a/agentic/.env-sample b/agentic/.env-sample index 7f3c323..8fa9c1b 100644 --- a/agentic/.env-sample +++ b/agentic/.env-sample @@ -6,6 +6,8 @@ CDP_API_KEY_SECRET=your_api_key_secret CDP_WALLET_SECRET=your_wallet_secret # Network Configuration +# NETWORK_ID uses the human-readable name; the x402 v2 client maps it to the +# CAIP-2 form on the wire (base-sepolia -> eip155:84532, base -> eip155:8453). NETWORK_ID=base-sepolia RPC_URL=https://sepolia.base.org USDC_CONTRACT=0x036CbD53842c5426634e7929541eC2318f3dCF7e diff --git a/agentic/README.md b/agentic/README.md index 8d37cf4..ff93467 100644 --- a/agentic/README.md +++ b/agentic/README.md @@ -164,14 +164,22 @@ Agent: 🎉 Success! Your futuristic city at sunset image has been generated suc ```json { - "x402Version": 1, + "x402Version": 2, "accepts": [{ "scheme": "exact", - "network": "base-sepolia", - "maxAmountRequired": "40000", + "network": "eip155:84532", + "amount": "40000", "payTo": "0x...", - "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - }] + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "maxTimeoutSeconds": 300, + "extra": { "name": "USDC", "version": "2" } + }], + "resource": { + "url": "https://.../generate_image", + "description": "AI image generation with Nova Canvas", + "mimeType": "application/json" + }, + "error": "Payment required" } ``` @@ -241,7 +249,7 @@ cdk diff - Review CloudWatch logs for specific error messages 5. **Gateway Returns 402 After Payment:** - - Check that the x402 client is using the correct network (base-sepolia) + - Check that the x402 client is using the correct network (CAIP-2 `eip155:84532` for Base Sepolia in x402 v2) - Verify the USDC contract address matches - Ensure the payment amount matches the required amount diff --git a/agentic/lambda/seller.js b/agentic/lambda/seller.js index 4691771..6d73c8a 100644 --- a/agentic/lambda/seller.js +++ b/agentic/lambda/seller.js @@ -4,14 +4,36 @@ import https from 'https'; const app = new Hono(); -// x402 Configuration +// x402 v2 Configuration +// Use the canonical facilitator host (www.x402.org). The apex x402.org 308-redirects +// every request to www, so pointing here avoids an extra round-trip per verify/settle. const X402_CONFIG = { - facilitatorUrl: 'https://x402.org/facilitator', + facilitatorUrl: 'https://www.x402.org/facilitator', usdcBase: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', - network: 'base-sepolia', + network: 'eip155:84532', // Base Sepolia in CAIP-2 form (required by x402 v2) scheme: 'exact' }; +const X402_VERSION = 2; + +// Build the v2 PaymentRequirements the seller expects for a given authorized amount. +// `accepted` (sent inside the payment payload to the facilitator) is the same object +// without the EIP-712 `extra` metadata. +const buildPaymentRequirements = (amount, resourceUrl) => ({ + scheme: X402_CONFIG.scheme, + network: X402_CONFIG.network, + amount: String(amount), + asset: X402_CONFIG.usdcBase, + payTo: process.env.SELLER_WALLET, + maxTimeoutSeconds: 300, + extra: { name: 'USDC', version: '2' } +}); + +const toAccepted = (paymentRequirements) => { + const { extra, ...accepted } = paymentRequirements; + return accepted; +}; + // Idempotency cache - for production, persist to DynamoDB for multi-instance scalability const processedPayments = new Map(); @@ -19,7 +41,7 @@ const processedPayments = new Map(); app.use('*', async (c, next) => { c.header('Access-Control-Allow-Origin', '*'); c.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-PAYMENT, PAYMENT-SIGNATURE'); + c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, PAYMENT-SIGNATURE'); c.header('Access-Control-Expose-Headers', 'PAYMENT-REQUIRED, PAYMENT-RESPONSE'); if (c.req.method === 'OPTIONS') { @@ -56,19 +78,18 @@ const generateCDPJWT = async (requestMethod, requestPath) => { }; */ -// Verify payment with x402.org facilitator +// Verify payment with x402.org facilitator (x402 v2 wire format) const verifyPayment = async (paymentPayload, paymentRequirements) => { const requestBody = { - x402Version: 1, + x402Version: X402_VERSION, paymentPayload: { - x402Version: 1, - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, + x402Version: X402_VERSION, + accepted: toAccepted(paymentRequirements), payload: paymentPayload }, paymentRequirements }; - + console.log('=== VERIFY REQUEST ==='); const bodyString = JSON.stringify(requestBody); @@ -111,23 +132,22 @@ const verifyPayment = async (paymentPayload, paymentRequirements) => { req.end(); }; - makeRequest('https://x402.org/facilitator/verify'); + makeRequest(`${X402_CONFIG.facilitatorUrl}/verify`); }); }; -// Settle payment with x402.org facilitator +// Settle payment with x402.org facilitator (x402 v2 wire format) const settlePayment = async (paymentPayload, paymentRequirements) => { const requestBody = { - x402Version: 1, + x402Version: X402_VERSION, paymentPayload: { - x402Version: 1, - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, + x402Version: X402_VERSION, + accepted: toAccepted(paymentRequirements), payload: paymentPayload }, paymentRequirements }; - + console.log('=== SETTLE REQUEST ==='); const bodyString = JSON.stringify(requestBody); @@ -168,7 +188,7 @@ const settlePayment = async (paymentPayload, paymentRequirements) => { req.end(); }; - makeRequest('https://x402.org/facilitator/settle'); + makeRequest(`${X402_CONFIG.facilitatorUrl}/settle`); }); }; @@ -181,35 +201,27 @@ app.use('/generate_image', async (c, next) => { // Use provided price or default estimate (in USDC wei) const estimatedCost = price || '20000'; // ~$0.02 default - // Check for PAYMENT-SIGNATURE header (x402 v2 standard) or X-PAYMENT (legacy) - const paymentHeader = c.req.header('PAYMENT-SIGNATURE') || c.req.header('X-PAYMENT'); - + // x402 v2: client sends the signed payment in the PAYMENT-SIGNATURE header. + const paymentHeader = c.req.header('PAYMENT-SIGNATURE'); + const resourceUrl = `${(process.env.GATEWAY_URL || 'https://example.com').replace(/\/$/, '')}/generate_image`; + if (!paymentHeader) { - const sellerWallet = process.env.SELLER_WALLET; - const paymentRequirements = { - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, - maxAmountRequired: String(estimatedCost), - resource: `${(process.env.GATEWAY_URL || 'https://example.com').replace(/\/$/, '')}/generate_image`, - description: 'AI image generation with Nova Canvas', - mimeType: 'application/json', - outputSchema: { status: 'string', request_id: 'string', message: 'string' }, - payTo: sellerWallet, - asset: X402_CONFIG.usdcBase, - maxTimeoutSeconds: 300, - extra: { - name: 'USDC', - version: '2', - chainId: 84532 - } - }; + const paymentRequirements = buildPaymentRequirements(estimatedCost, resourceUrl); + // x402 v2: resource metadata is hoisted to the top level of the 402 body. const x402Response = { - x402Version: 1, + x402Version: X402_VERSION, accepts: [paymentRequirements], + resource: { + url: resourceUrl, + description: 'AI image generation with Nova Canvas', + mimeType: 'application/json' + }, error: 'Payment required' }; console.log('=== 402 RESPONSE ==='); console.log(JSON.stringify(x402Response, null, 2)); + // x402 v2: also surface requirements in the PAYMENT-REQUIRED header (base64 JSON). + c.header('PAYMENT-REQUIRED', Buffer.from(JSON.stringify(x402Response)).toString('base64')); return c.json(x402Response, 402); } @@ -225,45 +237,29 @@ app.use('/generate_image', async (c, next) => { return c.json({ error: 'Invalid payment payload' }, 400); } - // Extract authorization (handle both formats) - const authorization = paymentPayload.payload?.authorization || paymentPayload.authorization; + // x402 v2 payload shape: { x402Version, payload: { signature, authorization }, accepted } + const authorization = paymentPayload.payload?.authorization; const authorizedValue = authorization?.value; if (!authorizedValue) { console.log('Missing authorization value'); return c.json({ error: 'Missing authorization value' }, 400); } - + // Idempotency check using nonce const nonce = authorization?.nonce; if (nonce && processedPayments.has(nonce)) { return c.json({ error: 'Payment already processed' }, 409); } - - // Create payment requirements using EXACT value from authorization - const sellerWallet = process.env.SELLER_WALLET; - const paymentRequirements = { - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, - maxAmountRequired: authorizedValue, - resource: `${(process.env.GATEWAY_URL || 'https://example.com').replace(/\/$/, '')}/generate_image`, - description: 'AI image generation with Nova Canvas', - mimeType: 'application/json', - outputSchema: { status: 'string', request_id: 'string', message: 'string' }, - payTo: sellerWallet, - asset: X402_CONFIG.usdcBase, - maxTimeoutSeconds: 300, - extra: { - name: 'USDC', - version: '2', - chainId: 84532 - } - }; - + + // Rebuild payment requirements using the EXACT value from the authorization (do not + // trust the client's `accepted` copy — the seller is the source of truth). + const paymentRequirements = buildPaymentRequirements(authorizedValue, resourceUrl); + console.log('=== PAYMENT VERIFICATION ==='); console.log('Payment requirements:', JSON.stringify(paymentRequirements, null, 2)); console.log('Payment payload from client:', JSON.stringify(paymentPayload, null, 2)); console.log('Authorization value:', authorizedValue); - console.log('Seller wallet:', sellerWallet); + console.log('Seller wallet:', process.env.SELLER_WALLET); console.log('Asset (USDC):', X402_CONFIG.usdcBase); // Verify payment with x402.org facilitator (do NOT settle yet - settle after content delivery per x402 spec) diff --git a/agentic/requirements.txt b/agentic/requirements.txt index 5e3d075..742e192 100644 --- a/agentic/requirements.txt +++ b/agentic/requirements.txt @@ -6,6 +6,6 @@ coinbase-agentkit==0.7.4 requests python-dotenv web3>=6.0.0 -x402>=0.1.0,<1.0 +x402[httpx,evm]>=2.0.0,<3.0 httpx bedrock-agentcore diff --git a/agentic/wallet.py b/agentic/wallet.py index 4681843..489d193 100644 --- a/agentic/wallet.py +++ b/agentic/wallet.py @@ -8,7 +8,6 @@ ) from web3_provider import get_web3 from web3 import Web3 -from x402.clients.httpx import x402HttpxClient from dotenv import load_dotenv load_dotenv() @@ -40,15 +39,18 @@ } ] -class _SignedMessageAdapter: - """Minimal adapter for x402's expected signed message shape.""" +# Map human-readable network names to CAIP-2 identifiers required by x402 v2. +_CAIP2_BY_NAME = { + 'base-sepolia': 'eip155:84532', + 'base': 'eip155:8453', +} - def __init__(self, signature_hex: str): - if not isinstance(signature_hex, str): - raise ValueError("Expected hex string signature from CDP wallet signer") - normalized = signature_hex[2:] if signature_hex.startswith("0x") else signature_hex - self.signature = bytes.fromhex(normalized) +def to_caip2(network_id: str) -> str: + """Return the CAIP-2 form of a network id (pass through if already CAIP-2).""" + if network_id and ':' in network_id: + return network_id + return _CAIP2_BY_NAME.get(network_id, network_id) def _normalize_typed_data_values(value): @@ -62,24 +64,26 @@ def _normalize_typed_data_values(value): return value -class _CdpWalletAccountAdapter: - """Adapter so legacy x402 client can sign via CDP without key export.""" +class _CdpWalletSigner: + """Implements the x402 v2 ClientEvmSigner protocol, signing via CDP without key export. + + x402 v2 calls sign_typed_data(domain, types, primary_type, message) and expects the + raw 65-byte ECDSA signature back (vs the v1 3-arg call returning a .signature object). + """ def __init__(self, wallet): if not hasattr(wallet, "sign_typed_data"): raise ValueError("Wallet does not support sign_typed_data required by x402") self._wallet = wallet - self.address = wallet.get_address() + self._address = wallet.get_address() - def sign_typed_data(self, domain_data, message_types, message_data): - primary_types = [name for name in message_types if name != "EIP712Domain"] - if len(primary_types) != 1: - raise ValueError( - f"Unable to determine EIP-712 primary type from message types: {list(message_types.keys())}" - ) + @property + def address(self) -> str: + return self._address - # CDP requires EIP712Domain in types - types_with_domain = dict(message_types) + def sign_typed_data(self, domain, types, primary_type, message) -> bytes: + # CDP requires EIP712Domain to be present in the types map. + types_with_domain = dict(types) if "EIP712Domain" not in types_with_domain: types_with_domain["EIP712Domain"] = [ {"name": "name", "type": "string"}, @@ -89,40 +93,40 @@ def sign_typed_data(self, domain_data, message_types, message_data): ] typed_data = { - "domain": domain_data, + "domain": domain, "types": types_with_domain, - "primaryType": primary_types[0], - "message": _normalize_typed_data_values(message_data), + "primaryType": primary_type, + "message": _normalize_typed_data_values(message), } - signature = self._wallet.sign_typed_data(typed_data) - return _SignedMessageAdapter(signature) + signature_hex = self._wallet.sign_typed_data(typed_data) + if not isinstance(signature_hex, str): + raise ValueError("Expected hex string signature from CDP wallet signer") + normalized = signature_hex[2:] if signature_hex.startswith("0x") else signature_hex + return bytes.fromhex(normalized) def get_x402_httpx_client(wallet, base_url: str): - """Create x402 HTTP client using CDP-managed signing (no key export).""" - from x402.clients.base import x402Client + """Create an x402 v2 HTTP client using CDP-managed signing (no key export).""" + from x402 import x402Client, prefer_network, prefer_scheme + from x402.http.clients import x402HttpxClient + from x402.mechanisms.evm.exact import ExactEvmScheme + + network = to_caip2(os.getenv('NETWORK_ID', 'base-sepolia')) try: - account = _CdpWalletAccountAdapter(wallet) - logger.info('Using CDP wallet signer for x402 (private key remains in CDP)') + signer = _CdpWalletSigner(wallet) + logger.info('Using CDP wallet signer for x402 v2 (private key remains in CDP)') except Exception as e: logger.error(f'Failed to configure CDP wallet signer: {e}') raise ValueError(f'Failed to configure CDP wallet signing for x402: {e}') from e - - def payment_selector(accepts, network_filter=None, scheme_filter=None, max_value=None): - return x402Client.default_payment_requirements_selector( - accepts, - network_filter=os.getenv('NETWORK_ID', 'base-sepolia'), - scheme_filter=scheme_filter, - max_value=max_value - ) - - return x402HttpxClient( - account=account, - base_url=base_url, - payment_requirements_selector=payment_selector - ) + + client = x402Client() + client.register("eip155:*", ExactEvmScheme(signer=signer)) + client.register_policy(prefer_network(network)) + client.register_policy(prefer_scheme("exact")) + + return x402HttpxClient(client, base_url=base_url) _agentkit = None diff --git a/serverless/README.md b/serverless/README.md index 2e2e1cc..6f7d542 100644 --- a/serverless/README.md +++ b/serverless/README.md @@ -84,16 +84,26 @@ The numbers in the following flow correspond to the serverless stablecoin paymen } ``` -**Response (402 Payment Required):** +**Response (402 Payment Required):** (x402 v2 wire format) ```json { - "scheme": "exact", - "network": "base-sepolia", - "maxAmountRequired": "192", - "payTo": "0x...", - "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - "description": "AI content generation with nova-llm" + "x402Version": 2, + "accepts": [{ + "scheme": "exact", + "network": "eip155:84532", + "amount": "192", + "payTo": "0x...", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "maxTimeoutSeconds": 300, + "extra": { "name": "USDC", "version": "2" } + }], + "resource": { + "url": "https://.../generate", + "description": "AI content generation with nova-llm", + "mimeType": "application/json" + }, + "error": "Payment required" } ``` diff --git a/serverless/lambda/seller/seller.js b/serverless/lambda/seller/seller.js index ef11d2c..2eaaf4d 100644 --- a/serverless/lambda/seller/seller.js +++ b/serverless/lambda/seller/seller.js @@ -8,14 +8,37 @@ const https = require('https'); const app = new Hono(); const lambdaClient = new LambdaClient({ region: process.env.AWS_REGION }); -// x402 Configuration +// x402 v2 Configuration +// Use the canonical facilitator host (www.x402.org). The apex x402.org 308-redirects +// every request to www, so pointing here avoids an extra round-trip per verify/settle. const X402_CONFIG = { - facilitatorUrl: 'https://x402.org/facilitator', + facilitatorHost: 'www.x402.org', + facilitatorBasePath: '/facilitator', usdcBase: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', - network: 'base-sepolia', + network: 'eip155:84532', // Base Sepolia in CAIP-2 form (required by x402 v2) scheme: 'exact' }; +const X402_VERSION = 2; + +// Build the v2 PaymentRequirements the seller expects for a given authorized amount. +const buildPaymentRequirements = (amount) => ({ + scheme: X402_CONFIG.scheme, + network: X402_CONFIG.network, + amount: String(amount), + asset: X402_CONFIG.usdcBase, + payTo: process.env.SELLER_WALLET_ADDRESS, + maxTimeoutSeconds: 300, + extra: { name: 'USDC', version: '2' } +}); + +// `accepted` (sent inside the payment payload to the facilitator) mirrors the requirements +// without the EIP-712 `extra` metadata. +const toAccepted = (paymentRequirements) => { + const { extra, ...accepted } = paymentRequirements; + return accepted; +}; + // Idempotency cache - for production, persist to DynamoDB for multi-instance scalability const processedPayments = new Map(); @@ -23,7 +46,7 @@ const processedPayments = new Map(); app.use('*', async (c, next) => { c.header('Access-Control-Allow-Origin', '*'); c.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-PAYMENT, PAYMENT-SIGNATURE'); + c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, PAYMENT-SIGNATURE'); c.header('Access-Control-Expose-Headers', 'PAYMENT-REQUIRED, PAYMENT-RESPONSE'); if (c.req.method === 'OPTIONS') { @@ -77,29 +100,28 @@ const getEstimateFromLambda = async (content, model) => { return body.totalCost; }; -// Verify payment with x402.org facilitator +// Verify payment with x402.org facilitator (x402 v2 wire format) const verifyPayment = async (paymentPayload, paymentRequirements) => { const requestBody = { - x402Version: 1, + x402Version: X402_VERSION, paymentPayload: { - x402Version: 1, - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, + x402Version: X402_VERSION, + accepted: toAccepted(paymentRequirements), payload: paymentPayload }, paymentRequirements }; - + console.log('=== VERIFY REQUEST ==='); - console.log('URL: https://x402.org/facilitator/verify'); - + console.log(`URL: https://${X402_CONFIG.facilitatorHost}${X402_CONFIG.facilitatorBasePath}/verify`); + const bodyString = JSON.stringify(requestBody); - + return new Promise((resolve, reject) => { const options = { - hostname: 'x402.org', + hostname: X402_CONFIG.facilitatorHost, port: 443, - path: '/facilitator/verify', + path: `${X402_CONFIG.facilitatorBasePath}/verify`, method: 'POST', headers: { 'Content-Type': 'application/json', @@ -163,29 +185,28 @@ const verifyPayment = async (paymentPayload, paymentRequirements) => { }); }; -// Settle payment with x402.org facilitator +// Settle payment with x402.org facilitator (x402 v2 wire format) const settlePayment = async (paymentPayload, paymentRequirements) => { const requestBody = { - x402Version: 1, + x402Version: X402_VERSION, paymentPayload: { - x402Version: 1, - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, + x402Version: X402_VERSION, + accepted: toAccepted(paymentRequirements), payload: paymentPayload }, paymentRequirements }; - + console.log('=== SETTLE REQUEST ==='); - console.log('URL: https://x402.org/facilitator/settle'); - + console.log(`URL: https://${X402_CONFIG.facilitatorHost}${X402_CONFIG.facilitatorBasePath}/settle`); + const bodyString = JSON.stringify(requestBody); - + return new Promise((resolve, reject) => { const options = { - hostname: 'x402.org', + hostname: X402_CONFIG.facilitatorHost, port: 443, - path: '/facilitator/settle', + path: `${X402_CONFIG.facilitatorBasePath}/settle`, method: 'POST', headers: { 'Content-Type': 'application/json', @@ -272,85 +293,64 @@ app.use('/generate', async (c, next) => { // Only estimate if price not provided const estimatedCost = price || await getEstimateFromLambda(content, model); - // Check for PAYMENT-SIGNATURE header (x402 v2) or X-PAYMENT (v1 fallback) + // x402 v2: client sends the signed payment in the PAYMENT-SIGNATURE header. const paymentSignature = c.req.header('PAYMENT-SIGNATURE'); - const legacyPayment = c.req.header('X-PAYMENT'); - const paymentHeader = paymentSignature || legacyPayment; - - if (!paymentHeader) { - const publicWallet = process.env.SELLER_WALLET_ADDRESS; - const paymentRequirements = { - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, - maxAmountRequired: String(estimatedCost), - resource: `${process.env.API_GATEWAY_HTTP_URL}/generate`, - description: `AI content generation with ${model}`, - mimeType: 'application/json', - outputSchema: { content: 'string', model: 'string' }, - payTo: publicWallet, - asset: X402_CONFIG.usdcBase, - maxTimeoutSeconds: 300 + const resourceUrl = `${process.env.API_GATEWAY_HTTP_URL}/generate`; + + if (!paymentSignature) { + const paymentRequirements = buildPaymentRequirements(estimatedCost); + // x402 v2: resource metadata is hoisted to the top level of the 402 body. + const x402Response = { + x402Version: X402_VERSION, + accepts: [paymentRequirements], + resource: { + url: resourceUrl, + description: `AI content generation with ${model}`, + mimeType: 'application/json' + }, + error: 'Payment required' }; - console.log('=== 402 RESPONSE PAYMENT REQUIREMENTS ==='); - console.log(JSON.stringify(paymentRequirements, null, 2)); - // x402 spec: PAYMENT-REQUIRED header with Base64-encoded requirements - c.header('PAYMENT-REQUIRED', Buffer.from(JSON.stringify(paymentRequirements)).toString('base64')); - return c.json(paymentRequirements, 402); + console.log('=== 402 RESPONSE ==='); + console.log(JSON.stringify(x402Response, null, 2)); + // x402 v2: PAYMENT-REQUIRED header with Base64-encoded payment requirements. + c.header('PAYMENT-REQUIRED', Buffer.from(JSON.stringify(x402Response)).toString('base64')); + return c.json(x402Response, 402); } - - // Parse payment payload (Base64 for PAYMENT-SIGNATURE, JSON for X-PAYMENT) + + // Parse the Base64-encoded v2 PaymentPayload: { x402Version, payload: { signature, authorization }, accepted } let paymentPayload; try { - if (paymentSignature) { - paymentPayload = JSON.parse(Buffer.from(paymentSignature, 'base64').toString('utf-8')); - } else { - paymentPayload = JSON.parse(paymentHeader); - } + paymentPayload = JSON.parse(Buffer.from(paymentSignature, 'base64').toString('utf-8')); } catch (error) { return c.json({ error: 'Invalid payment payload' }, 400); } - - // Extract value from authorization to ensure consistency - const authorizedValue = paymentPayload.authorization?.value; + + // Extract value from the authorization to ensure consistency + const authorization = paymentPayload.payload?.authorization; + const authorizedValue = authorization?.value; if (!authorizedValue) { return c.json({ error: 'Missing authorization value' }, 400); } - + // Idempotency check using nonce - const nonce = paymentPayload.authorization?.nonce; + const nonce = authorization?.nonce; if (nonce && processedPayments.has(nonce)) { return c.json({ error: 'Payment already processed' }, 409); } - - // Create payment requirements using the EXACT value from authorization - const publicWallet = process.env.SELLER_WALLET_ADDRESS; - const paymentRequirements = { - scheme: X402_CONFIG.scheme, - network: X402_CONFIG.network, - maxAmountRequired: authorizedValue, - resource: `${process.env.API_GATEWAY_HTTP_URL}/generate`, - description: `AI content generation with ${model}`, - mimeType: 'application/json', - outputSchema: { content: 'string', model: 'string' }, - payTo: publicWallet, - asset: X402_CONFIG.usdcBase, - maxTimeoutSeconds: 300, - extra: { - name: 'USDC', - version: '2', - chainId: 84532 - } - }; - + + // Rebuild payment requirements using the EXACT value from the authorization (the seller + // is the source of truth, not the client's `accepted` copy). + const paymentRequirements = buildPaymentRequirements(authorizedValue); + console.log('=== PAYMENT VERIFICATION ==='); console.log('Payment requirements:', JSON.stringify(paymentRequirements, null, 2)); console.log('Payment payload from client:', JSON.stringify(paymentPayload, null, 2)); console.log('Authorization value:', authorizedValue); - console.log('Public wallet:', publicWallet); + console.log('Public wallet:', process.env.SELLER_WALLET_ADDRESS); console.log('Asset (USDC):', X402_CONFIG.usdcBase); - + // Verify payment with facilitator (do NOT settle yet - settle after content delivery per x402 spec) - const verification = await verifyPayment(paymentPayload, paymentRequirements); + const verification = await verifyPayment(paymentPayload.payload, paymentRequirements); if (!verification.isValid) { return c.json({ error: 'Payment verification failed', @@ -358,8 +358,8 @@ app.use('/generate', async (c, next) => { }, 402); } - // Store payment data for post-delivery settlement - c.set('paymentPayload', paymentPayload); + // Store the inner payment payload (signature + authorization) for post-delivery settlement + c.set('paymentPayload', paymentPayload.payload); c.set('paymentRequirements', paymentRequirements); c.set('nonce', nonce); diff --git a/serverless/lib/ai-content-monetization-stack.ts b/serverless/lib/ai-content-monetization-stack.ts index d47be48..e86645b 100644 --- a/serverless/lib/ai-content-monetization-stack.ts +++ b/serverless/lib/ai-content-monetization-stack.ts @@ -85,7 +85,7 @@ export class AiContentMonetizationStack extends cdk.Stack { corsPreflight: { allowOrigins: ['*'], allowMethods: [apigatewayv2.CorsHttpMethod.ANY], - allowHeaders: ['Content-Type', 'Authorization', 'X-PAYMENT', 'PAYMENT-SIGNATURE'] + allowHeaders: ['Content-Type', 'Authorization', 'PAYMENT-SIGNATURE'] } }); diff --git a/src/components/architectures/ServerlessInterface.tsx b/src/components/architectures/ServerlessInterface.tsx index bf3144f..4c1474c 100644 --- a/src/components/architectures/ServerlessInterface.tsx +++ b/src/components/architectures/ServerlessInterface.tsx @@ -91,7 +91,9 @@ export const ServerlessInterface = () => { throw new Error(data.error || 'Unexpected response'); } - const requirements = await initialResponse.json(); + // x402 v2: the 402 body is { x402Version, accepts: [...], resource, error }. + const body = await initialResponse.json(); + const requirements = body.accepts?.[0] ?? body; setPaymentRequirements(requirements); setShowPaymentModal(true); setIsLoading(false); @@ -133,7 +135,7 @@ export const ServerlessInterface = () => { const authorization = { from: account as `0x${string}`, to: paymentRequirements.payTo as `0x${string}`, - value: paymentRequirements.maxAmountRequired, + value: paymentRequirements.amount, validAfter: now.toString(), validBefore: (now + 3600).toString(), nonce @@ -176,21 +178,27 @@ export const ServerlessInterface = () => { setLoadingMessage('Processing payment...'); + // x402 v2 PaymentPayload: { x402Version, payload: { signature, authorization }, accepted } const paymentPayload = { - signature, - authorization: { - from: authorization.from, - to: authorization.to, - value: authorization.value, - validAfter: authorization.validAfter, - validBefore: authorization.validBefore, - nonce: authorization.nonce + x402Version: 2, + payload: { + signature, + authorization: { + from: authorization.from, + to: authorization.to, + value: authorization.value, + validAfter: authorization.validAfter, + validBefore: authorization.validBefore, + nonce: authorization.nonce + } }, - eip712Domain: { - name: domain.name, - version: domain.version, - chainId: domain.chainId, - verifyingContract: domain.verifyingContract + accepted: { + scheme: paymentRequirements.scheme, + network: paymentRequirements.network, + amount: authorization.value, + asset: paymentRequirements.asset, + payTo: paymentRequirements.payTo, + maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds } }; @@ -265,7 +273,7 @@ export const ServerlessInterface = () => { onClose={() => setShowPaymentModal(false)} onConfirm={handlePaymentConfirm} onCancel={handlePaymentCancel} - cost={paymentRequirements?.maxAmountRequired || 0} + cost={paymentRequirements?.amount || 0} model={config.model} walletAddress={account || undefined} />