UNPKG

@wuwei-labs/srsly

Version:
395 lines 15.7 kB
/** * Contract audit — reconstructs the full payment lifecycle from on-chain events. */ import { fetchContract } from '../accounts/contract'; import { getRpcUrl } from '../utils/config'; import { createRpc } from '../utils/rpc'; import { parseAnchorEvents } from './events'; const SRSLY_PROGRAM_ID = 'SRSLYxcFnjd5jG2DpJw4as6UEyjwJQK1U4J1TD1hvZH'; /** * Fetch and reconstruct a contract's audit trail from transaction history. */ export async function fetchContractAudit(rpcUrl, contract, options) { const resolvedRpcUrl = rpcUrl || getRpcUrl(); const rpc = createRpc(resolvedRpcUrl); // 1. Fetch on-chain contract state const contractAccount = await fetchContract(contract, resolvedRpcUrl); const onChain = { totalEarned: contractAccount.data.totalEarned, ownerClaimable: contractAccount.data.ownerClaimable, feeClaimable: contractAccount.data.feeClaimable, }; // 2. Fetch transaction signatures const lastN = options?.all ? undefined : (options?.lastN ?? 10); const sinceTimestamp = options?.since ? Math.floor(Date.now() / 1000) - options.since : undefined; const log = options?.onProgress ?? (() => { }); const { events: allEvents, exhausted } = await fetchEventHistory(rpc, contract, resolvedRpcUrl, lastN, sinceTimestamp, log); // 3. Group events into rental lifecycles // Filter out in-progress rentals — incomplete data makes reconciliation meaningless const allRentals = buildRentalAudits(allEvents).filter(r => r.status !== 'in-progress'); // Only consider full history when we scanned every signature (no cutoffs) const isFullHistory = exhausted; const rentals = lastN && allRentals.length > lastN ? allRentals.slice(0, lastN) : allRentals; // 4. Compute summary let ownerEarned = 0n; let feeCollected = 0n; let discount = 0n; let refunds = 0n; let ownerTransfers = 0n; let allMatch = true; for (const r of rentals) { ownerEarned += r.totalOwnerEarned; feeCollected += r.totalFeeCollected; discount += r.totalDiscount; refunds += r.totalRefund; for (const t of r.ownerTransfers) { ownerTransfers += t.amount; } if (r.reconciliation.status === 'mismatch') allMatch = false; } return { contract, onChain, isFullHistory, rentals, summary: { ownerEarned, feeCollected, discount, refunds, ownerTransfers, matchesTotalEarned: isFullHistory ? ownerEarned === onChain.totalEarned : null, allRentalsMatch: allMatch, }, }; } /** * Fetch all audit-relevant events from a contract's transaction history. */ async function fetchEventHistory(rpc, contract, rpcUrl, maxRentals, sinceTimestamp, log) { const allEvents = []; let before; let rentalCount = 0; let txCount = 0; let page = 0; while (true) { const params = { limit: 100 }; if (before) params.before = before; page++; log(`Fetching signatures (page ${page})...`); const signatures = await rpc.getSignaturesForAddress(contract, params).send(); if (!signatures || signatures.length === 0) { log(`No more signatures found`); break; } log(`Got ${signatures.length} signatures, parsing transactions...`); for (const sigInfo of signatures) { // Time-based cutoff if (sinceTimestamp && sigInfo.blockTime && Number(sigInfo.blockTime) < sinceTimestamp) { log(`Reached time cutoff after ${txCount} txs, ${allEvents.length} events, ${rentalCount} rentals`); return { events: allEvents, exhausted: false }; } // Skip failed txs if (sigInfo.err) continue; txCount++; const tx = await rpc .getTransaction(sigInfo.signature, { encoding: 'json', maxSupportedTransactionVersion: 0, }) .send(); if (!tx?.meta?.logMessages) continue; const events = parseAnchorEvents(tx.meta.logMessages, SRSLY_PROGRAM_ID, BigInt(tx.slot), sigInfo.signature); for (const evt of events) { allEvents.push(evt); if (evt.type === 'RentalAccepted') { rentalCount++; log(`Found rental #${rentalCount} (${txCount} txs scanned, ${allEvents.length} events)`); } } // Stop fetching once we have enough accepts to build N+1 lifecycles. // The extra +1 ensures we don't cut off the Nth lifecycle's older events. // The actual lastN trimming happens after grouping. if (maxRentals && rentalCount > maxRentals) { log(`Found ${rentalCount} rentals (need ${maxRentals}), ${txCount} txs scanned`); return { events: allEvents, exhausted: false }; } } // Pagination before = signatures[signatures.length - 1].signature; } log(`Done: ${txCount} txs scanned, ${allEvents.length} events, ${rentalCount} rentals`); return { events: allEvents, exhausted: true }; } /** * Group events into per-rental audit records. * * Challenges: * - Rental state PDAs are reused (reset on close) → same PDA, multiple lifecycles * - Queued rentals get promoted to active PDA → RentalAccepted references queued PDA, * but all subsequent events (settle/cancel/close) reference active PDA * * Strategy: match events by borrower (stable across PDA promotion). * RentalSettled has no borrower — match by PDA, then fall back to the only * open lifecycle without a close. * * OwnerPaid and FeesPaid are contract-level — associated with the most * recently closed rental via temporal proximity. */ function buildRentalAudits(events) { // Process chronologically (events come newest-first from RPC) const chronological = [...events].reverse(); // Track open lifecycles by borrower address (unique per contract at any time) const openByBorrower = new Map(); // Secondary: track which PDA a lifecycle is currently on (for RentalSettled) const pdaToBorrower = new Map(); const finalized = []; const contractEvents = []; for (const evt of chronological) { switch (evt.type) { case 'RentalAccepted': { const borrower = evt.borrower; const pda = evt.rentalState; // If this borrower already has an open lifecycle, finalize it const existing = openByBorrower.get(borrower); if (existing?.accepted) { finalized.push(existing); } // Start fresh lifecycle const entry = { accepted: evt, settlements: [], ownerTransfers: [], }; openByBorrower.set(borrower, entry); pdaToBorrower.set(pda, borrower); break; } case 'RentalSettled': { // RentalSettled has no borrower — resolve via PDA const pda = evt.rentalState; let borrower = pdaToBorrower.get(pda); if (!borrower) { // PDA changed (queued→active promotion). Find the only open // lifecycle that hasn't been closed yet. for (const [b, entry] of openByBorrower) { if (entry.accepted && !entry.closed && !entry.cancelled) { borrower = b; // Update PDA mapping for future settles pdaToBorrower.set(pda, b); break; } } } if (borrower) { const entry = openByBorrower.get(borrower); if (entry) (entry.settlements ??= []).push(evt); } break; } case 'RentalCancelled': { const borrower = evt.borrower; const entry = openByBorrower.get(borrower); if (entry) { entry.cancelled = evt; // Update PDA mapping (may have been promoted) pdaToBorrower.set(evt.rentalState, borrower); } break; } case 'RentalClosed': { const borrower = evt.borrower; const entry = openByBorrower.get(borrower); if (entry) { entry.closed = evt; pdaToBorrower.set(evt.rentalState, borrower); } break; } case 'FeesPaid': case 'OwnerPaid': { contractEvents.push({ evt, slot: evt.slot }); break; } } } // Finalize remaining open lifecycles for (const entry of openByBorrower.values()) { if (entry.accepted) finalized.push(entry); } // Associate contract-level events with the nearest closed rental const allWithCloseSlot = finalized .filter(e => e.closed || e.cancelled) .map(e => ({ entry: e, closeSlot: e.closed?.slot ?? e.cancelled.slot })) .sort((a, b) => (a.closeSlot < b.closeSlot ? -1 : a.closeSlot > b.closeSlot ? 1 : 0)); for (const { evt, slot } of contractEvents) { let best; for (const { entry, closeSlot } of allWithCloseSlot) { if (closeSlot <= slot) best = entry; else break; } if (best) { if (evt.type === 'FeesPaid') { best.feesPaid = evt; } else { (best.ownerTransfers ??= []).push(evt); } } } // Build final audits const rentals = finalized.filter(e => e.accepted).map(e => finalizeRental(e, [])); // Most recent first rentals.sort((a, b) => { const aTime = a.accepted.startTime; const bTime = b.accepted.startTime; return aTime > bTime ? -1 : aTime < bTime ? 1 : 0; }); return rentals; } function finalizeRental(partial, _unassigned) { const accepted = partial.accepted; const settlements = partial.settlements ?? []; const cancelled = partial.cancelled; const closed = partial.closed; const feesPaid = partial.feesPaid; const ownerTransfers = partial.ownerTransfers ?? []; // Derive status let status = 'in-progress'; if (cancelled) status = 'cancelled'; else if (closed) status = 'completed'; // Compute dates (Solana timestamps are unix seconds) const startTime = new Date(Number(accepted.startTime) * 1000); const effectiveEndTime = cancelled ? cancelled.newEndTime : accepted.endTime; const endTime = new Date(Number(effectiveEndTime) * 1000); const durationSeconds = Number(effectiveEndTime - accepted.startTime); // Sum actuals let totalOwnerEarned = 0n; let totalFeeCollected = 0n; let totalRefund = 0n; let totalDiscount = 0n; for (const s of settlements) { totalOwnerEarned += s.ownerEarned; } if (cancelled) { totalOwnerEarned += cancelled.ownerEarned; totalFeeCollected += cancelled.feeCollected; totalRefund += cancelled.refund; } if (closed) { totalOwnerEarned += closed.ownerEarned; totalFeeCollected += closed.feeCollected; } if (feesPaid) { totalDiscount += feesPaid.discount; } const reconciliation = computeReconciliation(accepted, totalOwnerEarned, totalFeeCollected, totalRefund, feesPaid); return { status, borrower: accepted.borrower, startTime, endTime, durationSeconds, rate: accepted.rate, feeBps: accepted.feeBps, escrow: accepted.escrow, serviceFee: accepted.serviceFee, accepted, settlements, cancelled, closed, feesPaid, ownerTransfers, totalOwnerEarned, totalFeeCollected, totalDiscount, totalRefund, reconciliation, }; } /** * Verify rental payment math e2e for all parties: * * Borrower: * - Paid correct escrow (rate * duration / 86400) * - Service fee correctly computed (escrow * fee_bps / 10000) * - Refund + earned + fee = escrow (got back what they should) * - Discount applied correctly if referrer present * * Owner: * - Earned = escrow - fee - refund (conservation) * - Fee ratio matches contracted fee_bps * * Protocol (slyvault): * - Fee collected matches fee_bps proportion * - Discount returned correctly to borrower */ function computeReconciliation(accepted, actualOwnerEarned, actualFeeCollected, actualRefund, feesPaid) { const escrow = accepted.escrow; const duration = accepted.endTime - accepted.startTime; // 1. Conservation: all escrow accounted for const escrowAccounted = actualOwnerEarned + actualFeeCollected + actualRefund; const escrowDelta = escrowAccounted - escrow; // 2. Borrower paid correct amount: escrow should = rate * duration / 86400 const expectedEscrow = duration > 0n ? (accepted.rate * duration) / 86400n : 0n; const escrowCorrect = abs(escrow - expectedEscrow) <= 1n; // 3. Service fee correctly calculated: service_fee = escrow * fee_bps / 10000 const expectedServiceFee = (escrow * BigInt(accepted.feeBps)) / 10000n; const serviceFeeCorrect = abs(accepted.serviceFee - expectedServiceFee) <= 1n; // 4. Fee ratio on payout: fee / (earned + fee) should ≈ fee_bps / 10000 const grossPayment = actualOwnerEarned + actualFeeCollected; const feeRatioBps = grossPayment > 0n ? Number((actualFeeCollected * 10000n) / grossPayment) : 0; const expectedFeeBps = accepted.feeBps; const feeRatioMatch = Math.abs(feeRatioBps - expectedFeeBps) <= 1; // 5. Discount verification (if FeesPaid event with discount_bps > 0) // Slyvault returns the actual discount amount via CPI return data; it is // computed off one member/affiliate slice, not the full fee, so the audit // cannot recompute it without vault config. Bound-check instead. let discountVerified = null; if (feesPaid && feesPaid.discountBps > 0) { const maxDiscount = (feesPaid.feeCollected * BigInt(feesPaid.discountBps)) / 10000n; discountVerified = feesPaid.discount > 0n && feesPaid.discount <= feesPaid.feeCollected && feesPaid.discount <= maxDiscount + 1n; } else if (feesPaid && feesPaid.discountBps === 0) { discountVerified = feesPaid.discount === 0n; } const isMatch = abs(escrowDelta) <= 1n && escrowCorrect && serviceFeeCorrect && feeRatioMatch && (discountVerified === null || discountVerified); return { escrow, escrowAccounted, escrowDelta, expectedEscrow, escrowCorrect, expectedServiceFee, serviceFeeCorrect, feeRatioBps, expectedFeeBps, feeRatioMatch, discountVerified, actualOwnerEarned, actualFeeCollected, actualRefund, status: isMatch ? 'match' : 'mismatch', }; } function abs(n) { return n < 0n ? -n : n; } //# sourceMappingURL=audit.js.map