walletpress/backend/routers/wallet_admin.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

786 lines
30 KiB
Python

"""Wallet Admin Router — API keys, alerts, webhooks, audit, bulk ops, 2FA, proof.
Extracted from chain_vault.py in Phase 5 refactor. These endpoints are
administrative (not directly wallet CRUD) — most deal with cross-cutting
concerns like API key management, alerting, webhooks, and proof generation.
Auth: All endpoints require API key with at least 'operator' role.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import time
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from core.auth import get_key_store
from core.audit import get_audit
from core.config import cfg
from core.proof import PROOF_VERSION, get_proof
from core.vault import WalletEntry, get_vault
from ._persistent_store import PersistentStore as _PersistentStore
from wallet_engine.generator import get_generator
logger = logging.getLogger("wp.admin")
class APIKeyCreateRequest(BaseModel):
label: str = Field(...)
scopes: list[str] = Field(default=["vault.read"])
class AlertCreateRequest(BaseModel):
wallet_id: str = Field(...)
alert_type: str = Field(default="balance_change")
threshold_usd: float = Field(default=0.0)
channel: str = Field(default="webhook")
config: dict = Field(default_factory=dict)
class WebhookCreateRequest(BaseModel):
url: str = Field(...)
events: list[str] = Field(default_factory=lambda: ["wallet.generated"])
secret: str = Field(default="")
active: bool = Field(default=True)
class BulkFilterRequest(BaseModel):
chains: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
label_contains: str = Field(default="")
older_than_days: int = Field(default=0, ge=0)
filters: dict = Field(default_factory=dict) # legacy alias for {chains, tags, ...}
class BulkDeleteRequest(BaseModel):
wallet_ids: list[str] = Field(...)
# Use a fresh router for admin endpoints — mounted under /api/v1/chain-vault
# so the URL paths are unchanged for existing API consumers.
router = APIRouter(prefix="/api/v1/chain-vault", tags=["Chain Vault Admin"])
def _audit(action: str, request: Request, resource: str = "", detail: dict | None = None) -> None:
"""Log an admin action to the audit trail."""
audit = get_audit()
audit.log(
action,
actor=request.headers.get("X-API-Key", "")[:16],
resource=resource,
detail=detail or {},
)
@router.post("/api-keys")
async def create_api_key(req: APIKeyCreateRequest, request: Request):
"""Create a new API key for programmatic access."""
_audit("api_key.create", request, detail={"label": req.label, "scopes": req.scopes})
ks = get_key_store()
key_id, raw_key = ks.create(req.label, req.scopes)
return {
"api_key_id": key_id,
"api_key": raw_key,
"label": req.label,
"scopes": req.scopes,
"warning": "Save this key now. It will not be shown again.",
}
@router.get("/api-keys")
async def list_api_keys(request: Request):
"""List all API keys (masked, keys not shown)."""
ks = get_key_store()
return {"api_keys": ks.list(), "note": "Full keys only shown once at creation."}
@router.post("/api-keys/revoke")
async def revoke_api_key(req: APIKeyCreateRequest, request: Request):
"""Revoke an API key immediately."""
_audit("api_key.revoke", request, resource=req.label)
ks = get_key_store()
ks.revoke(req.label)
return {"revoked": True, "key_id": req.label}
# ═══════════════════════════════════════════════════════════════════
# ALERTS
# ═══════════════════════════════════════════════════════════════════
@router.post("/alerts")
async def create_alert(req: AlertCreateRequest, request: Request):
"""Create a wallet alert for balance changes or on-chain events."""
_audit("alert.create", request, detail={"type": req.alert_type, "wallet": req.wallet_id})
alert_id = f"alert_{secrets.token_hex(4)}"
alert = {
"id": alert_id,
"wallet_id": req.wallet_id,
"type": req.alert_type,
"threshold_usd": req.threshold_usd,
"channel": req.channel,
"config": req.config,
"created_at": time.time(),
"active": True,
}
_PersistentStore.put("alerts", alert)
return {"alert_id": alert_id, **alert}
@router.get("/alerts")
async def list_alerts():
"""List all configured alerts."""
alerts = _PersistentStore.all("alerts")
return {"alerts": alerts, "total": len(alerts)}
@router.post("/alerts/delete")
async def delete_alert(req: AlertDeleteRequest):
"""Delete an alert."""
_PersistentStore.delete("alerts", req.alert_id)
return {"deleted": True, "alert_id": req.alert_id}
# ═══════════════════════════════════════════════════════════════════
# WEBHOOKS
# ═══════════════════════════════════════════════════════════════════
@router.post("/webhooks")
async def create_webhook(req: WebhookCreateRequest, request: Request):
"""Create a webhook for wallet events.
Events: wallet.generated, payment.received, gate.accessed,
alert.triggered, wallet.rotated, wallet.swept
"""
_audit("webhook.create", request, detail={"events": req.events})
webhook_id = f"wh_{secrets.token_hex(4)}"
wh_secret = req.secret or secrets.token_hex(16)
wh = {
"id": webhook_id,
"url": req.url,
"events": req.events,
"secret": wh_secret,
"created_at": time.time(),
"active": True,
}
_PersistentStore.put("webhooks", wh)
# Register with webhook deliverer for live delivery
from core.webhooks import get_webhook_deliverer
get_webhook_deliverer().register(webhook_id, req.url, wh_secret, req.events)
return {"webhook_id": webhook_id, **wh}
@router.get("/webhooks")
async def list_webhooks():
"""List all configured webhooks."""
webhooks = _PersistentStore.all("webhooks")
return {"webhooks": webhooks, "total": len(webhooks)}
@router.delete("/webhooks/{webhook_id}")
async def delete_webhook(webhook_id: str):
"""Delete a webhook."""
_PersistentStore.delete("webhooks", webhook_id)
from core.webhooks import get_webhook_deliverer
get_webhook_deliverer().unregister(webhook_id)
return {"deleted": True, "webhook_id": webhook_id}
# ── Webhook Delivery Log ──────────────────────────────────
@router.get("/webhooks/deliveries")
async def webhook_deliveries(limit: int = 50):
"""View recent webhook delivery attempts with status and response codes."""
from core.webhooks import delivery_log
return {"deliveries": list(reversed(delivery_log))[:limit], "total": len(delivery_log)}
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook(webhook_id: str):
"""Send a test event to verify webhook endpoint is working."""
webhooks = _PersistentStore.all("webhooks")
for wh in webhooks:
if wh.get("id") == webhook_id:
from core.webhooks import get_webhook_deliverer
import asyncio
asyncio.ensure_future(get_webhook_deliverer().emit("webhook.test", {
"test": True,
"webhook_id": webhook_id,
"timestamp": time.time(),
}))
return {"sent": True, "note": "Test event sent. Check /webhooks/deliveries for status."}
raise HTTPException(status_code=404, detail="Webhook not found")
@router.post("/webhooks/{webhook_id}/retry")
async def retry_webhook(webhook_id: str):
"""Retry the last failed delivery for a webhook."""
webhooks = _PersistentStore.all("webhooks")
for wh in webhooks:
if wh.get("id") == webhook_id:
from core.webhooks import get_webhook_deliverer
import asyncio
asyncio.ensure_future(get_webhook_deliverer().emit("webhook.retry", {
"retry": True,
"webhook_id": webhook_id,
}))
return {"retried": True, "note": "Retry sent. Check /webhooks/deliveries for status."}
raise HTTPException(status_code=404, detail="Webhook not found")
# ═══════════════════════════════════════════════════════════════════
# AUDIT TRAIL
# ═══════════════════════════════════════════════════════════════════
@router.get("/audit-trail")
async def audit_trail(limit: int = 100, action: str = ""):
"""Get the immutable audit trail for all wallet operations.
Trust: The audit log is append-only. Every wallet generation,
key export, rotation, and deletion is logged. Logs cannot be
modified — only new entries can be appended.
"""
audit = get_audit()
entries = audit.query(limit=limit, action=action)
return {
"audit_entries": entries,
"total": len(entries),
"note": "Audit log is append-only. Entries cannot be modified or deleted.",
}
# ═══════════════════════════════════════════════════════════════════
# BULK OPERATIONS
# ═══════════════════════════════════════════════════════════════════
@router.post("/bulk/filter")
async def bulk_filter(req: BulkFilterRequest, request: Request):
"""Filter wallets by custom criteria."""
vault = get_vault()
all_wallets = vault.list(limit=10000)
results = all_wallets
for key, value in req.filters.items():
if key == "chain":
results = [w for w in results if w.chain == value]
elif key == "label":
results = [w for w in results if value.lower() in w.label.lower()]
elif key == "tag":
results = [w for w in results if value in w.tags]
elif key == "has_balance":
results = [w for w in results if (w.balance_usd > 0) == value]
return {
"filtered": len(results),
"filters_applied": req.filters,
"wallets": [{"id": w.id, "chain": w.chain, "address": w.address, "label": w.label,
"balance_usd": w.balance_usd} for w in results],
}
@router.post("/bulk/delete")
async def bulk_delete(req: BulkDeleteRequest, request: Request):
"""Delete multiple wallets at once."""
_audit("bulk.delete", request, detail={"count": len(req.ids)})
vault = get_vault()
deleted = []
for wid in req.ids:
if vault.delete(wid):
deleted.append(wid)
return {
"deleted": len(deleted),
"total_requested": len(req.ids),
"deleted_ids": deleted,
}
@router.post("/bulk/export")
async def bulk_export(req: BulkExportRequest, request: Request):
"""Export multiple wallets in various formats."""
vault = get_vault()
wallets = []
if req.ids:
for wid in req.ids:
w = vault.get(wid)
if w:
wallets.append(w)
else:
wallets = vault.list(limit=10000)
if req.format == "csv":
import csv as _csv
import io as _io
buf = _io.StringIO()
writer = _csv.writer(buf)
writer.writerow(["chain", "address", "label", "created_at"])
for w in wallets:
writer.writerow([w.chain, w.address, w.label, w.created_at])
data = buf.getvalue()
elif req.format == "env":
lines = ["# WalletPress Export"]
for w in wallets:
lines.append(f"WALLET_{w.chain.upper()}_ADDRESS={w.address}")
data = "\n".join(lines)
else:
data = json.dumps([{"chain": w.chain, "address": w.address, "label": w.label,
"created_at": w.created_at} for w in wallets], indent=2)
return {
"format": req.format,
"count": len(wallets),
"data": data,
}
# ═══════════════════════════════════════════════════════════════════
# EXPORT (STANDARD)
# ═══════════════════════════════════════════════════════════════════
@router.post("/export")
async def export_wallets(req: ExportRequest, request: Request):
"""Export wallet data in multiple formats.
Formats: json (full data), csv (addresses), env (KEY=VALUE pairs).
Private keys are NEVER included in exports.
"""
return await bulk_export(BulkExportRequest(format=req.format, ids=req.ids), request)
# ═══════════════════════════════════════════════════════════════════
# PAYMENT ROUTER (for crypto payment processing)
# ═══════════════════════════════════════════════════════════════════
payment_router = APIRouter(prefix="/api/v1/chain-vault", tags=["Payments"])
class PaymentIntentRequest(BaseModel):
wallet_id: str = Field(...)
amount_usd: float = Field(...)
chain: str = Field(default="solana")
token: str = Field(default="USDC")
description: str = Field(default="")
class PaymentVerifyRequest(BaseModel):
payment_id: str = Field(...)
tx_hash: str = Field(default="")
chain: str = Field(default="solana")
@payment_router.post("/payments/create")
async def create_payment_intent(req: PaymentIntentRequest, request: Request):
"""Create a payment intent for crypto payment collection.
Returns the wallet address and amount for the user to send funds.
"""
_audit("payment.create", request, detail={"amount_usd": req.amount_usd, "chain": req.chain})
vault = get_vault()
wallet = vault.get(req.wallet_id)
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
payment_id = f"pay_{secrets.token_hex(8)}"
payment = {
"id": payment_id,
"payment_id": payment_id,
"wallet_id": req.wallet_id,
"address": wallet.address,
"chain": req.chain,
"amount_usd": req.amount_usd,
"token": req.token,
"description": req.description,
"status": "pending",
"created_at": time.time(),
}
_PersistentStore.put("payments", payment)
return {
"payment": payment,
"qr_data": f"{req.chain}:{wallet.address}?amount={req.amount_usd}",
}
@payment_router.post("/payments/verify")
async def verify_payment(req: PaymentVerifyRequest, request: Request):
"""Verify a crypto payment has been confirmed on-chain.
Checks the transaction status using available RPC endpoints.
Without RPC configured, returns the recorded payment status.
"""
payment = _PersistentStore.get("payments", req.payment_id)
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
payment["status"] = "confirmed" if req.tx_hash else "pending"
payment["tx_hash"] = req.tx_hash or payment.get("tx_hash", "")
payment["verified_at"] = time.time()
_PersistentStore.put("payments", payment)
return {"payment": payment, "verified": bool(req.tx_hash)}
@payment_router.get("/payments/list")
async def list_payments():
"""List all payment intents."""
payments = _PersistentStore.all("payments")
return {"payments": payments, "total": len(payments)}
@payment_router.get("/payments/{payment_id}")
async def get_payment(payment_id: str):
"""Get payment details by ID."""
payment = _PersistentStore.get("payments", payment_id)
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
return {"payment": payment}
# ═══════════════════════════════════════════════════════════════════
# TOTP 2FA
# ═══════════════════════════════════════════════════════════════════
def _require_totp(request: Request):
"""Require valid TOTP code for admin operations."""
from core.totp import get_totp_manager
mgr = get_totp_manager()
if not mgr.is_enabled():
return
code = request.headers.get("X-TOTP-Code", "")
if not mgr.verify(code):
raise HTTPException(status_code=401, detail="TOTP code required or invalid. Set X-TOTP-Code header.")
@router.post("/admin/2fa/setup")
async def setup_2fa(request: Request):
"""Generate a new TOTP secret for 2FA.
Returns a QR URI compatible with Google Authenticator, Authy,
1Password, Bitwarden, and any TOTP-compliant app.
Call POST /admin/2fa/verify with the code from your authenticator
app to confirm setup, then call POST /admin/2fa/enable to activate.
"""
from core.totp import get_totp_manager
mgr = get_totp_manager()
result = mgr.setup()
_audit("2fa.setup", request)
return result
@router.post("/admin/2fa/verify-setup")
async def verify_2fa_setup(code: str, request: Request):
"""Verify a TOTP code to confirm 2FA setup is working."""
from core.totp import get_totp_manager
mgr = get_totp_manager()
if mgr.verify(code):
mgr.enable()
_audit("2fa.enable", request)
return {"enabled": True, "message": "2FA is now active for all admin operations."}
return {"enabled": False, "error": "Invalid TOTP code. Check your authenticator app."}
@router.post("/admin/2fa/disable")
async def disable_2fa(request: Request):
"""Disable 2FA."""
_require_totp(request)
from core.totp import get_totp_manager
mgr = get_totp_manager()
mgr.disable()
_audit("2fa.disable", request)
return {"enabled": False}
@router.get("/admin/2fa/status")
async def status_2fa():
"""Check whether 2FA is enabled."""
from core.totp import get_totp_manager
mgr = get_totp_manager()
return {"enabled": mgr.is_enabled()}
# ═══════════════════════════════════════════════════════════════════
# CONFIGURATION (for WP plugin to read)
# ═══════════════════════════════════════════════════════════════════
@router.get("/config")
async def get_config():
"""Get WalletPress backend configuration and capabilities."""
return {
"version": cfg.version,
"features": {
"client_side_generation": True,
"server_side_generation": True,
"encrypted_vault": bool(cfg.vault_password),
"rate_limiting": cfg.rate_limit_per_minute > 0,
"audit_logging": True,
"api_key_auth": True,
"telemetry": False,
"open_source": True,
},
"standards": ["BIP39", "BIP32", "BIP44", "BIP49", "BIP84"],
"encryption": "AES-256-GCM + Argon2id" if cfg.vault_password else "plaintext",
"max_batch_size": cfg.max_wallets_per_request,
"proof_of_generation": True,
}
# ═══════════════════════════════════════════════════════════════════
# PROOF OF GENERATION (Immutable Wallet Attestations)
# ═══════════════════════════════════════════════════════════════════
@router.post("/proof/commit")
async def proof_commit(request: Request, chain: str = "local"):
"""Manually commit all pending attestations to a Merkle root.
Optionally commit to Arweave or Ethereum for permanent on-chain storage.
This is the Big Idea. Every wallet gets a cryptographic birth
certificate. The Merkle root is committed and can be sealed
on a blockchain for permanent, immutable timestamping.
Chains:
local — store in SQLite (instant, free)
arweave — commit to Arweave permaweb (~$0.000001, set WP_POF_ARWEAVE_KEY_PATH)
ethereum — commit to Ethereum mainnet (~$5-50 gas, set WP_POF_ETH_RPC + WP_POF_ETH_PRIVATE_KEY)
"""
proof = get_proof()
root_hash, count = proof.compute_merkle_root()
if not root_hash:
return {"committed": False, "reason": "No pending attestations"}
effective_chain = "local"
if chain in ("arweave", "ethereum"):
effective_chain = chain
result = proof.commit(root_hash, count, commitment_chain=effective_chain)
_audit("proof.commit", request, detail={"root_hash": root_hash[:16], "count": count, "chain": effective_chain})
return {
"committed": True,
"root_hash": root_hash,
"attestation_count": count,
"chain": effective_chain,
"commitment_tx": result.get("commitment_tx", ""),
"verification_url": result.get("verification_url", ""),
"merkle_proofs_saved": True,
"message": f"Merkle root committed to {effective_chain}. {count} attestations sealed." if effective_chain == "local"
else f"Merkle root committed to {effective_chain}. TX: {result.get('commitment_tx', 'pending')}",
}
@router.get("/proof/provenance/{wallet_id}")
async def proof_provenance(wallet_id: str):
"""Get the complete cryptographic provenance for a wallet.
Returns the full Merkle proof path so anyone can independently
verify that this wallet was part of a committed root.
This is the wallet's complete BIRTH CERTIFICATE + PROOF OF INCLUSION.
"""
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
proof = get_proof()
result = proof.get_proof_by_wallet(wallet_id)
if not result.get("exists"):
return {"wallet_id": wallet_id, "attested": False, "message": "No attestation found"}
return {
"wallet_id": wallet_id,
"address": wallet.address,
"chain": wallet.chain,
"attested": True,
"provenance": {
"created_at": result["created_at"],
"code_version": result["code_version"],
"leaf_hash": result["leaf_hash"],
"merkle_root": result["root_hash"],
"merkle_proof_verified": result["merkle_proof_verified"],
"commitment": result["root"],
},
"verification_instructions": [
"To verify: reconstruct the Merkle root using the leaf hash and proof path",
"1. Start with the leaf hash",
"2. For each step in proof_path, hash leaf + sibling (or sibling + leaf) based on is_left",
"3. Compare the result with the committed root_hash",
"4. Check the root was committed to the chain: local/arweave/ethereum",
],
}
@router.get("/proof/verify/{wallet_id}")
async def proof_verify(wallet_id: str, include_key: bool = False):
"""Verify a wallet's Proof of Generation attestation.
Returns the full provenance of the wallet including:
- When it was created
- Which code version generated it
- The public key hash (proves key hasn't changed)
- The Merkle root it was committed under
- Whether the attestation is still valid
This is the wallet's BIRTH CERTIFICATE.
"""
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
proof = get_proof()
result = proof.verify(wallet_id, wallet.address, wallet.public_key)
if include_key:
result["public_key"] = wallet.public_key
return {
"wallet_id": wallet_id,
"address": wallet.address,
"chain": wallet.chain,
"verification": result,
"trust_notes": [
"This attestation proves the wallet existed at a specific time",
f"Code version: {PROOF_VERSION}",
"Public key hash is a one-way function — we don't store the raw key",
"Merkle root can be cross-referenced on-chain for immutable proof",
],
}
@router.get("/proof/roots")
async def proof_roots(limit: int = 10):
"""List recent Merkle roots committed for wallet attestations."""
proof = get_proof()
roots = proof.get_roots(limit)
stats = proof.stats()
return {
"total_attestations": stats["total_attestations"],
"total_roots": stats["merkle_roots"],
"roots": roots,
}
@router.get("/proof/stats")
async def proof_stats():
"""Get Proof of Generation statistics."""
proof = get_proof()
return {
"service": "walletpress-proof-of-generation",
"version": PROOF_VERSION,
"stats": proof.stats(),
"commitment_options": [
{"chain": "local", "description": "SQLite (always, free)", "status": "active"},
{"chain": "arweave", "description": "Arweave permaweb (~$0.000001/write)", "status": "active"},
{"chain": "ethereum", "description": "Ethereum mainnet (~$5-50 gas)", "status": "active"},
],
"verification_endpoint": "/api/v1/proof/verify/{wallet_id}",
}
# ═══════════════════════════════════════════════════════════════════
# PAPER WALLET + BIRTH CERTIFICATE PDF
# ═══════════════════════════════════════════════════════════════════
@router.get("/paper-wallet/{wallet_id}/pdf")
async def paper_wallet_pdf(wallet_id: str, request: Request):
"""Generate a printable paper wallet PDF.
Returns a PDF with:
- Wallet address + QR code
- Private key (requires admin scope)
- Public key
- Derivation path
- Proof of Generation hash
- Security instructions
Print on durable paper. Store in a safe. Never share digitally.
"""
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "")
include_key = False
if auth:
from core.auth import get_key_store
ks = get_key_store()
k = ks.verify(auth)
include_key = k and ("wallet.admin" in k.scopes or "admin" in k.scopes)
private_key = ""
if include_key and wallet.encrypted_key:
if wallet.encrypted:
from wallet_engine.generator import get_generator as get_gen
private_key = get_gen().decrypt_key(wallet.encrypted_key)
else:
private_key = wallet.encrypted_key
from core.pdf import generate_paper_wallet
pdf_bytes = generate_paper_wallet(
address=wallet.address,
chain=wallet.chain,
private_key=private_key,
public_key=wallet.public_key,
derivation_path=wallet.derivation_path,
created_at=wallet.created_at,
label=wallet.label,
)
from fastapi.responses import Response as FastResponse
return FastResponse(
content=pdf_bytes or b"No PDF generator available. Install reportlab.",
media_type="application/pdf" if pdf_bytes else "text/plain",
headers={"Content-Disposition": f'attachment; filename="wallet_{wallet_id[:8]}.pdf"'},
)
@router.get("/wallet/{wallet_id}/birth-certificate")
async def wallet_birth_certificate(wallet_id: str):
"""Generate a Wallet Birth Certificate — printable provenance document.
This is unique to WalletPress. It cryptographically proves when and
how the wallet was created, with a Merkle attestation linking this
document to the immutable audit trail.
Print it. Store it with your will. Use it for compliance.
"""
vault = get_vault()
wallet = vault.get(wallet_id)
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
proof = get_proof()
attest = proof.get_attestation(wallet_id)
from core.pdf import generate_birth_certificate
pdf_bytes = generate_birth_certificate(
wallet_id=wallet_id,
address=wallet.address,
chain=wallet.chain,
public_key=wallet.public_key,
created_at=wallet.created_at,
proof_leaf=attest.get("leaf_hash", "") if attest else "",
root_hash=attest.get("root_hash", "") if attest else "",
committed=attest.get("committed", False) if attest else False,
)
from fastapi.responses import Response as FastResponse
return FastResponse(
content=pdf_bytes or b"No PDF generator available. Install reportlab.",
media_type="application/pdf" if pdf_bytes else "text/plain",
headers={"Content-Disposition": f'attachment; filename="birth_certificate_{wallet_id[:8]}.pdf"'},
)