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
24 changes: 24 additions & 0 deletions .env.example.base-mainnet
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Application Settings
APP_NAME=Payment Link Service
APP_LOGO=/static/logo.png
APP_HOST=0.0.0.0
APP_PORT=8000
APP_BASE_URL=http://localhost:8000

# x402 Payment Settings
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/

# Database Settings
DATABASE_PATH=payments.db
10 changes: 10 additions & 0 deletions .env.example → .env.example.base-sepolia
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,15 @@ 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/

# Database Settings
DATABASE_PATH=payments.db
15 changes: 15 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,27 @@ def __init__(self) -> None:
self.app_base_url: str = os.getenv("APP_BASE_URL", "http://localhost:8000")

# x402 Payment settings
# Valid networks: base-sepolia (testnet), base (mainnet)
self.network: str = os.getenv("NETWORK", "base-sepolia")
self.facilitator_url: str = os.getenv(
"FACILITATOR_URL", "https://x402f1.secondstate.io"
)
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"))
Comment on lines +30 to +36
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TOKEN_VERSION configuration is added to config.py and the .env examples but is never used in the codebase. The frontend retrieves token name and version from the payment requirements (req.extra?.name and req.extra?.version) at lines 295-296 in index.html, but the server configuration doesn't include tokenVersion in the /config endpoint response.

If TOKEN_VERSION is intended to be used, it should be:

  1. Added to the /config endpoint response (line 92-100)
  2. Used in the frontend instead of falling back to the hardcoded '2'

Otherwise, remove TOKEN_VERSION from the configuration to avoid confusion.

Copilot uses AI. Check for mistakes.

# Chain settings
self.chain_id: int = int(os.getenv("CHAIN_ID", "84532"))
self.explorer_url: str = os.getenv(
"EXPLORER_URL", "https://sepolia.basescan.org/tx/"
)
Comment on lines +30 to +42
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new token and chain configuration settings (TOKEN_ADDRESS, TOKEN_NAME, TOKEN_SYMBOL, TOKEN_DECIMALS, TOKEN_VERSION, CHAIN_ID, EXPLORER_URL) are not documented in the README's Configuration section. The README only lists the original settings and doesn't mention these new configurable options.

Update the Configuration section in README.md to include these new environment variables with their descriptions and defaults.

Copilot uses AI. Check for mistakes.

# Database settings
self.database_path: str = os.getenv("DATABASE_PATH", "payments.db")

Expand Down
56 changes: 53 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Payment Link Service - A web app for creating x402-protected payment links."""

import traceback
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
Expand Down Expand Up @@ -40,8 +41,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Catch all unhandled exceptions and return a JSON error response."""
import traceback

return JSONResponse(
status_code=500,
content={
Expand Down Expand Up @@ -71,6 +70,36 @@ async def root() -> Response:
)


@app.get("/create")
async def create_page() -> Response:
"""Serve the create payment link page."""
create_path = STATIC_DIR / "create-payment-link.html"
if create_path.exists():
return FileResponse(create_path)
return JSONResponse(
status_code=404,
content={"error": "Page not found"},
)
Comment on lines +73 to +82
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new /create endpoint (which serves the create-payment-link.html page) has no test coverage. While the /create-payment-link API endpoint has tests, this new UI endpoint should also be tested to ensure it returns the expected HTML page.

Add a test similar to test_root_endpoint to verify that the /create endpoint returns a 200 status and serves the create-payment-link.html page.

Copilot uses AI. Check for mistakes.


@app.get("/config")
async def get_config() -> dict[str, str | int]:
"""Return client configuration for the frontend.

Returns:
JSON with network, token, and chain configuration.
"""
return {
"network": settings.network,
"tokenAddress": settings.token_address,
"tokenName": settings.token_name,
"tokenSymbol": settings.token_symbol,
"tokenDecimals": settings.token_decimals,
Comment on lines +90 to +97
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new token configuration settings (token_address, token_name, token_symbol, token_decimals) are exposed via the /config endpoint but are not actually used by the backend PaymentService initialization (lines 201-212). The PaymentService only receives the network parameter, which likely determines the token settings internally.

This creates a potential inconsistency where:

  1. The frontend shows one set of token settings from /config
  2. The backend uses potentially different token settings from the x402 library based on the network parameter

Either:

  • Pass these token settings to PaymentService if it supports them, or
  • Remove these settings from the configuration and rely entirely on the x402 library's defaults based on network, or
  • Document that these settings are frontend-only and the backend uses network-based defaults

The current approach could confuse users who configure custom token settings expecting them to work end-to-end.

Suggested change
JSON with network, token, and chain configuration.
"""
return {
"network": settings.network,
"tokenAddress": settings.token_address,
"tokenName": settings.token_name,
"tokenSymbol": settings.token_symbol,
"tokenDecimals": settings.token_decimals,
JSON with network and chain configuration.
"""
return {
"network": settings.network,

Copilot uses AI. Check for mistakes.
"chainId": settings.chain_id,
"explorerUrl": settings.explorer_url,
}


@app.get("/create-payment-link")
async def create_payment_link(
amount: float = Query(..., gt=0, description="Payment amount in USD"),
Expand Down Expand Up @@ -162,11 +191,17 @@ async def pay(payment_id: str, request: Request) -> Response:
)

# Create payment service for x402 verification
headers_dict = dict(request.headers)

# Normalize header case - x402 library expects 'X-Payment' not 'x-payment'
if "x-payment" in headers_dict and "X-Payment" not in headers_dict:
headers_dict["X-Payment"] = headers_dict["x-payment"]

Comment on lines +197 to +199
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header normalization only handles the lowercase 'x-payment' to 'X-Payment' conversion. However, HTTP headers are case-insensitive, and various frameworks/proxies might normalize them differently (e.g., 'X-payment', 'x-Payment', etc.).

Consider using a case-insensitive header lookup instead. For example:

  • Iterate through headers_dict to find any case variation of 'x-payment'
  • Or normalize all header keys to lowercase and adjust the x402 library expectations accordingly

The current implementation could miss valid X-Payment headers that have different casing.

Suggested change
if "x-payment" in headers_dict and "X-Payment" not in headers_dict:
headers_dict["X-Payment"] = headers_dict["x-payment"]
# Perform a case-insensitive lookup for any variant of "x-payment"
payment_header_value = None
for header_name, header_value in headers_dict.items():
if header_name.lower() == "x-payment":
payment_header_value = header_value
break
# Ensure the canonical header key is present for the x402 library
if payment_header_value is not None and "X-Payment" not in headers_dict:
headers_dict["X-Payment"] = payment_header_value

Copilot uses AI. Check for mistakes.
try:
payment_service: PaymentServiceType = PaymentService(
app_name=settings.app_name,
app_logo=settings.app_logo,
headers=dict(request.headers),
headers=headers_dict,
resource_url=str(request.url),
price=payment_record["amount"],
description=f"Payment for order {payment_id}",
Expand Down Expand Up @@ -214,6 +249,19 @@ async def pay(payment_id: str, request: Request) -> Response:
)

# Step 3: Settle payment
# Patch httpx timeout - the x402 library doesn't set a timeout for settle(),
# but blockchain transactions can take longer than the default 5 seconds
import httpx

original_init = httpx.AsyncClient.__init__

def patched_init(self: httpx.AsyncClient, *args: object, **kwargs: object) -> None:
if "timeout" not in kwargs:
kwargs["timeout"] = 60.0 # 60 seconds for blockchain transactions
original_init(self, *args, **kwargs)

httpx.AsyncClient.__init__ = patched_init # type: ignore[method-assign]
Comment on lines +252 to +263
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The monkey patching approach to modify httpx.AsyncClient.init is fragile and could cause issues in concurrent requests or if the library changes. This modifies a global class method that could affect other parts of the application.

Consider one of these safer alternatives:

  1. Create a wrapper class that extends httpx.AsyncClient with the desired timeout
  2. Pass the timeout configuration to the x402 library if it supports it
  3. Use a context manager to temporarily patch and restore the method
  4. Submit a PR to the x402 library to support configurable timeouts

If monkey patching is necessary, at least use a thread-local or request-scoped approach to avoid affecting concurrent requests.

Copilot uses AI. Check for mistakes.

try:
(
settle_success,
Expand All @@ -226,6 +274,8 @@ async def pay(payment_id: str, request: Request) -> Response:
status_code=500,
content={"error": f"Failed to settle payment: {e}"},
)
finally:
httpx.AsyncClient.__init__ = original_init # type: ignore[method-assign]

if not settle_success:
return create_x402_response(
Expand Down
Loading
Loading