Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6be5685
feat(utils): implement YAML config loader and saver
AbdulmalikAlayande Jun 13, 2026
28f92be
feat(rpc): add TTL extension and restore transaction support
AbdulmalikAlayande Jun 13, 2026
0030b05
feat(core): implement TTL extension, auto-extension, and restore logic
AbdulmalikAlayande Jun 13, 2026
8c0cc1c
feat(core): implement footprint-based storage key discovery
AbdulmalikAlayande Jun 13, 2026
227901e
feat(cli): implement guard command for auto-extension policies
AbdulmalikAlayande Jun 13, 2026
249672e
feat(cli): implement costs command for extension history reporting
AbdulmalikAlayande Jun 13, 2026
2a5fa30
feat(cli): implement restore command for archived entry recovery
AbdulmalikAlayande Jun 13, 2026
ffbf20d
feat(cli): wire up guard, costs, and restore commands
AbdulmalikAlayande Jun 13, 2026
f2edb20
feat(daemon): integrate auto-extension into monitoring cycle
AbdulmalikAlayande Jun 13, 2026
88cb772
fix(core): address review feedback for extension module
AbdulmalikAlayande Jun 13, 2026
40fbd18
fix(rpc): address review feedback for RPC client
AbdulmalikAlayande Jun 13, 2026
7b0415a
fix(core): address review feedback for discovery module
AbdulmalikAlayande Jun 13, 2026
563adaf
fix(cli): address review feedback for guard command
AbdulmalikAlayande Jun 13, 2026
e3790b7
fix(cli): address review feedback for costs command
AbdulmalikAlayande Jun 13, 2026
6b5a57b
fix(cli): reject conflicting --entry and --all flags in restore
AbdulmalikAlayande Jun 13, 2026
fda5f77
fix(utils): harden config loader and writer
AbdulmalikAlayande Jun 13, 2026
7ad576c
fix(test): replace seed-like test fixtures with obvious fakes
AbdulmalikAlayande Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/commands/costs.ts
Original file line number Diff line number Diff line change
@@ -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 <contractId>")
.description("Show rent costs and extension history for a contract")
.option("--period <days>", "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";

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<string, { count: number; cost: number }> = {};

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.`));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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);
}
});
}
193 changes: 193 additions & 0 deletions src/commands/guard.ts
Original file line number Diff line number Diff line change
@@ -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 <contractId>")
.description("Configure auto-extension policy for a contract")
.option("--target-ttl <ledgers>", "Target TTL in ledgers after extension", "100000")
.option("--threshold <ledgers>", "Extend when TTL drops below this many ledgers", "20000")
.option("--keypair <secret>", "Stellar secret key for signing extension transactions")
.option("--keypair-env <var>", "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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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);
}
});
}
Loading
Loading