From 1c65447e171c5c9e8d8b69e2aa56d478968e0b83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:29:30 +0000 Subject: [PATCH 1/2] Initial plan From f9669f1a3df9bac70cc0c4d50a5e0d779dcfcba4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:47:47 +0000 Subject: [PATCH 2/2] feat: connect backend to deployed BNB testnet contracts Co-authored-by: OmLanke <92863779+OmLanke@users.noreply.github.com> --- backend/.env.example | 17 ++- backend/package-lock.json | 14 +-- backend/src/api/routes/pool.ts | 20 ++- backend/src/api/routes/relay.ts | 14 ++- backend/src/contracts/abis.ts | 106 ++++++++++++++++ backend/src/contracts/addresses.ts | 59 +++++++++ backend/src/index.ts | 5 + backend/src/indexer/depositIndexer.ts | 166 +++++++++++++++++++++++++ backend/src/relayer/paymasterClient.ts | 82 +++++++++--- backend/test/unit/contracts.test.ts | 91 ++++++++++++++ 10 files changed, 543 insertions(+), 31 deletions(-) create mode 100644 backend/src/contracts/abis.ts create mode 100644 backend/src/contracts/addresses.ts create mode 100644 backend/src/indexer/depositIndexer.ts create mode 100644 backend/test/unit/contracts.test.ts diff --git a/backend/.env.example b/backend/.env.example index 182e06c..92f93ad 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,20 +11,21 @@ REDIS_URL=redis://localhost:6379 # ─── BNB Chain RPC ──────────────────────────────────────────────────────────── BNB_RPC_URL=https://bsc-dataseed.binance.org/ BNB_TESTNET_RPC_URL=https://data-seed-prebsc-1-s1.binance.org:8545/ -CHAIN_ID=56 +CHAIN_ID=97 # ─── Relayer Wallet ─────────────────────────────────────────────────────────── # HOT wallet used only for submitting transactions — keep minimal BNB balance RELAYER_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE RELAYER_ADDRESS=0xYOUR_RELAYER_ADDRESS_HERE -# ─── Smart Contracts ────────────────────────────────────────────────────────── -PAYMASTER_CONTRACT_ADDRESS=0xYOUR_PAYMASTER_CONTRACT_ADDRESS -GHOST_POOL_ADDRESS=0xYOUR_GHOST_POOL_ADDRESS -ENTRYPOINT_ADDRESS=0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2781 +# ─── Smart Contracts (BNB Testnet — chainId 97) ─────────────────────────────── +# Deployed 2026-02-27 — see contracts/deployments/97/addresses.json +PAYMASTER_CONTRACT_ADDRESS=0x635f53515113C27f0ec0dE30aD030184487508b0 +GHOST_POOL_ADDRESS=0x154Fcb02A72E65a5c9Bc155E75CCFf16D0825bee +ENTRYPOINT_ADDRESS=0x0000000071727De22E5E9d8BAf0edAc6f37da032 # ─── Bundler (Pimlico / Alchemy) ────────────────────────────────────────────── -BUNDLER_URL=https://api.pimlico.io/v2/56/rpc +BUNDLER_URL=https://api.pimlico.io/v2/97/rpc PIMLICO_API_KEY=pim_YOUR_API_KEY_HERE # ─── ZK Circuit Artifacts ───────────────────────────────────────────────────── @@ -45,3 +46,7 @@ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 # ─── Logging ────────────────────────────────────────────────────────────────── LOG_LEVEL=info + +# ─── Indexer ───────────────────────────────────────────────────────────────── +# How often the deposit event indexer polls for new on-chain Deposit events (ms) +INDEXER_POLL_INTERVAL_MS=15000 diff --git a/backend/package-lock.json b/backend/package-lock.json index 7370264..12e75d5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1856,14 +1856,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1877,14 +1877,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -1896,7 +1896,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -5344,7 +5344,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -6118,7 +6118,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/backend/src/api/routes/pool.ts b/backend/src/api/routes/pool.ts index 5a6f1a3..5344afd 100644 --- a/backend/src/api/routes/pool.ts +++ b/backend/src/api/routes/pool.ts @@ -9,6 +9,8 @@ import { z } from 'zod'; import { depositRepo } from '../../db/repositories/depositRepo.js'; import { merkleTree } from '../../zk/merkleTree.js'; import { logger } from '../../utils/logger.js'; +import { isCommitmentInserted, getOnChainRoot, getNextLeafIndex } from '../../relayer/paymasterClient.js'; +import type { Hex } from 'viem'; const DepositSchema = z.object({ commitment: z.string().startsWith('0x').length(66), @@ -41,6 +43,16 @@ const poolRoutes: FastifyPluginAsync = async (fastify) => { }); } + // Verify the commitment exists on-chain before accepting it + const onChain = await isCommitmentInserted(commitment as Hex).catch(() => false); + if (!onChain) { + return reply.status(400).send({ + error: 'COMMITMENT_NOT_ON_CHAIN', + message: 'This commitment has not been inserted into the on-chain GhostPool yet.', + details: { commitment }, + }); + } + // Insert into Merkle tree const commitment256 = BigInt(commitment); const { root: newRoot, leafIndex } = await merkleTree.insert(commitment256); @@ -67,10 +79,12 @@ const poolRoutes: FastifyPluginAsync = async (fastify) => { // GET /v1/pool/status fastify.get('/pool/status', async (_request, reply) => { - const [totalDeposits, nextLeafIndex, root] = await Promise.all([ + const [totalDeposits, nextLeafIndex, root, onChainRoot, onChainLeafIndex] = await Promise.all([ depositRepo.countTotal(), Promise.resolve(merkleTree.getNextIndex()), Promise.resolve(merkleTree.getCurrentRoot()), + getOnChainRoot().catch(() => null), + getNextLeafIndex().catch(() => null), ]); const merkleRootHex = '0x' + root.toString(16).padStart(64, '0'); @@ -81,6 +95,10 @@ const poolRoutes: FastifyPluginAsync = async (fastify) => { merkleRoot: merkleRootHex, merkleTreeHeight: Number(process.env['MERKLE_TREE_HEIGHT'] ?? 20), anonymitySetSize: nextLeafIndex, + onChain: { + merkleRoot: onChainRoot ?? null, + nextLeafIndex: onChainLeafIndex ?? null, + }, }); }); diff --git a/backend/src/api/routes/relay.ts b/backend/src/api/routes/relay.ts index 68e3555..3923c6c 100644 --- a/backend/src/api/routes/relay.ts +++ b/backend/src/api/routes/relay.ts @@ -11,7 +11,7 @@ import { nullifierRepo } from '../../db/repositories/nullifierRepo.js'; import { operationRepo } from '../../db/repositories/operationRepo.js'; import { enqueueRelayJob } from '../../relayer/queue.js'; import { aspCheckBatch } from '../../compliance/asp.js'; -import { isKnownRoot, checkContractHealth } from '../../relayer/paymasterClient.js'; +import { isKnownRoot, isNullifierSpentOnChain } from '../../relayer/paymasterClient.js'; import { validatePaymasterAddress, validateChainId } from '../../relayer/userOpBuilder.js'; import { logger } from '../../utils/logger.js'; import type { Hex } from 'viem'; @@ -73,7 +73,7 @@ const relayRoutes: FastifyPluginAsync = async (fastify) => { const { merkleRoot, nullifierHash } = signals; - // ── Nullifier double-spend check ────────────────────────────────────── + // ── Nullifier double-spend check (local DB) ─────────────────────────── const alreadySpent = await nullifierRepo.isSpent(nullifierHash); if (alreadySpent) { return reply.status(400).send({ @@ -83,6 +83,16 @@ const relayRoutes: FastifyPluginAsync = async (fastify) => { }); } + // ── Nullifier double-spend check (on-chain) ─────────────────────────── + const spentOnChain = await isNullifierSpentOnChain(nullifierHash as Hex).catch(() => false); + if (spentOnChain) { + return reply.status(400).send({ + error: 'PROOF_ALREADY_SPENT_ON_CHAIN', + message: 'This nullifier has already been spent on-chain.', + details: { nullifierHash }, + }); + } + // ── On-chain Merkle root check ───────────────────────────────────────── const rootKnown = await isKnownRoot(merkleRoot as Hex).catch(() => false); if (!rootKnown) { diff --git a/backend/src/contracts/abis.ts b/backend/src/contracts/abis.ts new file mode 100644 index 0000000..538a55f --- /dev/null +++ b/backend/src/contracts/abis.ts @@ -0,0 +1,106 @@ +/** + * Minimal ABIs for Ghost Privacy Suite contracts. + * Only includes the functions and events the backend needs to call. + */ + +export const GHOST_POOL_ABI = [ + // ─── View functions ──────────────────────────────────────────────────── + { + name: 'getLastRoot', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'bytes32' }], + }, + { + name: 'isKnownRoot', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'root', type: 'bytes32' }], + outputs: [{ name: '', type: 'bool' }], + }, + { + name: 'isCommitmentInserted', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'commitment', type: 'bytes32' }], + outputs: [{ name: '', type: 'bool' }], + }, + { + name: 'nextLeafIndex', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint32' }], + }, + { + name: 'poolBalance', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'token', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + // ─── Events ──────────────────────────────────────────────────────────── + { + name: 'Deposit', + type: 'event', + inputs: [ + { name: 'commitment', type: 'bytes32', indexed: true }, + { name: 'leafIndex', type: 'uint32', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + { name: 'token', type: 'address', indexed: true }, + { name: 'timestamp', type: 'uint256', indexed: false }, + ], + }, + { + name: 'FeeDeducted', + type: 'event', + inputs: [ + { name: 'nullifierHash', type: 'bytes32', indexed: true }, + { name: 'amount', type: 'uint256', indexed: false }, + { name: 'token', type: 'address', indexed: false }, + ], + }, +] as const; + +export const GHOST_PAYMASTER_ABI = [ + // ─── View functions ──────────────────────────────────────────────────── + { + name: 'nullifiers', + type: 'function', + stateMutability: 'view', + inputs: [{ name: '', type: 'bytes32' }], + outputs: [{ name: '', type: 'bool' }], + }, + { + name: 'bnbToUsdcRate', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint256' }], + }, + { + name: 'zkVerificationEnabled', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'bool' }], + }, + // ─── Events ──────────────────────────────────────────────────────────── + { + name: 'NullifierSpent', + type: 'event', + inputs: [{ name: 'nullifierHash', type: 'bytes32', indexed: true }], + }, + { + name: 'GasSponsored', + type: 'event', + inputs: [ + { name: 'sender', type: 'address', indexed: true }, + { name: 'nullifierHash', type: 'bytes32', indexed: true }, + { name: 'gasCostWei', type: 'uint256', indexed: false }, + { name: 'feeUSDC', type: 'uint256', indexed: false }, + { name: 'feeToken', type: 'address', indexed: false }, + ], + }, +] as const; diff --git a/backend/src/contracts/addresses.ts b/backend/src/contracts/addresses.ts new file mode 100644 index 0000000..2cd8bb7 --- /dev/null +++ b/backend/src/contracts/addresses.ts @@ -0,0 +1,59 @@ +/** + * Contract address registry. + * + * Returns the deployed contract addresses for a given chainId. + * Falls back to environment-variable overrides so you can point at + * a local Hardhat node without touching the JSON file. + */ + +import { config } from '../config.js'; + +export interface ContractAddresses { + ghostPool: `0x${string}`; + ghostPaymaster: `0x${string}`; + entryPoint: `0x${string}`; + usdc: `0x${string}`; + usdt: `0x${string}`; +} + +// ─── BNB Testnet (chainId 97) ───────────────────────────────────────────────── + +const TESTNET_ADDRESSES: ContractAddresses = { + ghostPool: '0x154Fcb02A72E65a5c9Bc155E75CCFf16D0825bee', + ghostPaymaster: '0x635f53515113C27f0ec0dE30aD030184487508b0', + entryPoint: '0x0000000071727De22E5E9d8BAf0edAc6f37da032', + usdc: '0x6338e666BfA41e1fE638e8eF57CbCcA60D452872', + usdt: '0xab6a28bDEAB06d0902F5465097821F5A5BCd896d', +}; + +// ─── BNB Mainnet (chainId 56) ───────────────────────────────────────────────── + +const MAINNET_ADDRESSES: ContractAddresses = { + // Populated when contracts are deployed to mainnet + ghostPool: '0x0000000000000000000000000000000000000000', + ghostPaymaster: '0x0000000000000000000000000000000000000000', + entryPoint: '0x0000000071727De22E5E9d8BAf0edAc6f37da032', + usdc: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // BSC USDC + usdt: '0x55d398326f99059fF775485246999027B3197955', // BSC-Peg USDT +}; + +const DEPLOYMENT_MAP: Record = { + 97: TESTNET_ADDRESSES, + 56: MAINNET_ADDRESSES, +}; + +/** + * Get contract addresses for the configured chain. + * Environment variables always take precedence so local/testnet overrides work. + */ +export function getContractAddresses(): ContractAddresses { + const base = DEPLOYMENT_MAP[config.CHAIN_ID] ?? TESTNET_ADDRESSES; + + return { + ghostPool: (config.GHOST_POOL_ADDRESS as `0x${string}`) || base.ghostPool, + ghostPaymaster: (config.PAYMASTER_CONTRACT_ADDRESS as `0x${string}`) || base.ghostPaymaster, + entryPoint: (config.ENTRYPOINT_ADDRESS as `0x${string}`) || base.entryPoint, + usdc: base.usdc, + usdt: base.usdt, + }; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 8633f99..3f7dd0f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import { connectDatabase, disconnectDatabase } from './db/prisma/client.js'; import { merkleTree } from './zk/merkleTree.js'; import { startOfacSyncJob, stopOfacSyncJob } from './compliance/ofac.js'; import { createRelayWorker, redis } from './relayer/queue.js'; +import { startDepositIndexer, stopDepositIndexer } from './indexer/depositIndexer.js'; import { checkBundlerHealth } from './relayer/bundlerClient.js'; import { checkContractHealth } from './relayer/paymasterClient.js'; import { checkQueueHealth } from './relayer/queue.js'; @@ -93,6 +94,9 @@ async function main() { // Start OFAC sync job startOfacSyncJob(); + // Start on-chain deposit event indexer + startDepositIndexer(); + // Start BullMQ worker (in-process for simplicity; can be separated) const worker = createRelayWorker(); @@ -113,6 +117,7 @@ async function main() { await server.close(); await worker.close(); stopOfacSyncJob(); + stopDepositIndexer(); await disconnectDatabase(); await redis.quit(); logger.info('Shutdown complete'); diff --git a/backend/src/indexer/depositIndexer.ts b/backend/src/indexer/depositIndexer.ts new file mode 100644 index 0000000..b975b5c --- /dev/null +++ b/backend/src/indexer/depositIndexer.ts @@ -0,0 +1,166 @@ +/** + * Deposit Event Indexer + * + * Polls on-chain GhostPool `Deposit` events and mirrors them into the + * backend database and the off-chain Merkle tree. + * + * This means users do NOT have to call POST /v1/pool/deposit manually — + * the indexer picks up the on-chain event automatically within one poll cycle. + * + * Poll interval: configurable via INDEXER_POLL_INTERVAL_MS (default 15 s). + */ + +import { parseAbiItem } from 'viem'; +import { publicClient } from '../relayer/paymasterClient.js'; +import { getContractAddresses } from '../contracts/addresses.js'; +import { depositRepo } from '../db/repositories/depositRepo.js'; +import { merkleTree } from '../zk/merkleTree.js'; +import { logger } from '../utils/logger.js'; + +// ─── ABI item ──────────────────────────────────────────────────────────────── + +const DEPOSIT_EVENT = parseAbiItem( + 'event Deposit(bytes32 indexed commitment, uint32 indexed leafIndex, uint256 amount, address indexed token, uint256 timestamp)', +); + +// ─── State ──────────────────────────────────────────────────────────────────── + +const POLL_INTERVAL_MS = Number(process.env['INDEXER_POLL_INTERVAL_MS'] ?? 15_000); + +/** How many blocks to look back on the first run (≈ 24 hours on BSC @ 3s/block). */ +const INITIAL_LOOKBACK_BLOCKS = 28_800n; + +let _lastProcessedBlock: bigint | null = null; +let _pollTimer: ReturnType | null = null; +let _running = false; + +// ─── Core logic ─────────────────────────────────────────────────────────────── + +/** + * Process a single on-chain Deposit log. + * Inserts the commitment into the off-chain Merkle tree and DB if not seen before. + */ +async function processDepositLog(log: { + args: { commitment?: `0x${string}`; leafIndex?: number; amount?: bigint; token?: `0x${string}`; timestamp?: bigint }; + transactionHash: `0x${string}` | null; + blockNumber: bigint | null; +}): Promise { + const { commitment, amount, token } = log.args; + if (!commitment || !amount || !token) return; + + const txHash = log.transactionHash ?? '0x0000000000000000000000000000000000000000000000000000000000000000'; + + // Check if already indexed + const existing = await depositRepo.findByCommitment(commitment); + if (existing) return; + + // Insert into off-chain Merkle tree + const commitment256 = BigInt(commitment); + const { root: newRoot, leafIndex } = await merkleTree.insert(commitment256); + const merkleRootHex = '0x' + newRoot.toString(16).padStart(64, '0'); + + // Persist deposit record + await depositRepo.create({ + commitment, + leafIndex, + merkleRoot: merkleRootHex, + amount: amount.toString(), + token, + txHash, + blockNumber: log.blockNumber ?? undefined, + }); + + logger.info( + { commitment, leafIndex, merkleRoot: merkleRootHex, txHash }, + 'Deposit indexed from on-chain event', + ); +} + +/** + * Fetch Deposit events from fromBlock to toBlock and process them. + */ +async function fetchAndProcessDeposits(fromBlock: bigint, toBlock: bigint): Promise { + const { ghostPool } = getContractAddresses(); + + const logs = await publicClient.getLogs({ + address: ghostPool, + event: DEPOSIT_EVENT, + fromBlock, + toBlock, + }); + + if (logs.length > 0) { + logger.debug( + { count: logs.length, fromBlock: fromBlock.toString(), toBlock: toBlock.toString() }, + 'Processing Deposit events', + ); + } + + for (const log of logs) { + try { + await processDepositLog(log as Parameters[0]); + } catch (err) { + logger.error({ err, txHash: log.transactionHash }, 'Failed to process Deposit event'); + } + } +} + +/** + * Single poll tick — fetch events since the last processed block. + */ +async function poll(): Promise { + try { + const latestBlock = await publicClient.getBlockNumber(); + + if (_lastProcessedBlock === null) { + // First run: look back a fixed number of blocks to catch recent deposits. + // If the chain is shorter than INITIAL_LOOKBACK_BLOCKS (e.g., a fresh local node), + // start from block 0 (genesis) to ensure no events are missed. + const fromBlock = latestBlock > INITIAL_LOOKBACK_BLOCKS + ? latestBlock - INITIAL_LOOKBACK_BLOCKS + : 0n; + await fetchAndProcessDeposits(fromBlock, latestBlock); + } else if (latestBlock > _lastProcessedBlock) { + await fetchAndProcessDeposits(_lastProcessedBlock + 1n, latestBlock); + } + + _lastProcessedBlock = latestBlock; + } catch (err) { + logger.warn({ err }, 'Deposit indexer poll failed — will retry'); + } +} + +// ─── Lifecycle ──────────────────────────────────────────────────────────────── + +/** + * Start the deposit indexer. + * Safe to call multiple times — only one instance runs at a time. + */ +export function startDepositIndexer(): void { + if (_running) return; + _running = true; + + logger.info({ pollIntervalMs: POLL_INTERVAL_MS }, 'Deposit indexer started'); + + const tick = async () => { + await poll(); + if (_running) { + _pollTimer = setTimeout(tick, POLL_INTERVAL_MS); + } + }; + + // Run immediately then schedule + tick().catch((err) => logger.error({ err }, 'Deposit indexer tick error')); +} + +/** + * Stop the deposit indexer. + */ +export function stopDepositIndexer(): void { + _running = false; + if (_pollTimer !== null) { + clearTimeout(_pollTimer); + _pollTimer = null; + } + logger.info('Deposit indexer stopped'); +} diff --git a/backend/src/relayer/paymasterClient.ts b/backend/src/relayer/paymasterClient.ts index 34c1152..16b0552 100644 --- a/backend/src/relayer/paymasterClient.ts +++ b/backend/src/relayer/paymasterClient.ts @@ -2,35 +2,32 @@ * GhostPaymaster On-Chain Client * * Handles reads from the GhostPaymaster and GhostPool contracts. + * Shared viem public client used by all contract reads. */ -import { createPublicClient, http, parseAbi, type Hex } from 'viem'; +import { createPublicClient, http, type Hex } from 'viem'; import { bsc, bscTestnet } from 'viem/chains'; import { config } from '../config.js'; import { logger } from '../utils/logger.js'; +import { GHOST_POOL_ABI, GHOST_PAYMASTER_ABI } from '../contracts/abis.js'; +import { getContractAddresses } from '../contracts/addresses.js'; const chain = config.CHAIN_ID === 56 ? bsc : bscTestnet; -const publicClient = createPublicClient({ +export const publicClient = createPublicClient({ chain, transport: http(config.BNB_RPC_URL), }); -// Minimal ABIs — only the functions we need -const GHOST_POOL_ABI = parseAbi([ - 'function getLastRoot() external view returns (bytes32)', - 'function isKnownRoot(bytes32 root) external view returns (bool)', - 'function isCommitmentInserted(bytes32 commitment) external view returns (bool)', - 'function nextLeafIndex() external view returns (uint32)', - 'function poolBalance(address token) external view returns (uint256)', -]); +// ─── GhostPool reads ────────────────────────────────────────────────────────── /** * Fetch the current Merkle root from the on-chain GhostPool. */ export async function getOnChainRoot(): Promise { + const { ghostPool } = getContractAddresses(); const root = await publicClient.readContract({ - address: config.GHOST_POOL_ADDRESS as Hex, + address: ghostPool, abi: GHOST_POOL_ABI, functionName: 'getLastRoot', }); @@ -41,8 +38,9 @@ export async function getOnChainRoot(): Promise { * Check whether a root is known to the on-chain pool (within ROOT_HISTORY_SIZE). */ export async function isKnownRoot(root: Hex): Promise { + const { ghostPool } = getContractAddresses(); const result = await publicClient.readContract({ - address: config.GHOST_POOL_ADDRESS as Hex, + address: ghostPool, abi: GHOST_POOL_ABI, functionName: 'isKnownRoot', args: [root], @@ -54,8 +52,9 @@ export async function isKnownRoot(root: Hex): Promise { * Check whether a commitment has been inserted on-chain. */ export async function isCommitmentInserted(commitment: Hex): Promise { + const { ghostPool } = getContractAddresses(); const result = await publicClient.readContract({ - address: config.GHOST_POOL_ADDRESS as Hex, + address: ghostPool, abi: GHOST_POOL_ABI, functionName: 'isCommitmentInserted', args: [commitment], @@ -67,8 +66,9 @@ export async function isCommitmentInserted(commitment: Hex): Promise { * Get the next leaf index from the on-chain pool. */ export async function getNextLeafIndex(): Promise { + const { ghostPool } = getContractAddresses(); const idx = await publicClient.readContract({ - address: config.GHOST_POOL_ADDRESS as Hex, + address: ghostPool, abi: GHOST_POOL_ABI, functionName: 'nextLeafIndex', }); @@ -79,8 +79,9 @@ export async function getNextLeafIndex(): Promise { * Get the pool balance for a given token. */ export async function getPoolBalance(tokenAddress: string): Promise { + const { ghostPool } = getContractAddresses(); const balance = await publicClient.readContract({ - address: config.GHOST_POOL_ADDRESS as Hex, + address: ghostPool, abi: GHOST_POOL_ABI, functionName: 'poolBalance', args: [tokenAddress as Hex], @@ -88,6 +89,57 @@ export async function getPoolBalance(tokenAddress: string): Promise { return balance as bigint; } +// ─── GhostPaymaster reads ───────────────────────────────────────────────────── + +/** + * Check whether a nullifier hash has been spent on-chain. + * This is the authoritative check — the on-chain mapping is updated by the Paymaster. + */ +export async function isNullifierSpentOnChain(nullifierHash: Hex): Promise { + const { ghostPaymaster } = getContractAddresses(); + try { + const spent = await publicClient.readContract({ + address: ghostPaymaster, + abi: GHOST_PAYMASTER_ABI, + functionName: 'nullifiers', + args: [nullifierHash], + }); + return spent as boolean; + } catch (err) { + logger.warn({ err, nullifierHash }, 'Failed to check on-chain nullifier — defaulting to not-spent'); + return false; + } +} + +/** + * Get the current BNB → USDC conversion rate from the GhostPaymaster. + * Rate is stored scaled by 1e18 (e.g., BNB=$600, USDC 6dp → rate=600_000_000). + */ +export async function getBnbToUsdcRate(): Promise { + const { ghostPaymaster } = getContractAddresses(); + const rate = await publicClient.readContract({ + address: ghostPaymaster, + abi: GHOST_PAYMASTER_ABI, + functionName: 'bnbToUsdcRate', + }); + return rate as bigint; +} + +/** + * Check whether ZK verification is enabled on the GhostPaymaster. + */ +export async function isZkVerificationEnabled(): Promise { + const { ghostPaymaster } = getContractAddresses(); + const enabled = await publicClient.readContract({ + address: ghostPaymaster, + abi: GHOST_PAYMASTER_ABI, + functionName: 'zkVerificationEnabled', + }); + return enabled as boolean; +} + +// ─── Health ─────────────────────────────────────────────────────────────────── + /** * Check if the contracts are reachable. */ diff --git a/backend/test/unit/contracts.test.ts b/backend/test/unit/contracts.test.ts new file mode 100644 index 0000000..f4b1957 --- /dev/null +++ b/backend/test/unit/contracts.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { getContractAddresses } from '../../src/contracts/addresses.js'; +import { GHOST_POOL_ABI, GHOST_PAYMASTER_ABI } from '../../src/contracts/abis.js'; + +// ─── Contract ABIs ──────────────────────────────────────────────────────────── + +describe('GHOST_POOL_ABI', () => { + it('contains getLastRoot view function', () => { + const fn = GHOST_POOL_ABI.find((e) => e.type === 'function' && e.name === 'getLastRoot'); + expect(fn).toBeDefined(); + expect((fn as { stateMutability: string }).stateMutability).toBe('view'); + }); + + it('contains isKnownRoot with bytes32 input', () => { + const fn = GHOST_POOL_ABI.find((e) => e.type === 'function' && e.name === 'isKnownRoot'); + expect(fn).toBeDefined(); + expect((fn as { inputs: { type: string }[] }).inputs[0]?.type).toBe('bytes32'); + }); + + it('contains isCommitmentInserted with bytes32 input', () => { + const fn = GHOST_POOL_ABI.find((e) => e.type === 'function' && e.name === 'isCommitmentInserted'); + expect(fn).toBeDefined(); + expect((fn as { inputs: { type: string }[] }).inputs[0]?.type).toBe('bytes32'); + }); + + it('contains nextLeafIndex view function', () => { + const fn = GHOST_POOL_ABI.find((e) => e.type === 'function' && e.name === 'nextLeafIndex'); + expect(fn).toBeDefined(); + }); + + it('contains poolBalance with address input', () => { + const fn = GHOST_POOL_ABI.find((e) => e.type === 'function' && e.name === 'poolBalance'); + expect(fn).toBeDefined(); + expect((fn as { inputs: { type: string }[] }).inputs[0]?.type).toBe('address'); + }); + + it('contains Deposit event with commitment, leafIndex, amount, token, timestamp', () => { + const ev = GHOST_POOL_ABI.find((e) => e.type === 'event' && e.name === 'Deposit'); + expect(ev).toBeDefined(); + const inputs = (ev as { inputs: { name: string }[] }).inputs; + const names = inputs.map((i) => i.name); + expect(names).toContain('commitment'); + expect(names).toContain('leafIndex'); + expect(names).toContain('amount'); + expect(names).toContain('token'); + expect(names).toContain('timestamp'); + }); +}); + +describe('GHOST_PAYMASTER_ABI', () => { + it('contains nullifiers view function', () => { + const fn = GHOST_PAYMASTER_ABI.find((e) => e.type === 'function' && e.name === 'nullifiers'); + expect(fn).toBeDefined(); + expect((fn as { inputs: { type: string }[] }).inputs[0]?.type).toBe('bytes32'); + }); + + it('contains bnbToUsdcRate view function', () => { + const fn = GHOST_PAYMASTER_ABI.find((e) => e.type === 'function' && e.name === 'bnbToUsdcRate'); + expect(fn).toBeDefined(); + }); + + it('contains zkVerificationEnabled view function', () => { + const fn = GHOST_PAYMASTER_ABI.find((e) => e.type === 'function' && e.name === 'zkVerificationEnabled'); + expect(fn).toBeDefined(); + }); + + it('contains NullifierSpent event', () => { + const ev = GHOST_PAYMASTER_ABI.find((e) => e.type === 'event' && e.name === 'NullifierSpent'); + expect(ev).toBeDefined(); + }); +}); + +// ─── Contract Addresses ─────────────────────────────────────────────────────── + +describe('getContractAddresses', () => { + it('returns addresses with 0x prefix', () => { + const addrs = getContractAddresses(); + expect(addrs.ghostPool).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(addrs.ghostPaymaster).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(addrs.entryPoint).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(addrs.usdc).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(addrs.usdt).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('returns the BNB Testnet GhostPool address for chainId 97', () => { + // The env is set to chainId 97 in tests via process.env overrides in config + const addrs = getContractAddresses(); + // Either the env var or the hardcoded testnet address should be returned + expect(addrs.ghostPool.toLowerCase()).toMatch(/^0x[0-9a-f]{40}$/); + }); +});