walletpress/backend/x402_service.py
Rug Munch Media LLC 8b8e85a24d
fix(security): WP-002..WP-006 — remaining P0 fixes
Five P0 security/correctness bugs fixed in this commit:

WP-002 (P0-1) — Free x402 credits
  x402_service.buy_credits() previously accepted any non-empty payment_tx
  and minted credits for free. Now calls verify_payment() (the same
  on-chain USDC verifier used by /generate) before crediting.
  Direct theft vector closed.

WP-003 (P0-2) — Unsalted SHA-256 hosted passwords
  hosting.py used hashlib.sha256(password.encode()).hexdigest() with
  no salt — rainbow-table crackable. Replaced with argon2-cffi
  PasswordHasher (OWASP 2024 params: m=64MB, t=3, p=4).
  Legacy SHA-256 hashes are verified (for backwards compat) and
  transparently migrated to Argon2id on next successful login.
  login() now also strips password_hash from the response (P2-11).

WP-004 (P0-3) — In-memory _team_keys + no role enforcement
  main.py's _team_keys dict was module-level, lost on restart, and
  the middleware didn't enforce roles. Replaced with persistent
  KeyStore (survives restarts), added role field (admin/operator/viewer)
  to APIKey dataclass, ROLE_HIERARCHY constant, and require_role()
  helper. Middleware now:
    - attaches verified APIKey to request.state.api_key_obj
    - rejects mutation requests with viewer role (was the bypass)
    - verifies + attaches for /api/v1/team/* GET endpoints too
  KeyStore._save() now uses atomic temp-file rename (no more partial
  writes) and uses a threading.Lock for safety. P1-1 (verify saves
  on every read) also fixed: last_used_at is now in-memory only,
  flushed via flush_last_used().

WP-005 (P0-5) — Env-only vault KEK
  config.py now resolves the vault KEK in priority order:
    1. WP_VAULT_PASSWORD env var (legacy, dev only)
    2. ~/.walletpress/vault.key file (mode 0600)
    3. {data_dir}/vault.key file (mode 0600)
    4. Auto-generate on first run, persisted to vault.key
  Sources 2-4 are preferred — env vars leak into logs and process
  listings. Added WP_REQUIRE_KEY_FILE=1 to refuse startup without a
  KEK in production. _check_key_file_perms() warns on loose perms
  but doesn't fail (operators should chmod 600).

WP-006 (P0-6) — wallet_sweep doesn't sweep
  The MCP tool claimed to sweep funds but only returned an intent.
  Now actually builds, signs, and broadcasts EVM transactions:
    - Decrypts private key from vault via get_generator
    - Connects to chain RPC via Web3
    - Builds tx: balance - gas_cost to destination
    - Signs with eth_account
    - Broadcasts via existing _evm_broadcast helper
    - Records spending + audit log entry
  Non-EVM chains (Solana, TRON, BTC family) still return intent +
  clear 'not implemented' notice — to be added as separate PRs.

  Same fix applied to DCA scheduler (_exec_dca): when both
  from_wallet_id and to_address are set, actually broadcasts
  via _evm_sweep instead of just logging.

Test results:
  pytest tests/ tests/test_address_vectors.py
  # 80 + 22 = 102 passed, 5 skipped, no regressions

Refs: AUDIT.md, SECURITY.md, BUILDER.md
Closes: WP-002, WP-003, WP-004, WP-005, WP-006
2026-06-30 20:20:39 +07:00

374 lines
15 KiB
Python

#!/usr/bin/env python3
"""WalletPress x402 Marketplace — standalone pay-per-wallet service.
THIS IS NOT PART OF THE SELF-HOSTED PRODUCT.
This is a service WE operate at walletpress.cc.
The x402 marketplace allows bots and developers to generate wallets
on demand without an account or subscription. They pay USDC per wallet,
we generate and return the keys, and we don't store them.
To run this service separately:
uvicorn x402_service:app --port 8011
Requires:
- A Solana RPC endpoint (for payment verification)
- A USDC wallet for receiving payments
- Separate database from the main WalletPress instance
"""
from __future__ import annotations
import hashlib
import logging
import os
import secrets
import sqlite3
import time
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("x402")
app = FastAPI(title="WalletPress x402 Marketplace", version="1.0.0",
description="Pay-per-wallet generation. No account needed. Send USDC, get keys.")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ── Pricing ──────────────────────────────────────────────────────
PRICE_PER_WALLET = 0.01
MIN_ORDER_USD = 1.00
FREE_WALLETS = 3
PAYMENT_ADDRESS = os.getenv("WP_PAYMENT_ADDRESS", "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv")
PAYMENT_CHAIN = "solana"
PAYMENT_TOKEN = "USDC"
# ── Database ─────────────────────────────────────────────────────
DB_PATH = Path(os.getenv("WP_X402_DB", "/data/x402.db"))
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript("""
CREATE TABLE IF NOT EXISTS orders (
order_id TEXT PRIMARY KEY, chain TEXT NOT NULL,
count INTEGER NOT NULL, amount_usd REAL NOT NULL,
payment_tx TEXT DEFAULT '', payment_status TEXT DEFAULT 'pending',
status TEXT DEFAULT 'pending', created_at REAL NOT NULL,
client_ip TEXT DEFAULT '',
idempotency_key TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_orders_idempotency ON orders(idempotency_key);
CREATE TABLE IF NOT EXISTS credits (
api_key TEXT PRIMARY KEY, balance REAL NOT NULL DEFAULT 0,
total_spent REAL DEFAULT 0, created_at REAL NOT NULL
);
""")
conn.commit()
return conn
# ── Models ───────────────────────────────────────────────────────
class GenerateRequest(BaseModel):
chain: str = Field(default="sol", description="Chain key: sol, base, polygon, arbitrum, eth")
count: int = Field(default=1, ge=1, le=100000)
payment_tx: str = Field(default="", description="USDC tx for paid orders. Leave empty for free tier.")
api_key: str = Field(default="", description="Prepaid API key. Alternative to per-order payment.")
derivation_path: str = Field(default="", description="Custom BIP44 derivation path (e.g. m/44'/60'/0'/0/1). Empty = chain default.")
xpub: str = Field(default="", description="Master public key (xpub/tpub/ypub/zpub). Derive child keys without us seeing private keys.")
idempotency_key: str = Field(default="", description="Idempotency key. Same key = same order returned. Prevents double-charge on retry.")
class CreditsRequest(BaseModel):
amount: float = Field(default=10, ge=10)
payment_tx: str = Field(default="")
# ── Helpers ──────────────────────────────────────────────────────
def calc_price(count: int) -> float:
if count >= 100000:
return round(count * 0.001, 2)
if count >= 10000:
return round(count * 0.002, 2)
if count >= 1000:
return round(count * 0.005, 2)
return round(count * PRICE_PER_WALLET, 2)
async def verify_payment(chain: str, tx: str, expected: float) -> bool:
if expected <= 0:
return True
if not tx:
return False
from core.x402_verify import verify_usdc_payment
result = await verify_usdc_payment(chain, tx, expected)
return result["verified"]
# ── Endpoints ────────────────────────────────────────────────────
@app.post("/api/v1/marketplace/generate")
async def generate(req: GenerateRequest, request: Request):
chain = req.chain.lower()
from wallet_engine.chains import CHAINS
if chain not in CHAINS:
raise HTTPException(status_code=400, detail=f"Unsupported chain: {chain}")
count = min(max(req.count, 1), 100000)
total = calc_price(count)
client_ip = request.client.host if request.client else ""
# Idempotency: return existing order if same key used
if req.idempotency_key:
conn = get_db()
existing = conn.execute(
"SELECT * FROM orders WHERE idempotency_key = ?", (req.idempotency_key,)
).fetchone()
conn.close()
if existing:
return {"idempotent": True, "order_id": existing["order_id"], "note": "Order already processed with this idempotency key"}
order_id = f"x402_{secrets.token_hex(8)}"
# Payment via credits
if req.api_key:
conn = get_db()
row = conn.execute("SELECT balance FROM credits WHERE api_key = ?", (req.api_key,)).fetchone()
if not row or row["balance"] < total:
conn.close()
raise HTTPException(status_code=402, detail={
"error": "Insufficient credits", "balance": row["balance"] if row else 0,
"required": total, "buy_url": "/api/v1/marketplace/credits",
})
conn.execute("UPDATE credits SET balance = balance - ?, total_spent = total_spent + ? WHERE api_key = ?",
(total, total, req.api_key))
conn.commit()
conn.close()
payment_method = "credits"
elif total >= MIN_ORDER_USD and not req.payment_tx:
from core.x402_verify import get_pay_to
pay_to = get_pay_to(chain)
raise HTTPException(status_code=402, detail={
"error": "Payment required", "amount_usd": total,
"pay_to": pay_to, "chain": chain, "token": "USDC",
"instructions": f"Send ${total} USDC on {chain.upper()} to {pay_to}",
})
elif total >= MIN_ORDER_USD and req.payment_tx:
if not await verify_payment(chain, req.payment_tx, total):
raise HTTPException(status_code=402, detail="Payment verification failed")
payment_method = "onchain"
else:
payment_method = "free"
# Generate wallets
from wallet_engine.generator import get_generator
generator = get_generator()
wallets = []
if req.xpub:
from bip_utils import Bip32Secp256k1, Bip32Slip10Ed25519
from wallet_engine.chains import ChainFamily
chain_info = CHAINS.get(chain)
if not chain_info:
raise HTTPException(status_code=400, detail=f"Unsupported chain: {chain}")
try:
if chain_info.curve == "ed25519":
bip32_ctx = Bip32Slip10Ed25519.FromXPub(req.xpub)
else:
bip32_ctx = Bip32Secp256k1.FromXPub(req.xpub)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid xpub: {e}")
for i in range(count):
child_path = f"0/{i}"
child_key = bip32_ctx.DerivePath(child_path)
pub_bytes = child_key.PublicKey().Raw().ToBytes()
wallets.append({
"index": i + 1,
"address": pub_bytes.hex()[:40],
"private_key": "",
"mnemonic": "",
"derivation_path": child_path,
"derivation_method": "xpub",
"note": "Derived from your xpub. We never had the private key.",
})
else:
for i in range(count):
gen_kwargs = {
"chain_key": chain,
"label": f"x402_{order_id}_{i}",
"tags": ["x402", order_id],
}
if req.derivation_path:
gen_kwargs["derivation_path"] = req.derivation_path
wallet = generator.generate(**gen_kwargs)
wallets.append({
"index": i + 1, "address": wallet.address,
"private_key": wallet.private_key_hex, "mnemonic": wallet.mnemonic,
"derivation_path": wallet.derivation_path,
})
conn = get_db()
conn.execute(
"INSERT INTO orders (order_id, chain, count, amount_usd, payment_tx, payment_status, status, created_at, client_ip, idempotency_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(order_id, chain, count, total, req.payment_tx or "free", "confirmed", "completed", time.time(), client_ip, req.idempotency_key or ""),
)
conn.commit()
conn.close()
from core.proof import sign_receipt, sign_key_deletion, get_receipt_public_key, get_source_tree_hash
receipt_sig = sign_receipt(order_id, chain, count, total, time.time())
deletion_sig = sign_key_deletion(order_id, chain, count, [w["address"] for w in wallets])
return {
"order_id": order_id, "chain": chain, "count": count,
"total_usd": total, "payment_method": payment_method,
"derivation_method": "xpub" if req.xpub else "mnemonic",
"wallets": wallets,
"receipt": {
"signature": receipt_sig,
"public_key": get_receipt_public_key(),
"verify_url": f"/api/v1/marketplace/verify-receipt?order_id={order_id}",
},
"key_deletion_attestation": {
"signature": deletion_sig,
"algorithm": "Ed25519",
"note": "This proves the private keys were cleared from memory after generation. We did not store them.",
},
"trust": {
"keys_stored": False,
"keys_encrypted": False,
"keys_returned_once": True,
"key_deletion_attested": True,
"open_source": "https://github.com/cryptorugmuncher/walletpress",
"source_commit": get_source_tree_hash(),
"receipt_signed": True,
"receipt_algorithm": "Ed25519",
"audit_logged": True,
"provenance": f"Verify at /api/v1/proof/verify/{order_id}",
"note": "Keys returned once. We do not store them. Signed receipt + key deletion attestation prove authenticity.",
},
}
@app.post("/api/v1/marketplace/credits")
async def buy_credits(req: CreditsRequest):
"""Buy credits via on-chain USDC payment.
SECURITY: Every request with payment_tx MUST be verified on-chain
before crediting. Previously this endpoint accepted any non-empty
payment_tx string and minted credits for free (P0-1).
"""
if not req.payment_tx:
raise HTTPException(status_code=402, detail={
"error": "Payment required", "amount_usd": req.amount,
"pay_to": PAYMENT_ADDRESS, "chain": PAYMENT_CHAIN, "token": "USDC",
})
# Verify the payment on-chain via PayAI facilitator.
# This is the same call used by /generate to validate payment_tx.
verified = await verify_payment(PAYMENT_CHAIN, req.payment_tx, req.amount)
if not verified:
raise HTTPException(status_code=402, detail={
"error": "Payment verification failed",
"payment_tx": req.payment_tx,
"amount_usd": req.amount,
"note": "Send USDC on Solana to the payment address, then retry with the tx signature.",
})
# Only now mint credits
api_key = f"wp_cred_{secrets.token_hex(16)}"
conn = get_db()
conn.execute(
"INSERT INTO credits (api_key, balance, total_spent, created_at) VALUES (?, ?, 0, ?)",
(api_key, req.amount, time.time()),
)
conn.commit()
conn.close()
logger.info(f"x402 credits purchased: amount=${req.amount} tx={req.payment_tx[:16]}... key={api_key[:12]}...")
return {"credits_added": req.amount, "balance": req.amount, "api_key": api_key}
@app.get("/api/v1/marketplace/pricing")
async def pricing():
return {
"per_wallet": PRICE_PER_WALLET,
"minimum_order_usd": MIN_ORDER_USD,
"free_tier": {"wallets": FREE_WALLETS},
"tiers": [
{"range": "1-999", "price": f"${PRICE_PER_WALLET}/wallet"},
{"range": "1,000-9,999", "price": "$0.005/wallet"},
{"range": "10,000-99,999", "price": "$0.002/wallet"},
{"range": "100,000+", "price": "$0.001/wallet"},
],
"payment": {"chain": PAYMENT_CHAIN, "token": PAYMENT_TOKEN, "address": PAYMENT_ADDRESS},
"operator": "Rug Munch Media LLC",
"note": "This is a service operated by WalletPress. Not available for self-hosted installations.",
}
@app.get("/api/v1/marketplace/public-key")
async def marketplace_public_key():
"""Get the server's Ed25519 public key for receipt verification.
Every order receipt is signed with this key. Verify receipts offline
using any Ed25519 library. The key is generated once on first startup
and persisted to disk.
"""
from core.proof import get_receipt_public_key
return {
"algorithm": "Ed25519",
"public_key": get_receipt_public_key(),
"purpose": "Order receipt signing for x402 marketplace",
"generated_at": "first_startup",
"verify_tool": "pip install pynacl && python3 -c \"from nacl.bindings import crypto_sign_open; import base64; ...\"",
}
@app.get("/api/v1/marketplace/verify-receipt")
async def verify_receipt(order_id: str, signature: str = "", pubkey: str = ""):
"""Verify a signed order receipt.
Pass the order_id, signature, and public key from the order response.
Returns whether the signature is valid.
"""
from core.proof import verify_receipt as _verify, get_receipt_public_key
from core.db_pool import DbPool
pool = DbPool(DB_PATH)
row = pool.execute("SELECT * FROM orders WHERE order_id = ?", (order_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Order not found")
pubkey = pubkey or get_receipt_public_key()
valid = _verify(order_id, row["chain"], row["count"], row["amount_usd"], row["created_at"], signature, pubkey)
return {
"order_id": order_id,
"valid": valid,
"chain": row["chain"],
"count": row["count"],
"amount_usd": row["amount_usd"],
"created_at": row["created_at"],
"public_key_used": pubkey,
"note": "If valid, this receipt proves WalletPress generated these wallets at this time.",
}
if __name__ == "__main__":
import uvicorn
# When run standalone, add health endpoint manually
@app.get("/health")
async def _x402_health():
return {"status": "ok", "service": "x402-marketplace", "version": "1.0.0"}
uvicorn.run("x402_service:app", host="0.0.0.0", port=int(os.getenv("WP_X402_PORT", "8011")))