@wuwei-labs/srsly
Version:
TypeScript SDK for SRSLY
395 lines • 15.7 kB
JavaScript
/**
* 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