From a389506ab50d0a917ab3c850872f0bcdf6819f90 Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Wed, 18 Feb 2026 22:28:34 +0800 Subject: [PATCH 01/10] Add x402 payment integration (USDC on Solana and Base) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a machine-to-machine x402 payment flow as an alternative to the existing browser-based credit card checkout. The merchant backend serves HTTP 402 responses with payment requirements, and agents sign and settle USDC payments on-chain via a facilitator. - Add x402 setup module with server initialization and settlement logic - Rewrite x402 checkout endpoint as two-mode (402 โ†’ sign โ†’ 200) flow - Add wallet setup scripts for merchant and agent - Add Solana ATA creation script - Add x402 checkout UI to TAP agent Streamlit app - Add x402 header logging to CDN proxy - Update README with setup, facilitator, and production docs - Add *.db and .env to .gitignore Tested end-to-end on Solana devnet and Base Sepolia testnet. --- .gitignore | 4 +- README.md | 79 +++++++ cdn-proxy/server.js | 11 + merchant-backend/.env.example | 9 + merchant-backend/app/main.py | 7 + merchant-backend/app/models/models.py | 2 +- merchant-backend/app/routes/cart.py | 312 +++++++++++++------------- merchant-backend/app/x402_setup.py | 129 +++++++++++ merchant-backend/create_solana_ata.py | 146 ++++++++++++ merchant-backend/setup_x402_wallet.py | 110 +++++++++ requirements.txt | 5 +- tap-agent/.env.example | 4 + tap-agent/agent_app.py | 137 ++++++++++- tap-agent/setup_x402_wallet.py | 101 +++++++++ 14 files changed, 890 insertions(+), 166 deletions(-) create mode 100644 merchant-backend/app/x402_setup.py create mode 100644 merchant-backend/create_solana_ata.py create mode 100644 merchant-backend/setup_x402_wallet.py create mode 100644 tap-agent/setup_x402_wallet.py diff --git a/.gitignore b/.gitignore index f9b1a91..c0fb84d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ **TPA.code-workspace** **package-lock.json** venv** -scan** \ No newline at end of file +scan** +*.db +.env \ No newline at end of file diff --git a/README.md b/README.md index 7430be2..e4206f1 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,85 @@ Each component has detailed setup instructions: - **[CDN Proxy](./cdn-proxy/README.md)** - Node.js proxy implementing RFC 9421 signature verification - **[Agent Registry](./agent-registry/README.md)** - Public key registry service for agent verification +### ๐Ÿ’ฐ **x402 Payment Setup (Optional)** + +The sample includes an x402 payment flow as an alternative to the browser-based credit card checkout. This uses USDC on Solana and Base. + +#### Quick Setup (Testnet) + +```bash +# 1. Generate merchant receiving wallets +cd merchant-backend && python setup_x402_wallet.py + +# 2. Fund the merchant Solana wallet with devnet SOL +# https://faucet.solana.com/ + +# 3. Create the merchant's USDC token account (ATA) on Solana +python create_solana_ata.py + +# 4. Fund both wallets with testnet USDC +# https://faucet.circle.com + +# 5. Generate agent spending wallets +cd ../tap-agent && python setup_x402_wallet.py + +# 6. Fund agent wallets with testnet USDC +# https://faucet.circle.com +``` + +Each setup script creates Solana + EVM keypairs and writes them to the local `.env`. The `create_solana_ata.py` script creates the USDC Associated Token Account that the merchant needs to receive Solana payments โ€” no need to install the `spl-token` CLI. + +#### Manual Configuration + +If you prefer to use existing wallets, set these in `merchant-backend/.env`: +```bash +X402_FACILITATOR_URL=https://x402.org/facilitator +X402_SVM_ADDRESS= +X402_EVM_ADDRESS= +X402_SVM_NETWORK=solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 +X402_EVM_NETWORK=eip155:84532 +X402_ENABLED=true +``` + +And in `tap-agent/.env`: +```bash +EVM_PRIVATE_KEY= +SVM_PRIVATE_KEY= +``` + +The agent reuses the existing `MERCHANT_API_URL` env var (default `http://localhost:8000`). + +#### Facilitators + +A facilitator verifies and settles x402 payments on behalf of the merchant. Both production facilitators offer a free tier of 1,000 settlements per month. + +| Facilitator | URL | Docs | +|---|---|---| +| **x402.org** (default) | `https://x402.org/facilitator` | Testnet only | +| **PayAI** | `https://facilitator.payai.network` | [docs.payai.network](https://docs.payai.network/x402/quickstart#facilitator) | +| **Coinbase CDP** | `https://api.cdp.coinbase.com/platform/v2/x402` | [docs.cdp.coinbase.com](https://docs.cdp.coinbase.com/x402/quickstart-for-sellers#running-on-mainnet) | + +The default `https://x402.org/facilitator` is a public good suitable for testnet development. For production, switch to PayAI or Coinbase CDP. + +#### Moving to Production + +To accept real payments, update your `merchant-backend/.env` with mainnet values: + +| Setting | Testnet | Mainnet | +|---|---|---| +| `X402_SVM_NETWORK` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | +| `X402_EVM_NETWORK` | `eip155:84532` (Base Sepolia) | `eip155:8453` (Base) | +| USDC mint (Solana) | `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU` | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | +| USDC contract (Base) | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | + +Switch `X402_FACILITATOR_URL` to either PayAI or Coinbase CDP and refer to their docs for credential setup. + +#### Testing +1. Select "x402 Checkout" in the TAP Agent UI +2. Enter product ID and quantity +3. Click "Pay with Crypto" to complete the payment flow +4. The agent will create a cart, request payment requirements (HTTP 402), sign the payment, and settle on-chain + ### ๐Ÿ—๏ธ **Architecture Overview** The sample demonstrates a complete TAP ecosystem: diff --git a/cdn-proxy/server.js b/cdn-proxy/server.js index 703d9ce..fc73e77 100644 --- a/cdn-proxy/server.js +++ b/cdn-proxy/server.js @@ -670,6 +670,17 @@ app.use('/api', createProxyMiddleware({ onProxyReq: (proxyReq, req, res) => { console.log(`๐Ÿ”„ Forwarding API request to backend: ${sanitizeLogOutput(req.path)}`); // Simple passthrough - no header manipulation needed + }, + onProxyRes: (proxyRes, req, res) => { + // Log x402 payment headers for debugging + const paymentRequired = proxyRes.headers['payment-required']; + const paymentResponse = proxyRes.headers['payment-response']; + if (paymentRequired) { + console.log(`๐Ÿ’ฐ x402 PAYMENT-REQUIRED header present on response for ${sanitizeLogOutput(req.path)}`); + } + if (paymentResponse) { + console.log(`โœ… x402 PAYMENT-RESPONSE header present on response for ${sanitizeLogOutput(req.path)}`); + } } })); diff --git a/merchant-backend/.env.example b/merchant-backend/.env.example index 9cdd9d2..42b92b1 100644 --- a/merchant-backend/.env.example +++ b/merchant-backend/.env.example @@ -9,3 +9,12 @@ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:300 # Debug Configuration DEBUG=true + +# x402 Payment Configuration +# Facilitator: x402.org (default, testnet), PayAI, or Coinbase CDP (see README) +X402_FACILITATOR_URL=https://x402.org/facilitator +X402_SVM_ADDRESS= +X402_EVM_ADDRESS= +X402_SVM_NETWORK=solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 +X402_EVM_NETWORK=eip155:84532 +X402_ENABLED=true diff --git a/merchant-backend/app/main.py b/merchant-backend/app/main.py index fac5fec..f3b2b78 100644 --- a/merchant-backend/app/main.py +++ b/merchant-backend/app/main.py @@ -71,6 +71,13 @@ def startup_event(): logger.info("๐Ÿš€ Starting Reference Merchant API...") create_tables() logger.info("โœ… Database tables created/verified") + # Initialize x402 payments if enabled + from app.x402_setup import initialize_x402, is_enabled + initialize_x402() + if is_enabled(): + logger.info("๐Ÿ’ฐ x402 payments enabled") + else: + logger.info("โ„น๏ธ x402 payments disabled") @app.get("/") def read_root(): diff --git a/merchant-backend/app/models/models.py b/merchant-backend/app/models/models.py index b942d84..1213da2 100644 --- a/merchant-backend/app/models/models.py +++ b/merchant-backend/app/models/models.py @@ -70,7 +70,7 @@ class Order(Base): billing_different = Column(Boolean, default=False) # Payment information (stored securely - in production, use tokenization) card_last_four = Column(String(4), nullable=True) # Only store last 4 digits - card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc. + card_brand = Column(String(255), nullable=True) # Visa, Mastercard, x402:{network}, etc. payment_status = Column(String(20), default="pending") # pending, processed, failed created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/merchant-backend/app/routes/cart.py b/merchant-backend/app/routes/cart.py index fbd0e22..296b270 100644 --- a/merchant-backend/app/routes/cart.py +++ b/merchant-backend/app/routes/cart.py @@ -6,7 +6,8 @@ # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.database.database import get_db from app.models.models import ( @@ -761,143 +762,155 @@ def process_payment_with_provider(payment_info, amount): @router.post("/{session_id}/x402/checkout") async def x402_checkout( session_id: str, - checkout_data: dict, + request: Request, db: Session = Depends(get_db) ): """ - Machine-to-machine x402 checkout endpoint - Accepts delegation token as payment and settles through Payment Facilitator + Machine-to-machine x402 checkout endpoint. + + Two modes: + A) No PAYMENT-SIGNATURE header โ†’ return HTTP 402 with payment requirements + B) PAYMENT-SIGNATURE header present โ†’ verify, settle on-chain, create order """ + from app.x402_setup import ( + is_enabled, + build_checkout_requirements, + create_payment_required, + settle, + ) + from x402.http import ( + encode_payment_required_header, + encode_payment_response_header, + PAYMENT_SIGNATURE_HEADER, + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, + ) + + if not is_enabled(): + raise HTTPException( + status_code=503, + detail="x402 payments are not enabled on this server" + ) + + # --- Load cart and calculate totals --- + cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + if not cart.items: + raise HTTPException(status_code=400, detail="Cart is empty") + + subtotal = sum(item.quantity * item.product.price for item in cart.items) + tax_rate = 0.08 # 8% tax + tax_amount = round(subtotal * tax_rate, 2) + shipping_cost = 0.0 + total_amount = round(subtotal + tax_amount + shipping_cost, 2) + + # Build payment requirements for both Solana and Base USDC + requirements = build_checkout_requirements(total_amount) + if not requirements: + raise HTTPException( + status_code=503, + detail="No x402 wallet addresses configured on merchant" + ) + + # Build the checkout URL for the payment required response + checkout_url = str(request.url) + + # --- Check for payment header --- + payment_header = request.headers.get(PAYMENT_SIGNATURE_HEADER) + + if not payment_header: + # ======== Mode A: return 402 with payment options ======== + payment_required = create_payment_required( + requirements, + description=f"Payment of ${total_amount:.2f} required for checkout" + ) + pr_header_value = encode_payment_required_header(payment_required) + + return JSONResponse( + status_code=402, + content={ + "status": "payment_required", + "message": f"Payment of ${total_amount:.2f} USD required", + "total": total_amount, + "subtotal": subtotal, + "tax": tax_amount, + "shipping": shipping_cost, + "currency": "USD", + "accepts": [r.model_dump() for r in requirements], + "checkout_url": checkout_url, + }, + headers={PAYMENT_REQUIRED_HEADER: pr_header_value}, + ) + + # ======== Mode B: verify + settle + create order ======== try: - # Extract delegation token and agent info - delegation_token = checkout_data.get('delegation_token') - agent_id = checkout_data.get('agent_id') - - if not delegation_token or not agent_id: - raise HTTPException( - status_code=400, - detail="delegation_token and agent_id are required for x402 checkout" - ) - - # Get cart by session_id - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - if not cart.items: - raise HTTPException(status_code=400, detail="Cart is empty") - - # Calculate totals - subtotal = sum(item.quantity * item.product.price for item in cart.items) - shipping_cost = 15.00 # Standard shipping - tax_rate = 0.0875 # 8.75% tax - tax_amount = subtotal * tax_rate - total_amount = subtotal + shipping_cost + tax_amount - - # Prepare items for settlement request - items = [] - for cart_item in cart.items: - items.append({ - "product_id": cart_item.product_id, - "name": cart_item.product.name, - "quantity": cart_item.quantity, - "price": float(cart_item.product.price) - }) - - # Prepare settlement request to Payment Facilitator - merchant_id = "merchant_123" # Your merchant ID - merchant_name = "Reference Merchant" - - # Generate merchant signature for settlement - settlement_data = f"{merchant_id}:{session_id}:{total_amount}" - merchant_secret = f"merchant_{merchant_id}_secret" - - import hmac - import hashlib - merchant_signature = hmac.new( - merchant_secret.encode('utf-8'), - settlement_data.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - - settlement_request = { - "delegation_token": delegation_token, - "merchant_id": merchant_id, - "merchant_name": merchant_name, - "cart_id": session_id, - "amount": total_amount, - "currency": "USD", - "items": items, - "merchant_signature": merchant_signature - } - - # Call Payment Facilitator to settle payment - import requests - facilitator_url = "http://localhost:8001" - - try: - settlement_response = requests.post( - f"{facilitator_url}/x402/settle", - json=settlement_request, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - - if settlement_response.status_code != 200: - error_detail = settlement_response.text - raise HTTPException( - status_code=402, # Payment Required - detail=f"Payment settlement failed: {error_detail}" - ) - - settlement_data = settlement_response.json() - receipt = settlement_data["transaction_receipt"] - - except requests.RequestException as e: - raise HTTPException( - status_code=503, - detail=f"Payment Facilitator unavailable: {str(e)}" - ) - - # Payment settled successfully, create order - order = OrderModel( - order_number=generate_order_number(), - customer_email=f"agent_{agent_id}@system.local", - customer_name=f"Agent {agent_id}", - total_amount=total_amount, - status="confirmed", - payment_method="x402_delegation", - payment_status="processed", - # Store x402 payment details - card_last_four=None, # Not applicable for x402 - card_brand="x402_token" # Indicate x402 payment + success, settle_resp, error_msg = await settle( + payment_header, requirements ) - - db.add(order) - db.commit() - db.refresh(order) - - # Create order items from cart - for cart_item in cart.items: - order_item = OrderItemModel( - order_id=order.id, - product_id=cart_item.product_id, - quantity=cart_item.quantity, - price=cart_item.product.price - ) - db.add(order_item) - - # Clear cart after successful checkout - from app.models.models import CartItem as CartItemModel - db.query(CartItemModel).filter(CartItemModel.cart_id == cart.id).delete() - - db.commit() - - # Generate tracking number - tracking_number = f"TRK{uuid.uuid4().hex[:10].upper()}" - - # Return comprehensive order details to agent - return { + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"x402 payment processing error: {str(e)}" + ) + + if not success: + # Return 402 again so the client can retry + payment_required = create_payment_required(requirements, description=error_msg) + pr_header_value = encode_payment_required_header(payment_required) + return JSONResponse( + status_code=402, + content={ + "status": "payment_failed", + "error": error_msg, + "accepts": [r.model_dump() for r in requirements], + "checkout_url": checkout_url, + }, + headers={PAYMENT_REQUIRED_HEADER: pr_header_value}, + ) + + # Determine which network was used from settle response + network = getattr(settle_resp, "network", "unknown") + + # Create order in DB + order = OrderModel( + order_number=generate_order_number(), + customer_email="x402-agent@crypto.local", + customer_name="x402 Agent", + total_amount=total_amount, + status="confirmed", + payment_method="x402", + payment_status="settled", + card_last_four=None, + card_brand=f"x402:{network}", + ) + db.add(order) + db.commit() + db.refresh(order) + + # Create order items from cart + for cart_item in cart.items: + order_item = OrderItemModel( + order_id=order.id, + product_id=cart_item.product_id, + quantity=cart_item.quantity, + price=cart_item.product.price, + ) + db.add(order_item) + + # Clear cart + from app.models.models import CartItem as CartItemModel + db.query(CartItemModel).filter(CartItemModel.cart_id == cart.id).delete() + db.commit() + + tracking_number = f"TRK{uuid.uuid4().hex[:10].upper()}" + + # Encode settle receipt into response header + response_header_value = encode_payment_response_header(settle_resp) + + return JSONResponse( + status_code=200, + content={ "status": "success", "message": "x402 checkout completed successfully", "order": { @@ -919,37 +932,22 @@ async def x402_checkout( "product_name": item.product.name, "quantity": item.quantity, "unit_price": float(item.price), - "total_price": float(item.quantity * item.price) + "total_price": float(item.quantity * item.price), } for item in order.items - ] + ], }, "payment": { - "method": "x402_delegation", - "receipt_id": receipt["receipt_id"], - "transaction_id": receipt["transaction_id"], - "payment_rail": receipt["payment_rail_used"], - "amount_charged": float(receipt["amount"]), - "processing_fee": float(receipt["processing_fee"]), - "net_amount": float(receipt["net_amount"]), - "status": "completed" - }, - "delegation": { - "remaining_limit": float(settlement_data["remaining_delegation_limit"]), - "agent_id": agent_id + "method": "x402", + "network": network, + "status": "settled", }, "fulfillment": { "tracking_number": tracking_number, "estimated_delivery": "5-7 business days", "shipping_carrier": "Standard Shipping", - "status": "processing" - } - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"x402 checkout failed: {str(e)}" - ) + "status": "processing", + }, + }, + headers={PAYMENT_RESPONSE_HEADER: response_header_value}, + ) diff --git a/merchant-backend/app/x402_setup.py b/merchant-backend/app/x402_setup.py new file mode 100644 index 0000000..660e0f7 --- /dev/null +++ b/merchant-backend/app/x402_setup.py @@ -0,0 +1,129 @@ +import os +import logging + +logger = logging.getLogger(__name__) + +# Module-level singleton +_server = None + + +def initialize_x402(): + """Initialize x402 resource server at startup. Call once from main.py.""" + global _server + + if not os.getenv("X402_ENABLED", "false").lower() == "true": + logger.info("x402 payments disabled (X402_ENABLED != true)") + return + + from x402.server import x402ResourceServer + from x402.http import HTTPFacilitatorClient, FacilitatorConfig + from x402.mechanisms.evm.exact import ExactEvmServerScheme + from x402.mechanisms.svm.exact import ExactSvmServerScheme + + facilitator_url = os.getenv( + "X402_FACILITATOR_URL", "https://x402.org/facilitator" + ) + config = FacilitatorConfig(url=facilitator_url) + client = HTTPFacilitatorClient(config) + + svm_network = os.getenv("X402_SVM_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + evm_network = os.getenv("X402_EVM_NETWORK", "eip155:84532") + + _server = x402ResourceServer(facilitator_clients=[client]) + _server.register(evm_network, ExactEvmServerScheme()) + _server.register(svm_network, ExactSvmServerScheme()) + _server.initialize() + + logger.info("x402 resource server initialized (facilitator=%s)", facilitator_url) + + +def is_enabled() -> bool: + return _server is not None + + +def build_checkout_requirements(total_usd: float) -> list: + """Build PaymentRequirements list for configured networks. + + Uses the server's build_payment_requirements() which enriches requirements + with facilitator data (e.g. feePayer for SVM transactions). + """ + from x402.schemas import ResourceConfig + + if _server is None: + return [] + + svm_address = os.getenv("X402_SVM_ADDRESS", "") + evm_address = os.getenv("X402_EVM_ADDRESS", "") + svm_network = os.getenv("X402_SVM_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + evm_network = os.getenv("X402_EVM_NETWORK", "eip155:84532") + + requirements = [] + + if svm_address: + config = ResourceConfig( + scheme="exact", + pay_to=svm_address, + price=total_usd, + network=svm_network, + max_timeout_seconds=300, + ) + requirements.extend(_server.build_payment_requirements(config)) + + if evm_address: + config = ResourceConfig( + scheme="exact", + pay_to=evm_address, + price=total_usd, + network=evm_network, + max_timeout_seconds=300, + ) + requirements.extend(_server.build_payment_requirements(config)) + + return requirements + + +def create_payment_required(requirements: list, description: str = ""): + """Build a PaymentRequired object for the 402 response.""" + from x402.schemas import PaymentRequired + + return PaymentRequired( + accepts=requirements, + error=description or "Payment Required", + ) + + +async def settle(payment_header: str, requirements: list): + """ + Decode the payment header, match requirements, and settle on-chain. + settle_payment() calls verify internally, so no separate verify step needed. + + Returns (success: bool, settle_response | None, error_msg: str) + """ + from x402.http import decode_payment_signature_header + + if _server is None: + return False, None, "x402 not initialized" + + try: + payload = decode_payment_signature_header(payment_header) + except Exception as exc: + logger.error("Failed to decode payment header: %s", exc) + return False, None, f"Invalid payment header: {exc}" + + try: + matched_req = _server.find_matching_requirements(requirements, payload) + if matched_req is None: + return False, None, "Payment does not match any accepted requirement" + except Exception as exc: + logger.error("Error matching requirements: %s", exc) + return False, None, f"Requirement matching failed: {exc}" + + try: + settle_resp = await _server.settle_payment(payload, matched_req) + if not settle_resp.success: + return False, None, f"Settlement failed: {settle_resp.error_reason or 'unknown'}" + except Exception as exc: + logger.error("Payment settlement error: %s", exc) + return False, None, f"Settlement error: {exc}" + + return True, settle_resp, "" diff --git a/merchant-backend/create_solana_ata.py b/merchant-backend/create_solana_ata.py new file mode 100644 index 0000000..65120fc --- /dev/null +++ b/merchant-backend/create_solana_ata.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Create a USDC Associated Token Account (ATA) for the merchant's Solana wallet. + +Reads X402_SVM_PRIVATE_KEY and X402_SVM_NETWORK from .env. +The wallet must already be funded with SOL to pay for the account creation. + +Usage: python create_solana_ata.py +""" + +import base64 +import json +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +ENV_PATH = Path(__file__).parent / ".env" +load_dotenv(ENV_PATH) + +# USDC mint addresses per network +USDC_MINTS = { + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", # devnet + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", # mainnet +} + +RPC_URLS = { + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "https://api.devnet.solana.com", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "https://api.mainnet-beta.solana.com", +} + +TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" +SYSTEM_PROGRAM = "11111111111111111111111111111111" + + +def rpc(url: str, method: str, params=None): + """Make a Solana JSON-RPC call.""" + import httpx + + resp = httpx.post( + url, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []}, + timeout=30, + ) + body = resp.json() + if "error" in body: + raise RuntimeError(f"RPC {method} failed: {body['error']}") + return body["result"] + + +def main(): + from solders.keypair import Keypair + from solders.pubkey import Pubkey + from solders.instruction import Instruction, AccountMeta + from solders.hash import Hash + from solders.message import Message + from solders.transaction import Transaction + + # --- Read config --- + private_key = os.getenv("X402_SVM_PRIVATE_KEY", "") + network = os.getenv("X402_SVM_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + + if not private_key: + print("Error: X402_SVM_PRIVATE_KEY not found in .env") + print("Run python setup_x402_wallet.py first.") + sys.exit(1) + + if network not in USDC_MINTS: + print(f"Error: Unknown network '{network}'") + print(f"Supported: {', '.join(USDC_MINTS.keys())}") + sys.exit(1) + + rpc_url = RPC_URLS[network] + usdc_mint = Pubkey.from_string(USDC_MINTS[network]) + token_program = Pubkey.from_string(TOKEN_PROGRAM) + ata_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) + sys_program = Pubkey.from_string(SYSTEM_PROGRAM) + + payer = Keypair.from_base58_string(private_key) + wallet = payer.pubkey() + + print(f"Network: {network}") + print(f"RPC: {rpc_url}") + print(f"Wallet: {wallet}") + print(f"USDC mint: {usdc_mint}") + + # --- Derive ATA address --- + ata, _bump = Pubkey.find_program_address( + [bytes(wallet), bytes(token_program), bytes(usdc_mint)], + ata_program, + ) + print(f"ATA: {ata}") + + # --- Check if ATA already exists --- + info = rpc(rpc_url, "getAccountInfo", [str(ata), {"encoding": "base64"}]) + if info["value"] is not None: + print("\nATA already exists โ€” nothing to do.") + return + + # --- Check SOL balance --- + balance = rpc(rpc_url, "getBalance", [str(wallet)]) + lamports = balance["value"] + print(f"SOL balance: {lamports / 1e9:.4f} SOL") + if lamports < 10_000_000: # ~0.01 SOL minimum for ATA creation + print("\nError: Insufficient SOL. Fund this wallet first:") + print(f" https://faucet.solana.com/ (address: {wallet})") + sys.exit(1) + + # --- Build CreateAssociatedTokenAccount instruction --- + ix = Instruction( + program_id=ata_program, + data=bytes(), # no instruction data needed + accounts=[ + AccountMeta(wallet, is_signer=True, is_writable=True), # payer + AccountMeta(ata, is_signer=False, is_writable=True), # ATA + AccountMeta(wallet, is_signer=False, is_writable=False), # wallet owner + AccountMeta(usdc_mint, is_signer=False, is_writable=False), + AccountMeta(sys_program, is_signer=False, is_writable=False), + AccountMeta(token_program, is_signer=False, is_writable=False), + ], + ) + + # --- Get recent blockhash --- + bh_result = rpc(rpc_url, "getLatestBlockhash") + blockhash = Hash.from_string(bh_result["value"]["blockhash"]) + + # --- Build, sign, send transaction --- + msg = Message.new_with_blockhash([ix], wallet, blockhash) + tx = Transaction.new_unsigned(msg) + tx.partial_sign([payer], blockhash) + + tx_bytes = bytes(tx) + tx_b64 = base64.b64encode(tx_bytes).decode() + + print("\nSending transaction...") + sig = rpc(rpc_url, "sendTransaction", [tx_b64, {"encoding": "base64"}]) + print(f"Transaction signature: {sig}") + print(f"\nUSDC ATA created successfully: {ata}") + print(f"\nYou can now fund this wallet with testnet USDC at:") + print(f" https://faucet.circle.com (address: {wallet})") + + +if __name__ == "__main__": + main() diff --git a/merchant-backend/setup_x402_wallet.py b/merchant-backend/setup_x402_wallet.py new file mode 100644 index 0000000..84fe9ee --- /dev/null +++ b/merchant-backend/setup_x402_wallet.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Generate merchant receiving wallets for x402 payments and write them to .env. + +Creates: + - A Solana keypair (prints the address for ATA creation) + - An EVM account (prints the address for funding) + - Appends/updates X402_* vars in .env + +Run once: python setup_x402_wallet.py +""" + +import os +import sys +from pathlib import Path + +ENV_PATH = Path(__file__).parent / ".env" + + +def generate_solana_keypair(): + """Return (base58_private_key, base58_public_address).""" + from solders.keypair import Keypair # installed via x402[svm] + + kp = Keypair() + return str(kp), str(kp.pubkey()) + + +def generate_evm_account(): + """Return (hex_private_key_with_0x, hex_address).""" + from eth_account import Account # installed via eth-account + + acct = Account.create() + return acct.key.hex(), acct.address + + +def upsert_env(key: str, value: str): + """Insert or update a key=value in .env, preserving other content.""" + lines = [] + found = False + + if ENV_PATH.exists(): + lines = ENV_PATH.read_text().splitlines(keepends=True) + new_lines = [] + for line in lines: + stripped = line.lstrip() + if stripped.startswith(f"{key}="): + new_lines.append(f"{key}={value}\n") + found = True + else: + new_lines.append(line) + lines = new_lines + + if not found: + # Ensure trailing newline before appending + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + lines.append(f"{key}={value}\n") + + ENV_PATH.write_text("".join(lines)) + + +def main(): + print("=== x402 Merchant Wallet Setup ===\n") + + # --- Solana --- + try: + svm_privkey, svm_address = generate_solana_keypair() + print(f"Solana address: {svm_address}") + print(f" (private key stored in .env as X402_SVM_PRIVATE_KEY)") + upsert_env("X402_SVM_ADDRESS", svm_address) + upsert_env("X402_SVM_PRIVATE_KEY", svm_privkey) + except ImportError: + print("Skipping Solana โ€” 'solders' not installed (pip install x402[svm])") + svm_address = None + + # --- EVM --- + try: + evm_privkey, evm_address = generate_evm_account() + print(f"EVM address: {evm_address}") + print(f" (private key stored in .env as X402_EVM_PRIVATE_KEY)") + upsert_env("X402_EVM_ADDRESS", evm_address) + upsert_env("X402_EVM_PRIVATE_KEY", evm_privkey) + except ImportError: + print("Skipping EVM โ€” 'eth_account' not installed (pip install eth-account)") + evm_address = None + + # --- Defaults --- + upsert_env("X402_ENABLED", "true") + upsert_env("X402_FACILITATOR_URL", "https://x402.org/facilitator") + upsert_env("X402_SVM_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + upsert_env("X402_EVM_NETWORK", "eip155:84532") + + print(f"\nWrote configuration to {ENV_PATH}") + + # --- Next steps --- + print("\n--- Next Steps ---") + if svm_address: + print(f"1. Fund Solana wallet with devnet SOL:") + print(f" https://faucet.solana.com/") + print(f"2. Create USDC token account (ATA):") + print(f" python create_solana_ata.py") + print(f"3. Fund with testnet USDC:") + print(f" https://faucet.circle.com (address: {svm_address})") + if evm_address: + print(f"4. Fund EVM wallet with Base Sepolia ETH + testnet USDC:") + print(f" https://faucet.circle.com (address: {evm_address})") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 433c784..5ad4f79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,7 @@ pydantic[email]==2.5.0 python-jose[cryptography]>=3.4.0 passlib[bcrypt]==1.7.4 playwright>=1.40.0 -pandas==2.3.3 \ No newline at end of file +pandas==2.3.3 +x402[evm,svm]>=2.0.0 +httpx>=0.27.0 +eth-account>=0.13.0 \ No newline at end of file diff --git a/tap-agent/.env.example b/tap-agent/.env.example index b4e48ed..0603aa3 100644 --- a/tap-agent/.env.example +++ b/tap-agent/.env.example @@ -19,3 +19,7 @@ RSA_PUBLIC_KEY = "" # Static keys for Ed25519 algorithm ED25519_PRIVATE_KEY = "" ED25519_PUBLIC_KEY = "" + +# x402 Wallet (for x402 checkout) +EVM_PRIVATE_KEY= +SVM_PRIVATE_KEY= diff --git a/tap-agent/agent_app.py b/tap-agent/agent_app.py index 43b7862..3468d9b 100644 --- a/tap-agent/agent_app.py +++ b/tap-agent/agent_app.py @@ -1394,6 +1394,90 @@ def parse_url_components(url: str) -> tuple[str, str]: st.error(f"Error parsing URL: {str(e)}") return "", "" +def perform_x402_checkout(merchant_api_url: str, product_id: int, quantity: int = 1) -> dict: + """ + Perform an x402 checkout against the merchant backend API. + + Flow: + 1. Create cart + 2. Add items + 3. POST x402/checkout (no payment header) โ†’ get 402 + requirements + 4. Sign payment with x402 client + 5. POST x402/checkout (with payment header) โ†’ get 200 + order + """ + import httpx + from x402.client import x402ClientSync + from x402.http import ( + decode_payment_required_header, + encode_payment_signature_header, + PAYMENT_SIGNATURE_HEADER, + PAYMENT_REQUIRED_HEADER, + ) + + # --- Build x402 client with wallet signers --- + client = x402ClientSync() + + evm_key = os.getenv("EVM_PRIVATE_KEY", "") + svm_key = os.getenv("SVM_PRIVATE_KEY", "") + + if evm_key: + from eth_account import Account + from x402.mechanisms.evm.signers import EthAccountSigner + from x402.mechanisms.evm.exact import register_exact_evm_client + account = Account.from_key(evm_key) + evm_signer = EthAccountSigner(account) + register_exact_evm_client(client, evm_signer) + + if svm_key: + from x402.mechanisms.svm.signers import KeypairSigner + from x402.mechanisms.svm.exact import register_exact_svm_client + svm_signer = KeypairSigner.from_base58(svm_key) + register_exact_svm_client(client, svm_signer) + + base = merchant_api_url.rstrip("/") + http = httpx.Client(timeout=30) + + # 1. Create cart + resp = http.post(f"{base}/api/cart/") + resp.raise_for_status() + session_id = resp.json()["session_id"] + + # 2. Add item + resp = http.post( + f"{base}/api/cart/{session_id}/items", + json={"product_id": product_id, "quantity": quantity}, + ) + resp.raise_for_status() + + # 3. Initial checkout request (no payment) โ†’ 402 + checkout_url = f"{base}/api/cart/{session_id}/x402/checkout" + resp = http.post(checkout_url) + if resp.status_code != 402: + return {"success": False, "error": f"Expected 402, got {resp.status_code}: {resp.text}"} + + # 4. Decode payment requirements from header + pr_header = resp.headers.get(PAYMENT_REQUIRED_HEADER, "") + if not pr_header: + return {"success": False, "error": "No PAYMENT-REQUIRED header in 402 response"} + + payment_required = decode_payment_required_header(pr_header) + + # 5. Sign payment + payload = client.create_payment_payload(payment_required) + payment_sig_value = encode_payment_signature_header(payload) + + # 6. Retry with signed payment header + resp = http.post( + checkout_url, + headers={PAYMENT_SIGNATURE_HEADER: payment_sig_value}, + ) + + if resp.status_code == 200: + return {"success": True, **resp.json()} + else: + return {"success": False, "error": f"Checkout failed ({resp.status_code}): {resp.text}"} + + def main(): st.set_page_config( page_title="TAP Agent", @@ -1529,9 +1613,9 @@ def main(): st.subheader("๐ŸŽฏ Action Selection") action_choice = st.radio( "Choose an action:", - options=["Product Details", "Checkout"], + options=["Product Details", "Checkout", "x402 Checkout"], index=0, # Default to Product Details - help="Select whether to fetch product details or complete a checkout process.", + help="Select whether to fetch product details, complete a browser checkout, or pay with x402.", horizontal=True ) @@ -1622,13 +1706,54 @@ def update_input_data_with_action(): button_text = "๐Ÿ“ฆ Fetch Product Details" button_help = "Create RFC 9421 signature and fetch product details from the merchant" tag_value = "agent-browser-auth" - else: # Checkout + elif action_choice == "Checkout": button_text = "๐Ÿ›’ Complete Checkout" button_help = "Create RFC 9421 signature and complete the checkout process" tag_value = "agent-payer-auth" - - # Single launch button that adapts to the selected action - if st.button(button_text, type="primary", disabled=launch_disabled, help=button_help): + else: # x402 Checkout + button_text = "๐Ÿ’ฐ Pay with Crypto" + button_help = "Pay with x402 (USDC on Solana or Base)" + tag_value = "agent-payer-auth" + + # x402 Checkout has its own UI section + if action_choice == "x402 Checkout": + st.subheader("๐Ÿ’ฐ x402 Checkout") + x402_col1, x402_col2 = st.columns(2) + with x402_col1: + x402_product_id = st.number_input("Product ID", min_value=1, value=1, step=1) + with x402_col2: + x402_quantity = st.number_input("Quantity", min_value=1, value=1, step=1) + + x402_api_url = os.getenv("MERCHANT_API_URL", "http://localhost:8000") + st.caption(f"Merchant API: {x402_api_url}") + + if st.button(button_text, type="primary", help=button_help): + with st.spinner("Processing x402 payment..."): + try: + result = perform_x402_checkout(x402_api_url, x402_product_id, x402_quantity) + if result.get("success"): + st.success("๐ŸŽ‰ x402 Checkout completed successfully!") + order_data = result.get("order", {}) + if order_data: + st.markdown("### ๐Ÿ“‹ Order Confirmation") + oc1, oc2 = st.columns(2) + with oc1: + st.metric("Order Number", order_data.get("order_number", "N/A")) + st.metric("Total", f"${order_data.get('total_amount', 0):.2f}") + with oc2: + st.metric("Payment", result.get("payment", {}).get("method", "x402")) + st.metric("Network", result.get("payment", {}).get("network", "unknown")) + with st.expander("๐Ÿ” Full Response"): + st.json(result) + else: + st.error(f"โŒ x402 Checkout failed: {result.get('error', 'Unknown error')}") + with st.expander("Error Details"): + st.json(result) + except Exception as e: + st.error(f"โŒ x402 Checkout error: {str(e)}") + + # Single launch button that adapts to the selected action (Product Details / Checkout) + elif st.button(button_text, type="primary", disabled=launch_disabled, help=button_help): if st.session_state.private_key: import time spinner_text = f"Creating RFC 9421 signature and {'fetching product details' if action_choice == 'Product Details' else 'completing checkout'}..." diff --git a/tap-agent/setup_x402_wallet.py b/tap-agent/setup_x402_wallet.py new file mode 100644 index 0000000..e44eeaf --- /dev/null +++ b/tap-agent/setup_x402_wallet.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Generate agent spending wallets for x402 payments and write them to .env. + +Creates: + - A Solana keypair (private key for signing x402 payments) + - An EVM account (private key for signing x402 payments) + - Appends/updates wallet vars in .env + +Run once: python setup_x402_wallet.py +""" + +import os +import sys +from pathlib import Path + +ENV_PATH = Path(__file__).parent / ".env" + + +def generate_solana_keypair(): + """Return (base58_private_key, base58_public_address).""" + from solders.keypair import Keypair # installed via x402[svm] + + kp = Keypair() + return str(kp), str(kp.pubkey()) + + +def generate_evm_account(): + """Return (hex_private_key_with_0x, hex_address).""" + from eth_account import Account # installed via eth-account + + acct = Account.create() + return acct.key.hex(), acct.address + + +def upsert_env(key: str, value: str): + """Insert or update a key=value in .env, preserving other content.""" + lines = [] + found = False + + if ENV_PATH.exists(): + lines = ENV_PATH.read_text().splitlines(keepends=True) + new_lines = [] + for line in lines: + stripped = line.lstrip() + if stripped.startswith(f"{key}="): + new_lines.append(f"{key}={value}\n") + found = True + else: + new_lines.append(line) + lines = new_lines + + if not found: + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + lines.append(f"{key}={value}\n") + + ENV_PATH.write_text("".join(lines)) + + +def main(): + print("=== x402 Agent Wallet Setup ===\n") + + # --- Solana --- + try: + svm_privkey, svm_address = generate_solana_keypair() + print(f"Solana address: {svm_address}") + print(f" (private key stored in .env as SVM_PRIVATE_KEY)") + upsert_env("SVM_PRIVATE_KEY", svm_privkey) + except ImportError: + print("Skipping Solana โ€” 'solders' not installed (pip install x402[svm])") + svm_address = None + + # --- EVM --- + try: + evm_privkey, evm_address = generate_evm_account() + print(f"EVM address: {evm_address}") + print(f" (private key stored in .env as EVM_PRIVATE_KEY)") + upsert_env("EVM_PRIVATE_KEY", evm_privkey) + except ImportError: + print("Skipping EVM โ€” 'eth_account' not installed (pip install eth-account)") + evm_address = None + + print(f"\nWrote configuration to {ENV_PATH}") + + # --- Next steps --- + print("\n--- Next Steps ---") + if svm_address: + print(f"1. Fund Solana wallet with devnet SOL: https://faucet.solana.com/") + print(f"2. Get testnet USDC: https://faucet.circle.com") + print(f" Fund address: {svm_address}") + if evm_address: + print(f"3. Fund EVM wallet with Base Sepolia ETH + testnet USDC:") + print(f" https://faucet.circle.com") + print(f" Fund address: {evm_address}") + print(f"\n4. Run the merchant setup script too:") + print(f" cd ../merchant-backend && python setup_x402_wallet.py") + + +if __name__ == "__main__": + main() From 9139c6eeb7168eca81964de7fd313ecc88b595cc Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Wed, 18 Feb 2026 22:44:18 +0800 Subject: [PATCH 02/10] Display PAYMENT-RESPONSE header in x402 checkout UI Capture and decode the base64-encoded PAYMENT-RESPONSE header returned by the merchant after settlement, showing the facilitator receipt in the Streamlit UI. --- tap-agent/agent_app.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tap-agent/agent_app.py b/tap-agent/agent_app.py index 3468d9b..88afd5e 100644 --- a/tap-agent/agent_app.py +++ b/tap-agent/agent_app.py @@ -1412,6 +1412,7 @@ def perform_x402_checkout(merchant_api_url: str, product_id: int, quantity: int encode_payment_signature_header, PAYMENT_SIGNATURE_HEADER, PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, ) # --- Build x402 client with wallet signers --- @@ -1473,7 +1474,8 @@ def perform_x402_checkout(merchant_api_url: str, product_id: int, quantity: int ) if resp.status_code == 200: - return {"success": True, **resp.json()} + payment_response_header = resp.headers.get(PAYMENT_RESPONSE_HEADER, "") + return {"success": True, "payment_response_header": payment_response_header, **resp.json()} else: return {"success": False, "error": f"Checkout failed ({resp.status_code}): {resp.text}"} @@ -1743,6 +1745,15 @@ def update_input_data_with_action(): with oc2: st.metric("Payment", result.get("payment", {}).get("method", "x402")) st.metric("Network", result.get("payment", {}).get("network", "unknown")) + pr_header = result.get("payment_response_header", "") + if pr_header: + st.markdown("### ๐Ÿงพ Payment Response Header") + import base64, json as _json + try: + decoded = _json.loads(base64.b64decode(pr_header)) + st.json(decoded) + except Exception: + st.code(pr_header, language="text") with st.expander("๐Ÿ” Full Response"): st.json(result) else: From 8c779328229533ff2448fbdfa4e2f60fd8a19913 Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Wed, 18 Feb 2026 23:00:45 +0800 Subject: [PATCH 03/10] Remove redundant accepts array from 402 response body The payment requirements are already encoded in the PAYMENT-REQUIRED header per the x402 spec. No need to duplicate them in the JSON body. --- merchant-backend/app/routes/cart.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/merchant-backend/app/routes/cart.py b/merchant-backend/app/routes/cart.py index 296b270..1a1b7cb 100644 --- a/merchant-backend/app/routes/cart.py +++ b/merchant-backend/app/routes/cart.py @@ -837,7 +837,6 @@ async def x402_checkout( "tax": tax_amount, "shipping": shipping_cost, "currency": "USD", - "accepts": [r.model_dump() for r in requirements], "checkout_url": checkout_url, }, headers={PAYMENT_REQUIRED_HEADER: pr_header_value}, @@ -863,7 +862,6 @@ async def x402_checkout( content={ "status": "payment_failed", "error": error_msg, - "accepts": [r.model_dump() for r in requirements], "checkout_url": checkout_url, }, headers={PAYMENT_REQUIRED_HEADER: pr_header_value}, From 2dce7a7ec0635db640e8869b617741713d742e2f Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Wed, 18 Feb 2026 23:06:32 +0800 Subject: [PATCH 04/10] Add digital products for x402 micropayment testing Add five low-cost digital products ($0.01-$0.50) to the sample data seed script, suitable for testing x402 payments with small amounts of testnet USDC. --- merchant-backend/create_sample_data.py | 43 +++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/merchant-backend/create_sample_data.py b/merchant-backend/create_sample_data.py index a0bc20b..ada15e8 100644 --- a/merchant-backend/create_sample_data.py +++ b/merchant-backend/create_sample_data.py @@ -173,7 +173,48 @@ def create_sample_products(): "category": "Books", "image_url": "https://images.unsplash.com/photo-1555066931-4365d14bab8c", "stock_quantity": 28 - } + }, + # Digital products suitable for x402 micropayments + { + "name": "API Call: Weather Data", + "description": "Single weather API call with current conditions and forecast", + "price": 0.01, + "category": "Digital", + "image_url": "https://images.unsplash.com/photo-1504608524841-42fe6f032b4b", + "stock_quantity": 9999 + }, + { + "name": "AI Image Generation", + "description": "Generate one AI image from a text prompt", + "price": 0.05, + "category": "Digital", + "image_url": "https://images.unsplash.com/photo-1547954575-855750c57bd3", + "stock_quantity": 9999 + }, + { + "name": "PDF Report: Market Summary", + "description": "Daily market summary report with key insights", + "price": 0.10, + "category": "Digital", + "image_url": "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3", + "stock_quantity": 9999 + }, + { + "name": "Code Review: Single File", + "description": "Automated code review and suggestions for one source file", + "price": 0.25, + "category": "Digital", + "image_url": "https://images.unsplash.com/photo-1555066931-4365d14bab8c", + "stock_quantity": 9999 + }, + { + "name": "Translation: 1000 Words", + "description": "Professional AI translation of up to 1000 words", + "price": 0.50, + "category": "Digital", + "image_url": "https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8", + "stock_quantity": 9999 + }, ] # Check if products already exist From 7574a0758f4b4188c872bb409d3167c279c09c5e Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Wed, 18 Feb 2026 23:45:52 +0800 Subject: [PATCH 05/10] Rename checkout button to "Pay with USDC (x402)" --- tap-agent/agent_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tap-agent/agent_app.py b/tap-agent/agent_app.py index 88afd5e..2dbf043 100644 --- a/tap-agent/agent_app.py +++ b/tap-agent/agent_app.py @@ -1713,7 +1713,7 @@ def update_input_data_with_action(): button_help = "Create RFC 9421 signature and complete the checkout process" tag_value = "agent-payer-auth" else: # x402 Checkout - button_text = "๐Ÿ’ฐ Pay with Crypto" + button_text = "๐Ÿ’ฐ Pay with USDC (x402)" button_help = "Pay with x402 (USDC on Solana or Base)" tag_value = "agent-payer-auth" From df06a72c6ab5f9937323196547dcaf6c209d7182 Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Thu, 19 Feb 2026 00:55:21 +0800 Subject: [PATCH 06/10] Route x402 checkout through CDN proxy Change x402 checkout to use CDN_PROXY_URL (localhost:3001) instead of hitting the merchant backend directly, aligning with the TAP architecture where all agent traffic flows through the CDN proxy. --- tap-agent/.env.example | 1 + tap-agent/agent_app.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tap-agent/.env.example b/tap-agent/.env.example index 0603aa3..c52fa1f 100644 --- a/tap-agent/.env.example +++ b/tap-agent/.env.example @@ -3,6 +3,7 @@ # Merchant API Configuration MERCHANT_API_URL=http://localhost:8000 +CDN_PROXY_URL=http://localhost:3001 # Agent Configuration AGENT_PORT=8501 diff --git a/tap-agent/agent_app.py b/tap-agent/agent_app.py index 2dbf043..a47e0df 100644 --- a/tap-agent/agent_app.py +++ b/tap-agent/agent_app.py @@ -1726,8 +1726,8 @@ def update_input_data_with_action(): with x402_col2: x402_quantity = st.number_input("Quantity", min_value=1, value=1, step=1) - x402_api_url = os.getenv("MERCHANT_API_URL", "http://localhost:8000") - st.caption(f"Merchant API: {x402_api_url}") + x402_api_url = os.getenv("CDN_PROXY_URL", "http://localhost:3001") + st.caption(f"CDN Proxy: {x402_api_url}") if st.button(button_text, type="primary", help=button_help): with st.spinner("Processing x402 payment..."): From 7668ed5ec4727165f1a215caf7130a2dd4d3082a Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Thu, 19 Feb 2026 14:25:57 +0800 Subject: [PATCH 07/10] Add setup_keys.py to auto-generate agent signing keys Users hitting a ValueError on quickstart because RSA/Ed25519 keys weren't configured. This adds a one-command setup script and updates the README quickstart with the new step. --- README.md | 18 ++++--- tap-agent/setup_keys.py | 114 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 tap-agent/setup_keys.py diff --git a/README.md b/README.md index e4206f1..96deee2 100644 --- a/README.md +++ b/README.md @@ -55,25 +55,31 @@ This repository contains a complete sample implementation demonstrating the Trus pip install -r requirements.txt ``` -2. **Start All Services**: +2. **Generate Agent Keys** (one-time setup): + ```bash + cd tap-agent && python setup_keys.py + ``` + This creates RSA and Ed25519 signing keys in `tap-agent/.env`. + +3. **Start All Services**: ```bash # Terminal 1: Agent Registry (port 8001) cd agent-registry && python main.py - + # Terminal 2: Merchant Backend (port 8000) cd merchant-backend && python -m uvicorn app.main:app --reload - + # Terminal 3: CDN Proxy (port 3002) cd cdn-proxy && npm install && npm start - + # Terminal 4: Merchant Frontend (port 3001) cd merchant-frontend && npm install && npm run dev - + # Terminal 5: TAP Agent (port 8501) cd tap-agent && streamlit run agent_app.py ``` -3. **Try the Demo**: +4. **Try the Demo**: - Open the TAP Agent at http://localhost:8501 - Configure merchant URL: http://localhost:3001 - Generate signatures and interact with the sample merchant diff --git a/tap-agent/setup_keys.py b/tap-agent/setup_keys.py new file mode 100644 index 0000000..e9de9d0 --- /dev/null +++ b/tap-agent/setup_keys.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Generate RSA and Ed25519 signing keys for the TAP Agent and write them to .env. + +Creates: + - An RSA-2048 key pair (for RSA-PSS-SHA256 signatures) + - An Ed25519 key pair (for Ed25519 signatures) + - Copies .env.example โ†’ .env if .env doesn't exist, then upserts key values + +Run once: python setup_keys.py +""" + +import base64 +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa, ed25519 + +ENV_PATH = Path(__file__).parent / ".env" +ENV_EXAMPLE_PATH = Path(__file__).parent / ".env.example" + + +def _pem_oneline(pem_bytes: bytes) -> str: + """Convert a multi-line PEM to a single-line string with literal \\n.""" + return pem_bytes.decode().replace("\n", "\\n").strip() + + +def generate_rsa_keypair() -> tuple[str, str]: + """Return (private_pem_oneline, public_pem_oneline).""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return _pem_oneline(private_pem), _pem_oneline(public_pem) + + +def generate_ed25519_keypair() -> tuple[str, str]: + """Return (private_b64, public_b64) as base64-encoded raw bytes.""" + private_key = ed25519.Ed25519PrivateKey.generate() + + private_bytes = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return base64.b64encode(private_bytes).decode(), base64.b64encode(public_bytes).decode() + + +def upsert_env(key: str, value: str): + """Insert or update a key=value in .env, preserving other content.""" + lines = [] + found = False + + if ENV_PATH.exists(): + lines = ENV_PATH.read_text().splitlines(keepends=True) + new_lines = [] + for line in lines: + stripped = line.lstrip() + if stripped.startswith(f"{key}=") or stripped.startswith(f"{key} ="): + new_lines.append(f'{key}="{value}"\n') + found = True + else: + new_lines.append(line) + lines = new_lines + + if not found: + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + lines.append(f'{key}="{value}"\n') + + ENV_PATH.write_text("".join(lines)) + + +def main(): + print("=== TAP Agent Key Setup ===\n") + + # Bootstrap .env from .env.example if it doesn't exist yet + if not ENV_PATH.exists() and ENV_EXAMPLE_PATH.exists(): + ENV_PATH.write_text(ENV_EXAMPLE_PATH.read_text()) + print(f"Created {ENV_PATH} from .env.example\n") + + # --- RSA --- + rsa_priv, rsa_pub = generate_rsa_keypair() + upsert_env("RSA_PRIVATE_KEY", rsa_priv) + upsert_env("RSA_PUBLIC_KEY", rsa_pub) + print("RSA-2048 key pair generated") + + # --- Ed25519 --- + ed_priv, ed_pub = generate_ed25519_keypair() + upsert_env("ED25519_PRIVATE_KEY", ed_priv) + upsert_env("ED25519_PUBLIC_KEY", ed_pub) + print("Ed25519 key pair generated") + + print(f"\nKeys written to {ENV_PATH}") + print("\n--- Next Steps ---") + print("1. Start the agent registry: cd ../agent-registry && python main.py") + print("2. Register this agent's public keys with the registry:") + print(" cd ../agent-registry && python populate_sample_data.py") + print("3. Start the TAP Agent: streamlit run agent_app.py") + + +if __name__ == "__main__": + main() From 6737b5f7e16b2e4a8a9c74fa51259ed7dd63b85f Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Thu, 19 Feb 2026 14:34:29 +0800 Subject: [PATCH 08/10] Seed sample products on startup and remove tracked merchant.db Auto-seed the database (including digital products for x402) when the merchant backend starts, so users don't need to run create_sample_data.py manually. Also removes the stale merchant.db that was committed despite .gitignore, and cleans up setup_keys.py output. --- merchant-backend/app/main.py | 5 ++++- merchant-backend/merchant.db | Bin 180224 -> 0 bytes tap-agent/setup_keys.py | 5 ----- 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 merchant-backend/merchant.db diff --git a/merchant-backend/app/main.py b/merchant-backend/app/main.py index f3b2b78..db0ccc1 100644 --- a/merchant-backend/app/main.py +++ b/merchant-backend/app/main.py @@ -67,10 +67,13 @@ async def log_requests(request: Request, call_next): @app.on_event("startup") def startup_event(): - """Create database tables on startup""" + """Create database tables and seed sample data on startup""" logger.info("๐Ÿš€ Starting Reference Merchant API...") create_tables() logger.info("โœ… Database tables created/verified") + from create_sample_data import create_sample_products + create_sample_products() + logger.info("โœ… Sample products seeded") # Initialize x402 payments if enabled from app.x402_setup import initialize_x402, is_enabled initialize_x402() diff --git a/merchant-backend/merchant.db b/merchant-backend/merchant.db deleted file mode 100644 index d7e78bce304c6701802e4657a08a08e222accb35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180224 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 Date: Thu, 19 Feb 2026 16:06:00 +0800 Subject: [PATCH 09/10] Add interactive x402 setup script with blockchain polling - setup_x402.py: orchestrates wallet generation, ATA creation, and polls for SOL/USDC funding with in-place terminal updates - Wallet scripts now prompt before overwriting existing private keys - Remove redundant next-steps output from sub-scripts since the orchestrator handles user guidance - Only poll USDC for agent wallets (merchants receive payments) - Improve x402 testing instructions in README --- README.md | 41 ++-- merchant-backend/create_solana_ata.py | 2 - merchant-backend/setup_x402_wallet.py | 61 +++-- setup_x402.py | 316 ++++++++++++++++++++++++++ tap-agent/setup_x402_wallet.py | 57 +++-- 5 files changed, 408 insertions(+), 69 deletions(-) create mode 100755 setup_x402.py diff --git a/README.md b/README.md index 96deee2..2c4a541 100644 --- a/README.md +++ b/README.md @@ -101,26 +101,17 @@ The sample includes an x402 payment flow as an alternative to the browser-based #### Quick Setup (Testnet) ```bash -# 1. Generate merchant receiving wallets -cd merchant-backend && python setup_x402_wallet.py - -# 2. Fund the merchant Solana wallet with devnet SOL -# https://faucet.solana.com/ - -# 3. Create the merchant's USDC token account (ATA) on Solana -python create_solana_ata.py +python setup_x402.py +``` -# 4. Fund both wallets with testnet USDC -# https://faucet.circle.com +This interactive script generates merchant and agent wallets (Solana + EVM), then walks you through funding each wallet. It polls the blockchain every 15 seconds so you can open the faucets in your browser while the script waits: -# 5. Generate agent spending wallets -cd ../tap-agent && python setup_x402_wallet.py +1. Generates merchant wallets and waits for devnet SOL funding (https://faucet.solana.com/) +2. Creates the merchant's USDC token account (ATA) on Solana +3. Generates agent wallets +4. Waits for testnet USDC funding on agent wallets (https://faucet.circle.com) -# 6. Fund agent wallets with testnet USDC -# https://faucet.circle.com -``` - -Each setup script creates Solana + EVM keypairs and writes them to the local `.env`. The `create_solana_ata.py` script creates the USDC Associated Token Account that the merchant needs to receive Solana payments โ€” no need to install the `spl-token` CLI. +Each setup script creates Solana + EVM keypairs and writes them to the local `.env`. The `create_solana_ata.py` script creates the USDC Associated Token Account that the merchant needs to receive Solana payments. #### Manual Configuration @@ -167,11 +158,15 @@ To accept real payments, update your `merchant-backend/.env` with mainnet values Switch `X402_FACILITATOR_URL` to either PayAI or Coinbase CDP and refer to their docs for credential setup. -#### Testing -1. Select "x402 Checkout" in the TAP Agent UI -2. Enter product ID and quantity -3. Click "Pay with Crypto" to complete the payment flow -4. The agent will create a cart, request payment requirements (HTTP 402), sign the payment, and settle on-chain +#### Testing x402 Payments + +1. Make sure all services are running (see [Quick Start](#-running-the-sample)) +2. Open the TAP Agent at http://localhost:8501 +3. Under **Action Selection**, choose **x402 Checkout** +4. Enter a **Product ID** (e.g. `21` for a digital product) and **Quantity** +5. Click **Pay with USDC (x402)** + +The agent will create a cart, send a checkout request (receives HTTP 402 with payment requirements), sign the USDC payment, and settle on-chain via the facilitator. ### ๐Ÿ—๏ธ **Architecture Overview** @@ -180,4 +175,4 @@ The sample demonstrates a complete TAP ecosystem: 2. **Merchant Frontend** provides the e-commerce interface 3. **CDN Proxy** intercepts and verifies agent signatures 4. **Merchant Backend** processes verified requests -5. **Agent Registry** manages agent public keys and metadata \ No newline at end of file +5. **Agent Registry** manages agent public keys and metadata diff --git a/merchant-backend/create_solana_ata.py b/merchant-backend/create_solana_ata.py index 65120fc..47922e3 100644 --- a/merchant-backend/create_solana_ata.py +++ b/merchant-backend/create_solana_ata.py @@ -138,8 +138,6 @@ def main(): sig = rpc(rpc_url, "sendTransaction", [tx_b64, {"encoding": "base64"}]) print(f"Transaction signature: {sig}") print(f"\nUSDC ATA created successfully: {ata}") - print(f"\nYou can now fund this wallet with testnet USDC at:") - print(f" https://faucet.circle.com (address: {wallet})") if __name__ == "__main__": diff --git a/merchant-backend/setup_x402_wallet.py b/merchant-backend/setup_x402_wallet.py index 84fe9ee..d27a49a 100644 --- a/merchant-backend/setup_x402_wallet.py +++ b/merchant-backend/setup_x402_wallet.py @@ -59,27 +59,55 @@ def upsert_env(key: str, value: str): ENV_PATH.write_text("".join(lines)) +def read_env(key: str) -> str | None: + """Return the value of *key* from .env, or None if not found.""" + if not ENV_PATH.exists(): + return None + for line in ENV_PATH.read_text().splitlines(): + stripped = line.lstrip() + if stripped.startswith(f"{key}="): + return stripped.split("=", 1)[1].strip() + return None + + +def confirm_overwrite(label: str, key: str) -> bool: + """Prompt the user to confirm overwriting an existing private key.""" + existing = read_env(key) + if not existing or existing in ("", "", ""): + return True + answer = input(f"{label} private key already exists in .env. Overwrite? [y/N] ").strip().lower() + return answer == "y" + + def main(): print("=== x402 Merchant Wallet Setup ===\n") # --- Solana --- try: - svm_privkey, svm_address = generate_solana_keypair() - print(f"Solana address: {svm_address}") - print(f" (private key stored in .env as X402_SVM_PRIVATE_KEY)") - upsert_env("X402_SVM_ADDRESS", svm_address) - upsert_env("X402_SVM_PRIVATE_KEY", svm_privkey) + if confirm_overwrite("Solana", "X402_SVM_PRIVATE_KEY"): + svm_privkey, svm_address = generate_solana_keypair() + print(f"Solana address: {svm_address}") + print(f" (private key stored in .env as X402_SVM_PRIVATE_KEY)") + upsert_env("X402_SVM_ADDRESS", svm_address) + upsert_env("X402_SVM_PRIVATE_KEY", svm_privkey) + else: + svm_address = read_env("X402_SVM_ADDRESS") + print(f"Solana address: {svm_address} (existing)") except ImportError: print("Skipping Solana โ€” 'solders' not installed (pip install x402[svm])") svm_address = None # --- EVM --- try: - evm_privkey, evm_address = generate_evm_account() - print(f"EVM address: {evm_address}") - print(f" (private key stored in .env as X402_EVM_PRIVATE_KEY)") - upsert_env("X402_EVM_ADDRESS", evm_address) - upsert_env("X402_EVM_PRIVATE_KEY", evm_privkey) + if confirm_overwrite("EVM", "X402_EVM_PRIVATE_KEY"): + evm_privkey, evm_address = generate_evm_account() + print(f"EVM address: {evm_address}") + print(f" (private key stored in .env as X402_EVM_PRIVATE_KEY)") + upsert_env("X402_EVM_ADDRESS", evm_address) + upsert_env("X402_EVM_PRIVATE_KEY", evm_privkey) + else: + evm_address = read_env("X402_EVM_ADDRESS") + print(f"EVM address: {evm_address} (existing)") except ImportError: print("Skipping EVM โ€” 'eth_account' not installed (pip install eth-account)") evm_address = None @@ -92,19 +120,6 @@ def main(): print(f"\nWrote configuration to {ENV_PATH}") - # --- Next steps --- - print("\n--- Next Steps ---") - if svm_address: - print(f"1. Fund Solana wallet with devnet SOL:") - print(f" https://faucet.solana.com/") - print(f"2. Create USDC token account (ATA):") - print(f" python create_solana_ata.py") - print(f"3. Fund with testnet USDC:") - print(f" https://faucet.circle.com (address: {svm_address})") - if evm_address: - print(f"4. Fund EVM wallet with Base Sepolia ETH + testnet USDC:") - print(f" https://faucet.circle.com (address: {evm_address})") - if __name__ == "__main__": main() diff --git a/setup_x402.py b/setup_x402.py new file mode 100755 index 0000000..70586d4 --- /dev/null +++ b/setup_x402.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +x402 Payment Setup โ€” generates wallets, creates token accounts, and waits +for the user to fund them via testnet faucets. + +Polls the blockchain every 15 seconds so the user can fund wallets in the +background while the script waits. + +Usage: python setup_x402.py +""" + +import logging +import os +import subprocess +import sys +import time +from pathlib import Path + +import requests +from dotenv import load_dotenv + +# Suppress python-dotenv warnings about PEM keys it can't parse +logging.getLogger("dotenv.main").setLevel(logging.ERROR) + +ROOT = Path(__file__).parent + +# --------------------------------------------------------------------------- +# Network constants +# --------------------------------------------------------------------------- +SOLANA_RPC = "https://api.devnet.solana.com" +BASE_SEPOLIA_RPC = "https://sepolia.base.org" + +USDC_MINT_DEVNET = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" +USDC_CONTRACT_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +POLL_INTERVAL = 15 # seconds + +# ANSI escape helpers +CLEAR_LINE = "\033[2K" +MOVE_UP = "\033[A" + + +# --------------------------------------------------------------------------- +# RPC helpers +# --------------------------------------------------------------------------- +def solana_sol_balance(address: str) -> float: + """Return SOL balance for *address* (in SOL, not lamports).""" + resp = requests.post( + SOLANA_RPC, + json={"jsonrpc": "2.0", "id": 1, "method": "getBalance", "params": [address]}, + timeout=10, + ) + lamports = resp.json()["result"]["value"] + return lamports / 1e9 + + +def solana_usdc_balance(owner: str) -> float: + """Return USDC (SPL) balance for *owner* on Solana devnet.""" + resp = requests.post( + SOLANA_RPC, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "getTokenAccountsByOwner", + "params": [ + owner, + {"mint": USDC_MINT_DEVNET}, + {"encoding": "jsonParsed"}, + ], + }, + timeout=10, + ) + accounts = resp.json()["result"]["value"] + if not accounts: + return 0.0 + info = accounts[0]["account"]["data"]["parsed"]["info"]["tokenAmount"] + return float(info["uiAmount"] or 0) + + +def evm_usdc_balance(address: str) -> float: + """Return USDC balance for *address* on Base Sepolia (6 decimals).""" + # balanceOf(address) selector = 0x70a08231 + addr_padded = address.lower().replace("0x", "").zfill(64) + data = f"0x70a08231{addr_padded}" + resp = requests.post( + BASE_SEPOLIA_RPC, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": USDC_CONTRACT_BASE_SEPOLIA, "data": data}, "latest"], + }, + timeout=10, + ) + hex_val = resp.json().get("result", "0x0") + return int(hex_val, 16) / 1e6 + + +# --------------------------------------------------------------------------- +# Polling +# --------------------------------------------------------------------------- +def wait_for_balance(label: str, faucet_url: str, address: str, check_fn, threshold=0.0): + """Poll *check_fn(address)* until the balance exceeds *threshold*. + + Uses a single line that rewrites itself for both countdown and status. + """ + print(f"\n Fund {label}:") + print(f" {faucet_url}") + print(f" Address: {address}") + + try: + balance = check_fn(address) + except Exception: + balance = 0.0 + + if balance > threshold: + print(f" Balance: {balance} โ€” already funded!") + return balance + + while True: + for i in range(POLL_INTERVAL, 0, -1): + sys.stdout.write(f"\r{CLEAR_LINE} Waiting for deposit... (next check in {i}s)") + sys.stdout.flush() + time.sleep(1) + sys.stdout.write(f"\r{CLEAR_LINE} Checking...") + sys.stdout.flush() + try: + balance = check_fn(address) + except Exception: + balance = 0.0 + if balance > threshold: + sys.stdout.write(f"\r{CLEAR_LINE} Balance: {balance} โ€” funded!\n") + sys.stdout.flush() + return balance + + +def wait_for_all_usdc(wallets: list[tuple[str, str, callable]]): + """Poll multiple wallets until all have USDC, rewriting status in place.""" + print("\n Fund all wallets with testnet USDC:") + print(f" https://faucet.circle.com") + + n = len(wallets) + balances = {} + funded = {} + + # Print address list + for label, address, _ in wallets: + print(f" {label}: {address}") + print() + + def check_all(): + for label, address, check_fn in wallets: + if funded.get(address): + continue + try: + bal = check_fn(address) + except Exception: + bal = 0.0 + balances[address] = bal + funded[address] = bal > 0 + + def print_status(): + for label, address, _ in wallets: + marker = "x" if funded.get(address) else " " + bal = balances.get(address, 0.0) + status = f"{bal} USDC" if funded.get(address) else "waiting..." + print(f" [{marker}] {label}: {status}") + + # Initial check + check_all() + print_status() + + while not all(funded.values()): + # Countdown on the line below the status block + for i in range(POLL_INTERVAL, 0, -1): + sys.stdout.write(f"\r{CLEAR_LINE} Next check in {i}s...") + sys.stdout.flush() + time.sleep(1) + + check_all() + + # Clear countdown line, move up over status lines, clear and reprint + sys.stdout.write(f"\r{CLEAR_LINE}") + for _ in range(n): + sys.stdout.write(f"{MOVE_UP}{CLEAR_LINE}") + sys.stdout.flush() + print_status() + + print("\n All wallets funded!") + + +# --------------------------------------------------------------------------- +# Subprocess helpers +# --------------------------------------------------------------------------- +def run_script(description: str, script: str, cwd: Path, interactive: bool = False) -> str: + """Run a Python script and return stdout. + + If *interactive* is True, stdin/stdout/stderr are inherited so the + subprocess can prompt the user directly (output is not captured). + """ + print(f"\n==> {description}") + if interactive: + result = subprocess.run( + [sys.executable, script], + cwd=cwd, + ) + if result.returncode != 0: + print(f" (script exited with code {result.returncode})") + return "" + + result = subprocess.run( + [sys.executable, script], + cwd=cwd, + capture_output=True, + text=True, + ) + output = result.stdout.strip() + if output: + for line in output.splitlines(): + print(f" {line}") + if result.returncode != 0: + stderr = result.stderr.strip() + if stderr: + for line in stderr.splitlines(): + print(f" {line}") + print(f" (script exited with code {result.returncode})") + return output + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(): + print("=" * 50) + print(" x402 Payment Setup (Testnet)") + print("=" * 50) + + # ---- 1. Generate merchant wallets -------------------------------------- + run_script( + "Generating merchant wallets...", + "setup_x402_wallet.py", + ROOT / "merchant-backend", + interactive=True, + ) + + # Read addresses from .env (most reliable source) + load_dotenv(ROOT / "merchant-backend" / ".env", override=True) + merchant_svm = os.getenv("X402_SVM_ADDRESS") + merchant_evm = os.getenv("X402_EVM_ADDRESS") + + # ---- 2. Wait for SOL funding (needed for ATA creation) ----------------- + if merchant_svm: + wait_for_balance( + label="merchant Solana wallet with devnet SOL", + faucet_url="https://faucet.solana.com/", + address=merchant_svm, + check_fn=solana_sol_balance, + ) + + # ---- 3. Create merchant USDC token account (ATA) ---------------------- + run_script( + "Creating merchant USDC token account (ATA)...", + "create_solana_ata.py", + ROOT / "merchant-backend", + ) + + # ---- 4. Generate agent wallets ----------------------------------------- + run_script( + "Generating agent wallets...", + "setup_x402_wallet.py", + ROOT / "tap-agent", + interactive=True, + ) + + # Derive agent addresses from private keys in .env + agent_svm = None + agent_evm = None + load_dotenv(ROOT / "tap-agent" / ".env", override=True) + try: + from solders.keypair import Keypair + svm_key = os.getenv("SVM_PRIVATE_KEY", "") + if svm_key: + agent_svm = str(Keypair.from_base58_string(svm_key).pubkey()) + except (ImportError, Exception): + pass + try: + from eth_account import Account + evm_key = os.getenv("EVM_PRIVATE_KEY", "") + if evm_key: + agent_evm = Account.from_key(evm_key).address + except (ImportError, Exception): + pass + + # ---- 5. Wait for USDC funding on agent wallets ------------------------- + usdc_wallets = [] + if agent_svm: + usdc_wallets.append(("Agent Solana", agent_svm, solana_usdc_balance)) + if agent_evm: + usdc_wallets.append(("Agent EVM", agent_evm, evm_usdc_balance)) + + if usdc_wallets: + wait_for_all_usdc(usdc_wallets) + + # ---- Done -------------------------------------------------------------- + print() + print("=" * 50) + print(" x402 setup complete!") + print() + print(" Restart these services to pick up the new config:") + print(" - Merchant Backend (cd merchant-backend && python -m uvicorn app.main:app --reload)") + print(" - TAP Agent (cd tap-agent && streamlit run agent_app.py)") + print("=" * 50) + + +if __name__ == "__main__": + main() diff --git a/tap-agent/setup_x402_wallet.py b/tap-agent/setup_x402_wallet.py index e44eeaf..5f7d118 100644 --- a/tap-agent/setup_x402_wallet.py +++ b/tap-agent/setup_x402_wallet.py @@ -58,44 +58,59 @@ def upsert_env(key: str, value: str): ENV_PATH.write_text("".join(lines)) +def read_env(key: str) -> str | None: + """Return the value of *key* from .env, or None if not found.""" + if not ENV_PATH.exists(): + return None + for line in ENV_PATH.read_text().splitlines(): + stripped = line.lstrip() + if stripped.startswith(f"{key}="): + return stripped.split("=", 1)[1].strip() + return None + + +def confirm_overwrite(label: str, key: str) -> bool: + """Prompt the user to confirm overwriting an existing private key.""" + existing = read_env(key) + if not existing or existing in ("", "", ""): + return True + answer = input(f"{label} private key already exists in .env. Overwrite? [y/N] ").strip().lower() + return answer == "y" + + def main(): print("=== x402 Agent Wallet Setup ===\n") # --- Solana --- try: - svm_privkey, svm_address = generate_solana_keypair() - print(f"Solana address: {svm_address}") - print(f" (private key stored in .env as SVM_PRIVATE_KEY)") - upsert_env("SVM_PRIVATE_KEY", svm_privkey) + if confirm_overwrite("Solana", "SVM_PRIVATE_KEY"): + svm_privkey, svm_address = generate_solana_keypair() + print(f"Solana address: {svm_address}") + print(f" (private key stored in .env as SVM_PRIVATE_KEY)") + upsert_env("SVM_PRIVATE_KEY", svm_privkey) + else: + print("Solana wallet: keeping existing key") + svm_address = None except ImportError: print("Skipping Solana โ€” 'solders' not installed (pip install x402[svm])") svm_address = None # --- EVM --- try: - evm_privkey, evm_address = generate_evm_account() - print(f"EVM address: {evm_address}") - print(f" (private key stored in .env as EVM_PRIVATE_KEY)") - upsert_env("EVM_PRIVATE_KEY", evm_privkey) + if confirm_overwrite("EVM", "EVM_PRIVATE_KEY"): + evm_privkey, evm_address = generate_evm_account() + print(f"EVM address: {evm_address}") + print(f" (private key stored in .env as EVM_PRIVATE_KEY)") + upsert_env("EVM_PRIVATE_KEY", evm_privkey) + else: + print("EVM wallet: keeping existing key") + evm_address = None except ImportError: print("Skipping EVM โ€” 'eth_account' not installed (pip install eth-account)") evm_address = None print(f"\nWrote configuration to {ENV_PATH}") - # --- Next steps --- - print("\n--- Next Steps ---") - if svm_address: - print(f"1. Fund Solana wallet with devnet SOL: https://faucet.solana.com/") - print(f"2. Get testnet USDC: https://faucet.circle.com") - print(f" Fund address: {svm_address}") - if evm_address: - print(f"3. Fund EVM wallet with Base Sepolia ETH + testnet USDC:") - print(f" https://faucet.circle.com") - print(f" Fund address: {evm_address}") - print(f"\n4. Run the merchant setup script too:") - print(f" cd ../merchant-backend && python setup_x402_wallet.py") - if __name__ == "__main__": main() From aadddd9064baddb94883ab5cc7ba632760471f66 Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Fri, 20 Feb 2026 12:41:03 +0800 Subject: [PATCH 10/10] Update merchant README to reflect auto-seeding on startup The database and sample data are now created automatically when the server starts, so remove the manual create_sample_data.py step from the setup instructions. --- merchant-backend/README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/merchant-backend/README.md b/merchant-backend/README.md index b2b288c..70a18b2 100644 --- a/merchant-backend/README.md +++ b/merchant-backend/README.md @@ -8,11 +8,8 @@ Sample e-commerce backend service demonstrating TAP (Trusted Agent Protocol) int # Install dependencies (from root directory) pip install -r requirements.txt -# Initialize sample database +# Start server (database is auto-created and seeded on startup) cd merchant-backend -python create_sample_data.py - -# Start server python -m uvicorn app.main:app --reload --port 8000 ``` @@ -44,8 +41,10 @@ This sample demonstrates: ## Data Management ### Initialize Sample Data +The database and sample products are automatically created when the server starts. No manual setup is needed. + +To re-seed manually (e.g., after deleting the database): ```bash -# Create database and populate with sample products python create_sample_data.py ``` @@ -114,9 +113,8 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 ### Database Management ```bash -# Reset database +# Reset database (will be re-created and seeded on next server start) rm merchant.db -python create_sample_data.py # Update schema python update_database.py