From f6de51ed63475034c2409d377609035e149565a2 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 15 Feb 2026 15:09:05 +0800 Subject: [PATCH 01/48] chore: remove verification-service --- verification-service/api/otp_service.py | 259 ----- .../api/verify_routes_rpc_zid_poll.py | 961 ----------------- .../api/verify_routes_rpc_zid_poll.vm.py | 983 ------------------ verification-service/core/__init__.py | 0 verification-service/core/supabase_client.py | 22 - verification-service/core/zcash_rpc.py | 84 -- verification-service/core/zcash_runner.py | 435 -------- verification-service/readme.md | 483 --------- verification-service/requirements.txt | Bin 38 -> 0 bytes 9 files changed, 3227 deletions(-) delete mode 100644 verification-service/api/otp_service.py delete mode 100644 verification-service/api/verify_routes_rpc_zid_poll.py delete mode 100644 verification-service/api/verify_routes_rpc_zid_poll.vm.py delete mode 100644 verification-service/core/__init__.py delete mode 100644 verification-service/core/supabase_client.py delete mode 100644 verification-service/core/zcash_rpc.py delete mode 100644 verification-service/core/zcash_runner.py delete mode 100644 verification-service/readme.md delete mode 100644 verification-service/requirements.txt diff --git a/verification-service/api/otp_service.py b/verification-service/api/otp_service.py deleted file mode 100644 index 2330deec..00000000 --- a/verification-service/api/otp_service.py +++ /dev/null @@ -1,259 +0,0 @@ -import secrets -import hashlib -import os -from decimal import Decimal -from datetime import datetime, timedelta, timezone -import re - -from core.supabase_client import get_client -from core import zcash_runner as zr -from core.zcash_runner import log_event -from core.zcash_rpc import ZcashRPC, text_to_memo_hex - - -DEFAULT_OTP_AMOUNT_ZEC = "0.0005" -DEFAULT_OTP_AUTO_SEND = "true" - - -def _build_otp_memo(otp: str, expires_at: str) -> str: - return f"OTP:{otp} EXPIRES:{expires_at}" - -def _parse_txid_from_stdout(stdout: str | None) -> str | None: - if not stdout: - return None - lines = [ln.strip() for ln in stdout.splitlines() if ln.strip()] - if not lines: - return None - last = lines[-1] - if re.fullmatch(r"[0-9a-f]{64}", last): - return last - return None - -def _send_otp_transaction( - to_address: str, - amount_zec: str, - memo: str, - timeout: int = 180, - sync_before_send: bool = True, -): - wallet_dir = os.getenv("WALLET_DIR") - account_id = os.getenv("ADMIN_ACCOUNT_ID") - identity = os.getenv("ADMIN_IDENTITY") - - if not wallet_dir: - raise RuntimeError("WALLET_DIR not set in environment") - if not account_id: - raise RuntimeError("ADMIN_ACCOUNT_ID not set in environment") - - if sync_before_send: - sync_result = zr.sync_wallet() - if sync_result.get("returncode") != 0: - raise RuntimeError(sync_result.get("stderr") or sync_result.get("stdout")) - - enhance_result = zr.enhance_wallet() - if enhance_result.get("returncode") != 0: - raise RuntimeError(enhance_result.get("stderr") or enhance_result.get("stdout")) - - zr.ensure_funds(min_zec=float(amount_zec) * 2) - - value_zat = int(Decimal(amount_zec) * Decimal(10**8)) - - args = ["send", account_id] - if identity: - args += ["-i", identity] - - args += [ - "--address", to_address, - "--value", str(value_zat), - "--memo", memo, - "--server", zr.SERVER_TOKEN, - ] - - return zr.run_command(args, timeout=timeout) - -def _send_otp_transaction_rpc( - to_address: str, - amount_zec: str, - memo: str, - from_address: str, -): - if not from_address: - raise RuntimeError("ADMIN_ADDRESS_INBOX not set in environment") - - value_zec = float(Decimal(amount_zec)) - memo_hex = text_to_memo_hex(memo) - outputs = [{ - "address": to_address, - "amount": value_zec, - "memo": memo_hex, - }] - - rpc = ZcashRPC() - opid = rpc.send_many(from_address, outputs, minconf=1) - return {"returncode": 0, "stdout": str(opid), "stderr": "", "opid": opid} - - -def create_and_send_otp( - zcasher_id: int, - to_address: str = None, - sync_before_send: bool = True, - send_mode: str = "devtool", - phase_callback=None, -): - """ - Phase III (manual-send version): - - One row per user (PRIMARY KEY = zcasher_id) - - UPSERT replaces the entire row - - Creates fresh OTP every time - """ - - sb = get_client() - now = datetime.now(timezone.utc).replace(microsecond=0) - - log_event(zcasher_id, "otp_create", "ok", - f"Starting OTP creation for {zcasher_id}") - if phase_callback: - try: - phase_callback("creating", {"zcasher_id": zcasher_id}) - except Exception: - pass - - # ------------------------------------------------------------ - # 1. Resolve to_address if missing - # ------------------------------------------------------------ - if not to_address: - result = ( - sb.table("zcasher") - .select("address") - .eq("id", zcasher_id) - .limit(1) - .execute() - ) - data = getattr(result, "data", None) or [] - if not data: - log_event( - zcasher_id, "otp_create_error", "error", - f"No address found for zcasher_id={zcasher_id}" - ) - raise ValueError(f"No address found for zcasher_id={zcasher_id}") - - to_address = data[0]["address"] - - # ------------------------------------------------------------ - # 2. Generate OTP - # ------------------------------------------------------------ - otp = str(secrets.randbelow(1_000_000)).zfill(6) - code_hash = hashlib.sha256(otp.encode()).hexdigest() - - expires_at = (now + timedelta(hours=120)).isoformat() - created_at = now.isoformat() - - # ------------------------------------------------------------ - # 3. UPSERT new OTP record (overwrite old one) - # ------------------------------------------------------------ - try: - sb.table("verification_codes").upsert({ - "zcasher_id": zcasher_id, - "code_hash": code_hash, - "otp": otp, - "expires_at": expires_at, - "created_at": created_at, - "attempts_left": 3, - "is_verified": False, - "otp_send_success": None, - "otp_send_txid": None, - }).execute() - - log_event(zcasher_id, "otp_store", "ok", "Stored new OTP") - if phase_callback: - try: - phase_callback("stored", {"zcasher_id": zcasher_id}) - except Exception: - pass - - except Exception as e: - log_event(zcasher_id, "otp_store_error", "error", str(e)) - raise ValueError(f"Failed to generate OTP: {e}") - - # ------------------------------------------------------------ - # 4. Send OTP memo via zcash-devtool (optional) - # ------------------------------------------------------------ - auto_send_raw = os.getenv("OTP_AUTO_SEND", DEFAULT_OTP_AUTO_SEND).strip().lower() - auto_send = auto_send_raw in ("1", "true", "yes", "y", "on") - amount_zec = os.getenv("OTP_AMOUNT_ZEC", DEFAULT_OTP_AMOUNT_ZEC) - memo = _build_otp_memo(otp, expires_at) - - send_result = None - status = "skipped" - if auto_send: - log_event(zcasher_id, "otp_send_start", "ok", f"Sending OTP to {to_address} via {send_mode}") - if phase_callback: - try: - phase_callback("sending", {"zcasher_id": zcasher_id}) - except Exception: - pass - try: - if send_mode == "rpc": - from_address = os.getenv("ADMIN_ADDRESS_INBOX") - send_result = _send_otp_transaction_rpc( - to_address, - amount_zec, - memo, - from_address, - ) - else: - send_result = _send_otp_transaction( - to_address, - amount_zec, - memo, - sync_before_send=sync_before_send, - ) - except Exception as e: - log_event(zcasher_id, "otp_send_error", "error", str(e)) - sb.table("verification_codes").update( - { - "otp_send_success": False, - "otp_send_txid": None, - } - ).eq("zcasher_id", zcasher_id).execute() - raise - - status = "ok" if send_result.get("returncode") == 0 else "error" - if send_mode == "rpc": - # We store opid for now; polling for the txid adds latency/complexity. - txid = send_result.get("opid") or send_result.get("stdout") - else: - txid = _parse_txid_from_stdout(send_result.get("stdout")) - send_success = status == "ok" - message = send_result.get("stderr") or send_result.get("stdout") or "" - log_event(zcasher_id, "otp_send", status, message) - if phase_callback: - try: - phase_callback("sent" if send_success else "failed", {"zcasher_id": zcasher_id}) - except Exception: - pass - - sb.table("verification_codes").update( - { - "otp_send_success": send_success, - "otp_send_txid": txid, - } - ).eq("zcasher_id", zcasher_id).execute() - else: - log_event(zcasher_id, "otp_ready_manual_send", "ok", - f"OTP ready for manual send to {to_address}") - - if auto_send: - final_status = "otp_sent" if status == "ok" else "otp_send_failed" - else: - final_status = "otp_generated" - - return { - "status": final_status, - "zcasher_id": zcasher_id, - "otp": otp, - "send_to_address": to_address, - "expires_at": expires_at, - "created_at": created_at, - "send_result": send_result, - } diff --git a/verification-service/api/verify_routes_rpc_zid_poll.py b/verification-service/api/verify_routes_rpc_zid_poll.py deleted file mode 100644 index 6d23a560..00000000 --- a/verification-service/api/verify_routes_rpc_zid_poll.py +++ /dev/null @@ -1,961 +0,0 @@ -import os -import json -import time -import re -import hashlib -import uuid -import threading -import secrets -from datetime import datetime, timezone -from api.otp_service import create_and_send_otp -from fastapi import FastAPI, HTTPException - -# Routers (optional) -try: - from api.admin_routes import router as admin_router -except Exception: - admin_router = None - -from core.supabase_client import get_client -from core.zcash_runner import ( - parse_and_store_transactions, - log_event, -) -from core.zcash_rpc import ZcashRPC, decode_memo_hex - -from fastapi.middleware.cors import CORSMiddleware - -app = FastAPI(title="Zcash Verification API") - -# --- CORS SETTINGS --- -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:5173", - "http://localhost:3000", - "http://localhost:3001", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Mount admin routes (if present) -if admin_router: - app.include_router(admin_router, prefix="/admin") - -POLL_REQUESTS_TABLE = os.getenv("POLL_REQUESTS_TABLE", "verification_poll_requests") -# Polling path should be as lean as possible. -RPC_POLL_DIAGNOSTICS = False -RPC_TAIL_SIZE = 8 -REQUEST_ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" -REQUEST_ID_LEN = 6 - - -@app.get("/debug/zcashers") -def debug_zcashers(): - sb = get_client() - rows = sb.table("zcasher").select("id, name, address").execute() - return rows.data - - -@app.get("/") -def root(): - return {"status": "ok", "service": "zcash-verification"} - - -def _parse_iso(dt_raw: str | None): - if not dt_raw: - return None - clean = dt_raw.replace("Z", "+00:00") - try: - return datetime.fromisoformat(clean) - except Exception: - return None - - -def _get_last_otp_status(sb, zcasher_id: int): - result = ( - sb.table("verification_codes") - .select("created_at, otp_send_success") - .eq("zcasher_id", zcasher_id) - .order("created_at", desc=True) - .limit(1) - .execute() - ) - rows = getattr(result, "data", None) or [] - if not rows: - return None, None - row = rows[0] - return _parse_iso(row.get("created_at")), row.get("otp_send_success") - - -def _collect_new_verification_requests(sb, run_start_iso: str): - result = ( - sb.table("transactions") - .select("txid, zid, tx_time, ts, memo, tx_ignore") - .gte("ts", run_start_iso) - .execute() - ) - rows = getattr(result, "data", None) or [] - candidates = {} - for row in rows: - if row.get("tx_ignore"): - continue - zid_raw = row.get("zid") - if zid_raw is None: - continue - try: - zid = int(zid_raw) - except Exception: - continue - tx_time = _parse_iso(row.get("tx_time")) - if not tx_time: - continue - existing = candidates.get(zid) - if not existing or tx_time > existing["tx_time"]: - candidates[zid] = { - "zid": zid, - "txid": row.get("txid"), - "tx_time": tx_time, - "memo": row.get("memo"), - } - return list(candidates.values()) - - -def _fast_match_receipt(receipts, zid: int, ignore_txids: set[str] | None = None): - zid_tag = f"{{z:{zid}" - ignore_txids = ignore_txids or set() - # Newest-first scan over a small tail of receipts. - for r in reversed(receipts): - txid = r.get("txid") - if txid and txid in ignore_txids: - continue - memo_text = decode_memo_hex(r.get("memo")) - if not memo_text or zid_tag not in memo_text: - continue - now_utc = datetime.now(timezone.utc) - return { - "zid": zid, - "txid": txid, - "tx_time": now_utc, - "ts": now_utc, - "event_time": now_utc, - "memo": memo_text, - "raw_receipt": dict(r), - } - return None - - -def _rpc_receipts_to_devtool_output(receipts, rpc: ZcashRPC) -> str: - lines = [] - for r in receipts: - txid = r.get("txid") - if not txid: - continue - - memo_text = decode_memo_hex(r.get("memo")) - height_raw = r.get("blockheight") - height = None - if height_raw is not None: - try: - height = int(height_raw) - except Exception: - height = None - - mined = None - if height is not None: - try: - block_hash = rpc.get_block_hash(height) - header = rpc.get_block_header(block_hash) - block_time = header.get("time") - if block_time: - dt = datetime.fromtimestamp(block_time, tz=timezone.utc) - mined = dt.strftime("%Y-%m-%d %H:%M:%S+00:00") - except Exception: - mined = None - - lines.append(txid) - if height is not None: - if mined: - lines.append(f"Mined: {height} ({mined})") - else: - lines.append(f"Mined: {height}") - if memo_text: - escaped = memo_text.replace("\\", "\\\\").replace('"', '\\"') - lines.append(f'Memo::Text("{escaped}")') - lines.append("") - - return "\n".join(lines) - - -def _get_cached_receipts(rpc: ZcashRPC, admin_inbox: str, zid: int): - print( - "[rpc_call_start] " - f"zid={zid} method=z_listreceivedbyaddress " - f"url={rpc.url} timeout_s={rpc.timeout} minconf=0 inbox={admin_inbox}" - ) - t0 = time.perf_counter() - receipts = rpc.list_received_by_address(admin_inbox, 0) - elapsed_ms = int((time.perf_counter() - t0) * 1000) - print( - "[rpc_call_done] " - f"zid={zid} z_listreceivedbyaddress in {elapsed_ms} ms receipts={len(receipts)}" - ) - return receipts, False - - - - -def _log_rpc_ingest(receipts, sb) -> None: - if not receipts: - return - for r in receipts: - txid = r.get("txid") - if not txid: - continue - memo_hex = r.get("memo") - memo_text = decode_memo_hex(memo_hex) - try: - sb.table("transaction_ingest_log").insert({ - "txid": txid, - "source": "rpc", - "memo_raw": memo_hex, - "memo_norm": memo_text, - "raw_payload": dict(r), - }).execute() - except Exception as e: - print(f"Failed to log rpc ingest for tx {txid}: {e}") - - -def _print_last_blocks(raw_output: str, label: str, count: int = 10) -> None: - blocks = re.split(r"(?=^[0-9a-f]{64}$)", raw_output, flags=re.MULTILINE) - blocks = [b.strip() for b in blocks if b.strip()] - if not blocks: - print(f"\n[{label}] No tx blocks found.") - return - tail = blocks[-count:] - print(f"\n[{label}] Last {len(tail)} tx blocks (devtool-like):\n") - for block in tail: - print(block) - print("") - - -def _extract_rpc_error(entries) -> str | None: - if not entries: - return None - for entry in entries: - if entry.get("status") == "failed": - err = entry.get("error") or {} - if isinstance(err, dict): - msg = err.get("message") or str(err) - else: - msg = str(err) - return msg - return None - - -def _rpc_debug_operation(opid: str | None, rpc: ZcashRPC, zcasher_id: int | None) -> None: - if not opid: - log_event(zcasher_id, "otp_send_rpc_debug_skip", "error", "missing opid") - return - try: - status_entries = [] - result_entries = [] - for _ in range(5): - status_entries = rpc.get_operation_status([opid]) - result_entries = rpc.get_operation_result([opid]) - if result_entries or _extract_rpc_error(status_entries): - break - time.sleep(1) - print(f"\n[RPC_DEBUG_OPS] opid={opid}") - print(f"status={status_entries}") - print(f"result={result_entries}") - log_event(zcasher_id, "otp_send_rpc_status", "ok", json.dumps(status_entries, default=str)) - log_event(zcasher_id, "otp_send_rpc_result", "ok", json.dumps(result_entries, default=str)) - error_msg = _extract_rpc_error(result_entries) or _extract_rpc_error(status_entries) - if error_msg: - log_event(zcasher_id, "otp_send_rpc_failed", "error", f"opid={opid} error={error_msg}") - except Exception as e: - log_event(zcasher_id, "otp_send_rpc_debug_error", "error", str(e)) - - -def _get_poll_request(sb, request_id: str): - result = ( - sb.table(POLL_REQUESTS_TABLE) - .select("*") - .eq("id", request_id) - .limit(1) - .execute() - ) - rows = getattr(result, "data", None) or [] - return rows[0] if rows else None - - -def _create_poll_request(sb, zid: int): - request_id = None - for _ in range(10): - candidate = "".join(secrets.choice(REQUEST_ID_ALPHABET) for _ in range(REQUEST_ID_LEN)) - if not _get_poll_request(sb, candidate): - request_id = candidate - break - if not request_id: - request_id = "".join(secrets.choice(REQUEST_ID_ALPHABET) for _ in range(REQUEST_ID_LEN)) - started_at = datetime.utcnow().isoformat() + "Z" - payload = { - "id": request_id, - "zid": zid, - "status": "pending", - "started_at": started_at, - "created_at": started_at, - } - sb.table(POLL_REQUESTS_TABLE).insert(payload).execute() - return request_id, started_at - - -def _update_poll_request(sb, request_id: str, updates: dict) -> None: - sb.table(POLL_REQUESTS_TABLE).update(updates).eq("id", request_id).execute() - - -def _get_last_matched_txid(sb, zid: int) -> str | None: - try: - result = ( - sb.table(POLL_REQUESTS_TABLE) - .select("matched_txid, matched_at") - .eq("zid", zid) - .eq("status", "matched") - .not_.is_("matched_txid", "null") - .order("matched_at", desc=True) - .limit(1) - .execute() - ) - rows = getattr(result, "data", None) or [] - if not rows: - return None - return rows[0].get("matched_txid") - except Exception as e: - print(f"[last_matched_txid_error] zid={zid} error={e}") - return None - - -def _get_seen_txids(sb, txids: list[str]) -> set[str]: - if not txids: - return set() - try: - result = ( - sb.table("transactions") - .select("txid") - .in_("txid", txids) - .execute() - ) - rows = getattr(result, "data", None) or [] - return {r.get("txid") for r in rows if r.get("txid")} - except Exception as e: - print(f"[seen_txids_error] error={e}") - return set() - - -def _append_otp_phase(request_id: str, phase: str) -> None: - sb = get_client() - req = _get_poll_request(sb, request_id) - if not req: - return - history = req.get("otp_phase_history") or [] - if not isinstance(history, list): - history = [] - history.append( - { - "phase": phase, - "ts": datetime.now(timezone.utc).isoformat(), - } - ) - try: - _update_poll_request( - sb, - request_id, - { - "otp_phase": phase, - "otp_phase_history": history, - }, - ) - except Exception as e: - log_event(req.get("zid"), "otp_phase_update_error", "error", str(e)) - - -def _start_otp_async(request_id: str, zid: int) -> None: - def _runner(): - def _phase_cb(phase: str, _meta: dict): - _append_otp_phase(request_id, phase) - - try: - result = create_and_send_otp( - zid, - sync_before_send=False, - send_mode="rpc", - phase_callback=_phase_cb, - ) - otp_status = result.get("status") - _update_poll_request( - get_client(), - request_id, - { - "otp_status": otp_status, - }, - ) - except Exception as e: - _append_otp_phase(request_id, "failed") - log_event(zid, "otp_async_error", "error", str(e)) - - thread = threading.Thread(target=_runner, daemon=True) - thread.start() - - -def _parse_store_matched_receipts(matched_receipts) -> int: - if not matched_receipts: - return 0 - try: - sb = get_client() - rpc = ZcashRPC() - rpc_stdout = _rpc_receipts_to_devtool_output(matched_receipts, rpc) - return parse_and_store_transactions(rpc_stdout, sb, source="rpc") - except Exception as e: - print(f"[parse_store_error] error={e}") - return 0 - - -@app.post("/verify/poll/start") -def verify_poll_start(zid: int): - sb = get_client() - request_id, started_at = _create_poll_request(sb, zid) - return { - "status": "pending", - "request_id": request_id, - "started_at": started_at, - "zid": zid, - } - - -@app.get("/verify/poll/{request_id}/status") -def verify_poll_status(request_id: str, debug_ops: bool = False): - sb = get_client() - req = _get_poll_request(sb, request_id) - if not req: - raise HTTPException(404, "Verification poll request not found") - - if req.get("status") and req.get("status") != "pending": - return { - "status": req.get("status"), - "request_id": request_id, - "zid": req.get("zid"), - "matched_txid": req.get("matched_txid"), - "matched_memo": req.get("matched_memo"), - "otp_status": req.get("otp_status"), - "otp_phase": req.get("otp_phase"), - "otp_phase_history": req.get("otp_phase_history") or [], - } - - zid_raw = req.get("zid") - if zid_raw is None: - raise HTTPException(500, "Poll request missing zid") - try: - zid = int(zid_raw) - except Exception: - raise HTTPException(500, "Poll request has invalid zid") - - run_start_iso = req.get("started_at") or req.get("created_at") - if not run_start_iso: - run_start_iso = datetime.utcnow().isoformat() + "Z" - started_at_dt = _parse_iso(run_start_iso) - - admin_inbox = os.getenv("ZCASH_ADMIN_INBOX") or os.getenv("ADMIN_ADDRESS_INBOX") - if not admin_inbox: - raise HTTPException(500, "ZCASH_ADMIN_INBOX or ADMIN_ADDRESS_INBOX not set") - - now_utc = datetime.now(timezone.utc) - started_at_str = started_at_dt.isoformat() if started_at_dt else "(unknown)" - lag_s = None - if started_at_dt: - lag_s = max(0, int((now_utc - started_at_dt).total_seconds())) - print( - "[wallet_rpc_poll_start] " - f"request_id={request_id} zid={zid} started_at={started_at_str} " - f"elapsed_since_start_s={(lag_s if lag_s is not None else '(unknown)')} " - f"inbox={admin_inbox}" - ) - rpc = ZcashRPC() - try: - receipts, from_cache = _get_cached_receipts(rpc, admin_inbox, zid) - except Exception as e: - print(f"[rpc_call_error] zid={zid} error={e}") - return { - "status": "pending", - "request_id": request_id, - "zid": zid, - "rpc_error": str(e), - } - if receipts: - receipts.sort(key=lambda r: r.get("blocktime") or r.get("time") or 0) - tail = receipts[-RPC_TAIL_SIZE:] if RPC_TAIL_SIZE > 0 else receipts - print( - "[rpc_tail] " - f"request_id={request_id} zid={zid} tail_size={len(tail)} " - f"total_receipts={len(receipts)}" - ) - ignore_txids: set[str] = set() - last_txid = _get_last_matched_txid(sb, zid) - if last_txid: - ignore_txids.add(last_txid) - print(f"[ignore_last_matched_txid] zid={zid} txid={last_txid}") - req_txid = req.get("matched_txid") - if req_txid: - ignore_txids.add(req_txid) - tail_txids = [r.get("txid") for r in tail if r.get("txid")] - seen_txids = _get_seen_txids(sb, tail_txids) - if seen_txids: - ignore_txids.update(seen_txids) - print(f"[ignore_seen_txids] zid={zid} count={len(seen_txids)}") - fast_match = _fast_match_receipt(tail, zid, ignore_txids=ignore_txids) - eligible = [] - if fast_match: - matched_txid = fast_match.get("txid") - matched_memo = fast_match.get("memo") or "" - print( - "[fast_match_found] " - f"request_id={request_id} zid={zid} txid={matched_txid}" - ) - print(f"[fast_match_memo_full] txid={matched_txid} memo={matched_memo}") - matched_receipts = [r for r in tail if r.get("txid") == matched_txid] - print( - "[parse_store_start] " - f"request_id={request_id} zid={zid} txid={matched_txid} receipts={len(matched_receipts)}" - ) - stored = _parse_store_matched_receipts(matched_receipts) - print( - "[parse_store_done] " - f"request_id={request_id} zid={zid} txid={matched_txid} stored={stored}" - ) - last_otp_at, last_otp_send_success = _get_last_otp_status(sb, zid) - event_time = fast_match.get("event_time") - if last_otp_send_success is not False: - if last_otp_at and event_time and event_time <= last_otp_at: - print( - "[fast_match_ignored] " - f"request_id={request_id} zid={zid} reason=older-than-last-otp" - ) - else: - fast_match["last_otp_at"] = last_otp_at - fast_match["last_otp_send_success"] = last_otp_send_success - eligible.append(fast_match) - else: - fast_match["last_otp_at"] = last_otp_at - fast_match["last_otp_send_success"] = last_otp_send_success - eligible.append(fast_match) - else: - print( - "[fast_match_miss] " - f"request_id={request_id} zid={zid} receipts_checked={len(tail)}" - ) - - if not eligible: - return { - "status": "pending", - "request_id": request_id, - "zid": zid, - } - - match_time = datetime.now(timezone.utc) - zids = sorted({r["zid"] for r in eligible}) - zcasher_map = {} - if zids: - z_rows = ( - sb.table("zcasher") - .select("id, name, display_name") - .in_("id", zids) - .execute() - ) - for row in getattr(z_rows, "data", None) or []: - zcasher_map[int(row["id"])] = row - - auto_send_raw = os.getenv("OTP_HITL", "true").strip().lower() - hitl = auto_send_raw in ("1", "true", "yes", "y", "on") - approved = True - if hitl and debug_ops: - response = input("Generate/send OTPs for these requests? [y/N]: ").strip().lower() - approved = response in ("y", "yes") - - sent = [] - otp_phase = req.get("otp_phase") - if approved and eligible and not otp_phase: - _append_otp_phase(request_id, "creating") - _start_otp_async(request_id, zid) - otp_phase = "creating" - - matched = eligible[0] - elapsed_seconds = None - started_at_raw = req.get("started_at") or req.get("created_at") - started_at = _parse_iso(started_at_raw) if started_at_raw else None - if started_at: - elapsed_seconds = max(0, int((match_time - started_at).total_seconds())) - update_payload = { - "status": "matched", - "matched_txid": matched.get("txid"), - "matched_memo": matched.get("memo"), - "matched_at": match_time.isoformat(), - "otp_status": req.get("otp_status"), - } - if elapsed_seconds is not None: - update_payload["elapsed_seconds"] = elapsed_seconds - _update_poll_request(sb, request_id, update_payload) - if elapsed_seconds is not None: - try: - sb.table("verification_codes").update( - {"verification_elapsed_seconds": elapsed_seconds} - ).eq("zcasher_id", zid).execute() - except Exception as e: - log_event(zid, "verify_elapsed_update_error", "error", str(e)) - - return { - "status": "matched", - "request_id": request_id, - "zid": zid, - "matched_txid": update_payload["matched_txid"], - "otp_sent": sent, - "otp_phase": otp_phase, - "otp_phase_history": (_get_poll_request(sb, request_id) or {}).get("otp_phase_history") or [], - } - - -@app.post("/verify/check") -def verify_check(zid: int, debug_ops: bool = False): - sb = get_client() - run_start_iso = datetime.utcnow().isoformat() + "Z" - - # RPC scan + adapter to devtool-like output - admin_inbox = os.getenv("ZCASH_ADMIN_INBOX") or os.getenv("ADMIN_ADDRESS_INBOX") - if not admin_inbox: - raise HTTPException(500, "ZCASH_ADMIN_INBOX or ADMIN_ADDRESS_INBOX not set") - - log_event(None, "wallet_rpc_scan_start", "ok", f"Scanning inbox {admin_inbox}") - rpc = ZcashRPC() - receipts = rpc.list_received_by_address(admin_inbox, 0) - rpc_stdout = _rpc_receipts_to_devtool_output(receipts, rpc) - _log_rpc_ingest(receipts, sb) - - # Optional debug: dump reconstructed output - dump_txid = os.getenv("RPC_DUMP_TXID", "").strip().lower() - dump_all = os.getenv("RPC_DUMP_ALL", "").strip().lower() in ( - "1", - "true", - "yes", - "y", - "on", - ) - if dump_txid or dump_all: - if dump_all: - print("\n[RPC_DUMP_ALL] Reconstructed devtool-like output:\n") - print(rpc_stdout) - else: - blocks = [b for b in rpc_stdout.split("\n\n") if b.strip().startswith(dump_txid)] - print(f"\n[RPC_DUMP_TXID={dump_txid}] Reconstructed block:\n") - print(blocks[0] if blocks else "txid not found in RPC output") - - _print_last_blocks(rpc_stdout, "RPC_SCAN_ADAPTED") - count = parse_and_store_transactions(rpc_stdout, sb, source="rpc") - log_event(None, "wallet_rpc_scan_done", "ok", f"Stored {count} memos via RPC") - - new_requests = _collect_new_verification_requests(sb, run_start_iso) - eligible = [] - for req in new_requests: - if req["zid"] != zid: - continue - last_otp_at, last_otp_send_success = _get_last_otp_status(sb, req["zid"]) - if last_otp_send_success is not False: - if last_otp_at and req["tx_time"] <= last_otp_at: - continue - req["last_otp_at"] = last_otp_at - req["last_otp_send_success"] = last_otp_send_success - eligible.append(req) - - zids = sorted({r["zid"] for r in eligible}) - zcasher_map = {} - if zids: - z_rows = ( - sb.table("zcasher") - .select("id, name, display_name") - .in_("id", zids) - .execute() - ) - for row in getattr(z_rows, "data", None) or []: - zcasher_map[int(row["id"])] = row - - auto_send_raw = os.getenv("OTP_HITL", "true").strip().lower() - hitl = auto_send_raw in ("1", "true", "yes", "y", "on") - approved = True - if hitl and not eligible: - print(f"\nNo pending verification requests matched zid={zid}.") - log_event(None, "verify_check", "ok", f"No pending verification requests for zid={zid}") - if hitl and eligible: - print("\nThese are verification requests who did not receive verification codes yet.") - print("Pending verification requests (tx_time newer than last OTP):") - for item in eligible: - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - last_otp = item.get("last_otp_at") - last_otp_str = last_otp.isoformat() if last_otp else "(none)" - last_otp_send_success = item.get("last_otp_send_success") - memo = item.get("memo") or "" - print( - " " - f"zid={item['zid']} | name={name} | display_name={display_name} | " - f"tx_time={item['tx_time'].isoformat()} | last_otp={last_otp_str} | " - f"last_otp_send_success={last_otp_send_success} | memo={memo}" - ) - - if eligible: - print("\nPending verification requests (tx_ignore != True):") - for item in eligible: - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - last_otp = item.get("last_otp_at") - last_otp_str = last_otp.isoformat() if last_otp else "(none)" - last_otp_send_success = item.get("last_otp_send_success") - memo = item.get("memo") or "" - print( - " " - f"zid={item['zid']} | name={name} | display_name={display_name} | " - f"tx_time={item['tx_time'].isoformat()} | last_otp={last_otp_str} | " - f"last_otp_send_success={last_otp_send_success} | memo={memo}" - ) - - if debug_ops: - response = input("Generate/send OTPs for these requests? [y/N]: ").strip().lower() - approved = response in ("y", "yes") - - sent = [] - if approved and eligible: - for item in eligible: - try: - r = create_and_send_otp( - item["zid"], - sync_before_send=False, - send_mode="rpc", - ) - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - status = r.get("status") - send_result = r.get("send_result") or {} - if debug_ops: - _rpc_debug_operation(send_result.get("opid") or send_result.get("stdout"), rpc, item["zid"]) - print( - f"OTP send status: zid={item['zid']} | name={name} | " - f"display_name={display_name} | status={status}" - ) - sent.append({ - "zid": item["zid"], - "status": status, - }) - except Exception as e: - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - print( - f"OTP send status: zid={item['zid']} | name={name} | " - f"display_name={display_name} | status=error" - ) - log_event(item["zid"], "otp_send_error", "error", str(e)) - - if count > 0: - log_event(None, "verify_check", "ok", f"{count} transactions with memos stored") - else: - log_event(None, "verify_check", "ok", "No memos with z-pattern found") - - return { - "status": "message_received", - "count": count, - "scan_mode": "rpc", - "otp_candidates": [c["zid"] for c in eligible], - "otp_sent": sent, - "otp_approved": approved, - } - - -@app.post("/verify/confirm") -def verify_confirm(zcasher_id: int, otp: str): - sb = get_client() - - supplied_hash = hashlib.sha256(otp.encode()).hexdigest() - - result = ( - sb.table("verification_codes") - .select("*") - .eq("zcasher_id", zcasher_id) - .order("id", desc=True) - .execute() - ) - - rows = getattr(result, "data", None) or [] - if not rows: - raise HTTPException(404, f"No OTP record for zcasher_id {zcasher_id}") - - vc = rows[0] - - if len(rows) > 1: - old_ids = [r["id"] for r in rows[1:] if not r.get("is_verified")] - if old_ids: - sb.table("verification_codes").update( - {"expires_at": datetime.now(timezone.utc).isoformat()} - ).in_("id", old_ids).execute() - - if vc.get("is_verified"): - return { - "status": "otp_already_used", - "zcasher_id": zcasher_id, - } - - attempts_left = vc.get("attempts_left", 0) - if attempts_left <= 0: - return {"status": "locked", "zcasher_id": zcasher_id} - - expires_raw = vc.get("expires_at", "") - clean_expires = expires_raw.replace("Z", "+00:00") - try: - expires_at = datetime.fromisoformat(clean_expires) - except Exception: - raise HTTPException(500, f"Malformed expires_at: {expires_raw}") - - now_utc = datetime.now(timezone.utc) - if now_utc > expires_at: - return {"status": "expired", "zcasher_id": zcasher_id} - - if supplied_hash != vc.get("code_hash"): - new_attempts = max(0, attempts_left - 1) - sb.table("verification_codes").update( - {"attempts_left": new_attempts} - ).eq("id", vc["id"]).execute() - - log_event(zcasher_id, "verify_invalid_otp", "fail", f"Attempts left: {new_attempts}") - - return { - "status": "invalid", - "attempts_left": new_attempts, - "zcasher_id": zcasher_id, - } - - sb.table("verification_codes").update( - {"is_verified": True} - ).eq("id", vc["id"]).execute() - - sb.table("zcasher").update( - {"address_verified": True, "last_verified_at": now_utc.isoformat()} - ).eq("id", zcasher_id).execute() - - pending = ( - sb.table("pending_zcasher_edits") - .select("*") - .eq("zcasher_id", zcasher_id) - .eq("processed", False) - .order("created_at", desc=True) - .limit(1) - .execute() - ) - - pending_rows = getattr(pending, "data", None) or [] - if not pending_rows: - log_event(zcasher_id, "verify_confirm", "ok", "Verified: no pending edits") - return {"status": "verified_and_no_pending_edits", "zcasher_id": zcasher_id} - - edit = pending_rows[0] - - profile_updates = {} - profile = edit.get("profile") or {} - - delete_map = { - "a": "address", - "n": "name", - "b": "bio", - "i": "profile_image_url", - "h": "display_name", - } - - for code in profile.get("d", []): - col = delete_map.get(code) - if col: - profile_updates[col] = None - - for key in ["name", "bio", "profile_image_url", "address", "display_name"]: - if key in profile: - profile_updates[key] = profile[key] - - if profile_updates: - sb.table("zcasher").update(profile_updates).eq("id", zcasher_id).execute() - - from urllib.parse import urlparse - - def normalize_url(u: str) -> str: - u = u.strip() - if not u.startswith(("http://", "https://")): - return "https://" + u - return u - - def label_from_url(url: str) -> str: - try: - p = urlparse(url) - if p.netloc: - return p.netloc - return url.split("/")[0] - except Exception: - return url - - for token in edit.get("links", []): - token = str(token) - - if token.startswith("-"): - try: - sb.table("zcasher_links").delete().eq("id", int(token[1:])).execute() - except Exception: - continue - - elif token.startswith("+!"): - url = normalize_url(token[2:]) - label = label_from_url(url) - sb.table("zcasher_links").insert( - { - "zcasher_id": zcasher_id, - "label": label, - "url": url, - "order_index": 0, - "is_verified": False, - "pending_verif": True, - } - ).execute() - - elif token.startswith("!"): - lid = token[1:] - sb.table("zcasher_links").update( - { - "pending_verif": True, - "is_verified": False, - } - ).eq("id", lid).execute() - - elif token.startswith("+"): - url = normalize_url(token[1:]) - label = label_from_url(url) - sb.table("zcasher_links").insert( - { - "zcasher_id": zcasher_id, - "label": label, - "url": url, - "order_index": 0, - "is_verified": False, - "pending_verif": False, - } - ).execute() - - sb.table("pending_zcasher_edits").update({"processed": True}).eq("id", edit["id"]).execute() - - log_event(zcasher_id, "verify_confirm", "ok", "OTP successfully applied") - return {"status": "verified", "zcasher_id": zcasher_id} diff --git a/verification-service/api/verify_routes_rpc_zid_poll.vm.py b/verification-service/api/verify_routes_rpc_zid_poll.vm.py deleted file mode 100644 index f7aac47f..00000000 --- a/verification-service/api/verify_routes_rpc_zid_poll.vm.py +++ /dev/null @@ -1,983 +0,0 @@ -# NOTE: Reference copy pulled from VM: -# /home/zecviewkey/zvs/zcash-verification-service/api/verify_routes_rpc_zid_poll.py -# (pulled locally via scp for diffing) -import os -import json -import time -import re -import hashlib -import uuid -import threading -import secrets -from datetime import datetime, timezone -from api.otp_service import create_and_send_otp -from fastapi import FastAPI, HTTPException - -# Routers -from api.admin_routes import router as admin_router - -from core.supabase_client import get_client -from core.zcash_runner import ( - parse_and_store_transactions, - log_event, -) -from core.zcash_rpc import ZcashRPC, decode_memo_hex - -from fastapi.middleware.cors import CORSMiddleware - -app = FastAPI(title="Zcash Verification API") - -# --- CORS SETTINGS --- -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:5173", - "http://localhost:3000", - "http://localhost:3001", - "https://zcash.me", - "https://www.zcash.me", - "https://verify.zcash.me", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Mount admin routes -app.include_router(admin_router, prefix="/admin") - -POLL_REQUESTS_TABLE = os.getenv("POLL_REQUESTS_TABLE", "verification_poll_requests") -# Polling path should be as lean as possible. -RPC_POLL_DIAGNOSTICS = False - - -@app.get("/debug/zcashers") -def debug_zcashers(): - sb = get_client() - rows = sb.table("zcasher").select("id, name, address").execute() - return rows.data - - -@app.get("/") -def root(): - return {"status": "ok", "service": "zcash-verification"} - - -def _parse_iso(dt_raw: str | None): - if not dt_raw: - return None - clean = dt_raw.replace("Z", "+00:00") - try: - return datetime.fromisoformat(clean) - except Exception: - return None - - -def _get_last_otp_status(sb, zcasher_id: int): - result = ( - sb.table("verification_codes") - .select("created_at, otp_send_success") - .eq("zcasher_id", zcasher_id) - .order("created_at", desc=True) - .limit(1) - .execute() - ) - rows = getattr(result, "data", None) or [] - if not rows: - return None, None - row = rows[0] - return _parse_iso(row.get("created_at")), row.get("otp_send_success") - - -def _collect_new_verification_requests(sb, run_start_iso: str): - result = ( - sb.table("transactions") - .select("txid, zid, tx_time, ts, memo, tx_ignore") - .gte("ts", run_start_iso) - .execute() - ) - rows = getattr(result, "data", None) or [] - candidates = {} - for row in rows: - if row.get("tx_ignore"): - continue - zid_raw = row.get("zid") - if zid_raw is None: - continue - try: - zid = int(zid_raw) - except Exception: - continue - tx_time = _parse_iso(row.get("tx_time")) - if not tx_time: - continue - existing = candidates.get(zid) - if not existing or tx_time > existing["tx_time"]: - candidates[zid] = { - "zid": zid, - "txid": row.get("txid"), - "tx_time": tx_time, - "memo": row.get("memo"), - } - return list(candidates.values()) - - -def _fast_match_receipt( - receipts, - zid: int, - request_id: str, - ignore_txids: set[str] | None = None, -): - zid_tag = f"{{z:{zid}" - rid_tag = f"rid:{request_id}" - ignore_txids = ignore_txids or set() - # Newest-first scan over a small tail of receipts. - for r in reversed(receipts): - txid = r.get("txid") - if txid and txid in ignore_txids: - continue - memo_text = decode_memo_hex(r.get("memo")) - if not memo_text or zid_tag not in memo_text or rid_tag not in memo_text: - continue - now_utc = datetime.now(timezone.utc) - return { - "zid": zid, - "txid": txid, - "tx_time": now_utc, - "ts": now_utc, - "event_time": now_utc, - "memo": memo_text, - "raw_receipt": dict(r), - } - return None - - -def _rpc_receipts_to_devtool_output(receipts, rpc: ZcashRPC) -> str: - lines = [] - for r in receipts: - txid = r.get("txid") - if not txid: - continue - - memo_text = decode_memo_hex(r.get("memo")) - height_raw = r.get("blockheight") - height = None - if height_raw is not None: - try: - height = int(height_raw) - except Exception: - height = None - - mined = None - if height is not None: - try: - block_hash = rpc.get_block_hash(height) - header = rpc.get_block_header(block_hash) - block_time = header.get("time") - if block_time: - dt = datetime.fromtimestamp(block_time, tz=timezone.utc) - mined = dt.strftime("%Y-%m-%d %H:%M:%S+00:00") - except Exception: - mined = None - - lines.append(txid) - if height is not None: - if mined: - lines.append(f"Mined: {height} ({mined})") - else: - lines.append(f"Mined: {height}") - if memo_text: - escaped = memo_text.replace("\\", "\\\\").replace('"', '\\"') - lines.append(f'Memo::Text("{escaped}")') - lines.append("") - - return "\n".join(lines) - - -def _get_cached_receipts(rpc: ZcashRPC, admin_inbox: str, zid: int): - print( - "[rpc_call_start] " - f"zid={zid} method=z_listreceivedbyaddress " - f"url={rpc.url} timeout_s={rpc.timeout} minconf=0 inbox={admin_inbox}" - ) - t0 = time.perf_counter() - receipts = rpc.list_received_by_address(admin_inbox, 0) - elapsed_ms = int((time.perf_counter() - t0) * 1000) - print( - "[rpc_call_done] " - f"zid={zid} z_listreceivedbyaddress in {elapsed_ms} ms receipts={len(receipts)}" - ) - return receipts, False - - - - -def _log_rpc_ingest(receipts, sb) -> None: - if not receipts: - return - for r in receipts: - txid = r.get("txid") - if not txid: - continue - memo_hex = r.get("memo") - memo_text = decode_memo_hex(memo_hex) - try: - sb.table("transaction_ingest_log").insert({ - "txid": txid, - "source": "rpc", - "memo_raw": memo_hex, - "memo_norm": memo_text, - "raw_payload": dict(r), - }).execute() - except Exception as e: - print(f"Failed to log rpc ingest for tx {txid}: {e}") - - -def _print_last_blocks(raw_output: str, label: str, count: int = 10) -> None: - blocks = re.split(r"(?=^[0-9a-f]{64}$)", raw_output, flags=re.MULTILINE) - blocks = [b.strip() for b in blocks if b.strip()] - if not blocks: - print(f"\n[{label}] No tx blocks found.") - return - tail = blocks[-count:] - print(f"\n[{label}] Last {len(tail)} tx blocks (devtool-like):\n") - for block in tail: - print(block) - print("") - - -def _extract_rpc_error(entries) -> str | None: - if not entries: - return None - for entry in entries: - if entry.get("status") == "failed": - err = entry.get("error") or {} - if isinstance(err, dict): - msg = err.get("message") or str(err) - else: - msg = str(err) - return msg - return None - - -def _rpc_debug_operation(opid: str | None, rpc: ZcashRPC, zcasher_id: int | None) -> None: - if not opid: - log_event(zcasher_id, "otp_send_rpc_debug_skip", "error", "missing opid") - return - try: - status_entries = [] - result_entries = [] - for _ in range(5): - status_entries = rpc.get_operation_status([opid]) - result_entries = rpc.get_operation_result([opid]) - if result_entries or _extract_rpc_error(status_entries): - break - time.sleep(1) - print(f"\n[RPC_DEBUG_OPS] opid={opid}") - print(f"status={status_entries}") - print(f"result={result_entries}") - log_event(zcasher_id, "otp_send_rpc_status", "ok", json.dumps(status_entries, default=str)) - log_event(zcasher_id, "otp_send_rpc_result", "ok", json.dumps(result_entries, default=str)) - error_msg = _extract_rpc_error(result_entries) or _extract_rpc_error(status_entries) - if error_msg: - log_event(zcasher_id, "otp_send_rpc_failed", "error", f"opid={opid} error={error_msg}") - except Exception as e: - log_event(zcasher_id, "otp_send_rpc_debug_error", "error", str(e)) - - -def _get_poll_request(sb, request_id: str): - result = ( - sb.table(POLL_REQUESTS_TABLE) - .select("*") - .eq("id", request_id) - .limit(1) - .execute() - ) - rows = getattr(result, "data", None) or [] - return rows[0] if rows else None - - -def _create_poll_request(sb, zid: int): - alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - request_id = None - for _ in range(10): - candidate = "".join(secrets.choice(alphabet) for _ in range(6)) - try: - existing = ( - sb.table(POLL_REQUESTS_TABLE) - .select("id") - .eq("id", candidate) - .limit(1) - .execute() - ) - rows = getattr(existing, "data", None) or [] - if rows: - continue - except Exception: - # If the collision check fails, fall back to the candidate id. - pass - request_id = candidate - break - if request_id is None: - request_id = "".join(secrets.choice(alphabet) for _ in range(6)) - started_at = datetime.utcnow().isoformat() + "Z" - payload = { - "id": request_id, - "zid": zid, - "status": "pending", - "started_at": started_at, - "created_at": started_at, - } - sb.table(POLL_REQUESTS_TABLE).insert(payload).execute() - return request_id, started_at - - -def _update_poll_request(sb, request_id: str, updates: dict) -> None: - sb.table(POLL_REQUESTS_TABLE).update(updates).eq("id", request_id).execute() - - -def _get_last_matched_txid(sb, zid: int) -> str | None: - try: - result = ( - sb.table(POLL_REQUESTS_TABLE) - .select("matched_txid, matched_at") - .eq("zid", zid) - .eq("status", "matched") - .not_.is_("matched_txid", "null") - .order("matched_at", desc=True) - .limit(1) - .execute() - ) - rows = getattr(result, "data", None) or [] - if not rows: - return None - return rows[0].get("matched_txid") - except Exception as e: - print(f"[last_matched_txid_error] zid={zid} error={e}") - return None - - -def _get_seen_txids(sb, txids: list[str]) -> set[str]: - if not txids: - return set() - try: - result = ( - sb.table("transactions") - .select("txid") - .in_("txid", txids) - .execute() - ) - rows = getattr(result, "data", None) or [] - return {r.get("txid") for r in rows if r.get("txid")} - except Exception as e: - print(f"[seen_txids_error] error={e}") - return set() - - -def _append_otp_phase(request_id: str, phase: str) -> None: - sb = get_client() - req = _get_poll_request(sb, request_id) - if not req: - return - history = req.get("otp_phase_history") or [] - if not isinstance(history, list): - history = [] - history.append( - { - "phase": phase, - "ts": datetime.now(timezone.utc).isoformat(), - } - ) - try: - _update_poll_request( - sb, - request_id, - { - "otp_phase": phase, - "otp_phase_history": history, - }, - ) - except Exception as e: - log_event(req.get("zid"), "otp_phase_update_error", "error", str(e)) - - -def _start_otp_async(request_id: str, zid: int) -> None: - def _runner(): - def _phase_cb(phase: str, _meta: dict): - _append_otp_phase(request_id, phase) - - try: - result = create_and_send_otp( - zid, - sync_before_send=False, - send_mode="rpc", - phase_callback=_phase_cb, - ) - otp_status = result.get("status") - _update_poll_request( - get_client(), - request_id, - { - "otp_status": otp_status, - }, - ) - except Exception as e: - _append_otp_phase(request_id, "failed") - log_event(zid, "otp_async_error", "error", str(e)) - - thread = threading.Thread(target=_runner, daemon=True) - thread.start() - - -def _parse_store_matched_receipts(matched_receipts) -> int: - if not matched_receipts: - return 0 - try: - sb = get_client() - rpc = ZcashRPC() - rpc_stdout = _rpc_receipts_to_devtool_output(matched_receipts, rpc) - return parse_and_store_transactions(rpc_stdout, sb, source="rpc") - except Exception as e: - print(f"[parse_store_error] error={e}") - return 0 - - -@app.post("/verify/poll/start") -def verify_poll_start(zid: int): - sb = get_client() - request_id, started_at = _create_poll_request(sb, zid) - return { - "status": "pending", - "request_id": request_id, - "started_at": started_at, - "zid": zid, - } - - -@app.get("/verify/poll/{request_id}/status") -def verify_poll_status(request_id: str, debug_ops: bool = False): - sb = get_client() - req = _get_poll_request(sb, request_id) - if not req: - raise HTTPException(404, "Verification poll request not found") - - if req.get("status") and req.get("status") != "pending": - return { - "status": req.get("status"), - "request_id": request_id, - "zid": req.get("zid"), - "matched_txid": req.get("matched_txid"), - "matched_memo": req.get("matched_memo"), - "otp_status": req.get("otp_status"), - "otp_phase": req.get("otp_phase"), - "otp_phase_history": req.get("otp_phase_history") or [], - } - - zid_raw = req.get("zid") - if zid_raw is None: - raise HTTPException(500, "Poll request missing zid") - try: - zid = int(zid_raw) - except Exception: - raise HTTPException(500, "Poll request has invalid zid") - - run_start_iso = req.get("started_at") or req.get("created_at") - if not run_start_iso: - run_start_iso = datetime.utcnow().isoformat() + "Z" - started_at_dt = _parse_iso(run_start_iso) - - admin_inbox = os.getenv("ZCASH_ADMIN_INBOX") or os.getenv("ADMIN_ADDRESS_INBOX") - if not admin_inbox: - raise HTTPException(500, "ZCASH_ADMIN_INBOX or ADMIN_ADDRESS_INBOX not set") - - now_utc = datetime.now(timezone.utc) - started_at_str = started_at_dt.isoformat() if started_at_dt else "(unknown)" - lag_s = None - if started_at_dt: - lag_s = max(0, int((now_utc - started_at_dt).total_seconds())) - print( - "[wallet_rpc_poll_start] " - f"request_id={request_id} zid={zid} started_at={started_at_str} " - f"elapsed_since_start_s={(lag_s if lag_s is not None else '(unknown)')} " - f"inbox={admin_inbox}" - ) - rpc = ZcashRPC() - try: - receipts, from_cache = _get_cached_receipts(rpc, admin_inbox, zid) - except Exception as e: - print(f"[rpc_call_error] zid={zid} error={e}") - return { - "status": "pending", - "request_id": request_id, - "zid": zid, - "rpc_error": str(e), - } - scan_receipts = receipts - print( - "[rpc_scan] " - f"request_id={request_id} zid={zid} scan_size={len(scan_receipts)} " - f"total_receipts={len(receipts)}" - ) - ignore_txids: set[str] = set() - last_txid = _get_last_matched_txid(sb, zid) - if last_txid: - ignore_txids.add(last_txid) - print(f"[ignore_last_matched_txid] zid={zid} txid={last_txid}") - req_txid = req.get("matched_txid") - if req_txid: - ignore_txids.add(req_txid) - scan_txids = [r.get("txid") for r in scan_receipts if r.get("txid")] - seen_txids = _get_seen_txids(sb, scan_txids) - if seen_txids: - ignore_txids.update(seen_txids) - print(f"[ignore_seen_txids] zid={zid} count={len(seen_txids)}") - fast_match = _fast_match_receipt( - scan_receipts, - zid, - request_id, - ignore_txids=ignore_txids, - ) - eligible = [] - if fast_match: - matched_txid = fast_match.get("txid") - matched_memo = fast_match.get("memo") or "" - print( - "[fast_match_found] " - f"request_id={request_id} zid={zid} txid={matched_txid}" - ) - print(f"[fast_match_memo_full] txid={matched_txid} memo={matched_memo}") - matched_receipts = [r for r in scan_receipts if r.get("txid") == matched_txid] - print( - "[parse_store_start] " - f"request_id={request_id} zid={zid} txid={matched_txid} receipts={len(matched_receipts)}" - ) - stored = _parse_store_matched_receipts(matched_receipts) - print( - "[parse_store_done] " - f"request_id={request_id} zid={zid} txid={matched_txid} stored={stored}" - ) - last_otp_at, last_otp_send_success = _get_last_otp_status(sb, zid) - event_time = fast_match.get("event_time") - if last_otp_send_success is not False: - if last_otp_at and event_time and event_time <= last_otp_at: - print( - "[fast_match_ignored] " - f"request_id={request_id} zid={zid} reason=older-than-last-otp" - ) - else: - fast_match["last_otp_at"] = last_otp_at - fast_match["last_otp_send_success"] = last_otp_send_success - eligible.append(fast_match) - else: - fast_match["last_otp_at"] = last_otp_at - fast_match["last_otp_send_success"] = last_otp_send_success - eligible.append(fast_match) - else: - print( - "[fast_match_miss] " - f"request_id={request_id} zid={zid} receipts_checked={len(scan_receipts)}" - ) - - if not eligible: - return { - "status": "pending", - "request_id": request_id, - "zid": zid, - } - - match_time = datetime.now(timezone.utc) - zids = sorted({r["zid"] for r in eligible}) - zcasher_map = {} - if zids: - z_rows = ( - sb.table("zcasher") - .select("id, name, display_name") - .in_("id", zids) - .execute() - ) - for row in getattr(z_rows, "data", None) or []: - zcasher_map[int(row["id"])] = row - - auto_send_raw = os.getenv("OTP_HITL", "true").strip().lower() - hitl = auto_send_raw in ("1", "true", "yes", "y", "on") - approved = True - if hitl and debug_ops: - response = input("Generate/send OTPs for these requests? [y/N]: ").strip().lower() - approved = response in ("y", "yes") - - sent = [] - otp_phase = req.get("otp_phase") - if approved and eligible and not otp_phase: - _append_otp_phase(request_id, "creating") - _start_otp_async(request_id, zid) - otp_phase = "creating" - - matched = eligible[0] - elapsed_seconds = None - started_at_raw = req.get("started_at") or req.get("created_at") - started_at = _parse_iso(started_at_raw) if started_at_raw else None - if started_at: - elapsed_seconds = max(0, int((match_time - started_at).total_seconds())) - update_payload = { - "status": "matched", - "matched_txid": matched.get("txid"), - "matched_memo": matched.get("memo"), - "matched_at": match_time.isoformat(), - "otp_status": req.get("otp_status"), - } - if elapsed_seconds is not None: - update_payload["elapsed_seconds"] = elapsed_seconds - _update_poll_request(sb, request_id, update_payload) - if elapsed_seconds is not None: - try: - sb.table("verification_codes").update( - {"verification_elapsed_seconds": elapsed_seconds} - ).eq("zcasher_id", zid).execute() - except Exception as e: - log_event(zid, "verify_elapsed_update_error", "error", str(e)) - - return { - "status": "matched", - "request_id": request_id, - "zid": zid, - "matched_txid": update_payload["matched_txid"], - "otp_sent": sent, - "otp_phase": otp_phase, - "otp_phase_history": (_get_poll_request(sb, request_id) or {}).get("otp_phase_history") or [], - } - - -@app.post("/verify/check") -def verify_check(zid: int, debug_ops: bool = False): - sb = get_client() - run_start_iso = datetime.utcnow().isoformat() + "Z" - - # RPC scan + adapter to devtool-like output - admin_inbox = os.getenv("ZCASH_ADMIN_INBOX") or os.getenv("ADMIN_ADDRESS_INBOX") - if not admin_inbox: - raise HTTPException(500, "ZCASH_ADMIN_INBOX or ADMIN_ADDRESS_INBOX not set") - - log_event(None, "wallet_rpc_scan_start", "ok", f"Scanning inbox {admin_inbox}") - rpc = ZcashRPC() - receipts = rpc.list_received_by_address(admin_inbox, 0) - rpc_stdout = _rpc_receipts_to_devtool_output(receipts, rpc) - _log_rpc_ingest(receipts, sb) - - # Optional debug: dump reconstructed output - dump_txid = os.getenv("RPC_DUMP_TXID", "").strip().lower() - dump_all = os.getenv("RPC_DUMP_ALL", "").strip().lower() in ( - "1", - "true", - "yes", - "y", - "on", - ) - if dump_txid or dump_all: - if dump_all: - print("\n[RPC_DUMP_ALL] Reconstructed devtool-like output:\n") - print(rpc_stdout) - else: - blocks = [b for b in rpc_stdout.split("\n\n") if b.strip().startswith(dump_txid)] - print(f"\n[RPC_DUMP_TXID={dump_txid}] Reconstructed block:\n") - print(blocks[0] if blocks else "txid not found in RPC output") - - _print_last_blocks(rpc_stdout, "RPC_SCAN_ADAPTED") - count = parse_and_store_transactions(rpc_stdout, sb, source="rpc") - log_event(None, "wallet_rpc_scan_done", "ok", f"Stored {count} memos via RPC") - - new_requests = _collect_new_verification_requests(sb, run_start_iso) - eligible = [] - for req in new_requests: - if req["zid"] != zid: - continue - last_otp_at, last_otp_send_success = _get_last_otp_status(sb, req["zid"]) - if last_otp_send_success is not False: - if last_otp_at and req["tx_time"] <= last_otp_at: - continue - req["last_otp_at"] = last_otp_at - req["last_otp_send_success"] = last_otp_send_success - eligible.append(req) - - zids = sorted({r["zid"] for r in eligible}) - zcasher_map = {} - if zids: - z_rows = ( - sb.table("zcasher") - .select("id, name, display_name") - .in_("id", zids) - .execute() - ) - for row in getattr(z_rows, "data", None) or []: - zcasher_map[int(row["id"])] = row - - auto_send_raw = os.getenv("OTP_HITL", "true").strip().lower() - hitl = auto_send_raw in ("1", "true", "yes", "y", "on") - approved = True - if hitl and not eligible: - print(f"\nNo pending verification requests matched zid={zid}.") - log_event(None, "verify_check", "ok", f"No pending verification requests for zid={zid}") - if hitl and eligible: - print("\nThese are verification requests who did not receive verification codes yet.") - print("Pending verification requests (tx_time newer than last OTP):") - for item in eligible: - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - last_otp = item.get("last_otp_at") - last_otp_str = last_otp.isoformat() if last_otp else "(none)" - last_otp_send_success = item.get("last_otp_send_success") - memo = item.get("memo") or "" - print( - " " - f"zid={item['zid']} | name={name} | display_name={display_name} | " - f"tx_time={item['tx_time'].isoformat()} | last_otp={last_otp_str} | " - f"last_otp_send_success={last_otp_send_success} | memo={memo}" - ) - - if eligible: - print("\nPending verification requests (tx_ignore != True):") - for item in eligible: - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - last_otp = item.get("last_otp_at") - last_otp_str = last_otp.isoformat() if last_otp else "(none)" - last_otp_send_success = item.get("last_otp_send_success") - memo = item.get("memo") or "" - print( - " " - f"zid={item['zid']} | name={name} | display_name={display_name} | " - f"tx_time={item['tx_time'].isoformat()} | last_otp={last_otp_str} | " - f"last_otp_send_success={last_otp_send_success} | memo={memo}" - ) - - if debug_ops: - response = input("Generate/send OTPs for these requests? [y/N]: ").strip().lower() - approved = response in ("y", "yes") - - sent = [] - if approved and eligible: - for item in eligible: - try: - r = create_and_send_otp( - item["zid"], - sync_before_send=False, - send_mode="rpc", - ) - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - status = r.get("status") - send_result = r.get("send_result") or {} - if debug_ops: - _rpc_debug_operation(send_result.get("opid") or send_result.get("stdout"), rpc, item["zid"]) - print( - f"OTP send status: zid={item['zid']} | name={name} | " - f"display_name={display_name} | status={status}" - ) - sent.append({ - "zid": item["zid"], - "status": status, - }) - except Exception as e: - zrow = zcasher_map.get(item["zid"]) or {} - name = zrow.get("name") or "" - display_name = zrow.get("display_name") or "" - print( - f"OTP send status: zid={item['zid']} | name={name} | " - f"display_name={display_name} | status=error" - ) - log_event(item["zid"], "otp_send_error", "error", str(e)) - - if count > 0: - log_event(None, "verify_check", "ok", f"{count} transactions with memos stored") - else: - log_event(None, "verify_check", "ok", "No memos with z-pattern found") - - return { - "status": "message_received", - "count": count, - "scan_mode": "rpc", - "otp_candidates": [c["zid"] for c in eligible], - "otp_sent": sent, - "otp_approved": approved, - } - - -@app.post("/verify/confirm") -def verify_confirm(zcasher_id: int, otp: str): - sb = get_client() - - supplied_hash = hashlib.sha256(otp.encode()).hexdigest() - - result = ( - sb.table("verification_codes") - .select("*") - .eq("zcasher_id", zcasher_id) - .order("id", desc=True) - .execute() - ) - - rows = getattr(result, "data", None) or [] - if not rows: - raise HTTPException(404, f"No OTP record for zcasher_id {zcasher_id}") - - vc = rows[0] - - if len(rows) > 1: - old_ids = [r["id"] for r in rows[1:] if not r.get("is_verified")] - if old_ids: - sb.table("verification_codes").update( - {"expires_at": datetime.now(timezone.utc).isoformat()} - ).in_("id", old_ids).execute() - - if vc.get("is_verified"): - return { - "status": "otp_already_used", - "zcasher_id": zcasher_id, - } - - attempts_left = vc.get("attempts_left", 0) - if attempts_left <= 0: - return {"status": "locked", "zcasher_id": zcasher_id} - - expires_raw = vc.get("expires_at", "") - clean_expires = expires_raw.replace("Z", "+00:00") - try: - expires_at = datetime.fromisoformat(clean_expires) - except Exception: - raise HTTPException(500, f"Malformed expires_at: {expires_raw}") - - now_utc = datetime.now(timezone.utc) - if now_utc > expires_at: - return {"status": "expired", "zcasher_id": zcasher_id} - - if supplied_hash != vc.get("code_hash"): - new_attempts = max(0, attempts_left - 1) - sb.table("verification_codes").update( - {"attempts_left": new_attempts} - ).eq("id", vc["id"]).execute() - - log_event(zcasher_id, "verify_invalid_otp", "fail", f"Attempts left: {new_attempts}") - - return { - "status": "invalid", - "attempts_left": new_attempts, - "zcasher_id": zcasher_id, - } - - sb.table("verification_codes").update( - {"is_verified": True} - ).eq("id", vc["id"]).execute() - - sb.table("zcasher").update( - {"address_verified": True, "last_verified_at": now_utc.isoformat()} - ).eq("id", zcasher_id).execute() - - pending = ( - sb.table("pending_zcasher_edits") - .select("*") - .eq("zcasher_id", zcasher_id) - .eq("processed", False) - .order("created_at", desc=True) - .limit(1) - .execute() - ) - - pending_rows = getattr(pending, "data", None) or [] - if not pending_rows: - log_event(zcasher_id, "verify_confirm", "ok", "Verified: no pending edits") - return {"status": "verified_and_no_pending_edits", "zcasher_id": zcasher_id} - - edit = pending_rows[0] - - profile_updates = {} - profile = edit.get("profile") or {} - - delete_map = { - "a": "address", - "n": "name", - "b": "bio", - "i": "profile_image_url", - "h": "display_name", - } - - for code in profile.get("d", []): - col = delete_map.get(code) - if col: - profile_updates[col] = None - - for key in ["name", "bio", "profile_image_url", "address", "display_name"]: - if key in profile: - profile_updates[key] = profile[key] - - if profile_updates: - sb.table("zcasher").update(profile_updates).eq("id", zcasher_id).execute() - - from urllib.parse import urlparse - - def normalize_url(u: str) -> str: - u = u.strip() - if not u.startswith(("http://", "https://")): - return "https://" + u - return u - - def label_from_url(url: str) -> str: - try: - p = urlparse(url) - if p.netloc: - return p.netloc - return url.split("/")[0] - except Exception: - return url - - for token in edit.get("links", []): - token = str(token) - - if token.startswith("-"): - try: - sb.table("zcasher_links").delete().eq("id", int(token[1:])).execute() - except Exception: - continue - - elif token.startswith("+!"): - url = normalize_url(token[2:]) - label = label_from_url(url) - sb.table("zcasher_links").insert( - { - "zcasher_id": zcasher_id, - "label": label, - "url": url, - "order_index": 0, - "is_verified": False, - "pending_verif": True, - } - ).execute() - - elif token.startswith("!"): - lid = token[1:] - sb.table("zcasher_links").update( - { - "pending_verif": True, - "is_verified": False, - } - ).eq("id", lid).execute() - - elif token.startswith("+"): - url = normalize_url(token[1:]) - label = label_from_url(url) - sb.table("zcasher_links").insert( - { - "zcasher_id": zcasher_id, - "label": label, - "url": url, - "order_index": 0, - "is_verified": False, - "pending_verif": False, - } - ).execute() - - sb.table("pending_zcasher_edits").update({"processed": True}).eq("id", edit["id"]).execute() - - log_event(zcasher_id, "verify_confirm", "ok", "OTP successfully applied") - return {"status": "verified", "zcasher_id": zcasher_id} diff --git a/verification-service/core/__init__.py b/verification-service/core/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/verification-service/core/supabase_client.py b/verification-service/core/supabase_client.py deleted file mode 100644 index 984698c4..00000000 --- a/verification-service/core/supabase_client.py +++ /dev/null @@ -1,22 +0,0 @@ -# core/supabase_client.py -from dotenv import load_dotenv, find_dotenv -import os - -# Ensure .env is loaded no matter what the current working directory is -load_dotenv(find_dotenv()) - -from supabase import create_client - - -def get_client(): - url = os.getenv("SUPABASE_URL") - key = os.getenv("SUPABASE_KEY") - return create_client(url, key) - -def insert_transaction(tx): - sb = get_client() - sb.table("transactions").upsert(tx).execute() - -def insert_log(entry): - sb = get_client() - sb.table("devtool_logs").insert(entry).execute() diff --git a/verification-service/core/zcash_rpc.py b/verification-service/core/zcash_rpc.py deleted file mode 100644 index b7aa7bbb..00000000 --- a/verification-service/core/zcash_rpc.py +++ /dev/null @@ -1,84 +0,0 @@ -import os -import requests - -RPC_TIMEOUT_SECONDS = 3.0 - -class ZcashRPC: - def __init__(self): - self.url = os.getenv("ZCASH_RPC_URL", "http://127.0.0.1:8232/") - self.auth = ( - os.getenv("ZCASH_RPC_USER", ""), - os.getenv("ZCASH_RPC_PASS", ""), - ) - # Hardcode a short timeout so slow RPC calls fail fast. - self.timeout = RPC_TIMEOUT_SECONDS - - def call(self, method: str, params=None): - if params is None: - params = [] - payload = { - "jsonrpc": "1.0", - "id": "verify-svc", - "method": method, - "params": params, - } - r = requests.post(self.url, json=payload, auth=self.auth, timeout=self.timeout) - r.raise_for_status() - data = r.json() - if data.get("error"): - raise RuntimeError(f"zcashd RPC error: {data['error']}") - return data["result"] - - def list_received_by_address(self, zaddr: str, minconf: int = 0): - """ - For shielded / unified receivers. - Returns notes with 'amount', 'memo' (hex), 'txid', 'blockheight', etc. - """ - return self.call("z_listreceivedbyaddress", [zaddr, minconf]) - - def send_many(self, from_addr: str, outputs, minconf: int = 1, fee: float = 0.0001): - """ - Wrapper for 'z_sendmany'. - outputs = [{ "address": ..., "amount": ..., "memo": }, ...] - """ - return self.call("z_sendmany", [from_addr, outputs, minconf, fee]) - - def get_operation_status(self, opids=None): - params = [] - if opids: - params = [opids] - return self.call("z_getoperationstatus", params) - - def get_operation_result(self, opids=None): - params = [] - if opids: - params = [opids] - return self.call("z_getoperationresult", params) - - def get_block_hash(self, height: int) -> str: - return self.call("getblockhash", [height]) - - def get_block_header(self, block_hash: str) -> dict: - return self.call("getblockheader", [block_hash]) - - -def decode_memo_hex(memo_hex: str) -> str: - """ - z_listreceivedbyaddress returns memo as hex ('f6' means empty). - Convert to UTF-8 text and strip trailing null bytes. - """ - if not memo_hex or memo_hex == "f6": - return "" - try: - raw = bytes.fromhex(memo_hex) - except ValueError: - return "" - raw = raw.rstrip(b"\x00") - return raw.decode("utf-8", "ignore") - - -def text_to_memo_hex(text: str) -> str: - raw = text.encode("utf-8") - if len(raw) > 512: - raw = raw[:512] - return raw.hex() diff --git a/verification-service/core/zcash_runner.py b/verification-service/core/zcash_runner.py deleted file mode 100644 index d5d05bed..00000000 --- a/verification-service/core/zcash_runner.py +++ /dev/null @@ -1,435 +0,0 @@ -import os, subprocess, shlex, sys, re -from datetime import datetime -from dotenv import load_dotenv, find_dotenv - -load_dotenv(find_dotenv()) - -# --- Server resolution ------------------------------------------------------- -# We only pass *server tokens* (e.g., "zecrocks") to the CLI. -# Env precedence: -# 1) ZCASH_SERVER_NAME (expected values: zecrocks, ecc, test, main, etc.) -# 2) ZCASH_SERVER (if it's a known token; URLs are ignored) -# default: "zecrocks" -KNOWN_SERVER_TOKENS = {"zecrocks", "ecc", "main", "test"} - -def _resolve_server_token() -> str: - name = os.getenv("ZCASH_SERVER_NAME", "").strip().lower() - if name in KNOWN_SERVER_TOKENS: - return name - # Back-compat: some envs had ZCASH_SERVER set to a token or a URL. - raw = os.getenv("ZCASH_SERVER", "").strip().lower() - if raw in KNOWN_SERVER_TOKENS: - return raw - # If ZCASH_SERVER looks like a URL/host:port, *do not* pass it to --server. - # The current zcash-devtool build expects a token; URLs caused the breakage. - return "zecrocks" - -SERVER_TOKEN = _resolve_server_token() - -# --- Supabase logging -------------------------------------------------------- -from supabase import create_client, Client - -def log_event(zid=None, action=None, status=None, message=None, meta=None): - url = os.getenv("SUPABASE_URL") - key = os.getenv("SUPABASE_KEY") - if not url or not key: - print("Missing Supabase credentials, skipping log insert.") - return - supabase: Client = create_client(url, key) - payload = { - "zcasher_id": zid, - "action": action, - "status": status, - "message": message, - "meta": meta, - "ts": datetime.utcnow().isoformat() + "Z", - } - try: - supabase.table("devtool_logs").insert(payload).execute() - print(f"✅ Logged event: {action} ({status})") - except Exception as e: - print(f"❌ Failed to log event: {e}") - -# --- Runner ------------------------------------------------------------------ -def run_command(args, timeout=60): - """ - Execute zcash-devtool.exe exactly as in PowerShell: - zcash-devtool.exe wallet --wallet-dir - - Streams output live for long-running ops like `sync`/`enhance`. - """ - load_dotenv(find_dotenv()) - - devtool_path = os.getenv("DEVTOOL_PATH") - wallet_dir = os.getenv("WALLET_DIR") - exe_path = os.path.join(devtool_path, "target", "release", "zcash-devtool.exe") - - if not os.path.exists(exe_path): - raise FileNotFoundError(f"Executable not found: {exe_path}") - if not os.path.exists(wallet_dir): - raise FileNotFoundError(f"Wallet dir not found: {wallet_dir}") - - cmd = [exe_path, "wallet", "--wallet-dir", wallet_dir] + args - print("🔧 Running:", " ".join(shlex.quote(c) for c in cmd)) - - env = os.environ.copy() - env["ZCASH_PARAMS_DIR"] = os.path.expandvars(r"%USERPROFILE%\.zcash-params") - env["ZCASH_WALLET_DIR"] = wallet_dir - - long_running = any(x in args for x in ["sync", "enhance", "scan", "fetch"]) - - if long_running: - process = subprocess.Popen( - cmd, - cwd=os.path.dirname(exe_path), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=env, - encoding="utf-8", - errors="replace", - ) - output = [] - print("📡 [Streaming live output...]\n") - for line in process.stdout: - ts = datetime.now().strftime("%H:%M:%S") - formatted = f"[{ts}] {line}" - sys.stdout.write(formatted) - sys.stdout.flush() - output.append(formatted) - process.wait() - print(f"\n✅ Completed with exit code {process.returncode}") - return {"stdout": "".join(output), "stderr": "", "returncode": process.returncode} - - result = subprocess.run( - cmd, - cwd=os.path.dirname(exe_path), - capture_output=True, - text=True, - timeout=timeout, - shell=False, - env=env, - encoding="utf-8", - errors="replace", - ) - return {"stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode} - -# --- High-level helpers ------------------------------------------------------ -def sync_wallet(server: str | None = None): - token = server or SERVER_TOKEN - # Always pass a token (e.g., "zecrocks"), never a URL. - result = run_command(["sync", "--server", token]) - log_event( - None, - "wallet_sync", - "ok" if result["returncode"] == 0 else "error", - result["stderr"] or result["stdout"], - result, - ) - return result - -def enhance_wallet(server: str | None = None): - token = server or SERVER_TOKEN - result = run_command(["enhance", "--server", token]) - log_event( - None, - "wallet_enhance", - "ok" if result["returncode"] == 0 else "error", - result["stderr"] or result["stdout"], - result, - ) - return result - -def scan_wallet(): - return run_command(["list-tx"]) - -def parse_memos(raw_output: str): - """ - NEW VERSION: - - Parse ALL transactions. - - Do NOT drop any transactions. - - Capture txid, memo text (even if unparsable), and zid if present. - """ - - txs = [] - current_tx = None - - for line in raw_output.splitlines(): - line = line.strip() - - # txid line - if re.match(r"^[0-9a-f]{64}$", line): - if current_tx: - txs.append(current_tx) - current_tx = { - "txid": line, - "memo": None, - "zid": None, - } - continue - - # memo line - if "Memo::Text" in line and current_tx: - m = re.search(r'Memo::Text\("(.+)"\)', line) - if m: - memo_text = m.group(1) - memo_text = memo_text.replace("\\\\", "\\").replace('\\"', '"') - current_tx["memo"] = memo_text - - # Try to extract {z:###} if present - zid_match = re.search(r"\{z:(\d+)", memo_text) - if zid_match: - current_tx["zid"] = zid_match.group(1) - - if current_tx: - txs.append(current_tx) - - return txs # NEVER FILTER ANYMORE - -def parse_and_store_transactions(raw_output: str, sb, source: str = "devtool"): - """ - NEW VERSION: - - Stores ALL transactions in public.transactions. - - ALWAYS captures raw memo. - - NEVER skips transactions when memo parsing fails. - - Extracts pending edits only when a {z:...} memo is actually present. - - Accurately captures tx_time from the 'Mined:' line. - """ - - import re - from datetime import datetime - - memos = parse_memos(raw_output) - upserts = 0 - pending_candidates = [] - - # ------------------------------------------------------- - # Build mapping: txid → mined timestamp string - # ------------------------------------------------------- - blocks = re.split(r"(?=^[0-9a-f]{64}$)", raw_output, flags=re.MULTILINE) - txid_to_mined = {} - txid_to_block = {} - mined_re = re.compile(r"Mined:\s+\d+\s+\(([^)]+)\)") - - for block in blocks: - block = block.strip() - if not block: - continue - first_line = block.splitlines()[0].strip() - if not re.match(r"^[0-9a-f]{64}$", first_line): - continue - - txid = first_line - txid_to_block[txid] = block - m = mined_re.search(block) - if m: - txid_to_mined[txid] = m.group(1).strip() - - # ------------------------------------------------------- - # Store only the latest transaction per zid - # ------------------------------------------------------- - tx_candidates = [] - for tx in memos: - txid = tx["txid"] - memo = tx["memo"] - zid = tx["zid"] - - mined_raw = txid_to_mined.get(txid) - tx_time = None - - # Normalize mined timestamp - if mined_raw: - ts = mined_raw - ts = ts.replace(".0 ", " ") - ts = ts.replace("+00:00:00", "+00:00") - ts = re.sub(r" (\d):", r" 0\1:", ts) - - try: - mined_at = datetime.fromisoformat(ts) - tx_time = mined_at.isoformat() - except: - tx_time = None - - tx_candidates.append({ - "txid": txid, - "memo": memo, - "zid": zid, - "tx_time": tx_time, - "mined_raw": mined_raw, - "order": len(tx_candidates), - }) - - latest_by_zid = {} - for candidate in tx_candidates: - zid = candidate["zid"] - if zid is None: - continue - existing = latest_by_zid.get(zid) - if not existing: - latest_by_zid[zid] = candidate - continue - existing_time = existing.get("tx_time") - candidate_time = candidate.get("tx_time") - if existing_time and candidate_time: - if candidate_time > existing_time: - latest_by_zid[zid] = candidate - continue - if candidate_time and not existing_time: - latest_by_zid[zid] = candidate - continue - if not candidate_time and not existing_time: - if candidate["order"] > existing["order"]: - latest_by_zid[zid] = candidate - - pending_candidates = [] - - for candidate in latest_by_zid.values(): - txid = candidate["txid"] - memo = candidate["memo"] - zid = candidate["zid"] - tx_time = candidate["tx_time"] - mined_raw = candidate["mined_raw"] - - record = { - "zid": zid, - "txid": txid, - "memo": memo, - "tx_time": tx_time, - "ts": datetime.utcnow().isoformat() + "Z", - "raw": mined_raw, - } - - try: - sb.table("transactions").upsert(record, on_conflict=["txid"]).execute() - upserts += 1 - except Exception as e: - print(f"Upsert failed for tx {txid}: {e}") - continue - - try: - raw_block = txid_to_block.get(txid) - sb.table("transaction_ingest_log").insert({ - "txid": txid, - "source": source, - "memo_raw": memo, - "memo_norm": memo, - "raw_payload": {"block": raw_block} if raw_block else None, - }).execute() - except Exception as e: - print(f"Ingest log failed for tx {txid}: {e}") - - # Extract pending edits ONLY IF memo is a {z:...} - if not memo: - continue - - m = re.search(r"\{z:(\d+)(.*)\}", memo) - if not m: - continue - - zcid = int(m.group(1)) - body = m.group(2) - - profile_edits = {} - link_edits = [] - - kv_tokens = re.findall(r'([nbiach]):"([^"]*)"', body) - for key, val in kv_tokens: - if key == "n": - profile_edits["name"] = val - elif key == "b": - profile_edits["bio"] = val - elif key == "i": - profile_edits["profile_image_url"] = val - elif key == "a": - profile_edits["address"] = val - elif key == "c": - profile_edits["c"] = val - elif key == "h": - profile_edits["display_name"] = val - - city_match = re.search(r'c:([0-9]+|-)', body) - if city_match: - cid = city_match.group(1) - if cid == "-": - profile_edits["nearest_city_id"] = None - else: - profile_edits["nearest_city_id"] = int(cid) - - d_match = re.search(r'd:\[(.*?)\]', body) - if d_match: - delete_codes = re.findall(r'"([abnih])"', d_match.group(1)) - if delete_codes: - profile_edits["d"] = delete_codes - - l_match = re.search(r'l:\[(.*?)\]', body) - if l_match: - inner = l_match.group(1) - tokens = re.findall(r'"([^"]+)"', inner) - if tokens: - link_edits = tokens - - if profile_edits or link_edits: - pending_candidates.append({ - "zcasher_id": zcid, - "raw_memo": memo, - "profile": profile_edits, - "links": link_edits, - }) - - for candidate in pending_candidates: - zcid = candidate["zcasher_id"] - try: - sb.table("pending_zcasher_edits").update( - {"processed": True} - ).eq("zcasher_id", zcid).eq("processed", False).execute() - except Exception as e: - print(f"Failed to mark older pending edits for z:{zcid}: {e}") - - try: - sb.table("pending_zcasher_edits").insert({ - "zcasher_id": zcid, - "raw_memo": candidate["raw_memo"], - "profile": candidate["profile"], - "links": candidate["links"], - }).execute() - except Exception as e: - print(f"Failed to insert pending edits for z:{zcid}: {e}") - - # ------------------------------------------------------- - log_event(None, "wallet_scan_insert_complete", "ok", f"{upserts} memos inserted") - return upserts - - -def scan_wallet_and_store(sb): - enhance_wallet() - result = scan_wallet() - if result["returncode"] != 0: - print("❌ scan_wallet failed:", result["stderr"] or result["stdout"]) - return 0 - count = parse_and_store_transactions(result["stdout"], sb, source="devtool") - print(f"✅ Stored {count} memos with ZIDs.") - return count - -def health_check(): - print("🔍 Checking wallet health...") - r = run_command(['balance']) - if "Height" not in r['stdout']: - print("❌ Wallet unreachable or broken.") - return - print("✅ Wallet responsive.") - if "Sapling" not in r['stdout']: - print("⚠️ Schema might be incomplete, run full resync.") - else: - print("✅ Schema OK, balance query normal.") - -def ensure_funds(min_zec=0.001): - from decimal import Decimal - result = run_command(['balance']) - stdout = result['stdout'] - m = re.search(r'Balance:\s+([\d\.]+)', stdout) - if not m: - raise RuntimeError("Unable to read wallet balance") - balance = Decimal(m.group(1)) - if balance < Decimal(min_zec): - raise RuntimeError(f"Insufficient funds: {balance} ZEC < {min_zec} ZEC") - print(f"✅ Wallet has sufficient balance: > {min_zec} ZEC") diff --git a/verification-service/readme.md b/verification-service/readme.md deleted file mode 100644 index 870986b7..00000000 --- a/verification-service/readme.md +++ /dev/null @@ -1,483 +0,0 @@ -# Zcash Verification Service v1.0 - -*A fully manual, admin-driven Zcash profile verification and edit-promotion pipeline.* - -This service receives verification and edit requests via encrypted Zcash memos, extracts pending edits, stores them, and after manual OTP confirmation promotes those edits into the public Zcashers registry. - -### Safety guarantees - -* Hash-based OTP comparison -* Attempt limits -* Expiration -* Single-use OTPs -* Single pending-edit consumption - -This document describes the system end-to-end. - ---- - -## Local Development Quickstart - -**FastAPI entry point (current)** - -``` -api/verify_routes_rpc_zid_poll.py -``` - -**Run locally (RPC-based)** - -``` -.\.venv\Scripts\activate -uvicorn api.verify_routes_rpc_zid_poll:app --reload -``` - -**API** - -``` -http://127.0.0.1:8000 -``` - -**Docs** - -``` -http://127.0.0.1:8000/docs -``` - ---- - -## Deployment Reality Check (Important) - -The **VM code is separate** from anything on GitHub. Updating code locally or pushing to GitHub **does not** change the running backend unless you also deploy it to the VM. - -**Implication:** -- Local testing alongside the localhost frontend is **not** production. -- Local and VM environments can differ. A flow that works locally may still fail or behave differently on the VM. Concrete differences observed between the current VM file and local file: - - **CORS allowlist**: VM allows production domains (`https://zcash.me`, `https://www.zcash.me`, `https://verify.zcash.me`) in addition to localhost; local only allows localhost origins. - - **Memo match rule**: VM’s `_fast_match_receipt` requires both `{z:}` and a `rid:` tag; local only requires `{z:}`. - - **Receipt scan scope**: VM scans all receipts; local sorts receipts and only scans a tail (`RPC_TAIL_SIZE = 8`). - - **Request ID generation**: VM generates IDs with a local `alphabet` + manual collision check; local uses `REQUEST_ID_ALPHABET`, `REQUEST_ID_LEN`, and `_get_poll_request`. - - **Admin router import**: VM imports and mounts `admin_routes` unconditionally; local makes it optional. -- After local testing, you must **both**: - 1) Commit + push the `zcash-verification-service` code to GitHub, **and** - 2) Copy or deploy that code to the VM. - -### VM Deployment (Current Process) - -The ZVS service runs on the VM at: - -``` -/home/zecviewkey/zvs/zcash-verification-service -``` - -It is started by systemd: - -``` -ExecStart=/home/zecviewkey/zvs/zcash-verification-service/.venv/bin/uvicorn \ - api.verify_routes_rpc_zid_poll:app --host 0.0.0.0 --port 8000 -``` - -### Deploy Steps (Manual Copy) - -If you are **not** using git pull on the VM: - -1. Copy your updated code to the VM (preserve the same directory). -2. Restart the service: - -``` -sudo systemctl restart zvs.service -``` - -3. Verify it is running: - -``` -systemctl status zvs.service -``` - -### Deploy Steps (GitHub-Driven) - -If the VM is a git checkout: - -``` -cd /home/zecviewkey/zvs/zcash-verification-service -git pull -sudo systemctl restart zvs.service -``` - -If you **only** commit + push locally and do **not** deploy to the VM, production will not change. - -**Quickstart: devtool vs RPC** - -PowerShell (devtool / default): - -``` -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/check" -``` - -PowerShell (RPC): - -``` -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/check?use_rpc=true" -``` - -PowerShell (RPC-only app): - -``` -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/check" -``` - ---- - -## Canonical System Flow (Authoritative) - -Zcashers submit profile updates by sending a Z→Z transaction to the admin wallet with a memo such as: - -``` -{z:15,b:"New bio",n:"New Name",+!https://twitter.com/...,-123} -``` - -The system flow is: - -1. Admin runs `/verify/check` -2. Wallet syncs and memos are parsed -3. Pending edits are stored in `public.pending_zcasher_edits` -4. Admin generates an OTP via `/verify/send-otp` (auto-sent on-chain) -6. User enters OTP in frontend (`SubmitOtp.jsx`) -7. Frontend calls Supabase RPC `public.confirm_otp_sql` -8. OTP is validated and edits are promoted -9. Pending edit row is marked processed - -This yields a wallet-based, Sybil-resistant identity update system. - ---- - -## Architecture - -``` -User Wallet → Admin Wallet -↓ -/verify/check → sync → scan → parse → pending edits -↓ -Admin triggers OTP send (auto Z-memo) -↓ -User enters OTP in frontend -↓ -Supabase RPC confirm_otp_sql → promote edits -``` - -All state is stored in Postgres via Supabase. - ---- - -## Wallet Sync, Parsing, and Storage - -### Wallet Sync Pipeline - -``` -/verify/check -→ sync_wallet -→ enhance_wallet -→ scan_wallet -→ parse_memos -→ parse_and_store_transactions -``` - -Uses: - -* `zcash-devtool.exe wallet list-tx` -* `parse_memos(raw_output)` -* `parse_and_store_transactions(raw_output, sb)` - -### Scan Mode: devtool (use_rpc=false, default) - -Runs the legacy zcash-devtool flow: - -* `sync_wallet` -> `enhance_wallet` -> `scan_wallet` -* `POST /verify/check` (or `POST /verify/check?use_rpc=false`) -* OTP sends use the devtool send path and require `WALLET_DIR` and `ADMIN_ACCOUNT_ID` - -### Scan Mode: RPC (use_rpc=true) - -Runs the zcashd RPC flow and adapts receipts to devtool-like output: - -* `POST /verify/check?use_rpc=true` -* OTP sends use RPC `sendmany` from `ADMIN_ADDRESS_INBOX` -* Optional debug: `RPC_DUMP_ALL=true` or `RPC_DUMP_TXID=` - -RPC env checklist: - -* `ZCASH_RPC_URL` -* `ZCASH_RPC_USER` -* `ZCASH_RPC_PASS` -* `ZCASH_ADMIN_INBOX` (or `ADMIN_ADDRESS_INBOX`) - -RPC via SSH tunnel (PowerShell): - -``` -ssh -i C:\Users\jjose\.ssh\zecviewkey-zechariah.pem -L 8232:127.0.0.1:8232 zecviewkey@172.206.17.233 -``` - -RPC-only app (no use_rpc param): - -``` -uvicorn api.verify_routes_rpc:app --reload -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/check" -``` - -Tunnel check: - -``` -Test-NetConnection -ComputerName 127.0.0.1 -Port 8232 -``` - -RPC health check (PowerShell): - -``` -$pair = "$env:ZCASH_RPC_USER:$env:ZCASH_RPC_PASS" -$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) -Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8232 ` - -Headers @{ Authorization = "Basic $auth" } ` - -Body '{"jsonrpc":"1.0","id":"ping","method":"getblockchaininfo","params":[]}' -``` - -### Memo Detection - -Memos matching: - -``` -Memo::Text("{z: ... }") -``` - -are treated as verification requests. - -### Stored Transaction Fields (`public.transactions`) - -| Column | Meaning | -| ------- | ------------------- | -| txid | Transaction ID | -| memo | Raw memo | -| zid | Parsed `{z:...}` | -| ts | Insert timestamp | -| raw | Raw mined timestamp | -| tx_time | Parsed chain time | - -Chronological correctness is preserved even with delayed scans. - ---- - -## Pending Edit Extraction - -### Profile Tokens → `pending_zcasher_edits.profile` - -| Token | Field | -| --------- | ----------------- | -| `n:"..."` | name | -| `b:"..."` | bio | -| `i:"..."` | profile_image_url | -| `h:"..."` | display_name | -| `a:"..."` | address | - -Stored as JSONB: - -``` -public.pending_zcasher_edits.profile -``` - -### Link Tokens → `pending_zcasher_edits.links` - -Stored as a JSONB array. See Link Mutation Semantics below. - ---- - -## OTP Generation - -**Endpoint** - -``` -POST /verify/send-otp?zcasher_id=15 -``` - -**PowerShell** - -``` -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/send-otp?zcasher_id=15" -``` - -**Config** - -``` -OTP_AUTO_SEND=true # set false to generate-only (manual send) -OTP_AMOUNT_ZEC=0.0005 -OTP_HITL=true # prompt before sending OTPs during /verify/check -``` - -Notes: -- `/verify/check` now detects new verification txs and (optionally) triggers OTP sends. -- When `/verify/check` triggers OTP sends, it skips the extra sync/enhance in the send path - to avoid double-sync. -- If the latest OTP send for a ZID failed (`otp_send_success=false`), that ZID is still - considered a candidate on the next `/verify/check` regardless of tx time. -- HITL mode can mark transactions with `tx_ignore=true` to suppress them in future runs. - -**Stored in `verification_codes`** - -| Field | Meaning | -| ------------- | ---------------------------------- | -| otp | Plaintext (returned to admin only) | -| code_hash | SHA-256 hash | -| attempts_left | Starts at 3 | -| expires_at | 24h UTC | -| is_verified | false | -| otp_send_success | true/false/null | -| otp_send_txid | txid if send succeeded | - -Also requires `transactions.tx_ignore` (boolean) to suppress ignored requests. - -OTP delivery is automatic via Zcash memo. - ---- - -## OTP Confirmation and Edit Promotion (Authoritative) - -### Submission - -User enters OTP in the frontend dialog: - -``` -zcashme\src\SubmitOtp.jsx -C:\Users\jjose\OneDrive\Desktop\zcashme\src\SubmitOtp.jsx -``` - -Frontend calls: - -``` -supabase.rpc("confirm_otp_sql", { in_zcasher_id, in_otp }) -``` - -OTP is sent on-chain via zcash-devtool. - -### Validation Checks - -| Check | Behavior | -| ----------- | ------------------------- | -| Recency | Only most recent OTP | -| Attempts | Fail if attempts_left ≤ 0 | -| Expiration | Fail if now > expires_at | -| Equivalence | SHA-256 hash comparison | - -### On Success - -1. `verification_codes.is_verified = true` -2. `zcasher.address_verified = true` -3. `zcasher.last_verified_at = now()` -4. Promote **latest unprocessed** pending edit: - - * name - * bio - * profile_image_url - * display_name - * address - * link mutations -5. `pending_zcasher_edits.processed = true` - -### Hash Comparison (only place equivalence is checked) - -```sql -supplied_hash := encode(sha256(in_otp::bytea), 'hex'); - -if supplied_hash <> vc.code_hash then -``` - -On failure: - -* attempts decremented -* status `invalid` -* no edits applied - ---- - -## Link Mutation and Verification Semantics - -### Grammar - -| Token | Effect | -| ------- | ----------------------------------- | -| `+url` | Insert link, unverified | -| `+!url` | Insert link, pending_verif = true | -| `!id` | Existing link: pending_verif = true | -| `-id` | Delete link | - -### Flags - -| Column | Meaning | -| ------------- | --------------- | -| is_verified | Admin-confirmed | -| pending_verif | User-requested | - -Only admin actions can set `is_verified = true`. - ---- - ---- - -## Complete Test Tract (Canonical) - -### Input Memo - -``` -{z:15,b:"Updated bio",+!https://reddit.com/r/Zcash,-42} -``` - -### Expected Pending Edits - -``` -profile.bio = "Updated bio" -links = ["+!https://reddit.com/r/Zcash", "-42"] -``` - -### Admin Actions - -``` -POST /verify/check -POST /verify/send-otp?zcasher_id=15 -``` - -**PowerShell** - -``` -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/check" -Invoke-WebRequest -Method POST "http://127.0.0.1:8000/verify/send-otp?zcasher_id=15" -``` - -### OTP Submission - -Handled via `SubmitOtp.jsx`, calling: - -``` -supabase.rpc("confirm_otp_sql", { in_zcasher_id, in_otp }) -``` - -### Expected Result - -* Link ID 42 deleted -* Reddit link inserted: - - * is_verified = false - * pending_verif = true - ---- - -## SQL Verification Cookbook - -```sql -select * from transactions order by ts desc; -select * from pending_zcasher_edits where zcasher_id=? order by created_at desc; -select * from verification_codes where zcasher_id=?; -select * from zcasher where id=?; -select * from zcasher_links where zcasher_id=?; -``` - ---- - -# End of README - ---- diff --git a/verification-service/requirements.txt b/verification-service/requirements.txt deleted file mode 100644 index 88cc43b833d58823c2513988a87d4dc9116d2453..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38 ncmezWFO4CQp_rirNER?;GVn5RF_bct0a?im`3yx2d0-I$$Y2N> From 7665d3f3a98c3676a26edb3e7f15ba572f48ff2c Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 15 Feb 2026 15:22:06 +0800 Subject: [PATCH 02/48] draft: add AGENT.md documentation for AI coding agents Add ~50 line documentation files to each major subfolder to help AI agents understand the codebase structure, Zcash integrations, and testing harnesses. --- AGENT.md | 82 ++++++++++++++++++++++++++++++ app/AGENT.md | 52 +++++++++++++++++++ lib/AGENT.md | 56 +++++++++++++++++++++ lib/api/AGENT.md | 92 ++++++++++++++++++++++++++++++++++ lib/directory/AGENT.md | 79 +++++++++++++++++++++++++++++ lib/leaderboard/AGENT.md | 77 ++++++++++++++++++++++++++++ lib/profile/AGENT.md | 75 ++++++++++++++++++++++++++++ lib/signup/AGENT.md | 92 ++++++++++++++++++++++++++++++++++ lib/stores/AGENT.md | 97 ++++++++++++++++++++++++++++++++++++ lib/supabase/AGENT.md | 83 +++++++++++++++++++++++++++++++ lib/swap/AGENT.md | 85 +++++++++++++++++++++++++++++++ lib/thread/AGENT.md | 102 ++++++++++++++++++++++++++++++++++++++ lib/validation/AGENT.md | 94 +++++++++++++++++++++++++++++++++++ lib/verification/AGENT.md | 69 ++++++++++++++++++++++++++ lib/zcash/AGENT.md | 68 +++++++++++++++++++++++++ ui/AGENT.md | 65 ++++++++++++++++++++++++ ui/common/AGENT.md | 81 ++++++++++++++++++++++++++++++ ui/messaging/AGENT.md | 80 ++++++++++++++++++++++++++++++ ui/profile/AGENT.md | 78 +++++++++++++++++++++++++++++ ui/signup/AGENT.md | 92 ++++++++++++++++++++++++++++++++++ ui/swap/AGENT.md | 72 +++++++++++++++++++++++++++ ui/thread/AGENT.md | 89 +++++++++++++++++++++++++++++++++ ui/verification/AGENT.md | 80 ++++++++++++++++++++++++++++++ 23 files changed, 1840 insertions(+) create mode 100644 AGENT.md create mode 100644 app/AGENT.md create mode 100644 lib/AGENT.md create mode 100644 lib/api/AGENT.md create mode 100644 lib/directory/AGENT.md create mode 100644 lib/leaderboard/AGENT.md create mode 100644 lib/profile/AGENT.md create mode 100644 lib/signup/AGENT.md create mode 100644 lib/stores/AGENT.md create mode 100644 lib/supabase/AGENT.md create mode 100644 lib/swap/AGENT.md create mode 100644 lib/thread/AGENT.md create mode 100644 lib/validation/AGENT.md create mode 100644 lib/verification/AGENT.md create mode 100644 lib/zcash/AGENT.md create mode 100644 ui/AGENT.md create mode 100644 ui/common/AGENT.md create mode 100644 ui/messaging/AGENT.md create mode 100644 ui/profile/AGENT.md create mode 100644 ui/signup/AGENT.md create mode 100644 ui/swap/AGENT.md create mode 100644 ui/thread/AGENT.md create mode 100644 ui/verification/AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..9ffe31af --- /dev/null +++ b/AGENT.md @@ -0,0 +1,82 @@ +# zcash.me - Agent Reference + +## Project Overview +A privacy-focused identity and payments platform built on Zcash. +Users create profiles linked to their Zcash addresses and prove ownership +via blockchain transactions. + +## Tech Stack +- **Framework**: Next.js 16 (App Router) + React 19 +- **Language**: TypeScript 5.9 +- **Database**: Supabase (PostgreSQL) +- **State**: Zustand (client) + React Query (server) +- **Styling**: TailwindCSS 4 +- **Animations**: Framer Motion + +## Directory Structure +``` +/app → Next.js pages and API routes +/lib → Core business logic and utilities +/ui → React components by feature +/public → Static assets +``` + +## Zcash Integration Points + +### Address Types (prefer unified) +| Prefix | Type | Privacy | Use | +|--------|------|---------|-----| +| `u1` | Unified | High | Recommended | +| `zs1` | Sapling | High | Acceptable | +| `t1`/`t3` | Transparent | None | Warn user | + +### Verification Flow +1. Generate 6-digit OTP +2. User sends 0.0001 ZEC with OTP in memo +3. External service scans blockchain +4. Profile marked verified + +### Key Utilities +- `/lib/zcash/zcashUtils.ts` - Address validation, URI building +- `/lib/verification/` - OTP confirmation logic +- `/lib/swap/` - OneClick SDK for cross-chain swaps + +## Testing Harnesses +- **No automated tests** - Use `/app/design-system` for visual testing +- Pure functions in `/lib/` are easily unit testable +- Mock Supabase and external APIs for integration tests + +## Environment Variables +``` +NEXT_PUBLIC_SUPABASE_URL - Database URL +NEXT_PUBLIC_SUPABASE_ANON_KEY - Public DB key +NEXT_PUBLIC_VERIFY_API_URL - Verification service +NEXT_PUBLIC_BASE_DOMAIN - zcash.me or localhost +ONECLICK_API_KEY - Defuse swap API +API_KEY - Server-side API auth +``` + +## Quick Start for Agents +1. Read `/lib/AGENT.md` for business logic overview +2. Read `/ui/AGENT.md` for component patterns +3. Check feature-specific AGENT.md in subfolders +4. Use `/app/design-system` to see components + +## Common Tasks + +### Add New Profile Field +1. Update types in `/lib/profile/types.ts` +2. Add validation in `/lib/validation/` +3. Update UI in `/ui/profile/` or `/ui/signup/` +4. Update server action if needed + +### Add New API Endpoint +1. Create route in `/app/api/[route]/route.ts` +2. Use `apiGuard` from `/lib/api/guard.ts` +3. Return consistent `ApiResponse` format + +### Add New UI Component +1. Create in appropriate `/ui/` subfolder +2. Export from folder's `index.ts` +3. Use `/ui/common/` building blocks +4. Add to design-system page if reusable diff --git a/app/AGENT.md b/app/AGENT.md new file mode 100644 index 00000000..7cbc6756 --- /dev/null +++ b/app/AGENT.md @@ -0,0 +1,52 @@ +# /app - Next.js App Router + +## Purpose +Contains all page routes and API endpoints for zcash.me. Uses Next.js 16 App Router with React 19. + +## Key Routes + +| Path | Description | +|------|-------------| +| `/` | Homepage with featured Zcash profiles | +| `/[slug]` | Dynamic profile pages (e.g., /alice) | +| `/ns` | Network School directory - filtered profile list | +| `/swap-app` | Cryptocurrency swap interface (Defuse OneClick) | +| `/leader-app` | Referral leaderboard dashboard | +| `/stats-app` | Network statistics | +| `/thread` | Discussion board (OTP-verified posting) | +| `/design-system` | Component showcase for manual testing | + +## API Routes + +### `/api/resolve/[username]` - GET +Profile lookup by username. Returns profile with links. + +### `/api/directory` - GET +Search profiles with ranking. Supports `q`, `limit`, `cursor`, `verified_only` params. +Features space-insensitive, case-insensitive matching with relevance ranking. + +### `/api/social` - GET +Social platform lookup (stub implementation). + +## Zcash-Specific Patterns +- Profile pages display Zcash unified addresses (u1...) prominently +- QR codes encode `zcash:` URIs with memo for verification +- Swap routes handle ZEC as primary currency with cross-chain support + +## Testing Harness +- No automated tests in /app +- Use `/design-system` route for manual component testing +- API routes use rate limiting via `/lib/api/guard.ts` + +## Adding New Pages +1. Create folder under `/app/[route-name]` +2. Add `page.tsx` with default export +3. Use server components by default, `'use client'` only when needed +4. Import UI from `/ui/*`, logic from `/lib/*` + +## Environment Variables +``` +NEXT_PUBLIC_BASE_DOMAIN - zcash.me or localhost:3000 +NEXT_PUBLIC_SUPABASE_URL - Database URL +NEXT_PUBLIC_VERIFY_API_URL - External verification service +``` diff --git a/lib/AGENT.md b/lib/AGENT.md new file mode 100644 index 00000000..4044525a --- /dev/null +++ b/lib/AGENT.md @@ -0,0 +1,56 @@ +# /lib - Core Business Logic + +## Purpose +Shared server-side logic, data fetching, server actions, types, and utilities. +This is the brain of zcash.me - all business logic lives here. + +## Directory Structure + +| Folder | Purpose | +|--------|---------| +| `/zcash` | Zcash address validation, URI building, memo encoding | +| `/profile` | Profile types, fetching, username policies, link handling | +| `/directory` | Search, city filtering, featured profiles | +| `/verification` | OTP confirmation, link verification | +| `/signup` | Profile creation server actions | +| `/swap` | OneClick SDK integration, token types | +| `/stores` | Zustand client state (swap, messaging, thread) | +| `/validation` | Composable form validators | +| `/leaderboard` | Referral commission calculations | +| `/thread` | Discussion board actions | +| `/supabase` | Database client initialization | +| `/api` | Rate limiting, API guards | + +## Key Exports + +### Server Actions +- `createProfileAction` - Create new profile +- `confirmOtpAction` - Verify OTP from transaction memo +- `updateLinkVerificationAction` - Mark links verified +- `getLeaderboardAction` - Fetch referral rankings + +### Utilities +- `validateZcashAddress()` - Full validation with type detection +- `buildZcashUri()` - Construct zcash: payment URIs +- `buildZcashEditMemo()` - Encode profile edits in memo + +## Zcash Address Types Supported +- **Unified (u1...)** - Recommended, privacy-preserving +- **Sapling (zs1...)** - Shielded pool +- **Transparent (t1.../t3...)** - Public (shown with warnings) +- **TEX (tex1...)** - Discouraged + +## Testing Harness +- No unit tests currently +- Server actions can be tested via API routes +- Validators are pure functions - easy to unit test + +## Database Access +All DB queries go through Supabase client in `/lib/supabase/`. +Main tables: `zcasher`, `zcasher_links`, `zcasher_searchable` + +## Adding New Logic +1. Create folder for feature domain +2. Add `types.ts` for interfaces +3. Add `actions.ts` for server actions (use 'use server') +4. Export from `index.ts` diff --git a/lib/api/AGENT.md b/lib/api/AGENT.md new file mode 100644 index 00000000..28fea168 --- /dev/null +++ b/lib/api/AGENT.md @@ -0,0 +1,92 @@ +# /lib/api - API Utilities + +## Purpose +Security utilities for API routes: rate limiting, API key validation, +and response formatting. + +## Key Files + +### guard.ts +API protection middleware: +```typescript +interface GuardOptions { + rateLimit?: { + window: number; // Time window in ms + maxRequests: number; // Max requests per window + }; + requireApiKey?: boolean; +} + +async function apiGuard( + request: Request, + options?: GuardOptions +): Promise<{ allowed: boolean; error?: string }> +``` + +**Rate Limiting:** +- Per-IP tracking +- Sliding window algorithm +- Returns 429 when exceeded + +**API Key Validation:** +```typescript +// Check header +const apiKey = request.headers.get('x-api-key'); +if (apiKey !== process.env.API_KEY) { + return { allowed: false, error: 'Invalid API key' }; +} +``` + +### types.ts +API response types: +```typescript +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + meta?: { + cursor?: string; + total?: number; + }; +} +``` + +## Usage in API Routes + +```typescript +// app/api/directory/route.ts +import { apiGuard } from '@/lib/api/guard'; + +export async function GET(request: Request) { + const guard = await apiGuard(request, { + rateLimit: { window: 60000, maxRequests: 100 } + }); + + if (!guard.allowed) { + return Response.json( + { error: guard.error }, + { status: 429 } + ); + } + + // ... handle request +} +``` + +## Environment Variables +``` +API_KEY - Server-side API key for validation +NEXT_PUBLIC_API_KEY - Client-side (for authenticated requests) +``` + +## Testing Harness +- Mock time for rate limit tests +- Test various IP scenarios +- Verify API key validation +- Check response format consistency + +## Security Notes +- Never expose server API_KEY to client +- Rate limits apply per-IP +- Log suspicious activity +- Return generic errors (don't leak info) diff --git a/lib/directory/AGENT.md b/lib/directory/AGENT.md new file mode 100644 index 00000000..75a83925 --- /dev/null +++ b/lib/directory/AGENT.md @@ -0,0 +1,79 @@ +# /lib/directory - Profile Discovery + +## Purpose +Search and discovery logic for the zcash.me profile directory. +Powers the main search functionality and featured profiles. + +## Key Files + +### searchProfiles.ts +Profile search with ranking: +```typescript +async function searchProfiles(query: string, options?: { + limit?: number; // Default: 25 + cursor?: string; // Pagination + verifiedOnly?: boolean; +}): Promise<{ + results: Profile[]; + nextCursor?: string; + exists: boolean; +}> +``` + +**Ranking Logic:** +1. Username starts with query (highest) +2. Username contains query +3. Display name matches +4. Link text contains query + +### searchCities.ts +Geographic filtering: +```typescript +async function searchCities(query: string): Promise +``` +Used for location-based profile discovery. + +### fetchFeaturedProfiles.server.ts +Homepage featured profiles: +```typescript +async function fetchFeaturedProfiles(): Promise +``` +Returns profiles marked as `featured: true` in database. + +### getNsProfilesAction.ts +Network School directory: +```typescript +async function getNsProfilesAction(): Promise +``` +Filters by `is_ns`, `is_ns_core`, `is_ns_longterm` flags. + +### types.ts +```typescript +interface City { + id: string; + name: string; + country: string; + iso2: string; +} +``` + +## Search Features +- **Case-insensitive** - "Alice" = "alice" +- **Space-insensitive** - "alice z" matches "alicez" +- **Fuzzy matching** - Searches username, display name, links +- **Cursor pagination** - For infinite scroll + +## Database +Queries `zcasher_searchable` - denormalized table optimized for search: +- Pre-computed `link_search_text` +- Indexed for fast queries + +## Testing Harness +- Mock Supabase responses +- Test ranking logic with various queries +- Verify pagination cursor handling +- Test empty/no-result states + +## API Integration +Exposed via `/api/directory` endpoint. +Rate-limited via `/lib/api/guard.ts`. diff --git a/lib/leaderboard/AGENT.md b/lib/leaderboard/AGENT.md new file mode 100644 index 00000000..31ca9a0f --- /dev/null +++ b/lib/leaderboard/AGENT.md @@ -0,0 +1,77 @@ +# /lib/leaderboard - Referral System + +## Purpose +Referral commission tracking and leaderboard calculations. +Rewards users for bringing new profiles to zcash.me. + +## Key File + +### getLeaderboardAction.ts +Server action for leaderboard data: +```typescript +'use server' +export async function getLeaderboardAction(): Promise<{ + leaders: LeaderEntry[]; + userRank?: number; + userStats?: UserStats; +}> +``` + +## Data Model + +### LeaderEntry +```typescript +interface LeaderEntry { + profileId: string; + username: string; + displayName: string; + avatarUrl?: string; + referralCount: number; + totalCommission: number; // In ZEC + rank: number; +} +``` + +### Commission Tiers +Multi-tier referral system: +- **Direct referrals**: Higher commission +- **Second-tier**: Smaller percentage +- **Eligibility window**: Time-limited earning period + +## Calculation Logic + +``` +User A refers User B + ↓ +User B creates profile + ↓ +User B receives payments + ↓ +User A earns X% commission on payments + ↓ +Tracked in leaderboard +``` + +## Database Fields +Profiles have referral tracking fields: +- `referred_by` - Profile ID of referrer +- `referral_code` - Unique code for sharing +- `commission_earned` - Total ZEC earned + +## Zcash Integration +- Commissions paid in ZEC +- Tracked via transaction memos +- Settlement to referrer's Zcash address + +## Testing Harness +- Mock referral chains +- Test commission calculations +- Verify ranking logic +- Test edge cases (self-referral, expired windows) + +## UI Integration +Displayed in `/app/leader-app` using data from this action. + +## Network School +NS members may have special referral bonuses tracked via +`is_ns_core` and `is_ns_longterm` flags. diff --git a/lib/profile/AGENT.md b/lib/profile/AGENT.md new file mode 100644 index 00000000..7dcf0fc3 --- /dev/null +++ b/lib/profile/AGENT.md @@ -0,0 +1,75 @@ +# /lib/profile - Profile Management + +## Purpose +Core profile logic: types, fetching, username validation, link handling, verification. +Central to the zcash.me identity system. + +## Key Files + +### types.ts +```typescript +interface Profile { + id: string; + name: string; // username (normalized) + display_name: string; // shown in UI + slug: string; // URL path + bio?: string; + address: string; // Zcash address + address_verified: boolean; + avatar_url?: string; + verified_links_count: number; + is_ns?: boolean; // Network School member +} + +interface ProfileLink { + id: string; + provider: string; // 'twitter', 'github', etc. + value: string; // handle or URL + verified: boolean; + verified_at?: string; +} +``` + +### profileFetcher.ts +Database queries for profile retrieval. Uses Supabase client. + +### usernamePolicy.ts +Username validation rules: +- Min/max length (3-30 chars) +- Allowed characters (alphanumeric, underscore) +- Reserved words blocked +- Profanity filter + +### usernameNormalizer.ts +Unicode normalization and sanitization: +- Lowercase conversion +- Diacritic removal +- Homoglyph normalization (prevent impersonation) + +### profileLinks.ts +Link enrichment utilities: +- Icon resolution by provider +- Label formatting +- URL construction + +### social-lookup.ts +Social platform detection from URLs/handles. +Maps input to canonical provider names. + +## Zcash Integration +- `address` field stores Zcash unified/sapling address +- `address_verified` confirms on-chain proof of ownership +- Links can be verified via blockchain transaction + +## Testing Harness +- `usernamePolicy` and `usernameNormalizer` are pure functions +- Mock Supabase client for `profileFetcher` tests +- Example: +```typescript +expect(normalizeUsername('Álice')).toBe('alice'); +expect(isValidUsername('__admin__')).toBe(false); +``` + +## Database Tables +- `zcasher` - Main profile records +- `zcasher_links` - Associated links diff --git a/lib/signup/AGENT.md b/lib/signup/AGENT.md new file mode 100644 index 00000000..9cdcfa48 --- /dev/null +++ b/lib/signup/AGENT.md @@ -0,0 +1,92 @@ +# /lib/signup - Profile Creation + +## Purpose +Server actions for creating new Zcash profiles. +Handles validation, database insertion, and initial setup. + +## Key Files + +### createProfileAction.ts +Main server action for profile creation: +```typescript +'use server' +export async function createProfileAction(input: { + username: string; + displayName: string; + bio?: string; + address: string; + links?: LinkInput[]; + cityId?: string; +}): Promise<{ + success: boolean; + profileId?: string; + slug?: string; + error?: string; +}> +``` + +### createProfile.ts +Core creation logic (called by action): +```typescript +async function createProfile(data: ProfileInput): Promise +``` + +## Validation Steps +1. **Username** - Policy check via `/lib/profile/usernamePolicy.ts` +2. **Address** - Zcash validation via `/lib/zcash/zcashUtils.ts` +3. **Uniqueness** - Check username not taken +4. **Links** - Validate URLs/handles + +## Database Operations +```typescript +// Insert profile +const { data: profile } = await supabase + .from('zcasher') + .insert({ + name: normalizedUsername, + display_name: displayName, + slug: generateSlug(username), + address: address, + bio: bio, + nearest_city_id: cityId + }) + .select() + .single(); + +// Insert links +if (links.length > 0) { + await supabase + .from('zcasher_links') + .insert(links.map(l => ({ + profile_id: profile.id, + provider: l.provider, + value: l.value + }))); +} +``` + +## Zcash Address Handling +- Validates address format before storage +- Stores original address (preserves case for unified) +- `address_verified` defaults to false +- User must complete OTP flow to verify + +## Error Handling +```typescript +// Common errors +{ error: 'Username already taken' } +{ error: 'Invalid Zcash address' } +{ error: 'Username contains invalid characters' } +{ error: 'Database error' } +``` + +## Testing Harness +- Mock Supabase client +- Test validation edge cases +- Verify slug generation +- Test link insertion + +## Related Files +- `/ui/signup/` - Form components +- `/lib/profile/usernamePolicy.ts` - Validation rules +- `/lib/zcash/zcashUtils.ts` - Address validation diff --git a/lib/stores/AGENT.md b/lib/stores/AGENT.md new file mode 100644 index 00000000..b4c8f39b --- /dev/null +++ b/lib/stores/AGENT.md @@ -0,0 +1,97 @@ +# /lib/stores - Zustand State Management + +## Purpose +Client-side state using Zustand. Lightweight stores for UI state that doesn't +belong in server components or React Query cache. + +## Stores + +### swap.ts - Swap State +```typescript +interface SwapStore { + // Token selection + fromToken: Token | null; + toToken: Token | null; + setFromToken: (token: Token) => void; + setToToken: (token: Token) => void; + + // Amounts + fromAmount: string; + toAmount: string; + setFromAmount: (amount: string) => void; + + // Quote + quote: SwapQuote | null; + setQuote: (quote: SwapQuote) => void; + + // Settings + slippage: number; + setSlippage: (slippage: number) => void; + + // Deposit + deposit: SwapDeposit | null; + setDeposit: (deposit: SwapDeposit) => void; + + // Reset + reset: () => void; +} +``` + +### messaging.ts - Memo Composer State +```typescript +interface MessagingStore { + memo: string; + setMemo: (memo: string) => void; + + // OTP verification polling + isPolling: boolean; + setPolling: (polling: boolean) => void; + pollInterval: number; +} +``` + +### thread.ts - Discussion Board State +```typescript +interface ThreadStore { + currentBoard: Board | null; + setCurrentBoard: (board: Board) => void; + + messages: ThreadMessage[]; + addMessage: (msg: ThreadMessage) => void; + + composerContent: string; + setComposerContent: (content: string) => void; +} +``` + +### edits.ts - Profile Edit Tracking +Tracks pending edits before blockchain confirmation. + +## Usage Pattern +```typescript +'use client'; +import { useSwapStore } from '@/lib/stores/swap'; + +function SwapComponent() { + const { fromToken, setFromToken, quote } = useSwapStore(); + // ... +} +``` + +## Testing Harness +Zustand stores are easily testable: +```typescript +import { useSwapStore } from '@/lib/stores/swap'; + +// Reset before each test +beforeEach(() => useSwapStore.getState().reset()); + +test('sets token', () => { + useSwapStore.getState().setFromToken(mockToken); + expect(useSwapStore.getState().fromToken).toEqual(mockToken); +}); +``` + +## When to Use Stores vs React Query +- **Stores**: UI state, form values, selections, local preferences +- **React Query**: Server data, cached responses, background updates diff --git a/lib/supabase/AGENT.md b/lib/supabase/AGENT.md new file mode 100644 index 00000000..7a05774c --- /dev/null +++ b/lib/supabase/AGENT.md @@ -0,0 +1,83 @@ +# /lib/supabase - Database Client + +## Purpose +Supabase client initialization and database connection management. +Single source of truth for all database access. + +## Client Setup + +### Server-Side Client +```typescript +import { createClient } from '@supabase/supabase-js'; + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_KEY! // Server-only key +); +``` + +### Client-Side Client +```typescript +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // Public key +); +``` + +## Environment Variables +``` +NEXT_PUBLIC_SUPABASE_URL - Supabase project URL +NEXT_PUBLIC_SUPABASE_ANON_KEY - Public anon key (client) +SUPABASE_SERVICE_KEY - Service role key (server only) +``` + +## Database Tables + +### zcasher (Profiles) +| Column | Type | Purpose | +|--------|------|---------| +| id | uuid | Primary key | +| name | text | Username (normalized) | +| display_name | text | Shown in UI | +| slug | text | URL path | +| address | text | Zcash address | +| address_verified | boolean | Blockchain verified | +| bio | text | Short description | +| avatar_url | text | Profile image | +| is_ns | boolean | Network School member | +| featured | boolean | Homepage featured | + +### zcasher_links (Profile Links) +| Column | Type | Purpose | +|--------|------|---------| +| id | uuid | Primary key | +| profile_id | uuid | FK to zcasher | +| provider | text | Platform name | +| value | text | Handle or URL | +| verified | boolean | Link verified | + +### zcasher_searchable (Search Index) +Denormalized view for fast search queries. + +## Query Patterns + +```typescript +// Fetch profile by slug +const { data } = await supabase + .from('zcasher') + .select('*, zcasher_links(*)') + .eq('slug', slug) + .single(); + +// Search profiles +const { data } = await supabase + .from('zcasher_searchable') + .select('*') + .ilike('name', `%${query}%`) + .limit(25); +``` + +## Testing Harness +- Mock Supabase client in tests +- Use test database for integration +- Never use production keys in tests diff --git a/lib/swap/AGENT.md b/lib/swap/AGENT.md new file mode 100644 index 00000000..625fa574 --- /dev/null +++ b/lib/swap/AGENT.md @@ -0,0 +1,85 @@ +# /lib/swap - Cryptocurrency Swap + +## Purpose +Integration with Defuse Protocol's OneClick SDK for cross-chain swaps. +Allows users to receive payments in any token, converted to ZEC. + +## Key Files + +### types.ts +```typescript +interface Token { + symbol: string; + name: string; + decimals: number; + address?: string; // contract address for ERC20 + chainId: string; +} + +interface SwapQuote { + fromToken: Token; + toToken: Token; // Usually ZEC + fromAmount: string; + toAmount: string; + rate: string; + slippage: number; + expiresAt: number; +} + +interface SwapDeposit { + address: string; // Deposit address (chain-specific) + memo?: string; // Required for some chains + expiresAt: number; +} +``` + +### oneClick.ts +OneClick SDK wrapper: +```typescript +import { OneClickClient } from '@anthropic/defuse-one-click-sdk'; + +// Initialize client +const client = new OneClickClient({ apiKey: ONECLICK_API_KEY }); + +// Get supported tokens +await client.getTokens(); + +// Get quote +await client.getQuote({ from, to, amount }); + +// Create deposit address +await client.createDeposit({ quoteId, destinationAddress }); +``` + +### utils.ts +Helper functions for swap calculations and formatting. + +## Zcash as Destination +Primary use case: receive any crypto → convert to ZEC +- User's Zcash address is the final destination +- Supports unified addresses for privacy +- OneClick handles cross-chain bridging + +## Environment Variables +``` +ONECLICK_API_KEY - Server-side Defuse API key +``` + +## Testing Harness +- Mock OneClick SDK responses +- Test quote calculations locally +- Use testnet for integration tests + +## State Management +Swap state lives in `/lib/stores/swap.ts` (Zustand): +- Selected tokens +- Amounts +- Current quote +- Deposit info +- Slippage tolerance + +## Error Handling +- Quote expiration (refresh needed) +- Insufficient liquidity +- Network errors +- Invalid addresses diff --git a/lib/thread/AGENT.md b/lib/thread/AGENT.md new file mode 100644 index 00000000..3466f35d --- /dev/null +++ b/lib/thread/AGENT.md @@ -0,0 +1,102 @@ +# /lib/thread - Discussion Board Logic + +## Purpose +Server actions and types for the OTP-verified discussion board. +Users post messages by proving identity via Zcash transactions. + +## Key Files + +### types.ts +```typescript +interface ThreadMessage { + id: string; + boardId: string; + authorId: string; + authorUsername: string; + authorDisplayName: string; + content: string; + createdAt: string; + verified: boolean; +} + +interface Board { + id: string; + name: string; + description?: string; + memberCount: number; + messageCount: number; + createdAt: string; +} + +interface ThreadStore { + currentBoard: Board | null; + messages: ThreadMessage[]; + composerContent: string; +} +``` + +### actions.ts +Server actions (partially implemented): +```typescript +'use server' + +// Fetch available boards +export async function fetchBoards(): Promise + +// Post verified message +export async function postMessage(input: { + boardId: string; + content: string; + otp: string; +}): Promise<{ success: boolean; message?: ThreadMessage }> + +// Create new board +export async function createBoard(input: { + name: string; + description?: string; +}): Promise<{ success: boolean; board?: Board }> +``` + +### utils.ts +Helper functions for thread operations. + +## Verification Flow +1. User writes message +2. Generates OTP +3. Sends Zcash tx with OTP in memo +4. Server confirms OTP +5. Message posted with verified badge + +## Anti-Spam Mechanism +- Each post requires on-chain proof +- Small fee (~0.0001 ZEC) per message +- Ties posts to verified profiles +- Rate limits per user + +## Database Tables +```sql +zcasher_boards ( + id, name, description, created_at +) + +zcasher_thread_messages ( + id, board_id, author_id, content, + verified, created_at +) +``` + +## Status: Partially Implemented +The actions file has TODO comments - some features pending: +- Board creation flow +- Message editing/deletion +- Moderation tools + +## Testing Harness +- Mock database responses +- Test message posting flow +- Verify OTP integration +- Test board switching + +## UI Integration +Components in `/ui/thread/` consume this logic. +State managed by Zustand store in `/lib/stores/thread.ts`. diff --git a/lib/validation/AGENT.md b/lib/validation/AGENT.md new file mode 100644 index 00000000..8e669e17 --- /dev/null +++ b/lib/validation/AGENT.md @@ -0,0 +1,94 @@ +# /lib/validation - Form Validators + +## Purpose +Composable, reusable validators for form inputs. Pure functions that return +structured validation results. + +## Key Files + +### validators.ts +Core validator functions: + +```typescript +interface ValidationResult { + valid: boolean; + reason?: string; + level?: 'error' | 'warning' | 'info'; +} + +// Basic validators +required(value: string): ValidationResult +minLength(min: number): (value: string) => ValidationResult +maxLength(max: number): (value: string) => ValidationResult +email(value: string): ValidationResult +url(value: string): ValidationResult +digits(value: string): ValidationResult +range(min: number, max: number): (value: number) => ValidationResult + +// Composable pattern +compose(...validators): (value: any) => ValidationResult +``` + +### useValidation.ts +React hook for form validation state: + +```typescript +function useValidation( + value: T, + validators: Validator[] +): { + valid: boolean; + error?: string; + level?: 'error' | 'warning' | 'info'; + touched: boolean; + setTouched: () => void; +} +``` + +## Usage Examples + +```typescript +// Simple validation +const result = required(''); +// { valid: false, reason: 'Required' } + +// Composed validators +const validateUsername = compose( + required, + minLength(3), + maxLength(30), + (v) => /^[a-z0-9_]+$/.test(v) + ? { valid: true } + : { valid: false, reason: 'Invalid characters' } +); + +// In React component +const { valid, error } = useValidation(username, [ + required, + minLength(3) +]); +``` + +## Zcash-Specific Validators +Zcash address validation lives in `/lib/zcash/zcashUtils.ts` but follows +the same `{ valid, reason }` pattern for consistency. + +## Testing Harness +All validators are pure functions - trivial to test: + +```typescript +test('required rejects empty', () => { + expect(required('')).toEqual({ valid: false, reason: 'Required' }); +}); + +test('email validates format', () => { + expect(email('test@example.com').valid).toBe(true); + expect(email('invalid').valid).toBe(false); +}); +``` + +## Adding New Validators +1. Add function to `validators.ts` +2. Return `{ valid: boolean, reason?: string, level?: string }` +3. Make it composable (curry if needs config) +4. Export from `index.ts` diff --git a/lib/verification/AGENT.md b/lib/verification/AGENT.md new file mode 100644 index 00000000..32dc3c75 --- /dev/null +++ b/lib/verification/AGENT.md @@ -0,0 +1,69 @@ +# /lib/verification - OTP Verification + +## Purpose +On-chain verification using Zcash transaction memos. Users prove address ownership +by sending a small transaction with an OTP code in the memo field. + +## How Verification Works + +1. **Generate OTP** - Server creates 6-digit code +2. **User sends transaction** - To their own address with OTP in memo +3. **Blockchain scan** - External service monitors for matching memo +4. **Confirmation** - Profile marked as verified + +## Key Files + +### confirmOtpAction.ts +Server action to confirm OTP verification. +```typescript +'use server' +export async function confirmOtpAction( + profileId: string, + otp: string +): Promise<{ success: boolean; error?: string }> +``` + +Calls external verification API at `NEXT_PUBLIC_VERIFY_API_URL`. + +### confirmOtp.ts +Client-side helper for calling the server action. + +### updateLinkVerificationAction.ts +Marks individual links as verified after proof. + +## External Verification Service +The actual blockchain scanning runs externally: +- Watches Zcash shielded pool for transactions +- Decodes memo fields +- Matches OTPs to pending verifications +- Calls back to update database + +Environment: `NEXT_PUBLIC_VERIFY_API_URL` + +## Zcash Memo Field +- Max 512 bytes +- Encoded as base64url in URI +- Format: `{"otp":"123456"}` or with edits +- Must be shielded transaction for privacy + +## Testing Harness +- Mock the external verification API +- Test OTP generation/validation locally +- Integration test with testnet transactions + +## Security Notes +- OTPs expire after 10 minutes +- One-time use only +- Rate limited to prevent brute force +- Never log OTP values + +## Flow Diagram +``` +User → Generate OTP → Build URI → QR Code + ↓ + Send tx with memo + ↓ +External Service → Scan blockchain → Match OTP + ↓ + confirmOtpAction → Update DB → Profile verified +``` diff --git a/lib/zcash/AGENT.md b/lib/zcash/AGENT.md new file mode 100644 index 00000000..f5a41dcd --- /dev/null +++ b/lib/zcash/AGENT.md @@ -0,0 +1,68 @@ +# /lib/zcash - Zcash Utilities + +## Purpose +Core Zcash blockchain utilities: address validation, URI construction, memo encoding. +This is the most critical module for Zcash-specific functionality. + +## Main File: zcashUtils.ts + +### Address Validation + +```typescript +validateZcashAddress(address: string): { + valid: boolean; + addressType: 'unified' | 'sapling' | 'transparent' | 'tex' | 'viewing_key' | 'invalid'; + reason?: string; +} +``` + +**Address Formats:** +| Prefix | Type | Privacy | Recommendation | +|--------|------|---------|----------------| +| `u1` | Unified | High | Recommended | +| `zs1` | Sapling | High | Acceptable | +| `t1`, `t3` | Transparent | None | Show warning | +| `tex1` | TEX | None | Discouraged | +| `uview`, `zview` | Viewing Key | N/A | Reject | + +### URI Construction + +```typescript +buildZcashUri(address: string, amount?: number, memo?: string): string +// Returns: zcash:u1abc...?amount=0.001&memo=base64encoded +``` + +Used for QR codes and wallet deep links. Memo is base64url encoded. + +### Edit Memo Encoding + +```typescript +buildZcashEditMemo(otp: string, edits: ProfileEdits): string +// Returns compact JSON for blockchain memo field (max 512 bytes) +``` + +Format: `{"otp":"123456","edits":{"name":"Alice"}}` +Must fit in Zcash memo field - keep edits minimal. + +### Helper: getZcashAddressHint() +Returns user-friendly guidance for each address type. +Used in UI to educate users about privacy implications. + +## Dependencies +- `bech32` / `bech32m` - Unified/Sapling address decoding +- `bs58check` - Transparent address validation + +## Testing Harness +Pure functions - ideal for unit testing: +```typescript +// Example test +expect(validateZcashAddress('u1abc...')).toEqual({ + valid: true, + addressType: 'unified' +}); +``` + +## Common Patterns +- Always validate before displaying/storing addresses +- Unified addresses preferred - nudge users toward privacy +- Memo encoding must handle UTF-8 properly diff --git a/ui/AGENT.md b/ui/AGENT.md new file mode 100644 index 00000000..63ca9c94 --- /dev/null +++ b/ui/AGENT.md @@ -0,0 +1,65 @@ +# /ui - React Components + +## Purpose +Reusable React components organized by feature domain. All UI presentation +lives here - pages in `/app` compose these components. + +## Directory Structure + +| Folder | Purpose | +|--------|---------| +| `/common` | Design system: buttons, forms, modals, layout | +| `/profile` | Profile cards, editors, avatars, badges | +| `/signup` | Profile creation form components | +| `/verification` | OTP input, QR codes, verification flows | +| `/swap` | Swap composer, token selection, quotes | +| `/thread` | Discussion board, message cards | +| `/messaging` | Memo composer with emoji support | +| `/social` | Social link verification UI | +| `/ns-directory` | Network School specific components | +| `/styles` | Shared style utilities | + +## Component Conventions + +### File Naming +- `ComponentName.tsx` - Main component +- `componentUtils.ts` - Helper functions +- `componentTypes.ts` - TypeScript interfaces +- `useComponentHook.ts` - Custom hooks + +### Client vs Server Components +```typescript +// Server component (default) +export function ProfileCard({ profile }) { ... } + +// Client component (when needed) +'use client'; +export function InteractiveForm() { ... } +``` + +Use `'use client'` only when component needs: +- Event handlers (onClick, onChange) +- Hooks (useState, useEffect) +- Browser APIs + +## Styling +- TailwindCSS 4 with utility classes +- No CSS modules or styled-components +- Framer Motion for animations + +## Zcash UI Patterns +- QR codes use `zcash:` URI scheme +- Address inputs validate on blur +- Privacy warnings for transparent addresses +- Unified address (u1...) shown prominently + +## Testing Harness +- Manual testing via `/app/design-system` route +- Components are stateless where possible +- Props-based API for easy snapshot testing + +## Adding Components +1. Create in appropriate feature folder +2. Export from folder's `index.ts` +3. Use `/common` components for consistency +4. Add to design-system page if reusable diff --git a/ui/common/AGENT.md b/ui/common/AGENT.md new file mode 100644 index 00000000..cb1be81d --- /dev/null +++ b/ui/common/AGENT.md @@ -0,0 +1,81 @@ +# /ui/common - Design System + +## Purpose +Core design system components. Building blocks for all UI in zcash.me. +~3500 LOC of reusable, accessible components. + +## Components + +### Forms +| Component | Purpose | +|-----------|---------| +| `Input` | Text input with validation states | +| `TextArea` | Multi-line text input | +| `Checkbox` | Checkbox with label | +| `Select` | Native select dropdown | +| `Dropdown` | Custom dropdown with search | +| `FormField` | Wrapper with label and error | + +### Buttons +| Component | Purpose | +|-----------|---------| +| `Button` | Primary action button | +| `IconButton` | Icon-only button | +| `CopyButton` | Copy-to-clipboard with feedback | + +### Layout +| Component | Purpose | +|-----------|---------| +| `Card` | Content container with shadow | +| `Section` | Page section with heading | +| `Divider` | Visual separator | + +### Modals +| Component | Purpose | +|-----------|---------| +| `Modal` | Base modal component | +| `ModalHeader` | Modal title bar | +| `ModalBody` | Modal content area | +| `ModalFooter` | Modal action buttons | +| `ConfirmDialog` | Yes/No confirmation | +| `TutorialModal` | Large tutorial/onboarding | +| `ModalPortal` | Portal for modal rendering | + +### Feedback +| Component | Purpose | +|-----------|---------| +| `Alert` | Info/warning/error messages | +| `Badge` | Status indicators | +| `Spinner` | Loading indicator | + +### Utilities +| Component | Purpose | +|-----------|---------| +| `HelpIcon` | Tooltip trigger icon | +| `Transitions` | Animation wrappers | + +## Usage Pattern +```typescript +import { Button, Input, Modal, Card } from '@/ui/common'; + + + + + +``` + +## Styling Conventions +- TailwindCSS utilities +- Consistent spacing scale (4px base) +- Color palette via Tailwind config +- Responsive: mobile-first + +## Testing Harness +Visit `/app/design-system` to see all components rendered. +Good for visual regression testing. + +## Adding Components +1. Create `ComponentName.tsx` +2. Export from `index.ts` +3. Add example to design-system page +4. Keep API minimal - props over config diff --git a/ui/messaging/AGENT.md b/ui/messaging/AGENT.md new file mode 100644 index 00000000..e4f5cfd7 --- /dev/null +++ b/ui/messaging/AGENT.md @@ -0,0 +1,80 @@ +# /ui/messaging - Memo Composer + +## Purpose +Components for composing Zcash transaction memos. +Used when sending payments with messages attached. + +## Components + +### MemoComposer.tsx +Main memo input with character limit: +```tsx + +``` + +Features: +- Character counter +- Emoji picker integration +- UTF-8 aware length calculation + +### useEmojiAutocomplete.ts +Emoji autocomplete hook: +```typescript +const { + suggestions, + query, + select, + isOpen +} = useEmojiAutocomplete(inputRef); +``` + +Triggered by `:` character (e.g., `:smile:`). +Uses `emojilib` for emoji lookup. + +## Zcash Memo Field + +### Constraints +- **Max 512 bytes** after encoding +- UTF-8 encoded +- Stored in shielded transaction +- Only sender and recipient can read + +### Encoding +Memos are base64url encoded when constructing URIs: +```typescript +const encoded = btoa(unescape(encodeURIComponent(memo))); +// Used in: zcash:u1...?memo={encoded} +``` + +## Privacy Features +- Memos are encrypted in shielded transactions +- Only visible to transaction participants +- Blockchain observers cannot read content + +## Use Cases +1. **Payment messages** - "Thanks for dinner!" +2. **OTP verification** - `{"otp":"123456"}` +3. **Profile edits** - `{"otp":"...","edits":{...}}` +4. **Thread posts** - Message content + verification + +## Character Counting +UTF-8 characters vary in byte size: +```typescript +function getByteLength(str: string): number { + return new Blob([str]).size; +} +// "Hello" = 5 bytes +// "你好" = 6 bytes +// "🎉" = 4 bytes +``` + +## Testing Harness +- Test byte limit enforcement +- Verify emoji insertion +- Check encoding roundtrip +- Test max length edge cases diff --git a/ui/profile/AGENT.md b/ui/profile/AGENT.md new file mode 100644 index 00000000..7897fc76 --- /dev/null +++ b/ui/profile/AGENT.md @@ -0,0 +1,78 @@ +# /ui/profile - Profile Components + +## Purpose +Components for displaying and editing Zcash user profiles. +The primary UI for the zcash.me identity system. + +## Components + +### Display +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileCard` | ProfileCard.tsx | Main profile display card | +| `ProfileCardContent` | ProfileCardContent.tsx | Card body rendering | +| `ProfileHeader` | ProfileHeader.tsx | Navigation with profile count | +| `ProfileAvatar` | ProfileAvatar.tsx | Avatar image with fallback | +| `ProfileLinkRow` | ProfileLinkRow.tsx | Individual link display | +| `VerifiedBadge` | VerifiedBadge.tsx | Checkmark for verified items | +| `CopyButton` | CopyButton.tsx | Copy address/link to clipboard | + +### Editing +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileEditor` | ProfileEditor.tsx | Full profile edit interface | +| `ProfileField` | ProfileField.tsx | Single editable field | +| `editorModals` | editorModals.tsx | Confirmation dialogs | + +### Search +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileSearchDropdown` | ProfileSearchDropdown.tsx | Search results dropdown | + +### Modals +| Component | File | Purpose | +|-----------|------|---------| +| `AuthExplainerModal` | AuthExplainerModal.tsx | Explains verification | +| `RedirectModal` | RedirectModal.tsx | External link warning | + +## Zcash-Specific Features + +### Address Display +```tsx + +// Shows Zcash address prominently +// QR code for easy wallet scanning +// Copy button for address +``` + +### Verification Badge +```tsx + +// Green checkmark if address proven via blockchain +``` + +### Link Verification +Each link can be verified independently: +```tsx + +// Shows verification status per link +``` + +## Hooks + +### useProfileLinks.ts +Manages link state for editing: +- Add/remove links +- Reorder links +- Track verification status + +### useProfileEvents.ts +Analytics and event tracking for profile interactions. + +## Testing Harness +- Components receive profile data via props +- Mock profile objects for unit tests +- Use design-system page for visual testing + +## Types +See `/lib/profile/types.ts` for `Profile` and `ProfileLink` interfaces. diff --git a/ui/signup/AGENT.md b/ui/signup/AGENT.md new file mode 100644 index 00000000..c5086753 --- /dev/null +++ b/ui/signup/AGENT.md @@ -0,0 +1,92 @@ +# /ui/signup - Profile Creation Forms + +## Purpose +Multi-step form components for creating new Zcash profiles. +Guides users through username, address, bio, and link setup. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `AddUserForm` | AddUserForm.tsx | Main multi-step form | +| `StepContainer` | StepContainer.tsx | Step wrapper with progress | +| `ZcashAddressInput` | ZcashAddressInput.tsx | Address input + validation | +| `LinkInput` | LinkInput.tsx | Generic link input | +| `SocialLinkInput` | SocialLinkInput.tsx | Social media handle input | +| `CitySearchDropdown` | CitySearchDropdown.tsx | Location selection | + +## Signup Flow + +``` +┌─────────────────────────────────────┐ +│ Step 1: Basic Info │ +│ ┌─────────────────────────────┐ │ +│ │ Username: alice │ │ +│ │ Display Name: Alice Z │ │ +│ │ Short Bio: Zcash enthusiast │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ Step 2: Zcash Address │ +│ ┌─────────────────────────────┐ │ +│ │ u1qw3rty... │ ✓ │ +│ └─────────────────────────────┘ │ +│ ⚠️ Use a unified address for │ +│ maximum privacy │ +├─────────────────────────────────────┤ +│ Step 3: Links (Optional) │ +│ ┌─────────────────────────────┐ │ +│ │ Twitter: @alice │ │ +│ │ GitHub: alice │ │ +│ │ [+ Add Link] │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ Step 4: Location (Optional) │ +│ ┌─────────────────────────────┐ │ +│ │ City: San Francisco, CA │ ▼ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +## Zcash Address Validation + +`ZcashAddressInput` provides real-time validation: +```typescript + { ... }} +/> +``` + +- Shows address type (unified, sapling, transparent) +- Warns about transparent address privacy +- Blocks viewing keys +- Hints toward unified addresses + +## Username Validation +Uses `/lib/profile/usernamePolicy.ts`: +- 3-30 characters +- Alphanumeric + underscore only +- No reserved words +- Profanity filter + +## Server Action +Form submits to `createProfileAction`: +```typescript +import { createProfileAction } from '@/lib/signup/createProfileAction'; + +const result = await createProfileAction({ + username, + displayName, + bio, + address, + links, + cityId +}); +``` + +## Testing Harness +- Mock `createProfileAction` for form tests +- Test each step independently +- Validate address input edge cases +- Check city search dropdown behavior diff --git a/ui/swap/AGENT.md b/ui/swap/AGENT.md new file mode 100644 index 00000000..36f78f6c --- /dev/null +++ b/ui/swap/AGENT.md @@ -0,0 +1,72 @@ +# /ui/swap - Swap Composer UI + +## Purpose +User interface for cryptocurrency swaps via Defuse Protocol OneClick. +Allows users to receive any token and convert to ZEC. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `SwapComposer` | SwapComposer.tsx | Main swap interface | +| `SwapCurrencyPair` | SwapCurrencyPair.tsx | From/To token selection | +| `SwapQuoteDisplay` | SwapQuoteDisplay.tsx | Quote details and rate | +| `SwapDepositDisplay` | SwapDepositDisplay.tsx | Deposit address & memo | +| `SwapAddressInput` | SwapAddressInput.tsx | Destination Zcash address | +| `SwapSlippageControl` | SwapSlippageControl.tsx | Slippage tolerance setting | + +## Swap Flow UI + +``` +┌─────────────────────────────────────┐ +│ From: [ETH ▼] [ 1.5 ] │ +│ ↓ │ +│ To: [ZEC ▼] [ ~245 ] │ +├─────────────────────────────────────┤ +│ Rate: 1 ETH = 163.33 ZEC │ +│ Slippage: [0.5%] [1%] [2%] │ +├─────────────────────────────────────┤ +│ Deposit to: 0x1234...5678 │ +│ [Copy Address] [Show QR] │ +├─────────────────────────────────────┤ +│ Your ZEC arrives at: │ +│ u1qw3r...xyz │ +└─────────────────────────────────────┘ +``` + +## Zcash Integration + +### Destination Address +- Must be valid Zcash address +- Unified addresses (u1...) preferred +- Validates using `/lib/zcash/zcashUtils.ts` + +### Privacy Note +- Swap deposits are on public chains (ETH, etc.) +- Final ZEC receipt can be to shielded address +- Users should understand privacy implications + +## State Management +Uses Zustand store at `/lib/stores/swap.ts`: +```typescript +const { fromToken, toToken, quote, deposit } = useSwapStore(); +``` + +## Quote Lifecycle +1. User selects tokens and amount +2. `SwapCurrencyPair` triggers quote fetch +3. `SwapQuoteDisplay` shows rate (expires in ~30s) +4. User confirms → deposit address generated +5. `SwapDepositDisplay` shows where to send + +## Testing Harness +- Mock OneClick SDK responses +- Test token selection +- Verify quote display formatting +- Check address validation errors + +## Error States +- Quote expired (refresh button) +- Insufficient liquidity +- Invalid destination address +- Network errors diff --git a/ui/thread/AGENT.md b/ui/thread/AGENT.md new file mode 100644 index 00000000..8aa9d6f0 --- /dev/null +++ b/ui/thread/AGENT.md @@ -0,0 +1,89 @@ +# /ui/thread - Discussion Board UI + +## Purpose +Components for Zcash-verified discussion boards. Users post messages +by proving identity via blockchain transaction. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `ThreadBoard` | ThreadBoard.tsx | Main board container | +| `ThreadFeed` | ThreadFeed.tsx | Scrollable message list | +| `ThreadCard` | ThreadCard.tsx | Individual message card | +| `ThreadComposer` | ThreadComposer.tsx | Message input form | +| `ZcashVerificationComposer` | ZcashVerificationComposer.tsx | OTP-verified composer | +| `BoardHeader` | BoardHeader.tsx | Board title and info | +| `BoardSelector` | BoardSelector.tsx | Board selection dropdown | +| `SidebarNav` | SidebarNav.tsx | Navigation sidebar | +| `CreateBoardModal` | CreateBoardModal.tsx | New board creation | + +## Board Structure + +``` +┌─────────────────────────────────────────────────────┐ +│ ┌──────────┐ ┌────────────────────────────────────┐ │ +│ │ Boards │ │ General Discussion │ │ +│ │ ─────── │ │ ───────────────── │ │ +│ │ General │ │ ┌──────────────────────────────┐ │ │ +│ │ Tech │ │ │ alice.zcash.me 2h ago│ │ │ +│ │ Trading │ │ │ Just sent my first shielded │ │ │ +│ │ │ │ │ transaction! ✓ │ │ │ +│ │ [+] │ │ └──────────────────────────────┘ │ │ +│ │ │ │ ┌──────────────────────────────┐ │ │ +│ │ │ │ │ bob.zcash.me 5h ago│ │ │ +│ │ │ │ │ Welcome to the community! │ │ │ +│ │ │ │ └──────────────────────────────┘ │ │ +│ └──────────┘ │ │ │ +│ │ ┌──────────────────────────────┐ │ │ +│ │ │ Write a message... │ │ │ +│ │ │ [Verify & Post]│ │ │ +│ │ └──────────────────────────────┘ │ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Zcash Verification + +### Verified Posting +Users must verify each post via Zcash transaction: +1. Write message +2. Generate OTP +3. Send small tx with OTP in memo +4. Message posted after confirmation + +```tsx + postMessage(message)} +/> +``` + +### Anti-Spam +- Each post requires on-chain proof +- Small fee (~0.0001 ZEC) per post +- Links posts to verified profiles + +## State Management +Uses Zustand store at `/lib/stores/thread.ts`: +- Current board selection +- Message list +- Composer content + +## Types +See `/lib/thread/types.ts`: +```typescript +interface ThreadMessage { + id: string; + boardId: string; + authorId: string; + content: string; + createdAt: string; + verified: boolean; +} +``` + +## Testing Harness +- Mock thread actions for unit tests +- Test message rendering +- Verify composer validation +- Test board switching diff --git a/ui/verification/AGENT.md b/ui/verification/AGENT.md new file mode 100644 index 00000000..da98f2e7 --- /dev/null +++ b/ui/verification/AGENT.md @@ -0,0 +1,80 @@ +# /ui/verification - OTP Verification UI + +## Purpose +User interface for blockchain-based identity verification. Users prove +Zcash address ownership by sending a transaction with an OTP in the memo. + +## Components + +### Main Flow +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileVerification` | ProfileVerification.tsx | Main verification container | +| `OtpInput` | OtpInput.tsx | 6-digit code input | +| `SubmitOtp` | SubmitOtp.tsx | Submit button with loading | +| `QrUriBlock` | QrUriBlock.tsx | QR code with zcash: URI | +| `AmountAndWallet` | AmountAndWallet.tsx | Transaction details | +| `ProgressStep` | ProgressStep.tsx | Multi-step progress | +| `HelpMessage` | HelpMessage.tsx | Contextual help | +| `InlineOtpForm` | InlineOtpForm.tsx | Compact inline form | + +## Verification Flow UI + +``` +┌─────────────────────────────────────┐ +│ Step 1: Enter OTP │ +│ ┌───┬───┬───┬───┬───┬───┐ │ +│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ │ +│ └───┴───┴───┴───┴───┴───┘ │ +├─────────────────────────────────────┤ +│ Step 2: Scan QR or Copy URI │ +│ ┌─────────────┐ │ +│ │ QR CODE │ Amount: 0.0001 ZEC│ +│ │ │ Memo: [OTP] │ +│ └─────────────┘ │ +├─────────────────────────────────────┤ +│ Step 3: Send & Confirm │ +│ [Waiting for transaction...] │ +│ ████████░░░░░░░░ Polling... │ +└─────────────────────────────────────┘ +``` + +## Zcash Integration + +### QR Code +```typescript + +// Generates: zcash:u1...?amount=0.0001&memo=MTIzNDU2 +``` + +### Memo Encoding +OTP is base64url encoded in the memo field: +- `123456` → `MTIzNDU2` +- Max 512 bytes in Zcash memo + +## Hooks + +### useOtpFlow.ts +Manages OTP generation and state. + +### useVerificationFlow.ts +Full verification flow state machine. + +### useVerificationPolling.ts +Polls server for transaction confirmation. + +## Testing Harness +- Mock the verification API responses +- Test OTP input validation +- Simulate polling states +- QR codes can be visually verified + +## Messages +`otpMessages.ts` contains user-facing copy: +- Error messages +- Help text +- Status updates From 58963bf4431c8de529e2ca9243ff48d7d179772a Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 15 Feb 2026 15:48:49 +0800 Subject: [PATCH 03/48] refactor: colocate Zustand stores with UI features - Move edits store to /ui/profile/store.ts - Move messaging store to /ui/verification/store.ts - Delete unused swap.ts and thread.ts stores - Remove /lib/stores/ folder entirely - Delete computePendingEdits logic (no longer needed) - Update all imports to new locations - Update AGENT.md files to reflect new structure Stores are now colocated with the features that use them, improving discoverability and separation of concerns. --- AGENT.md | 2 +- app/[slug]/ProfilePage.tsx | 2 +- lib/AGENT.md | 1 - lib/profile/types.ts | 1 + lib/stores/AGENT.md | 97 ----- lib/stores/edits.ts | 376 ------------------ lib/stores/swap.ts | 114 ------ lib/stores/thread.ts | 76 ---- ui/profile/AGENT.md | 16 + ui/profile/ProfileCard.tsx | 2 +- ui/profile/ProfileEditor.tsx | 2 +- ui/profile/store.ts | 172 ++++++++ ui/social/useVerificationFlow.ts | 2 +- ui/verification/AGENT.md | 16 + ui/verification/ProfileVerification.tsx | 2 +- .../messaging.ts => ui/verification/store.ts | 21 +- ui/verification/useVerificationPolling.ts | 2 +- 17 files changed, 225 insertions(+), 679 deletions(-) delete mode 100644 lib/stores/AGENT.md delete mode 100644 lib/stores/edits.ts delete mode 100644 lib/stores/swap.ts delete mode 100644 lib/stores/thread.ts create mode 100644 ui/profile/store.ts rename lib/stores/messaging.ts => ui/verification/store.ts (93%) diff --git a/AGENT.md b/AGENT.md index 9ffe31af..66bbe10a 100644 --- a/AGENT.md +++ b/AGENT.md @@ -9,7 +9,7 @@ via blockchain transactions. - **Framework**: Next.js 16 (App Router) + React 19 - **Language**: TypeScript 5.9 - **Database**: Supabase (PostgreSQL) -- **State**: Zustand (client) + React Query (server) +- **State**: Zustand (colocated in `/ui/*/store.ts`) + React Query (server) - **Styling**: TailwindCSS 4 - **Animations**: Framer Motion diff --git a/app/[slug]/ProfilePage.tsx b/app/[slug]/ProfilePage.tsx index cfc0fcf0..b4e217bf 100644 --- a/app/[slug]/ProfilePage.tsx +++ b/app/[slug]/ProfilePage.tsx @@ -5,7 +5,7 @@ import type { Profile } from "@/lib/profile/types"; import type { Token, SwapContextQuoteData, SwapQuoteDisplay } from "@/lib/swap/types"; // Stores -import { useEditsStore } from "@/lib/stores/edits"; +import { useEditsStore } from "@/ui/profile/store"; // Swap utilities import { getTokenId } from "@/lib/swap/utils"; diff --git a/lib/AGENT.md b/lib/AGENT.md index 4044525a..304c3a1d 100644 --- a/lib/AGENT.md +++ b/lib/AGENT.md @@ -14,7 +14,6 @@ This is the brain of zcash.me - all business logic lives here. | `/verification` | OTP confirmation, link verification | | `/signup` | Profile creation server actions | | `/swap` | OneClick SDK integration, token types | -| `/stores` | Zustand client state (swap, messaging, thread) | | `/validation` | Composable form validators | | `/leaderboard` | Referral commission calculations | | `/thread` | Discussion board actions | diff --git a/lib/profile/types.ts b/lib/profile/types.ts index e7c135a6..f85e8109 100644 --- a/lib/profile/types.ts +++ b/lib/profile/types.ts @@ -109,6 +109,7 @@ export interface PendingProfileChange { address?: string; c?: string; d?: string[]; + [key: string]: string | string[] | undefined; } export interface PendingEdits { diff --git a/lib/stores/AGENT.md b/lib/stores/AGENT.md deleted file mode 100644 index b4c8f39b..00000000 --- a/lib/stores/AGENT.md +++ /dev/null @@ -1,97 +0,0 @@ -# /lib/stores - Zustand State Management - -## Purpose -Client-side state using Zustand. Lightweight stores for UI state that doesn't -belong in server components or React Query cache. - -## Stores - -### swap.ts - Swap State -```typescript -interface SwapStore { - // Token selection - fromToken: Token | null; - toToken: Token | null; - setFromToken: (token: Token) => void; - setToToken: (token: Token) => void; - - // Amounts - fromAmount: string; - toAmount: string; - setFromAmount: (amount: string) => void; - - // Quote - quote: SwapQuote | null; - setQuote: (quote: SwapQuote) => void; - - // Settings - slippage: number; - setSlippage: (slippage: number) => void; - - // Deposit - deposit: SwapDeposit | null; - setDeposit: (deposit: SwapDeposit) => void; - - // Reset - reset: () => void; -} -``` - -### messaging.ts - Memo Composer State -```typescript -interface MessagingStore { - memo: string; - setMemo: (memo: string) => void; - - // OTP verification polling - isPolling: boolean; - setPolling: (polling: boolean) => void; - pollInterval: number; -} -``` - -### thread.ts - Discussion Board State -```typescript -interface ThreadStore { - currentBoard: Board | null; - setCurrentBoard: (board: Board) => void; - - messages: ThreadMessage[]; - addMessage: (msg: ThreadMessage) => void; - - composerContent: string; - setComposerContent: (content: string) => void; -} -``` - -### edits.ts - Profile Edit Tracking -Tracks pending edits before blockchain confirmation. - -## Usage Pattern -```typescript -'use client'; -import { useSwapStore } from '@/lib/stores/swap'; - -function SwapComponent() { - const { fromToken, setFromToken, quote } = useSwapStore(); - // ... -} -``` - -## Testing Harness -Zustand stores are easily testable: -```typescript -import { useSwapStore } from '@/lib/stores/swap'; - -// Reset before each test -beforeEach(() => useSwapStore.getState().reset()); - -test('sets token', () => { - useSwapStore.getState().setFromToken(mockToken); - expect(useSwapStore.getState().fromToken).toEqual(mockToken); -}); -``` - -## When to Use Stores vs React Query -- **Stores**: UI state, form values, selections, local preferences -- **React Query**: Server data, cached responses, background updates diff --git a/lib/stores/edits.ts b/lib/stores/edits.ts deleted file mode 100644 index 311d9b40..00000000 --- a/lib/stores/edits.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { create } from 'zustand'; -import type { Profile } from '@/lib/profile/types'; -import { isValidUrl } from '@/lib/validation/validators'; - -export interface ParsedLink { - id: number | null; - url: string; - username?: string; - previewUrl?: string; - valid: boolean; - reason: string | null; - is_verified: boolean; - verification_expires_at?: string; - _uid: string; - platform?: "X" | "GitHub" | "Instagram" | "Discord"; - otherUrl?: string; - label?: string; - icon?: string; - domain?: string; - handle?: string; -} - -export interface FormState { - address: string; - name: string; - display_name: string; - bio: string; - profile_image_url: string; - links: ParsedLink[]; - nearest_city_id: number | null; - nearest_city_name: string; -} - -export interface PendingEdits { - profile?: Record; - l?: any[]; - [key: string]: any; -} - -interface OriginalState { - address: string; - name: string; - display_name: string; - bio: string; - profile_image_url: string; - links: ParsedLink[]; - nearest_city_id: number | null; - nearest_city_name: string; -} - -interface DeletedFields { - address: boolean; - name: boolean; - display_name: boolean; - bio: boolean; - profile_image_url: boolean; - nearest_city: boolean; -} - -interface EditsState { - // Form state - form: FormState; - original: OriginalState; - deletedFields: DeletedFields; - linkAuthTokens: string[]; // Tokens like "!123" or "+!https://x.com/handle" - pendingEdits: PendingEdits; // Auto-computed from form vs original - - // Actions - setForm: (form: FormState | ((prev: FormState) => FormState)) => void; - updateField: (field: keyof FormState, value: any) => void; - setDeletedField: (field: keyof DeletedFields, value: boolean) => void; - initializeForm: (profile: Profile, links: ParsedLink[]) => void; - addLinkAuthToken: (token: string) => void; - removeLinkAuthToken: (token: string) => void; -} - -const emptyForm: FormState = { - address: '', - name: '', - display_name: '', - bio: '', - profile_image_url: '', - links: [], - nearest_city_id: null, - nearest_city_name: '', -}; - -const emptyDeletedFields: DeletedFields = { - address: false, - name: false, - display_name: false, - bio: false, - profile_image_url: false, - nearest_city: false, -}; - -// Helper to compute pendingEdits from current state -function computePendingEdits( - form: FormState, - original: OriginalState, - deletedFields: DeletedFields, - linkAuthTokens: string[] -): PendingEdits { - const profileChanges: Record = {}; - const deletedTokens: string[] = []; - - // Check each field for changes - const fieldMapping: Record = { - name: 'n', - display_name: 'h', - bio: 'b', - address: 'a', - profile_image_url: 'i', - }; - - for (const [field, token] of Object.entries(fieldMapping)) { - const key = field as keyof FormState; - if (deletedFields[key as keyof DeletedFields]) { - deletedTokens.push(token); - } else if (form[key] !== original[key]) { - profileChanges[field] = form[key]; - } - } - - if (deletedTokens.length > 0) { - profileChanges.d = deletedTokens; - } - - // Handle city changes - if (deletedFields.nearest_city && original.nearest_city_id) { - profileChanges.c = '-'; - } else if (form.nearest_city_id && form.nearest_city_id !== original.nearest_city_id) { - profileChanges.c = String(form.nearest_city_id); - } - - // Compute link tokens (complex logic for tracking link changes) - const effectTokens: string[] = []; - const originalById = new Map(); - const originalUrlSet = new Set(); - - for (const l of original.links) { - if (!l) continue; - const url = (l.url || '').trim(); - if (l.id) originalById.set(String(l.id), { ...l, url }); - if (url) originalUrlSet.add(url); - } - - const currentUrls = new Set( - form.links.map((l) => (l.url || '').trim()).filter(Boolean) - ); - const currentById = new Map( - form.links - .filter((l) => l.id) - .map((l) => [String(l.id), (l.url || '').trim()]) - ); - const currentIdSet = new Set( - form.links - .filter((l) => l.id) - .map((l) => String(l.id)) - ); - - // Normalize verification tokens - if a +! token's URL no longer exists, - // replace it with a new URL - let normalizedVerify = [...linkAuthTokens]; - for (const token of linkAuthTokens) { - if (!token.startsWith('+!')) continue; - const oldUrl = token.slice(2); - const stillExists = form.links.some( - (l) => (l.url || '').trim() === oldUrl.trim() - ); - - if (!stillExists) { - normalizedVerify = normalizedVerify.filter((t) => t !== token); - const newUrl = form.links - .map((l) => (l.url || '').trim()) - .find((u) => u && !originalUrlSet.has(u)); - if (newUrl) normalizedVerify.push(`+!${newUrl}`); - } - } - - // Compute changes for each link - for (const row of form.links) { - const id = row.id ?? null; - const newUrlRaw = (row.url || '').trim(); - const { valid: urlValid } = isValidUrl(newUrlRaw); - const newUrl = urlValid ? newUrlRaw : ''; - - if (id) { - const original = originalById.get(String(id)); - const originalUrl = original ? original.url : ''; - if (newUrl === originalUrl) continue; - if (!newUrl) { - effectTokens.push(`-${id}`); - continue; - } - effectTokens.push(`+${id}:${newUrl}`); - } else { - if (!newUrl) continue; - const isNew = !originalUrlSet.has(newUrl); - const verifyToken = `+!${newUrl}`; - const isExplicitVerify = normalizedVerify.includes(verifyToken); - if (isNew && !isExplicitVerify) { - effectTokens.push(`+${newUrl}`); - } - } - } - - // Any original link id missing from current form is a deletion. - for (const [id] of originalById) { - if (!currentIdSet.has(id)) { - effectTokens.push(`-${id}`); - } - } - - // Preserve old tokens that are still relevant - const preservedOld = normalizedVerify.filter((t) => { - if (/^![0-9]+$/.test(t) || /^\+!/.test(t)) return true; - if (/^-[0-9]+$/.test(t)) return true; - if (/^\+[0-9]+:/.test(t)) { - const id = t.slice(1, t.indexOf(':')); - const original = originalById.get(id); - const currentUrl = currentById.get(id) || ''; - const { valid: currentValid } = isValidUrl(currentUrl); - if (!currentUrl || !currentValid) return false; - if (original && currentUrl === original.url) return false; - const hasNewer = effectTokens.some((et) => et.startsWith(`+${id}:`)); - return !hasNewer; - } - if (/^\+[^!]/.test(t) && !t.includes(':')) { - const url = t.slice(1).trim(); - const hasExplicitVerify = normalizedVerify.includes(`+!${url}`); - return currentUrls.has(url) && !hasExplicitVerify; - } - return false; - }); - - // Merge and deduplicate - const uniqTokens = (arr: string[]) => { - const seen = new Set(); - const out: string[] = []; - for (const t of arr) { - if (!seen.has(t)) { - seen.add(t); - out.push(t); - } - } - return out; - }; - - const merged = uniqTokens([...effectTokens, ...preservedOld]); - - // Final filtering - const linkTokens = merged.filter((t) => { - if (t.startsWith('!')) { - const id = t.slice(1); - return !merged.includes(`-${id}`); - } - if (t.startsWith('+!')) { - const url = (t.slice(2) || '').trim(); - if (!url) return false; - return currentUrls.has(url); - } - return true; - }); - - const result: PendingEdits = {}; - if (Object.keys(profileChanges).length > 0) { - result.profile = profileChanges; - } - if (linkTokens.length > 0) { - result.l = linkTokens; - } - - return result; -} - -export const useEditsStore = create((set) => ({ - form: emptyForm, - original: emptyForm, - deletedFields: emptyDeletedFields, - linkAuthTokens: [], - pendingEdits: {}, - - setForm: (form) => - set((state) => { - const newForm = typeof form === 'function' ? form(state.form) : form; - return { - form: newForm, - pendingEdits: computePendingEdits(newForm, state.original, state.deletedFields, state.linkAuthTokens), - }; - }), - - updateField: (field, value) => - set((state) => { - const newForm = { ...state.form, [field]: value }; - return { - form: newForm, - pendingEdits: computePendingEdits(newForm, state.original, state.deletedFields, state.linkAuthTokens), - }; - }), - - setDeletedField: (field, value) => - set((state) => { - const newDeletedFields = { ...state.deletedFields, [field]: value }; - // If deleting, clear the field; if undeleting, restore original - const newForm = { ...state.form }; - if (field === 'nearest_city') { - // Special handling for city field - if (value) { - newForm.nearest_city_id = null; - newForm.nearest_city_name = ''; - } else { - newForm.nearest_city_id = state.original.nearest_city_id; - newForm.nearest_city_name = state.original.nearest_city_name; - } - } else { - if (value) { - newForm[field] = '' as any; - } else { - newForm[field] = state.original[field] as any; - } - } - return { - deletedFields: newDeletedFields, - form: newForm, - pendingEdits: computePendingEdits(newForm, state.original, newDeletedFields, state.linkAuthTokens), - }; - }), - - initializeForm: (profile, links) => - set({ - form: { - address: profile.address || '', - name: profile.name || '', - display_name: profile.display_name || '', - bio: profile.bio || '', - profile_image_url: profile.profile_image_url || '', - links: links || [], - nearest_city_id: profile.nearest_city_id || null, - nearest_city_name: profile.nearest_city_name || '', - }, - original: { - address: profile.address || '', - name: profile.name || '', - display_name: profile.display_name || '', - bio: profile.bio || '', - profile_image_url: profile.profile_image_url || '', - links: links || [], - nearest_city_id: profile.nearest_city_id || null, - nearest_city_name: profile.nearest_city_name || '', - }, - deletedFields: emptyDeletedFields, - linkAuthTokens: [], - pendingEdits: {}, // No changes initially - }), - - addLinkAuthToken: (token) => - set((state) => { - const newTokens = state.linkAuthTokens.includes(token) - ? state.linkAuthTokens - : [...state.linkAuthTokens, token]; - return { - linkAuthTokens: newTokens, - pendingEdits: computePendingEdits(state.form, state.original, state.deletedFields, newTokens), - }; - }), - - removeLinkAuthToken: (token) => - set((state) => { - const newTokens = state.linkAuthTokens.filter((t) => t !== token); - return { - linkAuthTokens: newTokens, - pendingEdits: computePendingEdits(state.form, state.original, state.deletedFields, newTokens), - }; - }), -})); diff --git a/lib/stores/swap.ts b/lib/stores/swap.ts deleted file mode 100644 index d999d12a..00000000 --- a/lib/stores/swap.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { create } from 'zustand'; -import type { - SwapContextQuoteData, - SwapQuoteDisplay, - Token, -} from '@/lib/swap/types'; - -interface SwapState { - currentProfileAddress: string | null; - tokens: Token[]; - originTokenId: string | null; - destinationTokenId: string | null; - swapAmount: string; - refundAddress: string; - destAddress: string; - slippageTolerance: string; - quoteData: SwapContextQuoteData; - quotePreview: SwapQuoteDisplay | null; - depositUri: string; - statusKey: { depositAddress: string } | null; - quoteStatus: string; - swapError: string; - - ensureProfile: (address: string, zecTokenId: string | null) => void; - setTokens: (tokens: Token[]) => void; - setOriginTokenId: (id: string | null) => void; - setDestinationTokenId: (id: string | null) => void; - setSwapAmount: (amount: string) => void; - setRefundAddress: (address: string) => void; - setDestAddress: (address: string) => void; - setSlippageTolerance: (tolerance: string) => void; - setQuoteData: (data: SwapContextQuoteData) => void; - setQuotePreview: (preview: SwapQuoteDisplay | null) => void; - setDepositUri: (uri: string) => void; - setStatusKey: (key: { depositAddress: string } | null) => void; - setQuoteStatus: (status: string) => void; - setSwapError: (error: string) => void; - swapDirection: () => void; - resetQuote: () => void; - resetSwapState: (zecTokenId: string | null) => void; -} - -export const useSwapStore = create((set, get) => ({ - currentProfileAddress: null, - tokens: [], - originTokenId: null, - destinationTokenId: null, - swapAmount: '', - refundAddress: '', - destAddress: '', - slippageTolerance: '1', - quoteData: null, - quotePreview: null, - depositUri: '', - statusKey: null, - quoteStatus: '', - swapError: '', - - ensureProfile: (address, zecTokenId) => { - if (get().currentProfileAddress !== address) { - set({ - currentProfileAddress: address, - originTokenId: zecTokenId, - destinationTokenId: null, - swapAmount: '', - refundAddress: '', - destAddress: '', - slippageTolerance: '1', - quoteData: null, - quotePreview: null, - quoteStatus: '', - depositUri: '', - statusKey: null, - swapError: '', - }); - } - }, - setTokens: (tokens) => set({ tokens }), - setOriginTokenId: (id) => set({ originTokenId: id }), - setDestinationTokenId: (id) => set({ destinationTokenId: id }), - setSwapAmount: (amount) => set({ swapAmount: amount }), - setRefundAddress: (address) => set({ refundAddress: address }), - setDestAddress: (address) => set({ destAddress: address }), - setSlippageTolerance: (tolerance) => set({ slippageTolerance: tolerance }), - setQuoteData: (data) => set({ quoteData: data }), - setQuotePreview: (preview) => set({ quotePreview: preview }), - setDepositUri: (uri) => set({ depositUri: uri }), - setStatusKey: (key) => set({ statusKey: key }), - setQuoteStatus: (status) => set({ quoteStatus: status }), - setSwapError: (error) => set({ swapError: error }), - swapDirection: () => { - const { originTokenId, destinationTokenId } = get(); - set({ - originTokenId: destinationTokenId, - destinationTokenId: originTokenId, - }); - }, - resetQuote: () => set({ quoteData: null, quotePreview: null, quoteStatus: '' }), - resetSwapState: (zecTokenId) => - set({ - originTokenId: zecTokenId, - destinationTokenId: null, - swapAmount: '', - refundAddress: '', - destAddress: '', - slippageTolerance: '1', - quoteData: null, - quotePreview: null, - quoteStatus: '', - depositUri: '', - statusKey: null, - swapError: '', - }), -})); diff --git a/lib/stores/thread.ts b/lib/stores/thread.ts deleted file mode 100644 index d413f778..00000000 --- a/lib/stores/thread.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { create } from 'zustand'; -import { immer } from 'zustand/middleware/immer'; -import type { ThreadStore, ThreadMessage, Board } from '@/lib/thread/types'; - -export const useThreadStore = create()( - immer((set) => ({ - // Messages - messages: [], - isLoadingMessages: false, - - // Current board - currentBoardId: '', - currentBoard: undefined, - - // All boards - boards: [], - isLoadingBoards: false, - - // UI state - showComposer: true, - - // Actions - setCurrentBoardId: (id: string) => - set((state) => { - state.currentBoardId = id; - }), - - setMessages: (messages: ThreadMessage[]) => - set((state) => { - state.messages = messages; - }), - - addMessage: (message: ThreadMessage) => - set((state) => { - state.messages.unshift(message); - }), - - setBoards: (boards: Board[]) => - set((state) => { - state.boards = boards; - }), - - setCurrentBoard: (board: Board) => - set((state) => { - state.currentBoard = board; - }), - - setLoadingMessages: (loading: boolean) => - set((state) => { - state.isLoadingMessages = loading; - }), - - setLoadingBoards: (loading: boolean) => - set((state) => { - state.isLoadingBoards = loading; - }), - - setShowComposer: (show: boolean) => - set((state) => { - state.showComposer = show; - }), - })) -); - -export function useResetThreadStore() { - return () => - useThreadStore.setState({ - messages: [], - isLoadingMessages: false, - currentBoardId: '', - currentBoard: undefined, - boards: [], - isLoadingBoards: false, - showComposer: true, - }); -} diff --git a/ui/profile/AGENT.md b/ui/profile/AGENT.md index 7897fc76..c9622d8a 100644 --- a/ui/profile/AGENT.md +++ b/ui/profile/AGENT.md @@ -58,6 +58,22 @@ Each link can be verified independently: // Shows verification status per link ``` +## State Management + +### store.ts (Zustand) +Profile editing state - colocated with components: +```typescript +import { useEditsStore } from "@/ui/profile/store"; + +const { form, setForm, initializeForm } = useEditsStore(); +``` + +State includes: +- `form` - Current form values +- `original` - Original values for diff +- `deletedFields` - Track field deletions +- `linkAuthTokens` - OAuth verification tokens + ## Hooks ### useProfileLinks.ts diff --git a/ui/profile/ProfileCard.tsx b/ui/profile/ProfileCard.tsx index b3967816..f263a416 100644 --- a/ui/profile/ProfileCard.tsx +++ b/ui/profile/ProfileCard.tsx @@ -23,7 +23,7 @@ import { startOAuthVerification, } from "@/lib/profile/accountAuthFlow"; import AuthExplainerModal from "@/ui/profile/AuthExplainerModal"; -import { useEditsStore } from "@/lib/stores/edits"; +import { useEditsStore } from "@/ui/profile/store"; import SubmitOtp from "@/ui/verification/SubmitOtp"; import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import type { EnrichedProfileLink, Profile } from "@/lib/profile/types"; diff --git a/ui/profile/ProfileEditor.tsx b/ui/profile/ProfileEditor.tsx index f1ce7321..a0ea1ff2 100644 --- a/ui/profile/ProfileEditor.tsx +++ b/ui/profile/ProfileEditor.tsx @@ -20,7 +20,7 @@ import { isValidUrl } from "@/lib/validation/validators"; import { isUsernameVerified } from "@/lib/profile/profileUtils"; import { sanitizeUsernameInput } from "@/lib/profile/usernamePolicy"; import useVerificationFlow from "@/ui/social/useVerificationFlow"; -import { useEditsStore, type ParsedLink, type FormState } from "@/lib/stores/edits"; +import { useEditsStore, type ParsedLink, type FormState } from "@/ui/profile/store"; import type { Profile, EnrichedProfileLink } from "@/lib/profile/types"; import { Alert, Button } from "@/ui/common"; import { withFieldBorderState } from "@/ui/styles/fields"; diff --git a/ui/profile/store.ts b/ui/profile/store.ts new file mode 100644 index 00000000..ec40f1f1 --- /dev/null +++ b/ui/profile/store.ts @@ -0,0 +1,172 @@ +import { create } from 'zustand'; +import type { Profile, PendingEdits } from '@/lib/profile/types'; + +export interface ParsedLink { + id: number | null; + url: string; + username?: string; + previewUrl?: string; + valid: boolean; + reason: string | null; + is_verified: boolean; + verification_expires_at?: string; + _uid: string; + platform?: "X" | "GitHub" | "Instagram" | "Discord"; + otherUrl?: string; + label?: string; + icon?: string; + domain?: string; + handle?: string; +} + +export interface FormState { + address: string; + name: string; + display_name: string; + bio: string; + profile_image_url: string; + links: ParsedLink[]; + nearest_city_id: number | null; + nearest_city_name: string; +} + +interface DeletedFields { + address: boolean; + name: boolean; + display_name: boolean; + bio: boolean; + profile_image_url: boolean; + nearest_city: boolean; +} + +interface EditsState { + form: FormState; + original: FormState; + deletedFields: DeletedFields; + linkAuthTokens: string[]; + pendingEdits: PendingEdits; + + setForm: (form: FormState | ((prev: FormState) => FormState)) => void; + updateField: (field: keyof FormState, value: any) => void; + setDeletedField: (field: keyof DeletedFields, value: boolean) => void; + initializeForm: (profile: Profile, links: ParsedLink[]) => void; + addLinkAuthToken: (token: string) => void; + removeLinkAuthToken: (token: string) => void; + setPendingEdits: (edits: PendingEdits) => void; + reset: () => void; +} + +const emptyForm: FormState = { + address: '', + name: '', + display_name: '', + bio: '', + profile_image_url: '', + links: [], + nearest_city_id: null, + nearest_city_name: '', +}; + +const emptyDeletedFields: DeletedFields = { + address: false, + name: false, + display_name: false, + bio: false, + profile_image_url: false, + nearest_city: false, +}; + +export const useEditsStore = create((set) => ({ + form: emptyForm, + original: emptyForm, + deletedFields: emptyDeletedFields, + linkAuthTokens: [], + pendingEdits: {}, + + setForm: (form) => + set((state) => ({ + form: typeof form === 'function' ? form(state.form) : form, + })), + + updateField: (field, value) => + set((state) => ({ + form: { ...state.form, [field]: value }, + })), + + setDeletedField: (field, value) => + set((state) => { + const newDeletedFields = { ...state.deletedFields, [field]: value }; + const newForm = { ...state.form }; + + if (field === 'nearest_city') { + if (value) { + newForm.nearest_city_id = null; + newForm.nearest_city_name = ''; + } else { + newForm.nearest_city_id = state.original.nearest_city_id; + newForm.nearest_city_name = state.original.nearest_city_name; + } + } else { + if (value) { + newForm[field] = '' as any; + } else { + newForm[field] = state.original[field] as any; + } + } + + return { + deletedFields: newDeletedFields, + form: newForm, + }; + }), + + initializeForm: (profile, links) => + set({ + form: { + address: profile.address || '', + name: profile.name || '', + display_name: profile.display_name || '', + bio: profile.bio || '', + profile_image_url: profile.profile_image_url || '', + links: links || [], + nearest_city_id: profile.nearest_city_id || null, + nearest_city_name: profile.nearest_city_name || '', + }, + original: { + address: profile.address || '', + name: profile.name || '', + display_name: profile.display_name || '', + bio: profile.bio || '', + profile_image_url: profile.profile_image_url || '', + links: links || [], + nearest_city_id: profile.nearest_city_id || null, + nearest_city_name: profile.nearest_city_name || '', + }, + deletedFields: emptyDeletedFields, + linkAuthTokens: [], + pendingEdits: {}, + }), + + addLinkAuthToken: (token) => + set((state) => ({ + linkAuthTokens: state.linkAuthTokens.includes(token) + ? state.linkAuthTokens + : [...state.linkAuthTokens, token], + })), + + removeLinkAuthToken: (token) => + set((state) => ({ + linkAuthTokens: state.linkAuthTokens.filter((t) => t !== token), + })), + + setPendingEdits: (edits) => set({ pendingEdits: edits }), + + reset: () => + set({ + form: emptyForm, + original: emptyForm, + deletedFields: emptyDeletedFields, + linkAuthTokens: [], + pendingEdits: {}, + }), +})); diff --git a/ui/social/useVerificationFlow.ts b/ui/social/useVerificationFlow.ts index ac88fe33..53c6ca7e 100644 --- a/ui/social/useVerificationFlow.ts +++ b/ui/social/useVerificationFlow.ts @@ -10,7 +10,7 @@ import { getDiscordUsername, normalizeDiscordHandle, } from "@/lib/profile/providerAvatars"; -import { useEditsStore } from "@/lib/stores/edits"; +import { useEditsStore } from "@/ui/profile/store"; interface LinkedInData { handle: string | null; diff --git a/ui/verification/AGENT.md b/ui/verification/AGENT.md index da98f2e7..5385a3c2 100644 --- a/ui/verification/AGENT.md +++ b/ui/verification/AGENT.md @@ -56,6 +56,22 @@ OTP is base64url encoded in the memo field: - `123456` → `MTIzNDU2` - Max 512 bytes in Zcash memo +## State Management + +### store.ts (Zustand) +Verification and messaging state - colocated with components: +```typescript +import { useMessagingStore } from "@/ui/verification/store"; + +const { mode, setMode, verify, pollStatus } = useMessagingStore(); +``` + +State includes: +- `mode` - Current mode (verification, swap, memo) +- `memo` / `amount` - Memo composition +- `verify` - Verification request data +- `poll*` - Polling status fields + ## Hooks ### useOtpFlow.ts diff --git a/ui/verification/ProfileVerification.tsx b/ui/verification/ProfileVerification.tsx index 70a359bf..fa0c35f5 100644 --- a/ui/verification/ProfileVerification.tsx +++ b/ui/verification/ProfileVerification.tsx @@ -9,7 +9,7 @@ import { buildZcashUri, buildZcashEditMemo } from "@/lib/zcash/zcashUtils"; import useVerificationPolling from "@/ui/verification/useVerificationPolling"; import ProgressStep from "@/ui/verification/ProgressStep"; -import { useMessagingStore } from "@/lib/stores/messaging"; +import { useMessagingStore } from "@/ui/verification/store"; import { Alert } from "@/ui/common"; const SIGNIN_ADDR = "u1lff6xhc9p2c3aefrms5624aqd5mdlys87xcu0u0g3rynnjfs4g5nf0u5q8sczex3jctc2xesauktvdr9gd77zauaejje3zrdpj4uppssdmzzu33lfkzc9y0hlq7rt94kt4rqpq6d4h8a0px597htclme3pav3wft4k94u4pqqn3h4dmdp8wcvvumgqak5ynwy7qm6e797t356ud38we"; diff --git a/lib/stores/messaging.ts b/ui/verification/store.ts similarity index 93% rename from lib/stores/messaging.ts rename to ui/verification/store.ts index 3d242814..2ec162cb 100644 --- a/lib/stores/messaging.ts +++ b/ui/verification/store.ts @@ -6,9 +6,6 @@ type OtpPhaseHistoryItem = { export type ProfileMode = "verification" | "swap" | "memo"; -/** - * Messaging store - manages memo/message composition and verification state for Zcash - */ interface MessagingState { currentProfileAddress: string | null; mode: ProfileMode; @@ -23,6 +20,7 @@ interface MessagingState { zId: number | null; requestId: string | null; }; + // Verification polling state verifyQrEnabled: boolean; pollStatus: string | null; @@ -62,7 +60,7 @@ const initialVerifyState = { pollStatus: null, pollOtpStatus: null, pollOtpPhase: null, - pollOtpPhaseHistory: [], + pollOtpPhaseHistory: [] as OtpPhaseHistoryItem[], otpInlineSuccess: false, pollError: '', pollDebug: '', @@ -92,13 +90,16 @@ export const useMessagingStore = create((set, get) => ({ }); } }, + setMode: (mode) => set((state) => ({ mode: typeof mode === 'function' ? mode(state.mode) : mode, })), + setShowBack: (showBack) => set({ showBack }), setMemo: (memo) => set({ memo }), setAmount: (amount) => set({ amount }), + setVerify: (verify) => set((state) => ({ verify: typeof verify === 'function' ? verify(state.verify) : verify, @@ -112,14 +113,18 @@ export const useMessagingStore = create((set, get) => ({ setPollOtpPhaseHistory: (history) => set({ pollOtpPhaseHistory: history }), setOtpInlineSuccess: (success) => set({ otpInlineSuccess: success }), setPollError: (error) => set({ pollError: error }), + setPollDebug: (debug) => set((state) => ({ pollDebug: typeof debug === 'function' ? debug(state.pollDebug) : debug, })), + setPollStartedAt: (startedAt) => set({ pollStartedAt: startedAt }), setPollElapsedMs: (elapsed) => set({ pollElapsedMs: elapsed }), - resetVerificationPolling: () => set((state) => ({ - ...initialVerifyState, - verify: { ...state.verify, zId: null, requestId: null }, - })), + + resetVerificationPolling: () => + set((state) => ({ + ...initialVerifyState, + verify: { ...state.verify, zId: null, requestId: null }, + })), })); diff --git a/ui/verification/useVerificationPolling.ts b/ui/verification/useVerificationPolling.ts index eb59a594..b6ab8c02 100644 --- a/ui/verification/useVerificationPolling.ts +++ b/ui/verification/useVerificationPolling.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef } from "react"; -import { useMessagingStore } from "@/lib/stores/messaging"; +import { useMessagingStore } from "@/ui/verification/store"; const VERIFY_API_BASE = process.env.NEXT_PUBLIC_VERIFY_API_URL || From dc294a19260687f78efc11b22aa1eb826e84d63f Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 15 Feb 2026 15:56:53 +0800 Subject: [PATCH 04/48] chore: update next-env.d.ts (auto-generated) --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 11569332a8566e126f58cc04ab002d470248c3d9 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 15 Feb 2026 16:31:43 +0800 Subject: [PATCH 05/48] refactor: remove pendingEdits system for ZVS verification Remove the old verification system that encoded profile changes in Zcash memos. The new ZVS (Zcash Verification Service) uses a simpler OTP-based flow where edits are submitted directly to the backend after address ownership is proven. - Remove PendingEdits/PendingProfileChange types - Remove isLinkAuthPending function - Remove pendingEdits state from store - Simplify ProfileVerification to use profile ID memo - Update AGENT.md docs for new ZVS flow --- app/[slug]/ProfilePage.tsx | 6 --- lib/profile/accountAuthFlow.ts | 9 +--- lib/profile/types.ts | 16 ------- ui/profile/AGENT.md | 4 +- ui/profile/ProfileCard.tsx | 8 ++-- ui/profile/ProfileEditor.tsx | 45 +++-------------- ui/profile/store.ts | 9 +--- ui/verification/AGENT.md | 42 ++++++++-------- ui/verification/ProfileVerification.tsx | 64 ++++--------------------- 9 files changed, 45 insertions(+), 158 deletions(-) diff --git a/app/[slug]/ProfilePage.tsx b/app/[slug]/ProfilePage.tsx index b4e217bf..839e87f7 100644 --- a/app/[slug]/ProfilePage.tsx +++ b/app/[slug]/ProfilePage.tsx @@ -4,8 +4,6 @@ import { useEffect, useState, useCallback } from "react"; import type { Profile } from "@/lib/profile/types"; import type { Token, SwapContextQuoteData, SwapQuoteDisplay } from "@/lib/swap/types"; -// Stores -import { useEditsStore } from "@/ui/profile/store"; // Swap utilities import { getTokenId } from "@/lib/swap/utils"; @@ -68,9 +66,6 @@ export default function ProfilePage({ const [forceShowQR, setForceShowQR] = useState(false); const [isProfileEditing, setIsProfileEditing] = useState(false); - // Granular subscriptions to prevent unnecessary re-renders - const pendingEdits = useEditsStore(state => state.pendingEdits); - // Local state const [mode, setMode] = useState<'donate' | 'swap' | 'verification'>('donate'); const [originTokenId, setOriginTokenId] = useState(null); @@ -249,7 +244,6 @@ export default function ProfilePage({ {mode === "verification" ? ( ) : mode === "swap" ? (
diff --git a/lib/profile/accountAuthFlow.ts b/lib/profile/accountAuthFlow.ts index 6612b3c5..b2b58d8a 100644 --- a/lib/profile/accountAuthFlow.ts +++ b/lib/profile/accountAuthFlow.ts @@ -1,10 +1,6 @@ import { supabase } from "@/lib/supabase/supabase-client"; import { buildSlug } from "@/lib/profile/profileUtils"; -import type { - Profile, - ProfileLink, - PendingEdits, -} from "@/lib/profile/types"; +import type { Profile, ProfileLink } from "@/lib/profile/types"; interface AuthProvider { key: "twitter" | "linkedin_oidc" | "github" | "discord"; @@ -71,9 +67,6 @@ export const getLinkAuthToken = (link: Partial | null | undefined): return trimmed ? `+!${trimmed}` : null; }; -export const isLinkAuthPending = (pendingEdits: PendingEdits | null | undefined, token: string | null): boolean => - Array.isArray(pendingEdits?.l) && !!token && pendingEdits.l.includes(token); - interface StartOAuthParams { providerKey: string; profile: Partial | undefined; diff --git a/lib/profile/types.ts b/lib/profile/types.ts index f85e8109..6572f6d3 100644 --- a/lib/profile/types.ts +++ b/lib/profile/types.ts @@ -101,22 +101,6 @@ export interface LinkVerificationPayload { [key: string]: unknown; } -export interface PendingProfileChange { - name?: string; - display_name?: string; - bio?: string; - profile_image_url?: string; - address?: string; - c?: string; - d?: string[]; - [key: string]: string | string[] | undefined; -} - -export interface PendingEdits { - profile?: PendingProfileChange; - l?: string[]; -} - /** * Rank type discriminator */ diff --git a/ui/profile/AGENT.md b/ui/profile/AGENT.md index c9622d8a..c24182cc 100644 --- a/ui/profile/AGENT.md +++ b/ui/profile/AGENT.md @@ -70,10 +70,12 @@ const { form, setForm, initializeForm } = useEditsStore(); State includes: - `form` - Current form values -- `original` - Original values for diff +- `original` - Original values for comparison - `deletedFields` - Track field deletions - `linkAuthTokens` - OAuth verification tokens +Note: Profile edits are submitted directly to the backend after OTP verification via ZVS (Zcash Verification Service). The old `pendingEdits` system that encoded changes in the Zcash memo has been removed. + ## Hooks ### useProfileLinks.ts diff --git a/ui/profile/ProfileCard.tsx b/ui/profile/ProfileCard.tsx index f263a416..6606917f 100644 --- a/ui/profile/ProfileCard.tsx +++ b/ui/profile/ProfileCard.tsx @@ -19,7 +19,6 @@ import useProfileLinks from "@/ui/profile/useProfileLinks"; import { getAuthProviderForUrl, getLinkAuthToken, - isLinkAuthPending, startOAuthVerification, } from "@/lib/profile/accountAuthFlow"; import AuthExplainerModal from "@/ui/profile/AuthExplainerModal"; @@ -58,7 +57,7 @@ export default function ProfileCard({ const [showDetail, setShowDetail] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [showBack, setShowBack] = useState(false); - const { pendingEdits, addLinkAuthToken } = useEditsStore(); + const { addLinkAuthToken } = useEditsStore(); const { linksArray } = useProfileLinks({ profile }); const tapProps = shouldReduceMotion ? {} @@ -70,7 +69,6 @@ export default function ProfileCard({ const { verifiedAddress, verifiedLinks, canAuthenticateLinks } = getProfileTrust(profile); const selectedAuthProvider = authLink ? getAuthProviderForUrl(authLink.url) : null; const authToken = authLink ? getLinkAuthToken(authLink) : null; - const authPending = authToken && isLinkAuthPending(pendingEdits, authToken); const totalLinks = profile.total_links ?? (Array.isArray(linksArray) ? linksArray.length : 0); const hasDuplicateNames = duplicateNameCount > 1; // Default to showing trust warnings unless caller explicitly disables via `warning={null}`. @@ -150,7 +148,7 @@ export default function ProfileCard({ }); return; } - if (!authToken || authPending) return; + if (!authToken) return; addLinkAuthToken(authToken); setAuthInfoOpen(false); }; @@ -641,7 +639,7 @@ export default function ProfileCard({ { diff --git a/ui/profile/ProfileEditor.tsx b/ui/profile/ProfileEditor.tsx index a0ea1ff2..6bca9a4b 100644 --- a/ui/profile/ProfileEditor.tsx +++ b/ui/profile/ProfileEditor.tsx @@ -8,7 +8,6 @@ import CitySearchDropdown from "@/ui/signup/CitySearchDropdown"; import { getAuthProviderForUrl, getLinkAuthToken, - isLinkAuthPending, startOAuthVerification, } from "@/lib/profile/accountAuthFlow"; import AuthExplainerModal from "@/ui/profile/AuthExplainerModal"; @@ -31,7 +30,6 @@ const LINK_FIELD_CLASS = `rounded-2xl border px-3 py-2 text-sm bg-transparent outline-hidden text-gray-800 placeholder-gray-400 appearance-none ${withFieldBorderState("border-[#0a1126]/60")}`; const LINK_CONTAINER_CLASS = "rounded-2xl border border-[#0a1126]/60 p-3 bg-transparent"; -const VERIFY_HINT_CLASS = "text-xs text-gray-500 italic"; interface CharCounterProps { text: string; @@ -68,22 +66,12 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { const { form, deletedFields, - pendingEdits, setForm, updateField, setDeletedField, initializeForm, addLinkAuthToken, - removeLinkAuthToken, } = useEditsStore(); - const pendingProfileEdits = pendingEdits?.profile || {}; - const pendingDeleted = Array.isArray(pendingProfileEdits?.d) - ? pendingProfileEdits.d - : []; - const hasPendingField = (key: string, token: string) => - Boolean(pendingProfileEdits?.[key]) || pendingDeleted.includes(token); - const hasPendingLinks = - Array.isArray(pendingEdits?.l) && pendingEdits.l.length > 0; const [showRedirect, setShowRedirect] = useState(false); const [redirectLabel, setRedirectLabel] = useState("X.com"); const [avatarPrompt, setAvatarPrompt] = useState(null); @@ -246,8 +234,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { const authInfoProvider = authInfoLink ? getAuthProviderForUrl(authInfoLink.url) : null; const authInfoToken = authInfoLink ? getLinkAuthToken(authInfoLink) : null; - const authInfoPending = - authInfoToken && isLinkAuthPending(pendingEdits, authInfoToken); // Profile field diffs and link tokens are now auto-computed in the store @@ -351,7 +337,7 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { { setAuthInfoOpen(false); setAuthInfoLink(null); }} @@ -359,7 +345,7 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { if (!authInfoLink) return; if (!profile.address_verified) return; if (authInfoProvider) { startOAuth(authInfoProvider.key, authInfoLink.url); return; } - if (!authInfoToken || authInfoPending) return; + if (!authInfoToken) return; addLinkAuthToken(authInfoToken); setAuthInfoOpen(false); }} @@ -396,8 +382,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Zcash Address" htmlFor="addr" helpText="Your Zcash address where verification codes are sent." - hasPending={hasPendingField("address", "a")} - pendingHint="Verify to apply edits" isDeleted={deletedFields.address} deleteDisabled={!profile.address_verified} onDelete={toggleAddress} @@ -422,9 +406,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Username" htmlFor="name" helpText="Your unique handle on Zcash.me." - hasPending={hasPendingField("name", "n")} - pendingHint={deletedFields.name ? "⚠ Verify to remove your profile from Zcash.me." : "Verify to apply changes"} - pendingHintClassName={deletedFields.name ? "text-xs text-red-600 italic" : undefined} isDeleted={deletedFields.name} deleteDisabled={!originals.name} onDelete={toggleNameDelete} @@ -488,7 +469,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Display Name" htmlFor="display_name" helpText="Your public display name." - hasPending={hasPendingField("display_name", "h")} isDeleted={deletedFields.display_name} deleteDisabled={!originals.display_name} onDelete={() => setDeletedField("display_name", !deletedFields.display_name)} @@ -508,7 +488,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Biography" htmlFor="bio" helpText="Your current story arc in 100 characters or less." - hasPending={hasPendingField("bio", "b")} isDeleted={deletedFields.bio} deleteDisabled={!originals.bio} onDelete={() => setDeletedField("bio", !deletedFields.bio)} @@ -531,7 +510,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { { @@ -564,7 +542,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Profile Image URL" htmlFor="pimg" helpText="Link to PNG or JPG. Search 'free image link host'." - hasPending={hasPendingField("profile_image_url", "i")} isDeleted={deletedFields.profile_image_url} deleteDisabled={!originals.profile_image_url} onDelete={() => setDeletedField("profile_image_url", !deletedFields.profile_image_url)} @@ -596,11 +573,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) {
- {hasPendingLinks && ( - - Verify to apply changes - - )}
)}
diff --git a/ui/profile/store.ts b/ui/profile/store.ts index ec40f1f1..852f75a8 100644 --- a/ui/profile/store.ts +++ b/ui/profile/store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Profile, PendingEdits } from '@/lib/profile/types'; +import type { Profile } from '@/lib/profile/types'; export interface ParsedLink { id: number | null; @@ -44,7 +44,6 @@ interface EditsState { original: FormState; deletedFields: DeletedFields; linkAuthTokens: string[]; - pendingEdits: PendingEdits; setForm: (form: FormState | ((prev: FormState) => FormState)) => void; updateField: (field: keyof FormState, value: any) => void; @@ -52,7 +51,6 @@ interface EditsState { initializeForm: (profile: Profile, links: ParsedLink[]) => void; addLinkAuthToken: (token: string) => void; removeLinkAuthToken: (token: string) => void; - setPendingEdits: (edits: PendingEdits) => void; reset: () => void; } @@ -81,7 +79,6 @@ export const useEditsStore = create((set) => ({ original: emptyForm, deletedFields: emptyDeletedFields, linkAuthTokens: [], - pendingEdits: {}, setForm: (form) => set((state) => ({ @@ -144,7 +141,6 @@ export const useEditsStore = create((set) => ({ }, deletedFields: emptyDeletedFields, linkAuthTokens: [], - pendingEdits: {}, }), addLinkAuthToken: (token) => @@ -159,14 +155,11 @@ export const useEditsStore = create((set) => ({ linkAuthTokens: state.linkAuthTokens.filter((t) => t !== token), })), - setPendingEdits: (edits) => set({ pendingEdits: edits }), - reset: () => set({ form: emptyForm, original: emptyForm, deletedFields: emptyDeletedFields, linkAuthTokens: [], - pendingEdits: {}, }), })); diff --git a/ui/verification/AGENT.md b/ui/verification/AGENT.md index 5385a3c2..418b69c2 100644 --- a/ui/verification/AGENT.md +++ b/ui/verification/AGENT.md @@ -1,8 +1,14 @@ # /ui/verification - OTP Verification UI ## Purpose -User interface for blockchain-based identity verification. Users prove -Zcash address ownership by sending a transaction with an OTP in the memo. +User interface for ZVS (Zcash Verification Service) based identity verification. +Users prove Zcash address ownership via OTP flow. + +## ZVS Verification Flow +1. User sends a transaction to ZVS address with their profile ID in memo +2. ZVS replies with a 6-digit OTP via Zcash memo +3. User enters OTP in the directory UI +4. Backend validates OTP and updates Supabase ## Components @@ -22,20 +28,19 @@ Zcash address ownership by sending a transaction with an OTP in the memo. ``` ┌─────────────────────────────────────┐ -│ Step 1: Enter OTP │ -│ ┌───┬───┬───┬───┬───┬───┐ │ -│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ │ -│ └───┴───┴───┴───┴───┴───┘ │ -├─────────────────────────────────────┤ -│ Step 2: Scan QR or Copy URI │ +│ Step 1: Send to ZVS address │ │ ┌─────────────┐ │ -│ │ QR CODE │ Amount: 0.0001 ZEC│ -│ │ │ Memo: [OTP] │ +│ │ QR CODE │ Amount: 0.003 ZEC │ +│ │ │ Memo: [profile_id]│ │ └─────────────┘ │ ├─────────────────────────────────────┤ -│ Step 3: Send & Confirm │ -│ [Waiting for transaction...] │ -│ ████████░░░░░░░░ Polling... │ +│ Step 2: Receive OTP in wallet │ +│ [Check your wallet for OTP...] │ +├─────────────────────────────────────┤ +│ Step 3: Enter OTP │ +│ ┌───┬───┬───┬───┬───┬───┐ │ +│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ │ +│ └───┴───┴───┴───┴───┴───┘ │ └─────────────────────────────────────┘ ``` @@ -45,17 +50,12 @@ Zcash address ownership by sending a transaction with an OTP in the memo. ```typescript -// Generates: zcash:u1...?amount=0.0001&memo=MTIzNDU2 +// Generates: zcash:u1...?amount=0.003&memo=... ``` -### Memo Encoding -OTP is base64url encoded in the memo field: -- `123456` → `MTIzNDU2` -- Max 512 bytes in Zcash memo - ## State Management ### store.ts (Zustand) diff --git a/ui/verification/ProfileVerification.tsx b/ui/verification/ProfileVerification.tsx index fa0c35f5..4b5524aa 100644 --- a/ui/verification/ProfileVerification.tsx +++ b/ui/verification/ProfileVerification.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from "react"; -import type { Profile, PendingEdits } from "@/lib/profile/types"; +import type { Profile } from "@/lib/profile/types"; import QrUriBlock from "@/ui/verification/QrUriBlock"; import AmountAndWallet from "@/ui/verification/AmountAndWallet"; import SubmitOtp from "@/ui/verification/SubmitOtp"; import InlineOtpForm from "@/ui/verification/InlineOtpForm"; -import { buildZcashUri, buildZcashEditMemo } from "@/lib/zcash/zcashUtils"; +import { buildZcashUri } from "@/lib/zcash/zcashUtils"; import useVerificationPolling from "@/ui/verification/useVerificationPolling"; import ProgressStep from "@/ui/verification/ProgressStep"; @@ -19,12 +19,10 @@ const DEFAULT_SIGNIN_AMOUNT = (MIN_SIGNIN_AMOUNT * 3).toFixed(3); interface ProfileVerificationProps { profile: Profile; - pendingEdits: PendingEdits; } export default function ProfileVerification({ profile, - pendingEdits, }: ProfileVerificationProps) { const verify = useMessagingStore(state => state.verify); const verifyQrEnabled = useMessagingStore(state => state.verifyQrEnabled); @@ -36,17 +34,12 @@ export default function ProfileVerification({ const setVerifyQrEnabled = useMessagingStore(state => state.setVerifyQrEnabled); const resetVerificationPolling = useMessagingStore(state => state.resetVerificationPolling); - // Compute verification memo reactively from pending edits + // Simple memo with profile ID for ZVS verification const memo = useMemo(() => { const zId = verify.zId ?? profile.id ?? null; if (!zId) return ""; - - const profileEdits = pendingEdits.profile ?? {}; - const linkTokens = pendingEdits.l ?? []; - const hasEdits = Object.keys(profileEdits).length > 0 || linkTokens.length > 0; - const profileDiff = hasEdits ? { ...profileEdits, l: linkTokens } : {}; - return buildZcashEditMemo(profileDiff, String(zId), verify.requestId ?? null); - }, [profile.id, verify.zId, verify.requestId, pendingEdits]); + return String(zId); + }, [profile.id, verify.zId]); const amount = verify?.amount ?? DEFAULT_SIGNIN_AMOUNT; @@ -66,46 +59,9 @@ export default function ProfileVerification({ handleInlineOtpSuccess, } = useVerificationPolling(); - const explainerText = useMemo(() => { - const profileEdits = pendingEdits?.profile ?? {}; - const deleted = Array.isArray(profileEdits?.d) ? profileEdits.d : []; - const changedFields: string[] = []; - - const hasField = (key: string, token: string) => - Boolean(profileEdits?.[key as keyof typeof profileEdits]) || deleted.includes(token); - - if (hasField("name", "n")) changedFields.push("username"); - if (hasField("display_name", "h")) changedFields.push("display name"); - if (hasField("bio", "b")) changedFields.push("bio"); - if (hasField("profile_image_url", "i")) - changedFields.push("profile image"); - if (profileEdits?.c) changedFields.push("nearest city"); - - const hasLinks = - Array.isArray(pendingEdits?.l) && pendingEdits.l.length > 0; - if (hasLinks) changedFields.push("links"); - - if (hasField("address", "a")) changedFields.push("address"); - - if (changedFields.length === 0) { - return "Waiting for edits, if any."; - } - - const last = changedFields[changedFields.length - 1]; - const prefix = changedFields.slice(0, -1); - const list = - changedFields.length === 1 - ? last - : changedFields.length === 2 - ? `${prefix[0]} and ${last}` - : `${prefix.join(", ")}, and ${last}`; - - return `Contains requested changes to ${list}.`; - }, [pendingEdits]); - useEffect(() => { resetVerificationPolling(); - }, [pendingEdits, resetVerificationPolling]); + }, [resetVerificationPolling]); const { validAmount, error, verifyUri } = useMemo(() => { const cleaned = (amount ?? "").trim(); @@ -115,7 +71,7 @@ export default function ProfileVerification({ const uri = buildZcashUri( SIGNIN_ADDR, raw, - memo && memo !== "N/A" ? memo : "" + memo ); return { validAmount: validMin, @@ -166,11 +122,11 @@ export default function ProfileVerification({
- {/* Memo Editor */} + {/* Memo Display */}
- {explainerText} + Send to verify your address ownership