diff --git a/src/commands/costs.ts b/src/commands/costs.ts index e69de29b..b508751e 100644 --- a/src/commands/costs.ts +++ b/src/commands/costs.ts @@ -0,0 +1,107 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import { getDatabase } from "../db/database.js"; +import { getContract, getExtensionHistory, getEntriesForContract } from "../db/repositories.js"; +import { formatContractID, formatTimeToCloseLedger } from "../utils/formatting.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "CostsCommand" }); + +export function registerCostsCommand(program: Command): void { + program + .command("costs ") + .description("Show rent costs and extension history for a contract") + .option("--period ", "Show costs for the last N days", "30") + .option("--all", "Show all extension history") + .action(async (contractId: string, options) => { + try { + const db = getDatabase(); + const contract = getContract(db, contractId); + + if (!contract) { + console.error(chalk.red(`Contract ${formatContractID(contractId)} not found. Run 'sentinel watch' first.`)); + process.exit(1); + } + + const days = options.all ? undefined : parseInt(options.period, 10); + if (days !== undefined && (!Number.isInteger(days) || days <= 0)) { + console.error(chalk.red("--period must be a positive integer number of days")); + process.exit(1); + } + const history = getExtensionHistory(db, contractId, days); + + const displayName = contract.name ?? formatContractID(contractId); + const periodLabel = days ? `last ${days} days` : "all time"; + + console.log(`\n${chalk.bold("Extension History")} — ${chalk.cyan(displayName)} (${periodLabel})`); + console.log(` Network: ${chalk.cyan(contract.network)}`); + + if (history.length === 0) { + console.log(chalk.dim("\n No extensions recorded for this period.")); + return; + } + + // Compute aggregates + const entries = getEntriesForContract(db, contractId); + const entryMap = new Map(entries.map(e => [e.id, e])); + + let totalCostXlm = 0; + const byType: Record = {}; + + for (const record of history) { + const cost = record.cost_xlm ?? 0; + totalCostXlm += cost; + + const entry = entryMap.get(record.contract_entry_id); + const entryType = entry?.entry_type ?? "unknown"; + + if (!byType[entryType]) { + byType[entryType] = { count: 0, cost: 0 }; + } + byType[entryType]!.count++; + byType[entryType]!.cost += cost; + } + + // Summary + console.log(`\n ${chalk.bold("Summary")}`); + console.log(` Total extensions: ${chalk.cyan(history.length.toString())}`); + console.log(` Total cost: ${chalk.cyan(totalCostXlm.toFixed(7))} XLM`); + + // Breakdown by entry type + console.log(`\n ${chalk.bold("By Entry Type")}`); + for (const [type, data] of Object.entries(byType)) { + console.log(` ${type}: ${data.count} extensions (${data.cost.toFixed(7)} XLM)`); + } + + // Cost projection + if (days && history.length > 0) { + const projectedCost = (totalCostXlm / (days)) * 30; + console.log(`\n ${chalk.bold("Projection")}`); + console.log(` Estimated 30-day cost: ~${chalk.cyan(projectedCost.toFixed(7))} XLM`); + } + + // Recent history + console.log(`\n ${chalk.bold("Recent Extensions")}`); + const recent = options.all ? history : history.slice(0, 10); + for (const record of recent) { + const entry = entryMap.get(record.contract_entry_id); + const label = entry?.label ?? entry?.entry_type ?? "unknown"; + const cost = record.cost_xlm !== null ? `${record.cost_xlm.toFixed(7)} XLM` : "N/A"; + const oldTTL = formatTimeToCloseLedger(record.old_ttl_ledgers); + const newTTL = formatTimeToCloseLedger(record.new_ttl_ledgers); + + console.log(` ${chalk.dim(record.executed_at)} ${label}: ${oldTTL} → ${newTTL} (${cost})`); + console.log(` ${chalk.dim(`tx: ${record.tx_hash.slice(0, 16)}...`)}`); + } + + if (!options.all && history.length > 10) { + console.log(chalk.dim(`\n ... and ${history.length - 10} more. Use --all to see everything.`)); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Costs command failed", { error: msg }); + console.error(chalk.red(`Error: ${msg}`)); + process.exit(1); + } + }); +} diff --git a/src/commands/guard.ts b/src/commands/guard.ts index e69de29b..71598c2a 100644 --- a/src/commands/guard.ts +++ b/src/commands/guard.ts @@ -0,0 +1,193 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import ora from "ora"; +import { getDatabase } from "../db/database.js"; +import { getContract, getEntriesForContract, upsertExtensionPolicy, getExtensionPolicy } from "../db/repositories.js"; +import { simulateExtension, extendEntries } from "../core/extension.js"; +import { formatContractID, formatTimeToCloseLedger } from "../utils/formatting.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "GuardCommand" }); + +export function registerGuardCommand(program: Command): void { + program + .command("guard ") + .description("Configure auto-extension policy for a contract") + .option("--target-ttl ", "Target TTL in ledgers after extension", "100000") + .option("--threshold ", "Extend when TTL drops below this many ledgers", "20000") + .option("--keypair ", "Stellar secret key for signing extension transactions") + .option("--keypair-env ", "Environment variable containing the secret key") + .option("--auto-extend", "Enable auto-extension (the daemon will extend automatically)") + .option("--dry-run", "Simulate the extension without submitting") + .option("--disable", "Disable auto-extension for this contract") + .action(async (contractId: string, options) => { + try { + const db = getDatabase(); + const contract = getContract(db, contractId); + + if (!contract) { + console.error(chalk.red(`Contract ${formatContractID(contractId)} not found. Run 'sentinel watch' first.`)); + process.exit(1); + } + + const targetTTL = parseInt(options.targetTtl, 10); + const threshold = parseInt(options.threshold, 10); + + if (isNaN(targetTTL) || targetTTL <= 0) { + console.error(chalk.red("--target-ttl must be a positive number")); + process.exit(1); + } + + if (isNaN(threshold) || threshold <= 0) { + console.error(chalk.red("--threshold must be a positive number")); + process.exit(1); + } + + if (threshold >= targetTTL) { + console.error(chalk.red("--threshold must be less than --target-ttl")); + process.exit(1); + } + + // Handle --disable + if (options.disable) { + upsertExtensionPolicy(db, { + contract_id: contractId, + enabled: false, + target_ttl_ledgers: targetTTL, + extend_when_below_ledgers: threshold, + }); + console.log(chalk.yellow(`Auto-extension disabled for ${contract.name ?? formatContractID(contractId)}`)); + return; + } + + // Resolve keypair source + let keypairSource: string | undefined; + let secretKey: string | undefined; + + if (options.keypairEnv) { + keypairSource = `env:${options.keypairEnv}`; + secretKey = process.env[options.keypairEnv]; + if (!secretKey) { + console.error(chalk.red(`Environment variable ${options.keypairEnv} is not set`)); + process.exit(1); + } + } else if (options.keypair) { + keypairSource = options.keypair; + secretKey = options.keypair; + } + + // Save policy + if (options.autoExtend) { + if (!options.keypairEnv) { + console.error(chalk.red("--auto-extend requires --keypair-env so the daemon can resolve the key at runtime")); + process.exit(1); + } + + // Extract public key from secret for storage (never store the secret itself) + const { Keypair } = await import("@stellar/stellar-sdk"); + const kp = Keypair.fromSecret(secretKey!); + + upsertExtensionPolicy(db, { + contract_id: contractId, + enabled: true, + target_ttl_ledgers: targetTTL, + extend_when_below_ledgers: threshold, + keypair_public: kp.publicKey(), + keypair_source: keypairSource!, + }); + + console.log(chalk.green(`\nAuto-extension enabled for ${contract.name ?? formatContractID(contractId)}`)); + console.log(` Target TTL: ${targetTTL.toLocaleString()} ledgers (${formatTimeToCloseLedger(targetTTL)})`); + console.log(` Threshold: ${threshold.toLocaleString()} ledgers (${formatTimeToCloseLedger(threshold)})`); + console.log(` Funded by: ${kp.publicKey().slice(0, 8)}...${kp.publicKey().slice(-4)}`); + console.log(chalk.dim("\n The daemon will auto-extend when TTL drops below the threshold.")); + console.log(chalk.dim(" Run 'sentinel daemon --network " + contract.network + "' to start monitoring.")); + return; + } + + // Dry-run: simulate extension + if (options.dryRun) { + if (!secretKey) { + console.error(chalk.red("--keypair or --keypair-env required for dry-run simulation")); + process.exit(1); + } + + const entries = getEntriesForContract(db, contractId); + if (entries.length === 0) { + console.log(chalk.yellow("No entries to extend")); + return; + } + + const spinner = ora("Simulating extension...").start(); + const { Keypair } = await import("@stellar/stellar-sdk"); + const kp = Keypair.fromSecret(secretKey); + + const result = await simulateExtension( + db, + contractId, + entries.map(e => e.entry_key_xdr), + targetTTL, + kp.publicKey(), + ); + + if (result.success) { + spinner.succeed(chalk.green("Simulation successful")); + console.log(` Entries: ${result.entriesExtended}`); + console.log(` Estimated fee: ${(result.estimatedFee! / 10_000_000).toFixed(7)} XLM`); + } else { + spinner.fail(chalk.red(`Simulation failed: ${result.error}`)); + } + return; + } + + // One-time manual extension + if (secretKey) { + const entries = getEntriesForContract(db, contractId); + if (entries.length === 0) { + console.log(chalk.yellow("No entries to extend")); + return; + } + + const spinner = ora("Extending TTL...").start(); + const result = await extendEntries( + db, + contractId, + entries.map(e => e.entry_key_xdr), + targetTTL, + secretKey, + ); + + if (result.success) { + spinner.succeed(chalk.green("TTL extended successfully")); + console.log(` Entries: ${result.entriesExtended}`); + console.log(` Tx hash: ${result.txHash}`); + console.log(` Ledger: ${result.ledger}`); + } else { + spinner.fail(chalk.red(`Extension failed: ${result.error}`)); + process.exit(1); + } + return; + } + + // No keypair provided — just show current policy + const policy = getExtensionPolicy(db, contractId); + if (policy) { + console.log(`\nExtension policy for ${contract.name ?? formatContractID(contractId)}:`); + console.log(` Status: ${policy.enabled ? chalk.green("ENABLED") : chalk.yellow("DISABLED")}`); + console.log(` Target: ${policy.target_ttl_ledgers.toLocaleString()} ledgers (${formatTimeToCloseLedger(policy.target_ttl_ledgers)})`); + console.log(` Threshold: ${policy.extend_when_below_ledgers.toLocaleString()} ledgers (${formatTimeToCloseLedger(policy.extend_when_below_ledgers)})`); + if (policy.keypair_public) { + console.log(` Funded by: ${policy.keypair_public.slice(0, 8)}...${policy.keypair_public.slice(-4)}`); + } + } else { + console.log(chalk.dim("\nNo extension policy configured for this contract.")); + console.log(chalk.dim("Use --auto-extend with --keypair to enable auto-extension.")); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Guard command failed", { error: msg }); + console.error(chalk.red(`Error: ${msg}`)); + process.exit(1); + } + }); +} diff --git a/src/commands/restore.ts b/src/commands/restore.ts index e69de29b..8fc0d112 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -0,0 +1,102 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import ora from "ora"; +import { getDatabase } from "../db/database.js"; +import { getContract, getEntriesForContract } from "../db/repositories.js"; +import { restoreEntries } from "../core/extension.js"; +import { formatContractID } from "../utils/formatting.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "RestoreCommand" }); + +export function registerRestoreCommand(program: Command): void { + program + .command("restore ") + .description("Restore archived entries for a contract") + .option("--keypair ", "Stellar secret key for signing restore transactions") + .option("--keypair-env ", "Environment variable containing the secret key") + .option("--entry ", "Specific entry key XDR to restore (can be used multiple times)", collect, []) + .option("--all", "Restore all tracked entries for the contract") + .action(async (contractId: string, options) => { + try { + const db = getDatabase(); + const contract = getContract(db, contractId); + + if (!contract) { + console.error(chalk.red(`Contract ${formatContractID(contractId)} not found. Run 'sentinel watch' first.`)); + process.exit(1); + } + + // Resolve secret key + let secretKey: string | undefined; + + if (options.keypairEnv) { + secretKey = process.env[options.keypairEnv]; + if (!secretKey) { + console.error(chalk.red(`Environment variable ${options.keypairEnv} is not set`)); + process.exit(1); + } + } else if (options.keypair) { + secretKey = options.keypair; + } + + if (!secretKey) { + console.error(chalk.red("--keypair or --keypair-env is required for restoration")); + process.exit(1); + } + + // Determine which entries to restore + let entryKeys: string[]; + + if (options.all && options.entry && options.entry.length > 0) { + console.error(chalk.red("Use either --entry or --all, not both")); + process.exit(1); + } + + if (options.entry && options.entry.length > 0) { + entryKeys = options.entry; + } else if (options.all) { + const entries = getEntriesForContract(db, contractId); + entryKeys = entries.map(e => e.entry_key_xdr); + } else { + console.error(chalk.red("Specify --entry or --all to select entries to restore")); + process.exit(1); + } + + if (entryKeys.length === 0) { + console.log(chalk.yellow("No entries to restore")); + return; + } + + const displayName = contract.name ?? formatContractID(contractId); + const spinner = ora(`Restoring ${entryKeys.length} entries for ${displayName}...`).start(); + + const result = await restoreEntries(db, contractId, entryKeys, secretKey!); + + if (result.success) { + spinner.succeed(chalk.green(`Restored ${result.entriesRestored} entries for ${displayName}`)); + console.log(` Tx hash: ${result.txHash}`); + console.log(` Ledger: ${result.ledger}`); + console.log(chalk.dim(`\n Run 'sentinel status ${formatContractID(contractId)}' to verify.`)); + } else { + spinner.fail(chalk.red(`Restore failed: ${result.error}`)); + if (result.txHash) { + console.log(` Tx hash: ${result.txHash}`); + } + process.exit(1); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Restore command failed", { error: msg }); + console.error(chalk.red(`Error: ${msg}`)); + process.exit(1); + } + }); +} + +/** + * Commander collect helper for repeatable options. + */ +function collect(value: string, previous: string[]): string[] { + return previous.concat([value]); +} diff --git a/src/core/discovery.ts b/src/core/discovery.ts index e69de29b..4d2801a3 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -0,0 +1,268 @@ +import type Database from "better-sqlite3"; +import { rpc, xdr, StrKey } from "@stellar/stellar-sdk"; +import { getEntriesForContract, upsertEntry, getAllContracts } from "../db/repositories.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "Discovery" }); + +// ─── Public contract ────────────────────────────────────────────────────────── + +export interface DiscoveryResult { + /** Contract ID that was scanned. */ + contractId: string; + /** Number of new storage keys discovered. */ + newKeysDiscovered: number; + /** Total transactions scanned. */ + transactionsScanned: number; + /** Error message if discovery failed. */ + error?: string; +} + +export interface BatchDiscoveryResult { + /** Total contracts scanned. */ + contractsScanned: number; + /** Total new keys discovered across all contracts. */ + totalNewKeys: number; + /** Per-contract results. */ + results: DiscoveryResult[]; + /** Errors that occurred during discovery. */ + errors: string[]; +} + +// ─── RPC URLs ────────────────────────────────────────────────────────────────── + +const RPC_URLS: Record = { + testnet: "https://soroban-testnet.stellar.org", + mainnet: "https://mainnet.sorobanrpc.com", +}; + +// ─── Core implementation ────────────────────────────────────────────────────── + +/** + * Discover new storage keys for a contract by scanning recent transactions. + * + * Uses the Stellar RPC `getEvents` endpoint to find contract invocation events, + * then extracts ledger keys from the event data to discover persistent and + * temporary storage entries that the contract has touched. + * + * This is Layer 2 of the discovery architecture — it learns keys over time + * from observed contract activity. + */ +export async function discoverStorageKeys( + db: Database.Database, + contractId: string, + network: string, + rpcUrl?: string, +): Promise { + const result: DiscoveryResult = { + contractId, + newKeysDiscovered: 0, + transactionsScanned: 0, + }; + + try { + const url = rpcUrl ?? RPC_URLS[network]; + if (!url) { + result.error = `Unknown network "${network}"`; + return result; + } + + const server = new rpc.Server(url); + + // Get the latest ledger to set up the event window + const health = await server.getHealth(); + const latestLedger = (health as any).latestLedger ?? 0; + if (latestLedger === 0) { + result.error = "Could not determine latest ledger"; + return result; + } + + // Look back ~1 hour of ledgers (approximately 655 ledgers at 5.5s/ledger) + // The RPC limits event lookback, so we use a reasonable window + const startLedger = Math.max(1, latestLedger - 655); + + // Get existing entry keys so we can identify new ones + const existingEntries = getEntriesForContract(db, contractId); + const existingKeys = new Set(existingEntries.map(e => e.entry_key_xdr)); + + // Fetch events for this contract with cursor-based pagination + const allEvents: rpc.Api.EventResponse[] = []; + let cursor: string | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const request: any = { + filters: [ + { + type: "contract", + contractIds: [contractId], + }, + ], + limit: 100, + }; + + if (cursor) { + request.pagination = { cursor }; + } else { + request.startLedger = startLedger; + } + + const page = await server.getEvents(request); + if (page.events && page.events.length > 0) { + allEvents.push(...page.events); + } + + // Continue if there's a cursor for the next page + if ((page as any).cursor && page.events && page.events.length === 100) { + cursor = (page as any).cursor; + } else { + break; + } + } + + if (allEvents.length === 0) { + logger.debug(`No events found for ${contractId} since ledger ${startLedger}`); + return result; + } + + result.transactionsScanned = allEvents.length; + + // For each event, try to extract ledger keys from the event data. + // Contract storage events often encode the storage key in the topic. + for (const event of allEvents) { + try { + // Events have topic entries that may contain storage key references + if (event.topic && event.topic.length > 0) { + for (const topicVal of event.topic) { + // Try to interpret topic values as potential ledger keys + // This is heuristic — not all topic values are storage keys + try { + const keyXdr = topicVal.toXDR("base64"); + if (!existingKeys.has(keyXdr) && keyXdr.length > 10) { + // Construct a contract data ledger key from this + const contractDataKey = buildContractDataKey(contractId, topicVal); + if (contractDataKey) { + const contractDataKeyXdr = contractDataKey.toXDR("base64"); + if (!existingKeys.has(contractDataKeyXdr)) { + // Verify the entry exists on-chain before adding + const entryResponse = await server.getLedgerEntries(contractDataKey); + if (entryResponse.entries && entryResponse.entries.length > 0) { + const entry = entryResponse.entries[0]!; + upsertEntry(db, { + contract_id: contractId, + entry_key_xdr: contractDataKeyXdr, + entry_type: "persistent", + label: `Discovered (event)`, + live_until_ledger: entry.liveUntilLedgerSeq ?? 0, + last_modified_ledger: entry.lastModifiedLedgerSeq ?? 0, + discovery_source: "footprint", + }); + existingKeys.add(contractDataKeyXdr); + result.newKeysDiscovered++; + } + } + } + } + } catch { + // Not a valid key — skip + } + } + } + } catch (err) { + // Individual event parsing failure — continue + logger.debug(`Failed to parse event for ${contractId}: ${err}`); + } + } + + logger.debug( + `Discovery for ${contractId}: scanned ${result.transactionsScanned} events, ` + + `found ${result.newKeysDiscovered} new keys`, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.error = message; + logger.error(`Discovery failed for ${contractId}: ${message}`, err); + } + + return result; +} + +/** + * Run discovery for all registered contracts on a network. + * Called by the daemon as an optional step after the monitor cycle. + */ +export async function runBatchDiscovery( + db: Database.Database, + network: string, + rpcUrl?: string, +): Promise { + const batchResult: BatchDiscoveryResult = { + contractsScanned: 0, + totalNewKeys: 0, + results: [], + errors: [], + }; + + const contracts = getAllContracts(db).filter(c => c.network === network); + + for (const contract of contracts) { + batchResult.contractsScanned++; + + try { + const result = await discoverStorageKeys(db, contract.id, network, rpcUrl); + batchResult.results.push(result); + batchResult.totalNewKeys += result.newKeysDiscovered; + + if (result.error) { + batchResult.errors.push(`${contract.id}: ${result.error}`); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + batchResult.errors.push(`${contract.id}: ${message}`); + } + } + + return batchResult; +} + +// ─── Private helpers ────────────────────────────────────────────────────────── + +/** + * Attempt to build a contract data ledger key from a contract ID and an XDR value. + * Returns null if the construction fails. + */ +function buildContractDataKey( + contractId: string, + keyVal: xdr.ScVal, +): xdr.LedgerKey | null { + try { + const raw = Buffer.from(contractId, "hex").length === 32 + ? Buffer.from(contractId, "hex") + : decodeContractId(contractId); + const contractAddress = xdr.ScAddress.scAddressTypeContract( + raw as unknown as xdr.Hash, + ); + + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contractAddress, + key: keyVal, + durability: xdr.ContractDataDurability.persistent(), + }), + ); + } catch { + return null; + } +} + +/** + * Decode a Stellar contract ID (C...) to raw 32-byte buffer. + */ +function decodeContractId(contractId: string): Buffer { + try { + return Buffer.from(StrKey.decodeContract(contractId)); + } catch { + // Fallback: assume hex + return Buffer.from(contractId, "hex"); + } +} diff --git a/src/core/extension.ts b/src/core/extension.ts index e69de29b..88575fbb 100644 --- a/src/core/extension.ts +++ b/src/core/extension.ts @@ -0,0 +1,409 @@ +import type Database from "better-sqlite3"; +import { StellarRpcClient } from "../rpc/client.js"; +import { + getAllContracts, + getContract, + getEntriesForContract, + getExtensionPolicy, + recordExtension, + upsertEntry, + updateLastCheckedLedger, +} from "../db/repositories.js"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "Extension" }); + +// ─── Public contract ────────────────────────────────────────────────────────── + +export interface ExtensionResult { + /** Whether the extension was successful. */ + success: boolean; + /** Contract ID that was extended. */ + contractId: string; + /** Number of entries that were extended. */ + entriesExtended: number; + /** Transaction hash if submitted. */ + txHash?: string; + /** New ledger number after extension. */ + ledger?: number; + /** Error message if failed. */ + error?: string; + /** Estimated fee in stroops (from simulation). */ + estimatedFee?: number; +} + +export interface AutoExtensionResult { + /** Total contracts checked for auto-extension. */ + contractsChecked: number; + /** Number of contracts where entries were actually extended. */ + contractsExtended: number; + /** Total entries extended across all contracts. */ + entriesExtended: number; + /** Per-contract errors (non-fatal). */ + errors: string[]; + /** Details of each successful extension. */ + extensions: Array<{ + contractId: string; + txHash: string; + entriesExtended: number; + ledger: number; + }>; +} + +export interface RestoreResult { + /** Whether the restore was successful. */ + success: boolean; + /** Contract ID. */ + contractId: string; + /** Number of entries restored. */ + entriesRestored: number; + /** Transaction hash if submitted. */ + txHash?: string; + /** Ledger number. */ + ledger?: number; + /** Error message if failed. */ + error?: string; +} + +// ─── Core implementation ────────────────────────────────────────────────────── + +/** + * Simulate a TTL extension for specific entries of a contract. + * Does NOT submit — only estimates fees. Useful for dry-run / cost preview. + */ +export async function simulateExtension( + db: Database.Database, + contractId: string, + entryKeyXdrs: string[], + extendToLedgers: number, + sourcePublicKey: string, + rpcUrl?: string, +): Promise { + const contract = getContract(db, contractId); + if (!contract) { + return { success: false, contractId, entriesExtended: 0, error: "Contract not found" }; + } + + const client = new StellarRpcClient(contract.network, rpcUrl); + + const sim = await client.simulateExtension(entryKeyXdrs, extendToLedgers, sourcePublicKey); + + if (!sim.success) { + return { + success: false, + contractId, + entriesExtended: 0, + error: sim.error, + }; + } + + return { + success: true, + contractId, + entriesExtended: entryKeyXdrs.length, + estimatedFee: sim.minResourceFee, + }; +} + +/** + * Extend TTL for specific entries of a contract. + * Builds, simulates, signs, and submits an ExtendFootprintTTLOp transaction. + */ +export async function extendEntries( + db: Database.Database, + contractId: string, + entryKeyXdrs: string[], + extendToLedgers: number, + secretKey: string, + rpcUrl?: string, +): Promise { + const contract = getContract(db, contractId); + if (!contract) { + return { success: false, contractId, entriesExtended: 0, error: "Contract not found" }; + } + + if (entryKeyXdrs.length === 0) { + return { success: false, contractId, entriesExtended: 0, error: "No entries to extend" }; + } + + const client = new StellarRpcClient(contract.network, rpcUrl); + + logger.info( + `Extending ${entryKeyXdrs.length} entries for ${contractId} to ${extendToLedgers} ledgers`, + ); + + const txResult = await client.submitExtension(entryKeyXdrs, extendToLedgers, secretKey); + + if (!txResult.success) { + logger.error(`Extension failed for ${contractId}: ${txResult.error}`); + return { + success: false, + contractId, + entriesExtended: 0, + txHash: txResult.txHash || undefined, + error: txResult.error, + }; + } + + // Fetch fresh TTLs after extension to update DB and record history + const freshTTLs = await client.getEntryTTLs(entryKeyXdrs); + const entries = getEntriesForContract(db, contractId); + const entryMap = new Map(entries.map(e => [e.entry_key_xdr, e])); + + // Wrap all DB updates in a transaction for atomicity + const updateDb = db.transaction(() => { + for (const freshEntry of freshTTLs.entries) { + const dbEntry = entryMap.get(freshEntry.entryKeyXdr); + if (!dbEntry) continue; + + const oldTTL = dbEntry.live_until_ledger + ? dbEntry.live_until_ledger - freshTTLs.latestLedger + : 0; + + // Record the extension in history + recordExtension(db, { + contract_id: contractId, + contract_entry_id: dbEntry.id, + old_ttl_ledgers: Math.max(0, oldTTL), + new_ttl_ledgers: freshEntry.remainingTTL, + tx_hash: txResult.txHash, + executed_at_ledger: freshTTLs.latestLedger, + }); + + // Update the entry with fresh TTL + upsertEntry(db, { + contract_id: contractId, + entry_key_xdr: freshEntry.entryKeyXdr, + entry_type: dbEntry.entry_type, + label: dbEntry.label ?? undefined, + live_until_ledger: freshEntry.liveUntilLedgerSeq, + last_modified_ledger: freshEntry.lastModifiedLedgerSeq, + discovery_source: dbEntry.discovery_source, + }); + } + + updateLastCheckedLedger(db, contractId, freshTTLs.latestLedger); + }); + updateDb(); + + logger.info( + `Extension successful for ${contractId}: tx=${txResult.txHash}, entries=${entryKeyXdrs.length}`, + ); + + return { + success: true, + contractId, + entriesExtended: entryKeyXdrs.length, + txHash: txResult.txHash, + ledger: txResult.ledger, + }; +} + +/** + * Run auto-extension for all contracts with enabled extension policies. + * Called by the daemon after each monitor cycle. + * + * For each contract with an enabled policy, checks if any entries have + * a remaining TTL below `extend_when_below_ledgers`. If so, extends them + * to `target_ttl_ledgers`. + * + * Errors for individual contracts are collected, not thrown. + */ +export async function runAutoExtensions( + db: Database.Database, + network: string, + rpcUrl?: string, +): Promise { + const result: AutoExtensionResult = { + contractsChecked: 0, + contractsExtended: 0, + entriesExtended: 0, + errors: [], + extensions: [], + }; + + const contracts = getAllContracts(db).filter(c => c.network === network); + + // Fetch latest ledger once for the entire run, not per-contract + let latestLedger: number | undefined; + if (contracts.some(c => { + const p = getExtensionPolicy(db, c.id); + return p && p.enabled; + })) { + const client = new StellarRpcClient(network, rpcUrl); + latestLedger = await client.getCurrentLedger(); + } + + for (const contract of contracts) { + const policy = getExtensionPolicy(db, contract.id); + if (!policy || !policy.enabled) continue; + + result.contractsChecked++; + + try { + const entries = getEntriesForContract(db, contract.id); + + // Find entries that need extension (exclude already-expired entries) + const needsExtension = entries.filter(e => { + if (!e.live_until_ledger) return false; + const remaining = e.live_until_ledger - latestLedger!; + return remaining > 0 && remaining < policy.extend_when_below_ledgers; + }); + + if (needsExtension.length === 0) continue; + + // Resolve the secret key from the policy's keypair_source + const secretKey = resolveSecretKey(policy.keypair_source); + if (!secretKey) { + result.errors.push( + `Contract ${contract.id}: Cannot resolve keypair from source "${policy.keypair_source}"`, + ); + continue; + } + + const entryKeys = needsExtension.map(e => e.entry_key_xdr); + + logger.info( + `Auto-extending ${entryKeys.length} entries for ${contract.id} ` + + `(below ${policy.extend_when_below_ledgers}, target ${policy.target_ttl_ledgers})`, + ); + + const extResult = await extendEntries( + db, + contract.id, + entryKeys, + policy.target_ttl_ledgers, + secretKey, + rpcUrl, + ); + + if (extResult.success) { + result.contractsExtended++; + result.entriesExtended += extResult.entriesExtended; + result.extensions.push({ + contractId: contract.id, + txHash: extResult.txHash!, + entriesExtended: extResult.entriesExtended, + ledger: extResult.ledger!, + }); + } else { + result.errors.push( + `Contract ${contract.id}: Extension failed — ${extResult.error}`, + ); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.errors.push(`Contract ${contract.id}: ${message}`); + logger.error(`Auto-extension error for ${contract.id}: ${message}`, err); + } + } + + return result; +} + +/** + * Restore archived entries for a contract. + * Submits a RestoreFootprintOp transaction. + */ +export async function restoreEntries( + db: Database.Database, + contractId: string, + entryKeyXdrs: string[], + secretKey: string, + rpcUrl?: string, +): Promise { + const contract = getContract(db, contractId); + if (!contract) { + return { success: false, contractId, entriesRestored: 0, error: "Contract not found" }; + } + + if (entryKeyXdrs.length === 0) { + return { success: false, contractId, entriesRestored: 0, error: "No entries to restore" }; + } + + const client = new StellarRpcClient(contract.network, rpcUrl); + + logger.info(`Restoring ${entryKeyXdrs.length} entries for ${contractId}`); + + const txResult = await client.submitRestore(entryKeyXdrs, secretKey); + + if (!txResult.success) { + logger.error(`Restore failed for ${contractId}: ${txResult.error}`); + return { + success: false, + contractId, + entriesRestored: 0, + txHash: txResult.txHash || undefined, + error: txResult.error, + }; + } + + // Refresh TTLs after restore + const freshTTLs = await client.getEntryTTLs(entryKeyXdrs); + const entries = getEntriesForContract(db, contractId); + const entryMap = new Map(entries.map(e => [e.entry_key_xdr, e])); + + let restored = 0; + + // Wrap all DB updates in a transaction for atomicity + const updateDb = db.transaction(() => { + for (const freshEntry of freshTTLs.entries) { + const dbEntry = entryMap.get(freshEntry.entryKeyXdr); + if (!dbEntry) continue; + + upsertEntry(db, { + contract_id: contractId, + entry_key_xdr: freshEntry.entryKeyXdr, + entry_type: dbEntry.entry_type, + label: dbEntry.label ?? undefined, + live_until_ledger: freshEntry.liveUntilLedgerSeq, + last_modified_ledger: freshEntry.lastModifiedLedgerSeq, + discovery_source: dbEntry.discovery_source, + }); + restored++; + } + + updateLastCheckedLedger(db, contractId, freshTTLs.latestLedger); + }); + updateDb(); + + logger.info(`Restore successful for ${contractId}: tx=${txResult.txHash}, entries=${restored}`); + + return { + success: true, + contractId, + entriesRestored: restored, + txHash: txResult.txHash, + ledger: txResult.ledger, + }; +} + +// ─── Private helpers ────────────────────────────────────────────────────────── + +/** + * Resolve a secret key from a keypair_source string. + * Supports: + * - "env:VAR_NAME" — reads from environment variable + * - Direct secret key string starting with "S" (56 chars) + */ +function resolveSecretKey(source: string | null): string | null { + if (!source) return null; + + if (source.startsWith("env:")) { + const envVar = source.slice(4); + const value = process.env[envVar]; + if (!value) { + logger.warn(`Environment variable ${envVar} not set`); + return null; + } + return value; + } + + // Direct secret key + if (source.startsWith("S") && source.length === 56) { + return source; + } + + logger.warn(`Unknown keypair_source format: ${source}`); + return null; +} diff --git a/src/daemon/loop.ts b/src/daemon/loop.ts index c82b8bb7..d65bf93d 100644 --- a/src/daemon/loop.ts +++ b/src/daemon/loop.ts @@ -1,6 +1,7 @@ import type Database from "better-sqlite3"; import { runMonitorCycle, type MonitorCycleResult } from "../core/monitor.js"; import { deliverPendingAlerts } from "../alerts/dispatcher.js"; +import { runAutoExtensions } from "../core/extension.js"; import { getLogger } from "../logging/index.js"; const logger = getLogger().child({ component: "DaemonLoop" }); @@ -127,6 +128,21 @@ async function executeCycle( logger.error("deliverPendingAlerts threw unexpectedly", deliveryErr); } + // Step 3: run auto-extensions for contracts with enabled policies. + try { + const extensions = await runAutoExtensions(db, network, rpcUrl); + if (extensions.contractsChecked > 0) { + logger.info( + `Auto-extensions — checked: ${extensions.contractsChecked}, ` + + `extended: ${extensions.contractsExtended}, ` + + `entries: ${extensions.entriesExtended}, ` + + `errors: ${extensions.errors.length}`, + ); + } + } catch (extensionErr: unknown) { + logger.error("runAutoExtensions threw unexpectedly", extensionErr); + } + safeOnCycle(onCycle, result, undefined); } catch (err: unknown) { const error = err instanceof Error ? err : new Error(String(err)); diff --git a/src/index.ts b/src/index.ts index 55a4783c..cc8a920a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ import { registerWatchCommand } from "./commands/watch.js"; import { registerStatusCommand } from "./commands/status.js"; import { registerDaemonCommand } from "./commands/daemon.js"; import { registerAlertsCommand } from "./commands/alerts.js"; +import { registerGuardCommand } from "./commands/guard.js"; +import { registerCostsCommand } from "./commands/costs.js"; +import { registerRestoreCommand } from "./commands/restore.js"; initLogger({ mode: "cli" }); @@ -13,27 +16,14 @@ const program = new Command(); program .name("sentinel") .description("Soroban Sentinel — The missing operations layer for deployed Soroban smart contracts") - .version("0.1.0"); + .version("0.1.2"); registerWatchCommand(program); registerStatusCommand(program); registerDaemonCommand(program); registerAlertsCommand(program); - -// Placeholder commands — future milestones -program - .command("guard ") - .description("Configure auto-extension policy for a contract") - .action(() => console.log("guard command — not yet implemented")); - -program - .command("costs ") - .description("Show rent costs and forecasts for a contract") - .action(() => console.log("costs command — not yet implemented")); - -program - .command("restore ") - .description("Restore archived entries for a contract") - .action(() => console.log("restore command — not yet implemented")); +registerGuardCommand(program); +registerCostsCommand(program); +registerRestoreCommand(program); program.parse(process.argv); diff --git a/src/rpc/client.ts b/src/rpc/client.ts index c3a953dd..89992189 100644 --- a/src/rpc/client.ts +++ b/src/rpc/client.ts @@ -1,4 +1,13 @@ -import { Contract, rpc, xdr } from "@stellar/stellar-sdk"; +import { + Contract, + rpc, + xdr, + TransactionBuilder, + Networks, + Account, + Operation, + Keypair, +} from "@stellar/stellar-sdk"; import { getLogger } from "../logging/index.js"; const logger = getLogger().child({ component: "StellarRpcClient" }); @@ -29,6 +38,31 @@ export interface EntryTTLsResult { entries: SentinelLedgerEntryResult[]; } +export interface SimulateExtensionResult { + /** Estimated fee in stroops. */ + minResourceFee: number; + /** Whether the simulation succeeded. */ + success: boolean; + /** Error message if simulation failed. */ + error?: string; +} + +export interface SubmitTransactionResult { + /** Whether the transaction succeeded. */ + success: boolean; + /** Transaction hash. */ + txHash: string; + /** Ledger the transaction was included in. */ + ledger: number; + /** Error message if the transaction failed. */ + error?: string; +} + +const NETWORK_PASSPHRASES: Record = { + testnet: Networks.TESTNET, + mainnet: Networks.PUBLIC, +}; + export class StellarRpcClient { private readonly network: string; private readonly server: rpc.Server; @@ -155,4 +189,256 @@ export class StellarRpcClient { return { latestLedger, entries }; } + + /** + * Simulate an ExtendFootprintTTLOp to estimate fees before submitting. + */ + async simulateExtension( + entryKeyXdrs: string[], + extendToLedgers: number, + sourcePublicKey: string, + ): Promise { + const passphrase = await this.getNetworkPassphrase(); + + // Fetch account to get a valid sequence number for simulation + const accountResponse = await this.server.getAccount(sourcePublicKey); + const account = new Account(sourcePublicKey, accountResponse.sequenceNumber()); + + const keys = entryKeyXdrs.map(k => xdr.LedgerKey.fromXDR(k, "base64")); + + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: passphrase, + }) + .addOperation( + Operation.extendFootprintTtl({ + extendTo: extendToLedgers, + }), + ) + .setTimeout(30) + .setSorobanData( + new (rpc as any).SorobanDataBuilder() + .setReadOnly(keys) + .build(), + ) + .build(); + + const sim = await this.server.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(sim)) { + return { + success: false, + minResourceFee: 0, + error: sim.error ?? "Simulation failed", + }; + } + + const successSim = sim as rpc.Api.SimulateTransactionSuccessResponse; + return { + success: true, + minResourceFee: Number(successSim.minResourceFee ?? 0), + }; + } + + /** + * Build, sign, and submit an ExtendFootprintTTLOp transaction. + * Uses simulation to prepare the transaction with correct resource parameters. + */ + async submitExtension( + entryKeyXdrs: string[], + extendToLedgers: number, + secretKey: string, + ): Promise { + const passphrase = await this.getNetworkPassphrase(); + const keypair = Keypair.fromSecret(secretKey); + const publicKey = keypair.publicKey(); + + // Fetch account sequence number + const accountResponse = await this.server.getAccount(publicKey); + const account = new Account(publicKey, accountResponse.sequenceNumber()); + + const keys = entryKeyXdrs.map(k => xdr.LedgerKey.fromXDR(k, "base64")); + + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: passphrase, + }) + .addOperation( + Operation.extendFootprintTtl({ + extendTo: extendToLedgers, + }), + ) + .setTimeout(30) + .setSorobanData( + new (rpc as any).SorobanDataBuilder() + .setReadOnly(keys) + .build(), + ) + .build(); + + // Simulate to prepare the transaction + const sim = await this.server.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(sim)) { + return { + success: false, + txHash: "", + ledger: 0, + error: sim.error ?? "Simulation failed", + }; + } + + // Assemble the transaction with simulation results + const prepared = rpc.assembleTransaction(tx, sim).build(); + prepared.sign(keypair); + + // Submit and poll for result + const sendResult = await this.server.sendTransaction(prepared); + + if (sendResult.status === "ERROR") { + const diagnostics = (sendResult as any).errorResult + ?? (sendResult as any).diagnosticEventsXdr + ?? ""; + return { + success: false, + txHash: sendResult.hash, + ledger: 0, + error: `Transaction send error: ${diagnostics || sendResult.status}`, + }; + } + + // Poll for completion + const txResult = await this.pollTransaction(sendResult.hash); + return txResult; + } + + /** + * Build, sign, and submit a RestoreFootprintOp transaction to restore archived entries. + */ + async submitRestore( + entryKeyXdrs: string[], + secretKey: string, + ): Promise { + const passphrase = await this.getNetworkPassphrase(); + const keypair = Keypair.fromSecret(secretKey); + const publicKey = keypair.publicKey(); + + const accountResponse = await this.server.getAccount(publicKey); + const account = new Account(publicKey, accountResponse.sequenceNumber()); + + const keys = entryKeyXdrs.map(k => xdr.LedgerKey.fromXDR(k, "base64")); + + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: passphrase, + }) + .addOperation( + Operation.restoreFootprint({}), + ) + .setTimeout(30) + .setSorobanData( + new (rpc as any).SorobanDataBuilder() + .setReadWrite(keys) + .build(), + ) + .build(); + + const sim = await this.server.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(sim)) { + return { + success: false, + txHash: "", + ledger: 0, + error: sim.error ?? "Simulation failed", + }; + } + + const prepared = rpc.assembleTransaction(tx, sim).build(); + prepared.sign(keypair); + + const sendResult = await this.server.sendTransaction(prepared); + + if (sendResult.status === "ERROR") { + const diagnostics = (sendResult as any).errorResult + ?? (sendResult as any).diagnosticEventsXdr + ?? ""; + return { + success: false, + txHash: sendResult.hash, + ledger: 0, + error: `Transaction send error: ${diagnostics || sendResult.status}`, + }; + } + + return this.pollTransaction(sendResult.hash); + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private _cachedPassphrase: string | undefined; + + private async getNetworkPassphrase(): Promise { + if (this._cachedPassphrase) return this._cachedPassphrase; + + // Try fetching from the RPC server first + try { + const networkInfo = await this.server.getNetwork(); + if (networkInfo.passphrase) { + this._cachedPassphrase = networkInfo.passphrase; + return networkInfo.passphrase; + } + } catch { + // Fall through to hardcoded table + } + + const passphrase = NETWORK_PASSPHRASES[this.network]; + if (!passphrase) { + throw new Error( + `No network passphrase for "${this.network}". Use "testnet" or "mainnet".`, + ); + } + this._cachedPassphrase = passphrase; + return passphrase; + } + + /** + * Poll getTransaction until it reaches a terminal state (SUCCESS or FAILED). + */ + private async pollTransaction( + txHash: string, + maxAttempts = 30, + intervalMs = 1000, + ): Promise { + for (let i = 0; i < maxAttempts; i++) { + const txResponse = await this.server.getTransaction(txHash); + + if (txResponse.status === "SUCCESS") { + return { + success: true, + txHash, + ledger: (txResponse as any).ledger ?? txResponse.latestLedger, + }; + } + + if (txResponse.status === "FAILED") { + return { + success: false, + txHash, + ledger: (txResponse as any).ledger ?? txResponse.latestLedger, + error: "Transaction failed on-chain", + }; + } + + // NOT_FOUND — still pending + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + return { + success: false, + txHash, + ledger: 0, + error: `Transaction polling timed out after ${maxAttempts} attempts`, + }; + } } \ No newline at end of file diff --git a/src/utils/config.ts b/src/utils/config.ts index e69de29b..70bd2372 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -0,0 +1,86 @@ +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import YAML from "yaml"; +import { getLogger } from "../logging/index.js"; + +const logger = getLogger().child({ component: "Config" }); + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface SentinelConfig { + /** Default network to use. */ + network: string; + /** Default RPC URL override. */ + rpcUrl?: string; + /** Default polling interval in seconds for the daemon. */ + pollingIntervalSeconds: number; + /** Slack bot token for Slack alert delivery. */ + slackToken?: string; +} + +// ─── Defaults ─────────────────────────────────────────────────────────────── + +const DEFAULT_CONFIG: SentinelConfig = { + network: "testnet", + pollingIntervalSeconds: 300, +}; + +const SENTINEL_DIR = path.join(os.homedir(), ".soroban-sentinel"); +const CONFIG_FILE = path.join(SENTINEL_DIR, "config.yaml"); + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Load configuration from ~/.soroban-sentinel/config.yaml. + * Returns defaults if the file does not exist. + */ +export function loadConfig(customPath?: string): SentinelConfig { + const configPath = customPath ?? CONFIG_FILE; + + if (!fs.existsSync(configPath)) { + logger.debug(`No config file found at ${configPath}, using defaults`); + return { ...DEFAULT_CONFIG }; + } + + try { + const raw = fs.readFileSync(configPath, "utf-8"); + const parsed = YAML.parse(raw) as Partial; + + return { + network: parsed.network ?? DEFAULT_CONFIG.network, + rpcUrl: parsed.rpcUrl, + pollingIntervalSeconds: typeof parsed.pollingIntervalSeconds === "number" && parsed.pollingIntervalSeconds > 0 + ? parsed.pollingIntervalSeconds + : DEFAULT_CONFIG.pollingIntervalSeconds, + slackToken: parsed.slackToken, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Failed to parse config at ${configPath}: ${message}. Using defaults.`); + return { ...DEFAULT_CONFIG }; + } +} + +/** + * Save configuration to ~/.soroban-sentinel/config.yaml. + */ +export function saveConfig(config: SentinelConfig, customPath?: string): void { + const configPath = customPath ?? CONFIG_FILE; + const dir = path.dirname(configPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const yamlStr = YAML.stringify(config); + fs.writeFileSync(configPath, yamlStr, { encoding: "utf-8", mode: 0o600 }); + logger.debug(`Config saved to ${configPath}`); +} + +/** + * Get the Sentinel data directory path. + */ +export function getSentinelDir(): string { + return SENTINEL_DIR; +} diff --git a/tests/core/extension.test.ts b/tests/core/extension.test.ts new file mode 100644 index 00000000..66da27ba --- /dev/null +++ b/tests/core/extension.test.ts @@ -0,0 +1,522 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type Database from "better-sqlite3"; +import { getDatabaseForTesting } from "../../src/db/database.js"; +import { + insertContract, + upsertEntry, + upsertExtensionPolicy, + getEntriesForContract, + getExtensionHistory, +} from "../../src/db/repositories.js"; + +// ─── Mock RPC client ──────────────────────────────────────────────────────── + +const mockSubmitExtension = vi.fn(); +const mockSubmitRestore = vi.fn(); +const mockGetEntryTTLs = vi.fn(); +const mockGetCurrentLedger = vi.fn(); +const mockSimulateExtension = vi.fn(); + +vi.mock("../../src/rpc/client.js", () => { + return { + StellarRpcClient: class MockStellarRpcClient { + constructor() {} + submitExtension = mockSubmitExtension; + submitRestore = mockSubmitRestore; + getEntryTTLs = mockGetEntryTTLs; + getCurrentLedger = mockGetCurrentLedger; + simulateExtension = mockSimulateExtension; + }, + }; +}); + +// Import after mocking +const { extendEntries, restoreEntries, simulateExtension, runAutoExtensions } = await import( + "../../src/core/extension.js" +); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function seedContract(db: Database.Database, overrides?: Partial<{ id: string; network: string; name: string }>) { + const id = overrides?.id ?? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + insertContract(db, { + id, + name: overrides?.name ?? "Test Contract", + network: overrides?.network ?? "testnet", + }); + + upsertEntry(db, { + contract_id: id, + entry_key_xdr: "instance-key-xdr", + entry_type: "instance", + label: "Contract Instance", + live_until_ledger: 2500000, + last_modified_ledger: 2400000, + discovery_source: "deterministic", + }); + + upsertEntry(db, { + contract_id: id, + entry_key_xdr: "wasm-key-xdr", + entry_type: "wasm", + label: "WASM Code", + live_until_ledger: 2600000, + last_modified_ledger: 2400000, + discovery_source: "deterministic", + }); + + return id; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe("Core Extension Logic", () => { + let db: Database.Database; + const savedEnv: Record = {}; + + beforeEach(() => { + db = getDatabaseForTesting(); + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore env vars + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } + }); + + function setEnv(key: string, value: string) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } + + // ========================================================================= + // 1. extendEntries + // ========================================================================= + describe("extendEntries", () => { + it("returns error when contract not found", async () => { + const result = await extendEntries( + db, "NONEXISTENT", ["key1"], 100000, "SECRETKEY123", + ); + expect(result.success).toBe(false); + expect(result.error).toBe("Contract not found"); + }); + + it("returns error when no entries provided", async () => { + const contractId = seedContract(db); + const result = await extendEntries(db, contractId, [], 100000, "SECRETKEY123"); + expect(result.success).toBe(false); + expect(result.error).toBe("No entries to extend"); + }); + + it("extends entries and records history on success", async () => { + const contractId = seedContract(db); + const entries = getEntriesForContract(db, contractId); + + mockSubmitExtension.mockResolvedValue({ + success: true, + txHash: "abc123txhash", + ledger: 2500100, + }); + + mockGetEntryTTLs.mockResolvedValue({ + latestLedger: 2500100, + entries: [ + { + entryKeyXdr: "instance-key-xdr", + latestLedger: 2500100, + liveUntilLedgerSeq: 2600100, + lastModifiedLedgerSeq: 2500100, + remainingTTL: 100000, + }, + { + entryKeyXdr: "wasm-key-xdr", + latestLedger: 2500100, + liveUntilLedgerSeq: 2700100, + lastModifiedLedgerSeq: 2500100, + remainingTTL: 200000, + }, + ], + }); + + const result = await extendEntries( + db, + contractId, + entries.map(e => e.entry_key_xdr), + 100000, + "SECRETKEY123", + ); + + expect(result.success).toBe(true); + expect(result.entriesExtended).toBe(2); + expect(result.txHash).toBe("abc123txhash"); + expect(result.ledger).toBe(2500100); + + // Verify extension history was recorded + const history = getExtensionHistory(db, contractId); + expect(history.length).toBe(2); + expect(history[0]!.tx_hash).toBe("abc123txhash"); + + // Verify entries were updated with fresh TTLs + const updatedEntries = getEntriesForContract(db, contractId); + const instanceEntry = updatedEntries.find(e => e.entry_key_xdr === "instance-key-xdr"); + expect(instanceEntry!.live_until_ledger).toBe(2600100); + }); + + it("returns error on transaction failure", async () => { + const contractId = seedContract(db); + const entries = getEntriesForContract(db, contractId); + + mockSubmitExtension.mockResolvedValue({ + success: false, + txHash: "failed-tx", + ledger: 0, + error: "Insufficient funds", + }); + + const result = await extendEntries( + db, + contractId, + entries.map(e => e.entry_key_xdr), + 100000, + "SECRETKEY123", + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Insufficient funds"); + + // No history should be recorded + const history = getExtensionHistory(db, contractId); + expect(history.length).toBe(0); + }); + }); + + // ========================================================================= + // 2. simulateExtension + // ========================================================================= + describe("simulateExtension", () => { + it("returns fee estimate on successful simulation", async () => { + const contractId = seedContract(db); + + mockSimulateExtension.mockResolvedValue({ + success: true, + minResourceFee: 50000, + }); + + const result = await simulateExtension( + db, contractId, ["instance-key-xdr"], 100000, "GPUBLICKEY", + ); + + expect(result.success).toBe(true); + expect(result.estimatedFee).toBe(50000); + expect(result.entriesExtended).toBe(1); + }); + + it("returns error on simulation failure", async () => { + const contractId = seedContract(db); + + mockSimulateExtension.mockResolvedValue({ + success: false, + minResourceFee: 0, + error: "Entry is archived", + }); + + const result = await simulateExtension( + db, contractId, ["instance-key-xdr"], 100000, "GPUBLICKEY", + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Entry is archived"); + }); + + it("returns error when contract not found", async () => { + const result = await simulateExtension( + db, "NONEXISTENT", ["key1"], 100000, "GPUBLICKEY", + ); + expect(result.success).toBe(false); + expect(result.error).toBe("Contract not found"); + }); + }); + + // ========================================================================= + // 3. restoreEntries + // ========================================================================= + describe("restoreEntries", () => { + it("returns error when contract not found", async () => { + const result = await restoreEntries( + db, "NONEXISTENT", ["key1"], "SECRETKEY123", + ); + expect(result.success).toBe(false); + expect(result.error).toBe("Contract not found"); + }); + + it("returns error when no entries provided", async () => { + const contractId = seedContract(db); + const result = await restoreEntries(db, contractId, [], "SECRETKEY123"); + expect(result.success).toBe(false); + expect(result.error).toBe("No entries to restore"); + }); + + it("restores entries and updates DB on success", async () => { + const contractId = seedContract(db); + + mockSubmitRestore.mockResolvedValue({ + success: true, + txHash: "restore-tx-hash", + ledger: 2500200, + }); + + mockGetEntryTTLs.mockResolvedValue({ + latestLedger: 2500200, + entries: [ + { + entryKeyXdr: "instance-key-xdr", + latestLedger: 2500200, + liveUntilLedgerSeq: 2600200, + lastModifiedLedgerSeq: 2500200, + remainingTTL: 100000, + }, + ], + }); + + const result = await restoreEntries( + db, contractId, ["instance-key-xdr"], "SECRETKEY123", + ); + + expect(result.success).toBe(true); + expect(result.entriesRestored).toBe(1); + expect(result.txHash).toBe("restore-tx-hash"); + expect(result.ledger).toBe(2500200); + + // Verify entry was updated + const updatedEntries = getEntriesForContract(db, contractId); + const instanceEntry = updatedEntries.find(e => e.entry_key_xdr === "instance-key-xdr"); + expect(instanceEntry!.live_until_ledger).toBe(2600200); + }); + + it("returns error on restore transaction failure", async () => { + const contractId = seedContract(db); + + mockSubmitRestore.mockResolvedValue({ + success: false, + txHash: "failed-restore", + ledger: 0, + error: "Entry not found in archive", + }); + + const result = await restoreEntries( + db, contractId, ["instance-key-xdr"], "SECRETKEY123", + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Entry not found in archive"); + }); + }); + + // ========================================================================= + // 4. runAutoExtensions + // ========================================================================= + describe("runAutoExtensions", () => { + it("skips contracts without extension policies", async () => { + seedContract(db); + + const result = await runAutoExtensions(db, "testnet"); + + expect(result.contractsChecked).toBe(0); + expect(result.contractsExtended).toBe(0); + }); + + it("skips contracts with disabled policies", async () => { + const contractId = seedContract(db); + upsertExtensionPolicy(db, { + contract_id: contractId, + enabled: false, + target_ttl_ledgers: 100000, + extend_when_below_ledgers: 20000, + }); + + const result = await runAutoExtensions(db, "testnet"); + + expect(result.contractsChecked).toBe(0); + }); + + it("extends entries below threshold when policy is enabled", async () => { + const contractId = seedContract(db); + + // Set instance entry with low TTL (remaining = 10000 when latest ledger = 2400000) + upsertEntry(db, { + contract_id: contractId, + entry_key_xdr: "instance-key-xdr", + entry_type: "instance", + label: "Contract Instance", + live_until_ledger: 2410000, + discovery_source: "deterministic", + }); + + upsertExtensionPolicy(db, { + contract_id: contractId, + enabled: true, + target_ttl_ledgers: 100000, + extend_when_below_ledgers: 20000, + keypair_source: "env:TEST_SECRET_KEY", + }); + + setEnv("TEST_SECRET_KEY", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + mockGetCurrentLedger.mockResolvedValue(2400000); + + mockSubmitExtension.mockResolvedValue({ + success: true, + txHash: "auto-ext-tx", + ledger: 2400100, + }); + + mockGetEntryTTLs.mockResolvedValue({ + latestLedger: 2400100, + entries: [ + { + entryKeyXdr: "instance-key-xdr", + latestLedger: 2400100, + liveUntilLedgerSeq: 2500100, + lastModifiedLedgerSeq: 2400100, + remainingTTL: 100000, + }, + ], + }); + + const result = await runAutoExtensions(db, "testnet"); + + expect(result.contractsChecked).toBe(1); + expect(result.contractsExtended).toBe(1); + expect(result.entriesExtended).toBeGreaterThanOrEqual(1); + expect(result.extensions[0]!.txHash).toBe("auto-ext-tx"); + }); + + it("does not extend entries above threshold", async () => { + const contractId = seedContract(db); + + // Entries have high TTL (remaining = 100000, above 20000 threshold) + upsertExtensionPolicy(db, { + contract_id: contractId, + enabled: true, + target_ttl_ledgers: 200000, + extend_when_below_ledgers: 20000, + keypair_source: "env:TEST_SECRET_KEY", + }); + + setEnv("TEST_SECRET_KEY", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + mockGetCurrentLedger.mockResolvedValue(2400000); + + const result = await runAutoExtensions(db, "testnet"); + + // Entries have TTL ~100000 and ~200000, both above 20000 — no extension needed + expect(result.contractsChecked).toBe(1); + expect(result.contractsExtended).toBe(0); + expect(mockSubmitExtension).not.toHaveBeenCalled(); + }); + + it("reports error when keypair cannot be resolved", async () => { + const contractId = seedContract(db); + + upsertEntry(db, { + contract_id: contractId, + entry_key_xdr: "instance-key-xdr", + entry_type: "instance", + live_until_ledger: 2410000, + discovery_source: "deterministic", + }); + + upsertExtensionPolicy(db, { + contract_id: contractId, + enabled: true, + target_ttl_ledgers: 100000, + extend_when_below_ledgers: 20000, + keypair_source: "env:NONEXISTENT_VAR_12345", + }); + + mockGetCurrentLedger.mockResolvedValue(2400000); + + const result = await runAutoExtensions(db, "testnet"); + + expect(result.contractsChecked).toBe(1); + expect(result.contractsExtended).toBe(0); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain("Cannot resolve keypair"); + }); + + it("filters by network", async () => { + seedContract(db, { id: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS3", network: "mainnet" }); + + upsertExtensionPolicy(db, { + contract_id: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS3", + enabled: true, + target_ttl_ledgers: 100000, + extend_when_below_ledgers: 20000, + }); + + const result = await runAutoExtensions(db, "testnet"); + + // Should not process mainnet contracts when running for testnet + expect(result.contractsChecked).toBe(0); + }); + + it("collects errors without aborting for individual contract failures", async () => { + const id1 = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS1"; + const id2 = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYS2"; + + seedContract(db, { id: id1 }); + seedContract(db, { id: id2 }); + + // Both with low TTL entries + for (const id of [id1, id2]) { + upsertEntry(db, { + contract_id: id, + entry_key_xdr: `instance-${id}`, + entry_type: "instance", + live_until_ledger: 2410000, + discovery_source: "deterministic", + }); + upsertExtensionPolicy(db, { + contract_id: id, + enabled: true, + target_ttl_ledgers: 100000, + extend_when_below_ledgers: 20000, + keypair_source: "env:TEST_SECRET_KEY", + }); + } + + setEnv("TEST_SECRET_KEY", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + mockGetCurrentLedger.mockResolvedValue(2400000); + + // First contract succeeds, second fails + let callCount = 0; + mockSubmitExtension.mockImplementation(async () => { + callCount++; + if (callCount === 1) { + return { success: true, txHash: "tx1", ledger: 2400100 }; + } + return { success: false, txHash: "tx2", ledger: 0, error: "Insufficient funds" }; + }); + + mockGetEntryTTLs.mockResolvedValue({ + latestLedger: 2400100, + entries: [{ + entryKeyXdr: `instance-${id1}`, + latestLedger: 2400100, + liveUntilLedgerSeq: 2500100, + lastModifiedLedgerSeq: 2400100, + remainingTTL: 100000, + }], + }); + + const result = await runAutoExtensions(db, "testnet"); + + expect(result.contractsChecked).toBe(2); + // At least one should have been checked, and we should have errors + expect(result.errors.length).toBeGreaterThanOrEqual(1); + }); + }); +}); diff --git a/tests/utils/config.test.ts b/tests/utils/config.test.ts new file mode 100644 index 00000000..14350614 --- /dev/null +++ b/tests/utils/config.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { loadConfig, saveConfig } from "../../src/utils/config.js"; + +const TEST_DIR = path.join(os.tmpdir(), "sentinel-config-test-" + Date.now()); +const TEST_CONFIG_PATH = path.join(TEST_DIR, "config.yaml"); + +afterEach(() => { + try { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true }); + } + } catch { /* ignore cleanup errors */ } +}); + +describe("Config", () => { + describe("loadConfig", () => { + it("returns defaults when config file does not exist", () => { + const config = loadConfig("/nonexistent/path/config.yaml"); + expect(config.network).toBe("testnet"); + expect(config.pollingIntervalSeconds).toBe(300); + expect(config.rpcUrl).toBeUndefined(); + expect(config.slackToken).toBeUndefined(); + }); + + it("loads config from a YAML file", () => { + fs.mkdirSync(TEST_DIR, { recursive: true }); + fs.writeFileSync(TEST_CONFIG_PATH, [ + "network: mainnet", + "pollingIntervalSeconds: 600", + "rpcUrl: https://custom.rpc.example.com", + "slackToken: xoxb-test-token", + ].join("\n")); + + const config = loadConfig(TEST_CONFIG_PATH); + expect(config.network).toBe("mainnet"); + expect(config.pollingIntervalSeconds).toBe(600); + expect(config.rpcUrl).toBe("https://custom.rpc.example.com"); + expect(config.slackToken).toBe("xoxb-test-token"); + }); + + it("applies defaults for missing fields in YAML", () => { + fs.mkdirSync(TEST_DIR, { recursive: true }); + fs.writeFileSync(TEST_CONFIG_PATH, "network: mainnet\n"); + + const config = loadConfig(TEST_CONFIG_PATH); + expect(config.network).toBe("mainnet"); + expect(config.pollingIntervalSeconds).toBe(300); // default + }); + + it("returns defaults for invalid YAML", () => { + fs.mkdirSync(TEST_DIR, { recursive: true }); + fs.writeFileSync(TEST_CONFIG_PATH, "{{invalid yaml::"); + + const config = loadConfig(TEST_CONFIG_PATH); + expect(config.network).toBe("testnet"); + expect(config.pollingIntervalSeconds).toBe(300); + }); + }); + + describe("saveConfig", () => { + it("saves config to a YAML file", () => { + saveConfig({ + network: "mainnet", + pollingIntervalSeconds: 120, + rpcUrl: "https://rpc.example.com", + }, TEST_CONFIG_PATH); + + expect(fs.existsSync(TEST_CONFIG_PATH)).toBe(true); + + const loaded = loadConfig(TEST_CONFIG_PATH); + expect(loaded.network).toBe("mainnet"); + expect(loaded.pollingIntervalSeconds).toBe(120); + expect(loaded.rpcUrl).toBe("https://rpc.example.com"); + }); + + it("creates directories if they do not exist", () => { + const deepPath = path.join(TEST_DIR, "deep", "nested", "config.yaml"); + saveConfig({ network: "testnet", pollingIntervalSeconds: 300 }, deepPath); + expect(fs.existsSync(deepPath)).toBe(true); + }); + }); +});