import { createWalletClient, http, keccak256, toHex } from 'viem'; import { baseSepolia, base } from 'viem/chains'; import { privateKeyToAccount } from 'viem/accounts'; import { writeFileSync, readFileSync, existsSync } from 'node:fs'; // ── Config ───────────────────────────────────────────── const AUDIT_CONTRACT = process.env.SPENDOS_AUDIT_CONTRACT ?? '0x37a66d3404aDCaf0a558189a338bCeaD3a06A5Ee'; const DEPLOYER_KEY = process.env.DEPLOYER_PRIVATE_KEY ?? ''; const CHAIN = process.env.SPENDOS_CHAIN === 'mainnet' ? base : baseSepolia; const RPC_URL = process.env.SPENDOS_RPC_URL ?? (process.env.SPENDOS_CHAIN === 'mainnet' ? 'https://mainnet.base.org' : 'https://sepolia.base.org'); const AUDIT_FILE = './audit.json'; const LOG_ABI = [{ name: 'log', type: 'function', inputs: [ { name: 'delegationHash', type: 'bytes32' }, { name: 'action', type: 'string' }, { name: 'details', type: 'string' }, { name: 'amount', type: 'uint256' }, ], outputs: [], stateMutability: 'nonpayable', }] as const; // ── Types ────────────────────────────────────────────── interface AuditEntry { timestamp: string; action: string; delegationId: string; details: string; amount: number; txHash?: string; } // ── In-Memory + File Store ───────────────────────────── const entries: AuditEntry[] = []; function persist(): void { writeFileSync(AUDIT_FILE, JSON.stringify(entries, null, 2)); } // Load existing entries on startup if (existsSync(AUDIT_FILE)) { try { const content = readFileSync(AUDIT_FILE, 'utf-8').trim(); if (content.startsWith('[')) { entries.push(...JSON.parse(content)); } else if (content) { // Legacy line-delimited format entries.push(...content.split('\n').map(l => JSON.parse(l))); } } catch { /* start fresh */ } } export function getAuditLog(): AuditEntry[] { return [...entries].reverse(); } // ── Nonce Queue ──────────────────────────────────────── const txQueue: Array<() => Promise> = []; let processing = false; async function drainQueue(): Promise { if (processing) return; processing = true; while (txQueue.length > 0) { const fn = txQueue.shift()!; try { await fn(); } catch (e) { console.error('[Audit] Queue tx failed:', e); } } processing = false; } // ── Public API ───────────────────────────────────────── const ONCHAIN_ACTIONS = new Set(['approved', 'rejected', 'revoked', 'expired']); export async function logAuditEvent( action: string, delegationId: string, details: string, amount: number, ): Promise { const entry: AuditEntry = { timestamp: new Date().toISOString(), action, delegationId, details, amount, }; // Add to in-memory store immediately (dashboard sees it right away) entries.push(entry); persist(); // Only governance events go on-chain if (!AUDIT_CONTRACT || !DEPLOYER_KEY || !ONCHAIN_ACTIONS.has(action)) { console.log(`[Audit] Local: ${action} — ${details}`); return null; } // Queue on-chain tx (updates entry.txHash when complete) return new Promise((resolve) => { txQueue.push(async () => { try { const hash = await sendOnChainLog(delegationId, action, details, amount); if (hash) { entry.txHash = hash; persist(); // Re-persist with txHash } resolve(hash); } catch { resolve(null); } }); drainQueue(); }); } // ── On-Chain Sender ──────────────────────────────────── // Track nonce to avoid "replacement transaction underpriced" let currentNonce: number | null = null; async function sendOnChainLog( delegationId: string, action: string, details: string, amount: number ): Promise { try { const account = privateKeyToAccount(DEPLOYER_KEY as `0x${string}`); const client = createWalletClient({ account, chain: CHAIN, transport: http(RPC_URL), }); // Get or increment nonce if (currentNonce === null) { const { createPublicClient } = await import('viem'); const pub = createPublicClient({ chain: CHAIN, transport: http(RPC_URL) }); currentNonce = await pub.getTransactionCount({ address: account.address }); } const nonce = currentNonce++; const hash = await client.writeContract({ address: AUDIT_CONTRACT as `0x${string}`, abi: LOG_ABI, functionName: 'log', args: [keccak256(toHex(delegationId)), action, details, BigInt(Math.round(amount * 1e6))], nonce, }); console.log(`[Audit] On-chain: ${action} — tx: ${hash}`); return hash; } catch (err) { // Reset nonce on failure so next tx refetches from chain currentNonce = null; console.error(`[Audit] On-chain failed (${action} for ${delegationId}): ${err}`); return null; } }