Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 54 additions & 3 deletions keeper/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -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:

Expand Down
27 changes: 25 additions & 2 deletions keeper/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions keeper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
121 changes: 121 additions & 0 deletions keeper/src/dryRun.js
Original file line number Diff line number Diff line change
@@ -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 };