walletpress/backend/cli/verify_receipt.py
Rug Munch Media LLC 85d8ef5eac
refactor(routers): split chain_vault.py god router — WP-085..WP-087
Extracted admin endpoints from chain_vault.py (2,178 lines) into
wallet_admin.py (768 lines). chain_vault.py is now 1,469 lines.

What moved to wallet_admin.py (29 routes):
- API keys: /api-keys, /api-keys/revoke
- Alerts: /alerts, /alerts/delete
- Webhooks: /webhooks, /webhooks/{id}, /webhooks/{id}/test,
  /webhooks/{id}/retry, /webhooks/deliveries
- Audit trail: /audit-trail
- Bulk ops: /bulk/filter, /bulk/delete, /bulk/export, /export
- 2FA: /admin/2fa/{setup,verify-setup,disable,status}
- Config: /config
- Proof of Generation: /proof/{commit,provenance,verify,roots,stats}

What stayed in chain_vault.py (32 routes):
- Chain metadata: /chains, /stats, /healthz, /health-score
- RPC config: /rpc-chains
- Wallet gen: /generate, /generate/batch, /generate/all,
  /import, /from-mnemonic, /derive-address, /hd-wallet
- Vault CRUD: /vault, /vault/{id}, /vault/{id}/full, DELETE
- Wallet ops: /tree, /cluster, /rotate, /rotate-sweep, /rotations,
  /distribute, /sweep, /escrow, /escrow/release, /paper-wallet, /tx
- Validation: /validate/{chain}/{address}, /validate/all
- Balances: /balances, /balances/snapshot
- PDF: /paper-wallet/{id}/pdf, /wallet/{id}/birth-certificate

Helpers extracted to _persistent_store.py:
- _PersistentStore class (webhooks/alerts/payments SQLite store)
  P3-7 fix: removed dead _webhooks global references in test/retry
  endpoints — now uses _PersistentStore.all('webhooks')

Added to wallet_admin.py:
- _require_totp() helper (also kept in chain_vault.py for the
  /vault/{id}/full endpoint that needs it)
- WebhookCreateRequest, BulkFilterRequest, BulkDeleteRequest models
  (these were inlined in original chain_vault.py body — now in
  the request schemas section)

P3-10 — WP plugin supported_chains() rewritten
  Plugin used to advertise chains the backend doesn't support
  ('bitcoin' vs backend 'btc', 'ethereum' vs 'eth', etc.). Rewrote
  to use correct backend keys + added a 'backend' field for clarity.
  Now matches ADDRESS_GENERATION.md truth table.

main.py: updated import + include_router for wallet_admin.router.

Test results: 80 passed, 5 skipped (no regressions).

Refs: AUDIT.md P2-16, P3-7, P3-10, P3-17
2026-06-30 21:40:45 +07:00

169 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""WalletPress Receipt Verifier — CLI tool to verify x402 order receipts.
Usage:
# Verify an order receipt
python3 verify_receipt.py --order-id x402_abc123 \\
--signature "base64signature..." \\
--pubkey "ed25519pubkeyhex"
# Verify a key deletion attestation
python3 verify_receipt.py --order-id x402_abc123 \\
--deletion-sig "base64signature..." \\
--pubkey "ed25519pubkeyhex" \\
--addresses addr1,addr2,addr3
# Fetch and verify from a running WalletPress instance
python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010
Trust: This tool is open source. It does NOT phone home. It does NOT
send your keys anywhere. It only verifies cryptographic signatures.
"""
import argparse
import hashlib
import sys
from typing import Any
try:
import httpx
except ImportError:
httpx = None # type: ignore
try:
from nacl.bindings import crypto_sign_open
except ImportError:
crypto_sign_open = None # type: ignore
def verify_receipt(order_id: str, chain: str, count: int, total_usd: float,
timestamp: float, signature: str, pubkey_hex: str) -> bool:
"""Verify an Ed25519-signed order receipt."""
if crypto_sign_open is None:
print("ERROR: pynacl is required. Install: pip install pynacl")
return False
message = f"walletpress:receipt:{order_id}:{chain}:{count}:{total_usd}:{timestamp}".encode()
import base64
sig_bytes = base64.b64decode(signature)
try:
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
return True
except Exception:
return False
def verify_key_deletion(order_id: str, chain: str, count: int,
addresses: list[str], signature: str, pubkey_hex: str) -> bool:
"""Verify a key deletion attestation — proves keys were wiped."""
if crypto_sign_open is None:
print("ERROR: pynacl is required. Install: pip install pynacl")
return False
addr_hash = hashlib.sha256("".join(sorted(addresses)).encode()).hexdigest()[:16]
message = f"walletpress:key-deletion:{order_id}:{chain}:{count}:{addr_hash}:{int(timestamp)}".encode()
import base64
sig_bytes = base64.b64decode(signature)
try:
crypto_sign_open(sig_bytes + message, bytes.fromhex(pubkey_hex))
return True
except Exception:
return False
def fetch_and_verify(server_url: str, order_id: str) -> dict[str, Any]:
"""Fetch order details from a WalletPress server and verify the receipt."""
if httpx is None:
return {"error": "httpx is required. Install: pip install httpx"}
try:
resp = httpx.get(f"{server_url}/api/v1/marketplace/verify-receipt", params={
"order_id": order_id,
}, timeout=10)
if resp.status_code == 404:
return {"error": f"Order {order_id} not found on {server_url}"}
data = resp.json()
return data
except httpx.RequestError as e:
return {"error": f"Failed to connect to {server_url}: {e}"}
def main():
parser = argparse.ArgumentParser(
description="WalletPress Receipt Verifier — prove your order was fulfilled",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Verify a receipt
python3 verify_receipt.py --order-id x402_abc123 --signature "sig" --pubkey "key"
# Verify key deletion
python3 verify_receipt.py --order-id x402_abc123 --deletion-sig "sig" --pubkey "key" --addresses addr1,addr2
# Fetch and verify from server
python3 verify_receipt.py --order-id x402_abc123 --server http://localhost:8010
""",
)
parser.add_argument("--order-id", required=True, help="Order ID from the generate response")
parser.add_argument("--signature", help="Receipt signature (base64)")
parser.add_argument("--deletion-sig", help="Key deletion attestation signature (base64)")
parser.add_argument("--pubkey", help="Server's Ed25519 public key (hex)")
parser.add_argument("--addresses", help="Comma-separated wallet addresses (for deletion verification)")
parser.add_argument("--server", help="WalletPress server URL to fetch order details from")
parser.add_argument("--chain", default="sol", help="Chain the wallets were generated on")
parser.add_argument("--count", type=int, default=1, help="Number of wallets generated")
parser.add_argument("--total-usd", type=float, default=0, help="Total amount paid in USD")
parser.add_argument("--timestamp", type=float, default=0, help="Order timestamp (Unix)")
args = parser.parse_args()
if args.server:
result = fetch_and_verify(args.server, args.order_id)
if "error" in result:
print(f"FAIL: {result['error']}")
sys.exit(1)
print(f"Order: {result.get('order_id', '?')}")
print(f"Chain: {result.get('chain', '?')}")
print(f"Count: {result.get('count', '?')}")
print(f"Amount: ${result.get('amount_usd', '?')}")
print(f"Valid: {result.get('valid', '?')}")
print(f"Status: {'VERIFIED' if result.get('valid') else 'INVALID'}")
sys.exit(0 if result.get('valid') else 1)
if args.signature and args.pubkey:
if not args.timestamp:
print("ERROR: --timestamp is required for receipt verification")
sys.exit(1)
valid = verify_receipt(
args.order_id, args.chain, args.count, args.total_usd,
args.timestamp, args.signature, args.pubkey,
)
print(f"Receipt: {'VALID' if valid else 'INVALID'}")
print(f" Order: {args.order_id}")
print(f" Chain: {args.chain}")
print(f" Count: {args.count}")
print(f" Amount: ${args.total_usd}")
print(" Algorithm: Ed25519")
if not valid:
sys.exit(1)
if args.deletion_sig and args.pubkey and args.addresses:
addresses = [a.strip() for a in args.addresses.split(",")]
valid = verify_key_deletion(
args.order_id, args.chain, args.count, addresses,
args.deletion_sig, args.pubkey,
)
print(f"Key Deletion: {'VERIFIED' if valid else 'FAILED'}")
print(f" Order: {args.order_id}")
print(f" Addresses: {len(addresses)} wallets")
print(" Algorithm: Ed25519")
if not valid:
print(" WARNING: Key deletion attestation is INVALID. The server may have kept your keys.")
sys.exit(1)
print(" Keys were generated in memory and wiped. We did not store them.")
if not args.signature and not args.deletion_sig and not args.server:
print("ERROR: Provide --signature, --deletion-sig, or --server")
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()