diff --git a/keeper/QUICKSTART.md b/keeper/QUICKSTART.md index ab740e0..a4ae62f 100644 --- a/keeper/QUICKSTART.md +++ b/keeper/QUICKSTART.md @@ -59,7 +59,58 @@ soroban contract deploy \ --network futurenet ``` -## Step 3: Start the Keeper +## Step 3 (Optional): Dry-Run the Keeper + +Before spending real XLM, use dry-run mode to verify your configuration and simulate task execution without submitting any transactions. + +```bash +npm run dry-run +# or directly: +node index.js --dry-run +``` + +In dry-run mode the keeper: +- Connects to the RPC and loads your account (read-only) +- Polls for due tasks exactly as it would in live mode +- **Builds and simulates** each task's `execute()` transaction against the network +- Logs the simulated fee estimate, task eligibility, and any contract errors +- **Skips** signing, submitting, and confirmation — nothing is written on-chain + +### Sample dry-run output + +``` +{"level":30,"module":"keeper","msg":"Starting SoroTask Keeper in DRY-RUN mode — no transactions will be submitted"} +{"level":30,"module":"keeper","msg":"Configuration loaded","network":"Test SDF Future Network ; October 2022"} +{"level":30,"module":"dry-run","msg":"Simulating task — no transaction will be submitted","taskId":1} +{"level":30,"module":"dry-run","msg":"Transaction built","taskId":1,"keeperAddress":"GXXX...","contractId":"CXXX..."} +{"level":30,"module":"dry-run","msg":"Sending simulation request to RPC","taskId":1} +{"level":30,"module":"dry-run","msg":"Simulation successful — task is eligible and would execute on-chain","taskId":1,"estimatedFee":12345,"latestLedger":987654} +{"level":30,"module":"dry-run","msg":"SKIPPING sign, submit, and confirmation (dry-run mode active)","taskId":1} +{"level":30,"module":"keeper","msg":"Dry-run result","taskId":1,"status":"DRY_RUN_SUCCESS","estimatedFee":12345,"error":null} +``` + +### Interpreting dry-run results + +| `status` | Meaning | +|---|---| +| `DRY_RUN_SUCCESS` | Task is due and the contract call would succeed on-chain | +| `DRY_RUN_SIM_FAILED` | Task is due but the contract would revert — check the `error` field | +| `DRY_RUN_ERROR` | Unexpected local error (bad config, RPC unreachable, etc.) | + +### Common dry-run issues + +**`DRY_RUN_SIM_FAILED` with "TaskPaused" or "TaskAlreadyActive"** +The task exists but is not eligible for execution right now. This is not a keeper misconfiguration. + +**`DRY_RUN_ERROR` with "Account not found"** +Your `KEEPER_SECRET` points to an unfunded account. Fund it via Friendbot before testing. + +**`DRY_RUN_ERROR` with "connect ECONNREFUSED"** +The `SOROBAN_RPC_URL` is unreachable. Check your `.env` and network. + +--- + +## Step 4: Start the Keeper ```bash npm start @@ -79,7 +130,7 @@ Starting polling loop with interval: 10000ms [Poller] Poll complete in 234ms | Checked: 5 | Due: 2 | Skipped: 1 | Errors: 0 ``` -## Step 4: Monitor Execution +## Step 5: Monitor Execution Watch the logs for task execution: @@ -101,7 +152,7 @@ Watch the logs for task execution: [Keeper] ===== Polling cycle complete ===== ``` -## Step 5: Graceful Shutdown +## Step 6: Graceful Shutdown Press `Ctrl+C` to stop the keeper: diff --git a/keeper/index.js b/keeper/index.js index f37bd8e..ca671cb 100644 --- a/keeper/index.js +++ b/keeper/index.js @@ -8,12 +8,20 @@ const { ExecutionQueue } = require('./src/queue'); const TaskPoller = require('./src/poller'); const TaskRegistry = require('./src/registry'); const { createLogger } = require('./src/logger'); +const { dryRunTask } = require('./src/dryRun'); // Create root logger for the main module const logger = createLogger('keeper'); +// Parse --dry-run flag from CLI arguments +const DRY_RUN = process.argv.includes('--dry-run'); + async function main() { - logger.info('Starting SoroTask Keeper'); + if (DRY_RUN) { + logger.info('Starting SoroTask Keeper in DRY-RUN mode — no transactions will be submitted'); + } else { + logger.info('Starting SoroTask Keeper'); + } let config; try { @@ -55,11 +63,26 @@ async function main() { queue.on('cycle:complete', (stats) => queueLogger.info('Cycle complete', stats)); // Task executor function - calls contract.execute(keeper, task_id) + // In dry-run mode, simulates the transaction without submitting it. const executeTask = async (taskId) => { + const account = await server.getAccount(keypair.publicKey()); + const deps = { + server, + keypair, + account, + contractId: config.contractId, + networkPassphrase: config.networkPassphrase || Networks.FUTURENET, + }; + + if (DRY_RUN) { + const result = await dryRunTask(taskId, deps); + logger.info('Dry-run result', { taskId, status: result.status, estimatedFee: result.simulation?.estimatedFee ?? null, error: result.error }); + return; + } + try { // Build the execute transaction const contract = new Contract(config.contractId); - const account = await server.getAccount(keypair.publicKey()); const operation = contract.call( 'execute', diff --git a/keeper/package.json b/keeper/package.json index ffa2053..1167d1c 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "start": "node index.js", + "dry-run": "node index.js --dry-run", "dev": "node index.js | pino-pretty", "test": "jest", "test:watch": "jest --watch", diff --git a/keeper/src/dryRun.js b/keeper/src/dryRun.js new file mode 100644 index 0000000..41fc611 --- /dev/null +++ b/keeper/src/dryRun.js @@ -0,0 +1,121 @@ +/** + * Dry-Run Executor for SoroTask Keeper + * + * Simulates task execution locally by building and simulating a Soroban + * transaction without signing or submitting it to the network. + * + * Use this to: + * - Validate keeper configuration before going live + * - Debug task eligibility and contract calls + * - Estimate fees without spending real tokens + * + * Usage: + * node index.js --dry-run + */ + +const { + Contract, + xdr, + TransactionBuilder, + BASE_FEE, + Networks, + rpc: SorobanRpc, +} = require('@stellar/stellar-sdk'); +const { createLogger } = require('./logger.js'); + +const logger = createLogger('dry-run'); + +/** + * Simulate a task execution without submitting a transaction. + * + * Mirrors the first three steps of executeTask (build → simulate → assemble) + * then stops. No signing, no network submission, no fees charged. + * + * @param {number|bigint} taskId + * @param {object} deps + * @param {import('@stellar/stellar-sdk').rpc.Server} deps.server + * @param {import('@stellar/stellar-sdk').Keypair} deps.keypair + * @param {import('@stellar/stellar-sdk').Account} deps.account + * @param {string} deps.contractId + * @param {string} deps.networkPassphrase + * @returns {Promise<{taskId, txHash: null, status: string, feePaid: 0, error: string|null, simulation: object|null}>} + */ +async function dryRunTask(taskId, { server, keypair, account, contractId, networkPassphrase }) { + const result = { + taskId, + txHash: null, + status: 'DRY_RUN_PENDING', + feePaid: 0, + error: null, + simulation: null, + }; + + logger.info('Simulating task — no transaction will be submitted', { taskId }); + + try { + const contract = new Contract(contractId); + const taskIdScVal = xdr.ScVal.scvU64(xdr.Uint64.fromString(taskId.toString())); + + // ── Step 1: Build ──────────────────────────────────────────────────────── + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: networkPassphrase || Networks.FUTURENET, + }) + .addOperation(contract.call('execute', taskIdScVal)) + .setTimeout(30) + .build(); + + logger.info('Transaction built', { + taskId, + keeperAddress: keypair.publicKey(), + contractId, + networkPassphrase, + }); + + // ── Step 2: Simulate ───────────────────────────────────────────────────── + logger.info('Sending simulation request to RPC', { taskId }); + const simResult = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + logger.warn('Simulation returned an error — task would fail on-chain', { + taskId, + error: simResult.error, + }); + result.status = 'DRY_RUN_SIM_FAILED'; + result.error = simResult.error; + return result; + } + + // ── Step 3: Assemble (validates footprint, does NOT sign/submit) ───────── + const assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); + + const estimatedFee = simResult.minResourceFee + ? Number(simResult.minResourceFee) + : 0; + + result.simulation = { + estimatedFee, + latestLedger: simResult.latestLedger, + transactionXdr: assembledTx.toEnvelope().toXDR('base64'), + }; + result.status = 'DRY_RUN_SUCCESS'; + + logger.info('Simulation successful — task is eligible and would execute on-chain', { + taskId, + estimatedFee, + latestLedger: simResult.latestLedger, + }); + + // ── Dry-run boundary — sign & submit are intentionally skipped ─────────── + logger.info('SKIPPING sign, submit, and confirmation (dry-run mode active)', { taskId }); + + } catch (err) { + result.status = 'DRY_RUN_ERROR'; + result.error = err.message || String(err); + logger.error('Unexpected error during simulation', { taskId, error: result.error }); + } + + return result; +} + +module.exports = { dryRunTask };