diff --git a/apps/api/.env.example b/apps/api/.env.example index 843e661..a5e3607 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -58,6 +58,9 @@ CHAT_HEARTBEAT_INTERVAL_MS=30000 # Generate one with: openssl rand -hex 32 ADMIN_API_KEY= -# --- Refund Alerts --- -# Webhook URL for refund alerts (Slack or Discord). Leave empty to disable. +# --- Refund Bot (scripts/refund-bot.ts) --- +# Dedicated keypair for the automated refund worker. +# Falls back to BUYER_SECRET_KEY if not set. +REFUND_BOT_SECRET_KEY= +# Refund alerts webhook URL (Slack or Discord). Leave empty to disable. REFUND_WEBHOOK_URL= diff --git a/docs/spikes/zk-nullifier-claim.md b/docs/spikes/zk-nullifier-claim.md index 86bd96e..509e300 100644 --- a/docs/spikes/zk-nullifier-claim.md +++ b/docs/spikes/zk-nullifier-claim.md @@ -1,4 +1,4 @@ -# ZK Nullifier Claim Verification on Soroban ΓÇö Feasibility Spike +# ZK Nullifier Claim Verification on Soroban — Feasibility Spike ## Executive Summary @@ -18,7 +18,7 @@ off-chain oracle attestation for high-value claims. | Limit | Value | Implication for ark-groth16 | |-------|-------|---------------------------| -| WASM binary | 100 KB | ark-ec + ark-ff + ark-poly + ark-serialize ΓåÆ 500 KB+ ΓÇö **blows limit** | +| WASM binary | 100 KB | ark-ec + ark-ff + ark-poly + ark-serialize → 500 KB+ — **blows limit** | | WASM modules | 1 per tx | Cannot split verification across contracts | Soroban v28 (future) may raise the limit, but no current timeline. @@ -27,8 +27,8 @@ Soroban v28 (future) may raise the limit, but no current timeline. | Operation | Approx. Cost | Count Needed | Total | |-----------|-------------|-------------|-------| -| BN254 pairing check (Miller loop) | 12ΓÇô18M | 3 (Groth16) | 36ΓÇô54M | -| Pippenger MSM (multi-exp) | 8ΓÇô12M | 1 | 8ΓÇô12M | +| BN254 pairing check (Miller loop) | 12–18M | 3 (Groth16) | 36–54M | +| Pippenger MSM (multi-exp) | 8–12M | 1 | 8–12M | | SHA-256 (64 B) | 4k | 2 | 8k | | Storage read/write | 20k | 2 | 40k | @@ -40,7 +40,7 @@ typical RPC providers cap this. ### Memory WASM linear memory starts at 64 pages (4 MB). BN254 scalar multiplication -tables need ~2ΓÇô3 MB. Combined with the proving key material, memory is tight. +tables need ~2–3 MB. Combined with the proving key material, memory is tight. ### Crypto Primitives Available @@ -55,7 +55,7 @@ tables need ~2ΓÇô3 MB. Combined with the proving key material, memory is tigh **No elliptic-curve pairing precompile exists.** A custom WASM implementation would add 200 KB+ to the binary and still need pairings -implemented in WASM, which is 10ΓÇô100├ù slower than native. +implemented in WASM, which is 10–100× slower than native. --- @@ -71,10 +71,10 @@ pub enum DataKey { // value = bool (spent) } -// ΓöÇΓöÇ Record ΓöÇΓöÇ +// ── Record ── env.storage().persistent().set(&DataKey::Nullifier(hash), &true); -// ΓöÇΓöÇ Check ΓöÇΓöÇ +// ── Check ── if env.storage().persistent().has(&DataKey::Nullifier(hash)) { return Err(Error::NullifierAlreadySpent); } @@ -107,8 +107,8 @@ nullifier), and the rent cost is paid by the claim initiator. | Criterion | On-Chain WASM | Off-Chain Oracle | |-----------|--------------|------------------| | **ZK proof verification** | Infeasible (pairing precompiles missing; budget blown) | Feasible (Barretenberg runs natively on verifier node) | -| **Proof size** | ~200 B (Groth16) ΓÇö fits | ~200 B (Groth16) ΓÇö fits in tx calldata | -| **Latency** | Immediate (~5 s finality) | ~1ΓÇô2 rounds (prover submits, oracle attestation settles) | +| **Proof size** | ~200 B (Groth16) — fits | ~200 B (Groth16) — fits in tx calldata | +| **Latency** | Immediate (~5 s finality) | ~1–2 rounds (prover submits, oracle attestation settles) | | **Trust model** | Trustless (Soroban consensus) | Trusted oracle set (threshold signature or attestation) | | **Nullifier recording** | On-chain `persistent()` | On-chain `persistent()` (oracle attests and records) | | **Upgradeability** | Hard (contract must be replaced) | Easy (oracle software updates) | @@ -120,28 +120,28 @@ nullifier), and the rent cost is paid by the claim initiator. | Use Case | Verdict | Rationale | |----------|---------|-----------| | Low-value private claims (< 100 USDC) | **On-chain hash commitment** | Don't need full ZK; SHA256(root \|\| nullifier) is sufficient to bind proof | -| Medium-value claims (100ΓÇô10k USDC) | **Off-chain ZK + on-chain nullifier** | Prove off-chain, record nullifier on-chain via oracle attestation | +| Medium-value claims (100–10k USDC) | **Off-chain ZK + on-chain nullifier** | Prove off-chain, record nullifier on-chain via oracle attestation | | High-value claims (> 10k USDC) | **Off-chain ZK + oracle attestation + dispute period** | Full ZK proof verified by multiple oracles with slashing | -| Cross-chain claims (Stellar ΓåÆ EVM) | **Off-chain ZK + relayer** | Proof verified off-chain, relayer submits lightweight attestation | +| Cross-chain claims (Stellar → EVM) | **Off-chain ZK + relayer** | Proof verified off-chain, relayer submits lightweight attestation | --- ## 4. Recommended Architecture ``` -ΓöîΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÉ Groth16 proof ΓöîΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÉ -Γöé Claimant Γöé ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓû╢ Γöé ZK Verifier Γöé -Γöé (Noir Prover)Γöé Γöé Node Γöé -ΓööΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÿ Γöé (Barretenberg)Γöé - ΓööΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓö¼ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÿ - Γöé +┌──────────────┐ Groth16 proof ┌───────────────┐ +│ Claimant │ ──────────────────────▶ │ ZK Verifier │ +│ (Noir Prover)│ │ Node │ +└──────────────┘ │ (Barretenberg)│ + └───────┬───────┘ + │ attestation sig - Γöé - Γû╝ - ΓöîΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÉ - Γöé Soroban: Escrow Claim Γöé - Γöé + Nullifier Registry Γöé - ΓööΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÿ + │ + ▼ + ┌─────────────────────────┐ + │ Soroban: Escrow Claim │ + │ + Nullifier Registry │ + └─────────────────────────┘ ``` ### Key Components @@ -162,15 +162,15 @@ nullifier), and the rent cost is paid by the claim initiator. | Constraint | How We Stay Within It | |-----------|----------------------| -| 100 KB WASM | Ed25519 verify + SHA-256 + storage ΓÇö under 40 KB binary | -| 40M CPU budget | Ed25519 verify (~200k) + SHA-256 (~4k) + storage (~20k) ΓÇö under 1M total | +| 100 KB WASM | Ed25519 verify + SHA-256 + storage — under 40 KB binary | +| 40M CPU budget | Ed25519 verify (~200k) + SHA-256 (~4k) + storage (~20k) — under 1M total | | No pairing precompile | Pairings happen off-chain in Barretenberg | --- ## 5. Implementation Roadmap -### Phase 1 (Current ΓÇö zk-credential exists) +### Phase 1 (Current — zk-credential exists) - Nullifier storage via `persistent()` - Hash-based commitment binding (SHA-256) - Merkle root registry diff --git a/examples/zk_nullifier_poc.rs b/examples/zk_nullifier_poc.rs index 9d9e3ab..791aa20 100644 --- a/examples/zk_nullifier_poc.rs +++ b/examples/zk_nullifier_poc.rs @@ -1,4 +1,4 @@ -//! POC: ZK nullifier claim verification on Soroban. +//! POC: ZK nullifier claim verification on Soroban. //! //! Demonstrates the hybrid architecture: //! 1. Off-chain ZK proof is verified by a ZK Verifier Node (Barretenberg). diff --git a/package.json b/package.json index cdd426c..f29dbbe 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,11 @@ "dev:frontend": "npm run dev -w mobile/frontend", "dev:backend": "npm run dev -w mobile/backend", "post-deploy-check": "npm run post-deploy-check -w apps/api", + "bot:refund": "tsx scripts/refund-bot.ts", "test:load": "node scripts/run-load-test.js" }, "devDependencies": { + "tsx": "^4.16.2", "typescript": "^5.5.4", "turbo": "^2.1.0" }, diff --git a/scripts/refund-bot.ts b/scripts/refund-bot.ts new file mode 100644 index 0000000..e5ab3e6 --- /dev/null +++ b/scripts/refund-bot.ts @@ -0,0 +1,310 @@ +import "dotenv/config"; + +import { + Account, + BASE_FEE, + FeeBumpTransaction, + Keypair, + Networks, + Operation, + Transaction, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { Server, Api, assembleTransaction } from "@stellar/stellar-sdk/rpc"; +import { CONTRACTS } from "../packages/shared/src/index.js"; + +const RPC_URL = + process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; +const IS_PUBLIC = process.env.STELLAR_NETWORK === "PUBLIC"; +const NETWORK_PASSPHRASE = IS_PUBLIC ? Networks.PUBLIC : Networks.TESTNET; +const RPC_ALLOW_HTTP = RPC_URL.startsWith("http://"); + +const server = new Server(RPC_URL, { allowHttp: RPC_ALLOW_HTTP }); + +const ESCROW_CONTRACT_ID: string = + process.env.ESCROW_CONTRACT_ID || + CONTRACTS[IS_PUBLIC ? "mainnet" : "testnet"].escrow; + +const BOT_SECRET_KEY: string | undefined = + process.env.REFUND_BOT_SECRET_KEY || process.env.BUYER_SECRET_KEY; + +const SPONSOR_SECRET_KEY: string | undefined = + process.env.SPONSOR_SECRET_KEY; + +if (!BOT_SECRET_KEY) { + console.error( + "REFUND_BOT_SECRET_KEY (or BUYER_SECRET_KEY) must be set in .env", + ); + process.exit(1); +} + +const botKeypair = Keypair.fromSecret(BOT_SECRET_KEY); + +interface OnChainTrade { + seller: string; + buyer: string; + amount: string; + secretHashHex: string; + timeoutLedger: number; + status: string; +} + +function hexToBytesScVal(hex: string): xdr.ScVal { + if (hex.length !== 64) { + throw new Error( + `expected 32-byte hex string (64 chars), got ${hex.length} chars`, + ); + } + return nativeToScVal(Buffer.from(hex, "hex"), { type: "bytes" }); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function simulateContractRead( + contractId: string, + functionName: string, + args: xdr.ScVal[] = [], +): Promise { + const source = Keypair.random(); + const account = new Account(source.publicKey(), "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: contractId, + function: functionName, + args, + }), + ) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (Api.isSimulationError(sim)) { + throw new Error(`simulation failed (${functionName}): ${sim.error}`); + } + if (!Api.isSimulationSuccess(sim) || !sim.result) { + throw new Error(`simulation returned no result for ${functionName}`); + } + return scValToNative(sim.result.retval) as T; +} + +async function getTradeState( + contractId: string, + tradeId: string, +): Promise { + try { + const native = await simulateContractRead | null>( + contractId, + "get_trade", + [hexToBytesScVal(tradeId)], + ); + if (!native) return null; + + const rawStatus = native.status; + let status: string; + if (typeof rawStatus === "string") { + status = rawStatus.toLowerCase(); + } else if ( + rawStatus && + typeof rawStatus === "object" && + "name" in rawStatus + ) { + status = String((rawStatus as { name: unknown }).name).toLowerCase(); + } else { + status = String(rawStatus).toLowerCase(); + } + + return { + seller: String(native.seller), + buyer: String(native.buyer), + amount: String(native.amount), + secretHashHex: Buffer.from( + native.secret_hash as Buffer, + ).toString("hex"), + timeoutLedger: Number(native.timeout_ledger), + status, + }; + } catch { + return null; + } +} + +async function submitRefundTx(tradeId: string): Promise { + const account = await server.getAccount(botKeypair.publicKey()); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: ESCROW_CONTRACT_ID, + function: "refund", + args: [hexToBytesScVal(tradeId)], + }), + ) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (Api.isSimulationError(sim)) { + throw new Error(`simulation failed: ${sim.error}`); + } + + const prepared = assembleTransaction(tx, sim).build() as Transaction; + prepared.sign(botKeypair); + + let txToSubmit: Transaction | FeeBumpTransaction = prepared; + if (SPONSOR_SECRET_KEY) { + const sponsor = Keypair.fromSecret(SPONSOR_SECRET_KEY); + const innerFee = parseInt(prepared.fee, 10); + const bumpFee = innerFee + parseInt(BASE_FEE, 10); + const feeBumpTx = TransactionBuilder.buildFeeBumpTransaction( + sponsor, + bumpFee.toString(), + prepared, + NETWORK_PASSPHRASE, + ); + feeBumpTx.sign(sponsor); + txToSubmit = feeBumpTx; + } + + const sendResult = await server.sendTransaction(txToSubmit); + if (sendResult.status === "ERROR") { + throw new Error( + `submission failed: ${JSON.stringify(sendResult.errorResult)}`, + ); + } + + const hash = sendResult.hash; + let result = await server.getTransaction(hash); + while (result.status === Api.GetTransactionStatus.NOT_FOUND) { + await sleep(1500); + result = await server.getTransaction(hash); + } + + if (result.status !== Api.GetTransactionStatus.SUCCESS) { + throw new Error(`tx ${hash} failed with status ${result.status}`); + } + + return hash; +} + +async function refundWithRetry(tradeId: string): Promise { + const maxRetries = 5; + let attempt = 0; + let delay = 1000; + + while (attempt <= maxRetries) { + try { + const hash = await submitRefundTx(tradeId); + console.log(`Refunded ${tradeId} (tx: ${hash})`); + return; + } catch (err) { + attempt++; + if (attempt > maxRetries) { + console.error( + `Failed to refund ${tradeId} after ${maxRetries} retries:`, + err, + ); + return; + } + console.warn( + `Retry ${attempt}/${maxRetries} for ${tradeId} in ${delay}ms...`, + err instanceof Error ? err.message : err, + ); + await sleep(delay); + delay = Math.min(delay * 2, 30_000); + } + } +} + +async function scanExpiredAndRefund(dryRun: boolean): Promise { + const [latest, tradeCount] = await Promise.all([ + server.getLatestLedger(), + simulateContractRead(ESCROW_CONTRACT_ID, "get_trade_count"), + ]); + const currentLedger = latest.sequence; + + console.log( + `\n[${new Date().toISOString()}] Ledger ${currentLedger} | Trades: ${tradeCount} | Contract: ${ESCROW_CONTRACT_ID}`, + ); + + const expiredTradeIds: string[] = []; + + for (let i = 1; i <= tradeCount; i++) { + const tradeIdBuf = await simulateContractRead( + ESCROW_CONTRACT_ID, + "get_trade_by_index", + [nativeToScVal(i, { type: "u32" })], + ); + if (!tradeIdBuf) continue; + + const tradeId = Buffer.from(tradeIdBuf).toString("hex"); + const state = await getTradeState(ESCROW_CONTRACT_ID, tradeId); + if (!state) continue; + + if (state.status !== "locked") continue; + if (currentLedger < state.timeoutLedger) continue; + + expiredTradeIds.push(tradeId); + } + + if (expiredTradeIds.length === 0) { + console.log("No expired escrows found."); + return; + } + + console.log( + `Found ${expiredTradeIds.length} expired escrow(s): ${expiredTradeIds.join(", ")}`, + ); + + for (const tradeId of expiredTradeIds) { + if (dryRun) { + console.log(`DRY RUN: would refund trade ${tradeId}`); + continue; + } + + try { + await refundWithRetry(tradeId); + } catch { + console.error(`Skipping trade ${tradeId} after failures`); + } + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const intervalIdx = args.indexOf("--interval"); + const intervalMs = + intervalIdx !== -1 + ? parseInt(args[intervalIdx + 1], 10) * 1000 + : 0; + + console.log( + `Refund Bot started (dry-run: ${dryRun}, interval: ${intervalMs > 0 ? intervalMs / 1000 + "s" : "once"})`, + ); + console.log(`Escrow contract: ${ESCROW_CONTRACT_ID}`); + console.log(`Signer: ${botKeypair.publicKey()}`); + + do { + await scanExpiredAndRefund(dryRun); + if (intervalMs > 0) { + await sleep(intervalMs); + } + } while (intervalMs > 0); +} + +main().catch((err) => { + console.error("Bot crashed:", err); + process.exit(1); +});