-
Notifications
You must be signed in to change notification settings - Fork 208
feat: implement core business logic for TTL extension, restore, discovery, and CLI commands #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 28f92be
feat(rpc): add TTL extension and restore transaction support
AbdulmalikAlayande 0030b05
feat(core): implement TTL extension, auto-extension, and restore logic
AbdulmalikAlayande 8c0cc1c
feat(core): implement footprint-based storage key discovery
AbdulmalikAlayande 227901e
feat(cli): implement guard command for auto-extension policies
AbdulmalikAlayande 249672e
feat(cli): implement costs command for extension history reporting
AbdulmalikAlayande 2a5fa30
feat(cli): implement restore command for archived entry recovery
AbdulmalikAlayande ffbf20d
feat(cli): wire up guard, costs, and restore commands
AbdulmalikAlayande f2edb20
feat(daemon): integrate auto-extension into monitoring cycle
AbdulmalikAlayande 88cb772
fix(core): address review feedback for extension module
AbdulmalikAlayande 40fbd18
fix(rpc): address review feedback for RPC client
AbdulmalikAlayande 7b0415a
fix(core): address review feedback for discovery module
AbdulmalikAlayande 563adaf
fix(cli): address review feedback for guard command
AbdulmalikAlayande e3790b7
fix(cli): address review feedback for costs command
AbdulmalikAlayande 6b5a57b
fix(cli): reject conflicting --entry and --all flags in restore
AbdulmalikAlayande fda5f77
fix(utils): harden config loader and writer
AbdulmalikAlayande 7ad576c
fix(test): replace seed-like test fixtures with obvious fakes
AbdulmalikAlayande File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
|
|
||
| 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.`)); | ||
| } | ||
|
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); | ||
| } | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
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); | ||
| } | ||
| }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.