diff --git a/.env.example.base-mainnet b/.env.example.base-mainnet index b3539b3..17e00af 100644 --- a/.env.example.base-mainnet +++ b/.env.example.base-mainnet @@ -10,12 +10,6 @@ NETWORK=base FACILITATOR_URL=https://x402f1.secondstate.io MAX_TIMEOUT_SECONDS=60 -# Token Settings (USDC on Base Mainnet) -TOKEN_ADDRESS=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -TOKEN_NAME=USD Coin -TOKEN_SYMBOL=USDC -TOKEN_DECIMALS=6 - # Chain Settings (Base Mainnet) CHAIN_ID=8453 EXPLORER_URL=https://basescan.org/tx/ diff --git a/.env.example.base-sepolia b/.env.example.base-sepolia index 4907687..fe8fa3f 100644 --- a/.env.example.base-sepolia +++ b/.env.example.base-sepolia @@ -6,17 +6,10 @@ APP_PORT=8000 APP_BASE_URL=http://localhost:8000 # x402 Payment Settings -# Valid networks: base-sepolia (testnet), base (mainnet) NETWORK=base-sepolia FACILITATOR_URL=https://x402f1.secondstate.io MAX_TIMEOUT_SECONDS=60 -# Token Settings (USDC on Base Sepolia Testnet) -TOKEN_ADDRESS=0x036CbD53842c5426634e7929541eC2318f3dCF7e -TOKEN_NAME=USD Coin -TOKEN_SYMBOL=USDC -TOKEN_DECIMALS=6 - # Chain Settings (Base Sepolia Testnet) CHAIN_ID=84532 EXPLORER_URL=https://sepolia.basescan.org/tx/ diff --git a/Dockerfile b/Dockerfile index 1bd93d9..b178e5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY pyproject.toml uv.lock* ./ RUN uv sync --frozen --no-dev --no-install-project # Copy application code -COPY main.py config.py database.py ./ +COPY main.py config.py database.py tokens.yaml ./ COPY static/ ./static/ # Expose port diff --git a/README.md b/README.md index 50cd2cd..4fee48e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # Payment Link Service -A Python web service for creating x402-protected payment links. This service allows you to generate unique payment URLs that require cryptocurrency payments before granting access. +A Python web service for creating x402-protected payment links. This service allows you to generate unique payment URLs that require cryptocurrency payments before granting access. Supports multiple ERC-3009 tokens (USDC, KII, etc.) via a configurable `tokens.yaml`. ## Quick Start with Docker -1. **Configure environment:** +1. **Configure environment and tokens:** ```bash -cp .env.example .env -# Edit .env with your wallet address and settings +# Pick a network config +cp .env.example.base-sepolia .env # testnet +# cp .env.example.base-mainnet .env # mainnet + +# Pick a token config +cp tokens.yaml.usdc tokens.yaml # USDC only +# cp tokens.yaml.kii tokens.yaml # KII only +# cp tokens.yaml.multiple-token tokens.yaml # USDC + KII ``` 2. **Build the image:** @@ -37,9 +43,32 @@ The `touch` command creates an empty database file, and the `-v` flag mounts it curl http://localhost:8000/ ``` -## Configuration +## Token Configuration -Configure the service using environment variables: +Available tokens are defined in `tokens.yaml`. Each token specifies its contract address per network; tokens without an address on the active network are automatically excluded. + +Example files: + +| File | Contents | +|------|----------| +| `tokens.yaml.usdc` | USDC (Base, Base Sepolia) | +| `tokens.yaml.kii` | KII (Base, Base Sepolia) | +| `tokens.yaml.multiple-token` | USDC + KII | + +Format: + +```yaml +tokens: + usdc: + symbol: USDC + name: USD Coin + decimals: 6 + addresses: + base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + base-sepolia: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +``` + +## Environment Variables | Variable | Default | Description | |----------|---------|-------------| @@ -49,6 +78,8 @@ Configure the service using environment variables: | `APP_LOGO` | `/static/logo.png` | Logo URL for payment UI | | `FACILITATOR_URL` | `https://x402f1.secondstate.io` | x402 facilitator service endpoint | | `MAX_TIMEOUT_SECONDS` | `60` | Payment timeout in seconds | +| `CHAIN_ID` | `84532` | Chain ID for the network | +| `EXPLORER_URL` | `https://sepolia.basescan.org/tx/` | Block explorer URL prefix | | `DATABASE_PATH` | `payments.db` | SQLite database file path | ## API Endpoints @@ -61,20 +92,44 @@ Open in a browser to access the interactive payment interface. --- +### GET /config + +Returns available tokens and chain configuration for the current network. + +```json +{ + "network": "base-sepolia", + "chainId": 84532, + "explorerUrl": "https://sepolia.basescan.org/tx/", + "tokens": [ + { + "id": "usdc", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + } + ] +} +``` + +--- + ### GET /create-payment-link Creates a new payment link with a unique ID. **Query Parameters:** -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `amount` | float | Yes | Payment amount in USD (must be > 0) | -| `receiver` | string | Yes | Blockchain address to receive the payment | +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `amount` | float | Yes | — | Payment amount (must be > 0) | +| `receiver` | string | Yes | — | Blockchain address to receive the payment | +| `token` | string | No | `usdc` | Token ID (e.g. `usdc`, `kii`) | **Example Request:** ```bash -curl "http://localhost:8000/create-payment-link?amount=0.01&receiver=0x1234567890abcdef1234567890abcdef12345678" +curl "http://localhost:8000/create-payment-link?amount=0.01&receiver=0x1234567890abcdef1234567890abcdef12345678&token=usdc" ``` **Response:** @@ -83,7 +138,8 @@ curl "http://localhost:8000/create-payment-link?amount=0.01&receiver=0x123456789 "payment_id": "550e8400-e29b-41d4-a716-446655440000", "payment_url": "http://localhost:8000/pay/550e8400-e29b-41d4-a716-446655440000", "amount": "0.01", - "receiver": "0x1234567890abcdef1234567890abcdef12345678" + "receiver": "0x1234567890abcdef1234567890abcdef12345678", + "token": "usdc" } ``` @@ -182,7 +238,7 @@ curl "http://localhost:8000/status/550e8400-e29b-41d4-a716-446655440000" 1. **Create a payment link:** ```bash -curl "http://localhost:8000/create-payment-link?amount=0.01" +curl "http://localhost:8000/create-payment-link?amount=0.01&receiver=0x1234567890abcdef1234567890abcdef12345678&token=usdc" ``` 2. **Share the `payment_url` with the payer.** When they open it in a browser, they'll see a payment interface. @@ -208,8 +264,8 @@ uv sync 2. **Configure environment:** ```bash -cp .env.example .env -# Edit .env with your settings +cp .env.example.base-sepolia .env +cp tokens.yaml.multiple-token tokens.yaml ``` 3. **Run the server:** @@ -233,4 +289,4 @@ The [x402 protocol](https://github.com/coinbase/x402) enables HTTP-native paymen 3. Client makes a blockchain payment and includes proof in the `X-Payment` header 4. Server verifies the payment and grants access -This service uses USDC on Base (or Base Sepolia for testing) for payments. +This service supports any ERC-3009 (TransferWithAuthorization) token on Base / Base Sepolia. diff --git a/config.py b/config.py index fa8cea9..402ac1e 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,94 @@ """Configuration module for loading settings from environment variables.""" import os +from pathlib import Path +from typing import Any +import yaml from dotenv import load_dotenv load_dotenv() +# Path to tokens.yaml, relative to this file +TOKENS_YAML_PATH = Path(__file__).parent / "tokens.yaml" + + +def load_tokens_config(path: Path | None = None) -> dict[str, Any]: + """Load token definitions from tokens.yaml. + + Args: + path: Optional path to tokens.yaml. Defaults to TOKENS_YAML_PATH. + + Returns: + Dictionary of token definitions keyed by token ID. + """ + yaml_path = path or TOKENS_YAML_PATH + with open(yaml_path) as f: + data = yaml.safe_load(f) + tokens = data.get("tokens", {}) + required_fields = {"symbol", "name", "decimals", "addresses"} + for token_id, token_def in tokens.items(): + missing = required_fields - set(token_def) + if missing: + raise ValueError(f"Token '{token_id}' missing required fields: {missing}") + return tokens + + +# Loaded once at import time (same pattern as `settings = Settings()` below) +_tokens_config: dict[str, Any] = load_tokens_config() + + +def get_available_tokens(network: str) -> list[dict[str, Any]]: + """Get tokens available on the given network. + + Args: + network: Network name (e.g. "base", "base-sepolia"). + + Returns: + List of token info dicts with id, symbol, name, decimals, address. + """ + all_tokens = _tokens_config + result = [] + for token_id, token_def in all_tokens.items(): + addresses = token_def.get("addresses", {}) + if network in addresses: + result.append( + { + "id": token_id, + "symbol": token_def["symbol"], + "name": token_def["name"], + "decimals": token_def["decimals"], + "address": addresses[network], + } + ) + return result + + +def get_token_by_id(token_id: str, network: str) -> dict[str, Any] | None: + """Look up a specific token by ID and network. + + Args: + token_id: Token identifier (e.g. "usdc", "kii"). + network: Network name (e.g. "base", "base-sepolia"). + + Returns: + Token info dict, or None if not found on the given network. + """ + all_tokens = _tokens_config + token_def = all_tokens.get(token_id) + if not token_def: + return None + addresses = token_def.get("addresses", {}) + if network not in addresses: + return None + return { + "id": token_id, + "symbol": token_def["symbol"], + "name": token_def["name"], + "decimals": token_def["decimals"], + "address": addresses[network], + } + class Settings: """Application settings loaded from environment variables.""" @@ -27,14 +110,6 @@ def __init__(self) -> None: ) self.max_timeout_seconds: int = int(os.getenv("MAX_TIMEOUT_SECONDS", "60")) - # Token settings - self.token_address: str = os.getenv( - "TOKEN_ADDRESS", "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - ) - self.token_name: str = os.getenv("TOKEN_NAME", "USD Coin") - self.token_symbol: str = os.getenv("TOKEN_SYMBOL", "USDC") - self.token_decimals: int = int(os.getenv("TOKEN_DECIMALS", "6")) - # Chain settings self.chain_id: int = int(os.getenv("CHAIN_ID", "84532")) self.explorer_url: str = os.getenv( diff --git a/database.py b/database.py index 9b5e4df..bc388e1 100644 --- a/database.py +++ b/database.py @@ -13,28 +13,40 @@ async def init_db() -> None: payment_id TEXT PRIMARY KEY, amount REAL NOT NULL, receiver TEXT NOT NULL, + token_id TEXT NOT NULL DEFAULT 'usdc', status TEXT NOT NULL DEFAULT 'pending', tx_hash TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + # Migration: add token_id column if missing (for existing databases) + cursor = await db.execute("PRAGMA table_info(payments)") + columns = [row[1] for row in await cursor.fetchall()] + if "token_id" not in columns: + await db.execute( + "ALTER TABLE payments ADD COLUMN token_id TEXT NOT NULL DEFAULT 'usdc'" + ) await db.commit() -async def create_payment(payment_id: str, amount: float, receiver: str) -> None: +async def create_payment( + payment_id: str, amount: float, receiver: str, token_id: str = "usdc" +) -> None: """Create a new payment record. Args: payment_id: Unique identifier for the payment. - amount: Payment amount in USD. + amount: Payment amount. receiver: Blockchain address to receive the payment. + token_id: Token identifier (e.g. "usdc", "kii"). """ async with aiosqlite.connect(settings.database_path) as db: await db.execute( - "INSERT INTO payments (payment_id, amount, receiver, status) " - "VALUES (?, ?, ?, ?)", - (payment_id, amount, receiver, "pending"), + "INSERT INTO payments " + "(payment_id, amount, receiver, token_id, status) " + "VALUES (?, ?, ?, ?, ?)", + (payment_id, amount, receiver, token_id, "pending"), ) await db.commit() diff --git a/main.py b/main.py index 91bef42..cd70056 100644 --- a/main.py +++ b/main.py @@ -4,13 +4,13 @@ import uuid from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING, AsyncGenerator +from typing import TYPE_CHECKING, Any, AsyncGenerator from fastapi import FastAPI, Query, Request, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from config import settings +from config import get_available_tokens, get_token_by_id, settings from database import create_payment, get_payment, init_db, update_payment_status # Static files directory @@ -83,47 +83,62 @@ async def create_page() -> Response: @app.get("/config") -async def get_config() -> dict[str, str | int]: +async def get_config() -> dict[str, Any]: """Return client configuration for the frontend. Returns: - JSON with network, token, and chain configuration. + JSON with network, tokens list, and chain configuration. """ + tokens = get_available_tokens(settings.network) return { "network": settings.network, - "tokenAddress": settings.token_address, - "tokenName": settings.token_name, - "tokenSymbol": settings.token_symbol, - "tokenDecimals": settings.token_decimals, "chainId": settings.chain_id, "explorerUrl": settings.explorer_url, + "tokens": tokens, } @app.get("/create-payment-link") async def create_payment_link( - amount: float = Query(..., gt=0, description="Payment amount in USD"), + amount: float = Query(..., gt=0, description="Payment amount"), receiver: str = Query(..., description="Blockchain address to receive payment"), -) -> dict[str, str]: + token: str = Query("usdc", description="Token ID (e.g. usdc, kii)"), +) -> Response: """Create a new payment link with a unique ID. Args: - amount: Payment amount in USD (must be greater than 0). + amount: Payment amount (must be greater than 0). receiver: Blockchain address to receive the payment. + token: Token identifier to use for payment. Returns: JSON with the payment link URL. """ + # Validate token exists on current network + token_info = get_token_by_id(token, settings.network) + if not token_info: + return JSONResponse( + status_code=400, + content={ + "error": ( + f"Token '{token}' not available on network '{settings.network}'" + ) + }, + ) + payment_id = str(uuid.uuid4()) - await create_payment(payment_id, amount, receiver) + await create_payment(payment_id, amount, receiver, token_id=token) payment_url = f"{settings.app_base_url}/pay/{payment_id}" - return { - "payment_id": payment_id, - "payment_url": payment_url, - "amount": str(amount), - "receiver": receiver, - } + return JSONResponse( + content={ + "payment_id": payment_id, + "payment_url": payment_url, + "amount": str(amount), + "receiver": receiver, + "token": token, + } + ) def create_x402_response(payment_service: "PaymentServiceType", error: str) -> Response: @@ -209,6 +224,7 @@ async def pay(payment_id: str, request: Request) -> Response: pay_to_address=payment_record["receiver"], facilitator_url=settings.facilitator_url, max_timeout_seconds=settings.max_timeout_seconds, + eip3009_token=payment_record["token_id"], ) except Exception as e: return JSONResponse( diff --git a/pyproject.toml b/pyproject.toml index 8038a95..0fbe14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "uvicorn>=0.32.0", "python-dotenv>=1.0.0", "aiosqlite>=0.20.0", + "pyyaml>=6.0", "x402-payment-service", ] diff --git a/static/create-payment-link.html b/static/create-payment-link.html index 39febab..117779d 100644 --- a/static/create-payment-link.html +++ b/static/create-payment-link.html @@ -31,13 +31,14 @@ font-weight: 600; color: #555; } - input[type="text"], input[type="number"] { + input[type="text"], input[type="number"], select { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px; margin-bottom: 16px; + background: white; } button { background: #0066cc; @@ -144,9 +145,14 @@

Create Payment Link

- + + + + @@ -197,9 +203,35 @@

Payment Links