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
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ APP_PORT=8000
APP_BASE_URL=http://localhost:8000

# x402 Payment Settings
PAY_TO_ADDRESS=0xYourWalletAddress
NETWORK=base-sepolia
FACILITATOR_URL=https://x402f1.secondstate.io
MAX_TIMEOUT_SECONDS=60
Expand Down
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,20 @@ jobs:
- name: "Test 1: Health Check"
run: |
RESPONSE=$(curl -s http://localhost:8080/)
echo "$RESPONSE"
echo "$RESPONSE" | grep -q '"status":"running"'
echo "$RESPONSE" | head -5
echo "$RESPONSE" | grep -q 'Payment Link Service'

- name: "Test 2: Create Payment Link"
run: |
RESPONSE=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05")
TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678"
RESPONSE=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05&receiver=$TEST_RECEIVER")
echo "$RESPONSE"
PAYMENT_ID=$(echo "$RESPONSE" | jq -r '.payment_id')
echo "PAYMENT_ID=$PAYMENT_ID" >> $GITHUB_ENV
echo "$RESPONSE" | jq -e '.payment_id' > /dev/null
echo "$RESPONSE" | jq -e '.payment_url' > /dev/null
echo "$RESPONSE" | jq -e '.amount' > /dev/null
echo "$RESPONSE" | jq -e '.receiver' > /dev/null

- name: "Test 2b: Verify Database Record"
run: |
Expand All @@ -64,15 +66,17 @@ jobs:
conn = sqlite3.connect('payments.db')
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute('SELECT payment_id, amount, status, tx_hash FROM payments WHERE payment_id=?', ('${{ env.PAYMENT_ID }}',))
cur.execute('SELECT payment_id, amount, receiver, status, tx_hash FROM payments WHERE payment_id=?', ('${{ env.PAYMENT_ID }}',))
row = cur.fetchone()
assert row is not None, 'Record not found in database'
assert row['amount'] == 0.05, f'Amount mismatch: {row[\"amount\"]}'
assert row['receiver'] == '0x1234567890abcdef1234567890abcdef12345678', f'Receiver mismatch: {row[\"receiver\"]}'
assert row['status'] == 'pending', f'Status mismatch: {row[\"status\"]}'
assert row['tx_hash'] is None, f'tx_hash should be None: {row[\"tx_hash\"]}'
print('Database record verified successfully')
print(f' payment_id: {row[\"payment_id\"]}')
print(f' amount: {row[\"amount\"]}')
print(f' receiver: {row[\"receiver\"]}')
print(f' status: {row[\"status\"]}')
print(f' tx_hash: {row[\"tx_hash\"]}')
conn.close()
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RUN uv sync --frozen --no-dev --no-install-project

# Copy application code
COPY main.py config.py database.py ./
COPY static/ ./static/

# Expose port
EXPOSE 8000
Expand Down
19 changes: 7 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,21 @@ Configure the service using environment variables:

| Variable | Default | Description |
|----------|---------|-------------|
| `PAY_TO_ADDRESS` | `0xYourWalletAddress` | Wallet address to receive payments |
| `NETWORK` | `base-sepolia` | Blockchain network (`base-sepolia` for testnet, `base` for mainnet) |
| `APP_BASE_URL` | `http://localhost:8000` | Public base URL for generated payment links |
| `APP_NAME` | `Payment Link Service` | Service name displayed in payment UI |
| `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 |
| `DATABASE_PATH` | `/data/payments.db` | SQLite database file path |
| `DATABASE_PATH` | `payments.db` | SQLite database file path |

## API Endpoints

### GET /

Health check endpoint.
Serves the web UI for creating and paying payment links.

**Response:**
```json
{
"service": "Payment Link Service",
"status": "running"
}
```
Open in a browser to access the interactive payment interface.

---

Expand All @@ -77,18 +70,20 @@ Creates a new payment link with a unique ID.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `amount` | float | Yes | Payment amount in USD (must be > 0) |
| `receiver` | string | Yes | Blockchain address to receive the payment |

**Example Request:**
```bash
curl "http://localhost:8000/create-payment-link?amount=0.01"
curl "http://localhost:8000/create-payment-link?amount=0.01&receiver=0x1234567890abcdef1234567890abcdef12345678"
```

**Response:**
```json
{
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"payment_url": "http://localhost:8000/pay/550e8400-e29b-41d4-a716-446655440000",
"amount": "0.01"
"amount": "0.01",
"receiver": "0x1234567890abcdef1234567890abcdef12345678"
}
```

Expand Down
35 changes: 20 additions & 15 deletions TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,26 +70,29 @@ curl -s http://localhost:8080/ | jq

**Request:**
```bash
curl -s "http://localhost:8080/create-payment-link?amount=0.01" | jq
TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678"
curl -s "http://localhost:8080/create-payment-link?amount=0.01&receiver=$TEST_RECEIVER" | jq
```

**Expected Response:**
```json
{
"payment_id": "<uuid>",
"payment_url": "http://localhost:8080/pay/<uuid>",
"amount": "0.01"
"amount": "0.01",
"receiver": "0x1234567890abcdef1234567890abcdef12345678"
}
```

**Pass Criteria:**
- Status code 200
- Response contains `payment_id`, `payment_url`, and `amount`
- Response contains `payment_id`, `payment_url`, `amount`, and `receiver`
- `payment_url` matches the format `http://localhost:8080/pay/<payment_id>`

**Save the payment_id for subsequent tests:**
```bash
PAYMENT_ID=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05" | jq -r '.payment_id')
TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678"
PAYMENT_ID=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05&receiver=$TEST_RECEIVER" | jq -r '.payment_id')
echo "Payment ID: $PAYMENT_ID"
```

Expand All @@ -101,29 +104,30 @@ Directly query the SQLite database to confirm the payment was stored correctly.

**Request:**
```bash
sqlite3 payments.db "SELECT payment_id, amount, status, tx_hash FROM payments WHERE payment_id='$PAYMENT_ID';"
sqlite3 payments.db "SELECT payment_id, amount, receiver, status, tx_hash FROM payments WHERE payment_id='$PAYMENT_ID';"
```

**Expected Output:**
```
<payment_id>|0.05|pending|
<payment_id>|0.05|0x1234567890abcdef1234567890abcdef12345678|pending|
```

**Alternative (formatted output):**
```bash
sqlite3 -header -column payments.db "SELECT payment_id, amount, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';"
sqlite3 -header -column payments.db "SELECT payment_id, amount, receiver, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';"
```

**Expected Output:**
```
payment_id amount status tx_hash created_at
------------------------------------ ---------- ---------- ---------- -------------------
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 0.05 pending 2024-01-01 12:00:00
payment_id amount receiver status tx_hash created_at
------------------------------------ ---------- ------------------------------------------ ---------- ---------- -------------------
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 0.05 0x1234567890abcdef1234567890abcdef12345678 pending 2024-01-01 12:00:00
```

**Pass Criteria:**
- Record exists in the database
- `amount` matches the requested amount (0.05)
- `receiver` matches the requested receiver address
- `status` is `pending`
- `tx_hash` is empty/null

Expand Down Expand Up @@ -287,28 +291,29 @@ Save this as `test_endpoints.sh` and run with `bash test_endpoints.sh`:
#!/bin/bash

BASE_URL="http://localhost:8080"
TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678"

echo "=== Test 1: Health Check ==="
curl -s "$BASE_URL/" | jq
curl -s "$BASE_URL/" | head -5
echo

echo "=== Test 2: Create Payment Link ==="
RESPONSE=$(curl -s "$BASE_URL/create-payment-link?amount=0.05")
RESPONSE=$(curl -s "$BASE_URL/create-payment-link?amount=0.05&receiver=$TEST_RECEIVER")
echo "$RESPONSE" | jq
PAYMENT_ID=$(echo "$RESPONSE" | jq -r '.payment_id')
echo "Saved Payment ID: $PAYMENT_ID"
echo

echo "=== Test 2b: Verify Database Record ==="
sqlite3 -header -column payments.db "SELECT payment_id, amount, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';"
sqlite3 -header -column payments.db "SELECT payment_id, amount, receiver, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';"
echo

echo "=== Test 3: Invalid Amount ==="
curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link?amount=-5" | head -1
curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link?amount=-5&receiver=$TEST_RECEIVER" | head -1
echo

echo "=== Test 4: Missing Amount ==="
curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link" | head -1
curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link?receiver=$TEST_RECEIVER" | head -1
echo

echo "=== Test 5: Check Payment Status (Pending) ==="
Expand Down
1 change: 0 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def __init__(self) -> None:
self.app_base_url: str = os.getenv("APP_BASE_URL", "http://localhost:8000")

# x402 Payment settings
self.pay_to_address: str = os.getenv("PAY_TO_ADDRESS", "0xYourWalletAddress")
self.network: str = os.getenv("NETWORK", "base-sepolia")
self.facilitator_url: str = os.getenv(
"FACILITATOR_URL", "https://x402f1.secondstate.io"
Expand Down
9 changes: 6 additions & 3 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ async def init_db() -> None:
CREATE TABLE IF NOT EXISTS payments (
payment_id TEXT PRIMARY KEY,
amount REAL NOT NULL,
receiver TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
tx_hash TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
Expand All @@ -21,17 +22,19 @@ async def init_db() -> None:
await db.commit()


async def create_payment(payment_id: str, amount: float) -> None:
async def create_payment(payment_id: str, amount: float, receiver: str) -> None:
"""Create a new payment record.

Args:
payment_id: Unique identifier for the payment.
amount: Payment amount in USD.
receiver: Blockchain address to receive the payment.
"""
async with aiosqlite.connect(settings.database_path) as db:
await db.execute(
"INSERT INTO payments (payment_id, amount, status) VALUES (?, ?, ?)",
(payment_id, amount, "pending"),
"INSERT INTO payments (payment_id, amount, receiver, status) "
"VALUES (?, ?, ?, ?)",
(payment_id, amount, receiver, "pending"),
)
await db.commit()

Expand Down
36 changes: 27 additions & 9 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, AsyncGenerator

from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles

from config import settings
from database import create_payment, get_payment, init_db, update_payment_status

# Static files directory
STATIC_DIR = Path(__file__).parent / "static"

if TYPE_CHECKING:
from x402_payment_service import PaymentService as PaymentServiceType

Expand Down Expand Up @@ -47,35 +52,48 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp
)


# Mount static files if directory exists
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")


@app.get("/")
async def root() -> dict[str, str]:
"""Root endpoint with service info."""
return {
"service": settings.app_name,
"status": "running",
}
async def root() -> Response:
"""Serve the index.html page."""
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return JSONResponse(
{
"service": settings.app_name,
"status": "running",
}
)


@app.get("/create-payment-link")
async def create_payment_link(
amount: float = Query(..., gt=0, description="Payment amount in USD"),
receiver: str = Query(..., description="Blockchain address to receive payment"),
) -> dict[str, str]:
"""Create a new payment link with a unique ID.

Args:
amount: Payment amount in USD (must be greater than 0).
receiver: Blockchain address to receive the payment.

Returns:
JSON with the payment link URL.
"""
payment_id = str(uuid.uuid4())
await create_payment(payment_id, amount)
await create_payment(payment_id, amount, receiver)

payment_url = f"{settings.app_base_url}/pay/{payment_id}"
return {
"payment_id": payment_id,
"payment_url": payment_url,
"amount": str(amount),
"receiver": receiver,
}


Expand Down Expand Up @@ -153,7 +171,7 @@ async def pay(payment_id: str, request: Request) -> Response:
price=payment_record["amount"],
description=f"Payment for order {payment_id}",
network=settings.network,
pay_to_address=settings.pay_to_address,
pay_to_address=payment_record["receiver"],
facilitator_url=settings.facilitator_url,
max_timeout_seconds=settings.max_timeout_seconds,
)
Expand Down
Loading