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..2c4a541 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 @@ -88,6 +94,80 @@ 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 +python setup_x402.py +``` + +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: + +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) + +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 + +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 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** The sample demonstrates a complete TAP ecosystem: @@ -95,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/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/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 diff --git a/merchant-backend/app/main.py b/merchant-backend/app/main.py index fac5fec..db0ccc1 100644 --- a/merchant-backend/app/main.py +++ b/merchant-backend/app/main.py @@ -67,10 +67,20 @@ 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() + 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..1a1b7cb 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,153 @@ 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", + "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, + "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 +930,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_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 diff --git a/merchant-backend/create_solana_ata.py b/merchant-backend/create_solana_ata.py new file mode 100644 index 0000000..47922e3 --- /dev/null +++ b/merchant-backend/create_solana_ata.py @@ -0,0 +1,144 @@ +#!/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}") + + +if __name__ == "__main__": + main() diff --git a/merchant-backend/merchant.db b/merchant-backend/merchant.db deleted file mode 100644 index d7e78bc..0000000 Binary files a/merchant-backend/merchant.db and /dev/null differ diff --git a/merchant-backend/setup_x402_wallet.py b/merchant-backend/setup_x402_wallet.py new file mode 100644 index 0000000..d27a49a --- /dev/null +++ b/merchant-backend/setup_x402_wallet.py @@ -0,0 +1,125 @@ +#!/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 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: + 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: + 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 + + # --- 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}") + + +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/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/.env.example b/tap-agent/.env.example index b4e48ed..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 @@ -19,3 +20,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..a47e0df 100644 --- a/tap-agent/agent_app.py +++ b/tap-agent/agent_app.py @@ -1394,6 +1394,92 @@ 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, + PAYMENT_RESPONSE_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: + 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}"} + + def main(): st.set_page_config( page_title="TAP Agent", @@ -1529,9 +1615,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 +1708,63 @@ 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 USDC (x402)" + 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("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..."): + 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")) + 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: + 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_keys.py b/tap-agent/setup_keys.py new file mode 100644 index 0000000..a78aa72 --- /dev/null +++ b/tap-agent/setup_keys.py @@ -0,0 +1,109 @@ +#!/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}") + + +if __name__ == "__main__": + main() diff --git a/tap-agent/setup_x402_wallet.py b/tap-agent/setup_x402_wallet.py new file mode 100644 index 0000000..5f7d118 --- /dev/null +++ b/tap-agent/setup_x402_wallet.py @@ -0,0 +1,116 @@ +#!/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 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: + 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: + 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}") + + +if __name__ == "__main__": + main()