"""API endpoints for enrollment, authentication and verification.""" from __future__ import annotations import logging import base64 import json import os import time from fastapi import APIRouter, Body, Depends, HTTPException, Request from amanpay.models.authenticator import AmanPayAuthenticator from api.auth import authorize, mint_token, rate_limit, require_auth from api.observability import record_auth, record_payment from api.dependencies import ( decode_image, get_model, preprocess_face, preprocess_fingerprint, preprocess_voice, state, ) from api.schemas import ( AuthRequest, AuthResponse, ChallengeRequest, ChallengeResponse, EnrollRequest, EnrollResponse, HealthResponse, PasskeyAuthRequest, PasskeyAuthResponse, PasskeyRegisterRequest, UserListResponse, VerifyRequest, VerifyResponse, ) logger = logging.getLogger(__name__) router = APIRouter() @router.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", model_loaded=state.loaded, device=state.device) @router.get("/biometrics/status") def biometrics_status() -> dict: """Per-modality capability so the React biometric UI can show available / demo_untrained / unavailable per service. Booleans + labels only — no secrets.""" return state.biometric_status() @router.post("/enroll", response_model=EnrollResponse) def enroll(req: EnrollRequest, model: AmanPayAuthenticator = Depends(get_model)) -> EnrollResponse: face = preprocess_face(decode_image(req.face_image)) fp = preprocess_fingerprint(decode_image(req.fingerprint_image)) result = model.enroll(req.user_id, face, fp) return EnrollResponse( success=result["success"], user_id=req.user_id, face_liveness=result.get("face_liveness", 0.0), fp_liveness=result.get("fp_liveness", 0.0), reason=result.get("reason"), ) @router.post("/authenticate", response_model=AuthResponse) def authenticate(req: AuthRequest, model: AmanPayAuthenticator = Depends(get_model)) -> AuthResponse: face_img = decode_image(req.face_image) df = state.deepfake_score(face_img) # P(attack) or None is_df = df is not None and df >= state.deepfake_threshold face = preprocess_face(face_img) fp = preprocess_fingerprint(decode_image(req.fingerprint_image)) result = model.authenticate(req.user_id, face, fp) success = result.get("success", False) and not is_df reason = "Deepfake/injection detected" if is_df else result.get("reason") return AuthResponse( success=success, similarity=result.get("similarity", 0.0), threshold=result.get("threshold", model.similarity_threshold), face_liveness=result.get("face_liveness", 0.0), fp_liveness=result.get("fp_liveness", 0.0), confidence=result.get("confidence"), face_quality=result.get("face_quality"), fp_quality=result.get("fp_quality"), protected=result.get("protected"), deepfake_score=df, is_deepfake=is_df, reason=reason, ) @router.post("/verify", response_model=VerifyResponse) def verify(req: VerifyRequest, model: AmanPayAuthenticator = Depends(get_model)) -> VerifyResponse: face1 = preprocess_face(decode_image(req.face1)) fp1 = preprocess_fingerprint(decode_image(req.fp1)) face2 = preprocess_face(decode_image(req.face2)) fp2 = preprocess_fingerprint(decode_image(req.fp2)) result = model.verify(face1, fp1, face2, fp2) return VerifyResponse(**result) @router.post("/passkey/register") def passkey_register(req: PasskeyRegisterRequest) -> dict: import base64 state.passkeys.register(req.user_id, base64.b64decode(req.public_key)) return {"success": True, "user_id": req.user_id} @router.post("/passkey/challenge", response_model=ChallengeResponse) def passkey_challenge(req: ChallengeRequest) -> ChallengeResponse: import base64 import time nonce = state.passkeys.issue_challenge(req.user_id, now=time.time()) if nonce is None: raise HTTPException(status_code=404, detail="device not registered") return ChallengeResponse(user_id=req.user_id, challenge=base64.b64encode(nonce).decode()) @router.post("/passkey/authenticate", response_model=PasskeyAuthResponse) def passkey_authenticate(req: PasskeyAuthRequest, model: AmanPayAuthenticator = Depends(get_model)) -> PasskeyAuthResponse: """Two-factor: multimodal biometric (inherence) + device-key signature over a single-use challenge (possession).""" import base64 import time face = preprocess_face(decode_image(req.face_image)) fp = preprocess_fingerprint(decode_image(req.fingerprint_image)) state.ensure_user_loaded(req.user_id) bio = model.authenticate(req.user_id, face, fp) bio_ok = bool(bio.get("success", False)) r = state.passkeys.verify_assertion( req.user_id, base64.b64decode(req.challenge), base64.b64decode(req.signature), bio_ok, now=time.time()) return PasskeyAuthResponse( success=r["success"], biometric_success=bio_ok, similarity=bio.get("similarity", 0.0), factors=r.get("factors"), reason=r.get("reason")) def _b64(s: str) -> bytes: if isinstance(s, str) and s.startswith("data:") and "," in s: s = s.split(",", 1)[1] return base64.b64decode(s) # ---- Voice passphrase factor ---- @router.post("/voice/enroll") def voice_enroll(body: dict = Body(...)) -> dict: return state.voice.enroll(body["user_id"], _b64(body["audio"])) @router.post("/voice/verify") def voice_verify(body: dict = Body(...)) -> dict: return state.voice.verify(body["user_id"], _b64(body["audio"])) # ---- Server-issued active-liveness challenge ---- @router.post("/liveness/challenge") def liveness_challenge(body: dict = Body(...)) -> dict: return state.liveness.issue(body.get("user_id", "user"), n=int(body.get("n", 3)), now=time.time()) @router.post("/liveness/verify") def liveness_verify(body: dict = Body(...)) -> dict: df_ok = True if body.get("face_image"): s = state.deepfake_score(decode_image(body["face_image"])) df_ok = s is None or s < state.deepfake_threshold return state.liveness.verify(body["session_id"], body.get("completed", []), now=time.time(), deepfake_ok=df_ok) # ---- Server-side WebAuthn (real FIDO2 ceremony) ---- def _webauthn(request: Request | None = None): if state.webauthn is None: from amanpay.security.webauthn_server import WebAuthnServer state.webauthn = WebAuthnServer() # Unless pinned via env, derive rp_id/origin from the request host so passkeys # work on whatever domain serves the app. To prevent host-header injection driving # the ceremony, only derive when the Host is in AMANPAY_ALLOWED_HOSTS (if that env # is set); otherwise trust the request host only for localhost. if request is not None and not os.getenv("AMANPAY_RP_ID"): host = (request.headers.get("host") or "").split(":")[0] allowed = [h.strip() for h in os.getenv("AMANPAY_ALLOWED_HOSTS", "").split(",") if h.strip()] ok = host and (host == "localhost" or not allowed or host in allowed) if ok: state.webauthn.rp_id = host state.webauthn.origin = ("https://localhost:8443" if host == "localhost" else f"https://{host}") return state.webauthn @router.post("/webauthn/register/begin") def wa_register_begin(request: Request, body: dict = Body(...)) -> dict: return json.loads(_webauthn(request).register_begin(body["user_id"])) @router.post("/webauthn/register/complete") def wa_register_complete(request: Request, body: dict = Body(...)) -> dict: try: r = _webauthn(request).register_complete(body["user_id"], body["credential"]) state.persist() # store the device passkey (public key) durably return r except Exception as exc: # attestation/verification errors -> clean 200 failure return {"success": False, "reason": str(exc)} @router.post("/webauthn/authenticate/begin") def wa_auth_begin(request: Request, body: dict = Body(...)) -> dict: state.ensure_user_loaded(body["user_id"]) opts = _webauthn(request).authenticate_begin(body["user_id"]) if opts is None: raise HTTPException(status_code=404, detail="no registered device") return json.loads(opts) @router.post("/webauthn/authenticate/complete") def wa_auth_complete(request: Request, body: dict = Body(...)) -> dict: state.ensure_user_loaded(body["user_id"]) try: return _webauthn(request).authenticate_complete(body["user_id"], body["credential"]) except Exception as exc: return {"success": False, "reason": str(exc)} # ---- Unified tri-modal identity (face + fingerprint + voice) ---- def _unified_samples(body: dict) -> dict: s = {} if body.get("face_image"): s["face"] = preprocess_face(decode_image(body["face_image"])) if body.get("fingerprint_image"): s["fingerprint"] = preprocess_fingerprint(decode_image(body["fingerprint_image"])) if body.get("voice_audio"): s["voice"] = preprocess_voice(_b64(body["voice_audio"])) return s @router.post("/unified/enroll") def unified_enroll(request: Request, body: dict = Body(...)) -> dict: if state.unified is None: raise HTTPException(status_code=503, detail="unified model not loaded") rate_limit(request, "enroll", capacity=6, refill_per_sec=0.2) r = state.unified.enroll(body["user_id"], **_unified_samples(body)) state.persist() state.audit(body["user_id"], "enroll", {"modalities": r.get("modalities")}) # Issue a session token so the client can call user-scoped endpoints when # AMANPAY_REQUIRE_AUTH is enabled. r["token"] = mint_token(body["user_id"]) return r @router.post("/unified/authenticate") def unified_authenticate(request: Request, body: dict = Body(...)) -> dict: if state.unified is None: raise HTTPException(status_code=503, detail="unified model not loaded") authorize(body["user_id"], request) rate_limit(request, "auth", capacity=8, refill_per_sec=0.3) state.ensure_user_loaded(body["user_id"]) result = state.unified.authenticate(body["user_id"], **_unified_samples(body)) record_auth("success" if result.get("success") else "fail") # deepfake/injection gate on the face modality if body.get("face_image"): df = state.deepfake_score(decode_image(body["face_image"])) if df is not None: result["deepfake_score"] = df if df >= state.deepfake_threshold: result["success"] = False result["reason"] = "Deepfake/injection detected" return result # ---- Active screen-flash liveness (Flashmark-style, defeats injection) ---- @router.post("/pad/challenge") def pad_challenge(body: dict = Body(...)) -> dict: """Issue a one-time random screen-colour sequence. The client flashes each colour from the screen during face capture and reports the reflected colours.""" return state.flash.issue(body.get("user_id", "user"), n=int(body.get("n", 4)), now=time.time()) @router.post("/pad/verify") def pad_verify(body: dict = Body(...)) -> dict: """Verify captured face-region reflection against the issued flash sequence.""" return state.flash.verify(body["session_id"], body["baseline"], body["measurements"], now=time.time()) # ---- Notification preferences + out-of-band payment confirmation ---- # Notification / out-of-band confirmation endpoints (/notify/*) live in the torch-free # api.routers.notifications router (included by api.main), so they and their API tests # stay isolated from the biometric-model imports. Paths/contracts are unchanged. # ---- Device-native biometric (platform authenticator: Touch ID / Face ID / fingerprint) ---- @router.post("/device/verify") def device_verify(request: Request, body: dict = Body(...)) -> dict: """Verify a WebAuthn platform-authenticator assertion (the device's built-in fingerprint / Face ID) over a single-use challenge — strong possession + on-device inherence that composes with AmanPay's server-side multimodal auth.""" import base64 import time authorize(body["user_id"], request) rate_limit(request, "device", capacity=8, refill_per_sec=0.3) state.ensure_user_loaded(body["user_id"]) r = state.passkeys.verify_assertion( body["user_id"], base64.b64decode(body["challenge"]), base64.b64decode(body["signature"]), True, now=time.time()) return {"success": bool(r.get("success")), "authenticator": "platform", "factors": r.get("factors"), "reason": r.get("reason")} # ---- Cardless biometric wallet (no physical card) ---- @router.post("/wallet/consent") def wallet_consent(request: Request, body: dict = Body(...)) -> dict: authorize(body["user_id"], request) return state.wallet.record_consent( body["user_id"], body.get("purpose", "biometric payment authentication"), body.get("duration", "until account closure"), now=time.time()) @router.post("/wallet/link") def wallet_link(request: Request, body: dict = Body(...)) -> dict: authorize(body["user_id"], request) r = state.wallet.link_account( body["user_id"], body.get("bank", "Bank"), body.get("kind", "checking"), body.get("funding_ref", "0000"), now=time.time()) state.persist() return r @router.get("/wallet/accounts") def wallet_accounts(user_id: str, request: Request) -> dict: authorize(user_id, request) return {"accounts": state.wallet.accounts(user_id)} @router.get("/wallet/transactions") def wallet_transactions(user_id: str, request: Request) -> dict: authorize(user_id, request) return {"transactions": state.wallet.transactions(user_id)} @router.post("/wallet/pay") def wallet_pay(request: Request, body: dict = Body(...)) -> dict: """Authorize a payment purely by biometrics: tri-modal verification (inherence) on a device-bound app (possession) = PSD2 SCA, signed to the transaction (SPC). Risk-adaptive (Visa-style): low-risk pays frictionlessly under an SCA exemption; high-risk must clear an active screen-flash liveness challenge first. Pass a prior ``flash_session`` that verified successfully to satisfy the step-up.""" if state.unified is None: raise HTTPException(status_code=503, detail="unified model not loaded") authorize(body["user_id"], request) rate_limit(request, "pay", capacity=8, refill_per_sec=0.3) uid = body["user_id"] state.ensure_user_loaded(uid) # read-through if enrolled on another replica amount = float(body.get("amount", 0)) merchant = body.get("merchant", "Merchant") samples = _unified_samples(body) bio = state.unified.authenticate(uid, **samples) biometric_ok = bool(bio.get("success", False)) # deepfake gate on the face modality is_df = False if body.get("face_image"): df = state.deepfake_score(decode_image(body["face_image"])) if df is not None and df >= state.deepfake_threshold: is_df = True; biometric_ok = False; bio["reason"] = "Deepfake/injection detected" # Extra step-up signals. liveness_ok = bool(body.get("liveness_ok", False)) device_verified = bool(body.get("device_verified", False)) oob_confirmed = state.notify.is_approved(body.get("confirmation_id"), now=time.time()) # Location signal (graduated — never a hard block for a cardless traveller). geo = state.geo.assess(uid, body.get("lat"), body.get("lon"), country=body.get("country"), now=time.time()) recent = len(state.wallet.transactions(uid)) risk = state.risk.assess( amount=amount, biometric_ok=biometric_ok, confidence=float(bio.get("confidence") or bio.get("similarity") or 0.0), modalities=bio.get("modalities"), recent_count=recent, deepfake=is_df, liveness_ok=liveness_ok, device_verified=device_verified, oob_confirmed=oob_confirmed, geo=geo) record_payment(risk["decision"]) bio_view = {"success": biometric_ok, "similarity": bio.get("similarity"), "modalities": bio.get("modalities"), "reason": bio.get("reason")} # High-risk payments must clear a step-up: EITHER an active-liveness challenge, # the device's built-in biometric (Touch ID / fingerprint), OR an out-of-band # confirmation. Offer all three so the client can pick. if risk["decision"] == "step_up": conf = state.notify.send_payment_confirmation(uid, amount, merchant, now=time.time()) return {"success": False, "decision": "step_up", "risk": risk, "challenge_required": True, "step_up_options": ["active_liveness", "device_biometric", "out_of_band"], "confirmation": {"confirmation_id": conf["confirmation_id"], "channels": conf["channels"], "dynamic_linked": conf["dynamic_linked"]}, "geo": geo, "reason": "Extra verification required — " + ", ".join(risk["reasons"]), "biometric": bio_view} if risk["decision"] == "decline": return {"success": False, "decision": "decline", "risk": risk, "geo": geo, "reason": risk["reasons"][0] if risk["reasons"] else "declined", "biometric": bio_view} # PSD2 SCA possession factor. A real possession proof is a registered device # passkey, a verified device-biometric assertion, or an out-of-band confirmation. # Submitting biometrics alone is NOT possession — only allowed as a fallback when # strict SCA is off (demo). Production sets AMANPAY_STRICT_SCA=1. import os as _os _strict = (_os.getenv("AMANPAY_STRICT_SCA", "0").strip().lower() not in ("", "0", "false", "no")) possession_ok = (state.passkeys.is_registered(uid) or device_verified or oob_confirmed or (bool(samples) and not _strict)) r = state.wallet.authorize_payment( uid, body["account_id"], amount, merchant, biometric_ok, possession_ok, now=time.time()) r["decision"] = risk["decision"] r["risk"] = risk r["geo"] = geo r["biometric"] = bio_view # Out-of-band receipt/confirmation on the user's preferred channel(s). if r.get("success"): r["confirmation"] = state.notify.send_payment_confirmation( uid, amount, merchant, now=time.time()) state.persist() # persist the new balance / transaction state.audit(uid, "pay", {"amount": amount, "merchant": merchant, "decision": risk["decision"], "risk": risk["risk"], "signature": r.get("transaction", {}).get("signature")}) return r # ---- One-click demo seed (default example biometrics) ---- _EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples") def _example_b64(name: str) -> str | None: path = os.path.join(_EXAMPLES_DIR, name) if not os.path.exists(path): return None mt = "audio/wav" if name.endswith(".wav") else \ "image/png" if name.endswith(".png") else "image/jpeg" with open(path, "rb") as fh: return f"data:{mt};base64," + base64.b64encode(fh.read()).decode() @router.post("/demo/seed") def demo_seed(body: dict = Body(...)) -> dict: """Seed a ready-to-pay demo: enroll the tri-modal `alice` identity from the bundled example samples, record consent, link a token-backed account, and return the *authenticate* samples so the client can run a payment immediately.""" if state.unified is None: raise HTTPException(status_code=503, detail="unified model not loaded") uid = body.get("user_id", "demo") enroll = {k: _example_b64(v) for k, v in { "face_image": "alice_face_enroll.jpg", "fingerprint_image": "alice_fp_enroll.png", "voice_audio": "alice_voice_enroll.wav"}.items()} if not any(enroll.values()): raise HTTPException(status_code=404, detail="example samples not found") en = state.unified.enroll(uid, **_unified_samples(enroll)) state.wallet.record_consent(uid, "biometric payments (demo)", "1 year", now=time.time()) acc = state.wallet.link_account(uid, "Chase", "checking", "4242424242424242", now=time.time()).get("account") auth = {k: _example_b64(v) for k, v in { "face_image": "alice_face_auth.jpg", "fingerprint_image": "alice_fp_auth.png", "voice_audio": "alice_voice_auth.wav"}.items()} return {"success": bool(en.get("success")), "user_id": uid, "modalities": en.get("modalities"), "account": acc, "token": mint_token(uid), "samples": {k: v for k, v in auth.items() if v}} # ---- Non-custodial Payment Core (provider-independent, Saudi-first) ---- @router.post("/payments") def payments_create(request: Request, body: dict = Body(...)) -> dict: """Initiate a customer-bank -> merchant payment via a licensed provider (mock for now). Non-custodial: AmanPay never holds funds. Requires an Idempotency-Key.""" from amanpay.payments import PaymentError authorize(body["user_id"], request) rate_limit(request, "payments", capacity=8, refill_per_sec=0.3) idem = request.headers.get("idempotency-key") or body.get("idempotency_key") if not idem: raise HTTPException(status_code=400, detail="Idempotency-Key required") try: p = state.payments.create_payment( user_id=body["user_id"], amount_minor=int(body["amount_minor"]), country=body.get("country"), currency=body.get("currency"), merchant_id=body["merchant_id"], payee_iban=body["payee_iban"], consent=body.get("consent") or {}, idempotency_key=idem, correlation_id=request.headers.get("x-correlation-id"), description=body.get("description", ""), now=time.time()) except PaymentError as exc: raise HTTPException(status_code=422, detail=str(exc)) return p.view() @router.get("/payments/providers") def payments_providers() -> dict: """Provider routing + capabilities per market, and the Saudi-first defaults.""" from amanpay.payments.config import (DEFAULT_COUNTRY, DEFAULT_CURRENCY, DEFAULT_TIMEZONE, LOCALES) from amanpay.payments.registry import get_provider, provider_name_for routing = {} for cc in ["SA", "AE", "GB", "EU", "US"]: try: routing[cc] = {"provider": provider_name_for(cc), "capabilities": get_provider(cc).capabilities().__dict__} except NotImplementedError as exc: routing[cc] = {"provider": provider_name_for(cc), "status": str(exc)} return {"default": {"country": DEFAULT_COUNTRY, "currency": DEFAULT_CURRENCY, "timezone": DEFAULT_TIMEZONE, "locales": LOCALES}, "non_custodial": True, "routing": routing} @router.get("/payments/{payment_id}") def payments_get(payment_id: str, request: Request) -> dict: from amanpay.payments import PaymentError try: p = state.payments.get_status(payment_id, now=time.time()) except PaymentError: raise HTTPException(status_code=404, detail="payment not found") authorize(p.user_id, request) return p.view() @router.post("/payments/{payment_id}/cancel") def payments_cancel(payment_id: str, request: Request) -> dict: from amanpay.payments import PaymentError p = state.payments.store.get(payment_id) if p is None: raise HTTPException(status_code=404, detail="payment not found") authorize(p.user_id, request) try: return state.payments.cancel(payment_id, now=time.time()).view() except PaymentError as exc: raise HTTPException(status_code=422, detail=str(exc)) @router.post("/payments/{payment_id}/refund") def payments_refund(payment_id: str, request: Request, body: dict = Body(...)) -> dict: from amanpay.payments import PaymentError p = state.payments.store.get(payment_id) if p is None: raise HTTPException(status_code=404, detail="payment not found") authorize(p.user_id, request) idem = (request.headers.get("idempotency-key") or body.get("idempotency_key") or ("rf_" + str(int(time.time() * 1000)))) try: return state.payments.refund(payment_id, int(body["amount_minor"]), idem, body.get("reason", ""), now=time.time()).view() except PaymentError as exc: raise HTTPException(status_code=422, detail=str(exc)) @router.post("/payments/{payment_id}/mock-advance") def payments_mock_advance(payment_id: str, request: Request, body: dict = Body(...)) -> dict: """DEV/mock only: simulate the provider advancing this payment by delivering a *signed* webhook internally (so it exercises the real signature/idempotency/ordering path). Rejected for any real provider — never a production settlement path.""" import secrets as _secrets p = state.payments.store.get(payment_id) if p is None: raise HTTPException(status_code=404, detail="payment not found") authorize(p.user_id, request) if p.provider != "mock": raise HTTPException(status_code=403, detail="mock-advance is only for the mock provider") from amanpay.payments.registry import get_provider prov = get_provider(p.country) payload = {"event_id": "mev_" + _secrets.token_hex(6), "provider_ref": p.provider_ref, "raw_status": body.get("raw_status", "SETTLED"), "sequence": int(time.time() * 1000)} headers, raw = prov.sign_webhook(payload, now=time.time()) return state.payments.handle_webhook(p.country, headers, raw, now=time.time()) @router.post("/payments/webhooks/{provider}") async def payments_webhook(provider: str, request: Request) -> dict: """Provider webhook receiver — settlement evidence comes ONLY from signed webhooks, never from redirect/callback query params. Unauthenticated by design (verified by the provider's webhook signature inside handle_webhook).""" raw = await request.body() headers = {k.lower(): v for k, v in request.headers.items()} return state.payments.handle_webhook(provider, headers, raw, now=time.time()) @router.get("/report-card") def report_card(refresh: bool = False, model: AmanPayAuthenticator = Depends(get_model)) -> dict: """Standards & performance conformance summary (recognition, PAD/FIDO, ISO/IEC 24745 template protection, deployment). Cached; ``?refresh=true`` rebuilds.""" if state.report_card is None or refresh: from amanpay.evaluation.report_card import build_report_card cfg = model.config state.report_card = build_report_card( fusion_dim=cfg.fusion.output_dim, protection_bits=cfg.auth.protection_bits) return state.report_card @router.get("/users", response_model=UserListResponse) def list_users(request: Request, model: AmanPayAuthenticator = Depends(get_model)) -> UserListResponse: # Enumeration is admin-only when auth is enforced (needs X-Admin-Token). if require_auth(): if request.headers.get("x-admin-token") != os.getenv("AMANPAY_ADMIN_TOKEN", ""): raise HTTPException(status_code=403, detail="admin only") return UserListResponse(users=list(model.enrolled_templates.keys())) @router.delete("/users/{user_id}") def delete_user(user_id: str, request: Request) -> dict: """Right-to-erasure: cascade-delete the user across memory + datastore.""" authorize(user_id, request) # self-delete only (or no-op when auth off) ok = state.erase(user_id) state.audit(user_id, "erasure", {"cascade": True}) return {"success": True, "user_id": user_id, "erased_in_store": ok, "message": "User and all linked data erased"} @router.get("/wallet/spc-key") def wallet_spc_key() -> dict: """Publish the server's Secure-Payment-Confirmation Ed25519 verification key so a merchant/auditor can independently verify transaction signatures.""" from amanpay.banking.wallet import spc_public_key_hex return {"alg": "Ed25519", "public_key_hex": spc_public_key_hex()}