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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .env.example.base-mainnet
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
7 changes: 0 additions & 7 deletions .env.example.base-sepolia
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 72 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:**
Expand Down Expand Up @@ -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 |
|----------|---------|-------------|
Expand All @@ -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
Expand All @@ -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:**
Expand All @@ -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"
}
```

Expand Down Expand Up @@ -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.
Expand All @@ -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:**
Expand All @@ -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.
91 changes: 83 additions & 8 deletions config.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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(
Expand Down
22 changes: 17 additions & 5 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading