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
15 changes: 5 additions & 10 deletions keeper/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ POLLING_INTERVAL_MS=10000
MAX_CONCURRENT_READS=10
MAX_CONCURRENT_EXECUTIONS=3
MAX_TASK_ID=100
LOG_LEVEL=info
# LOG_FORMAT=pretty
```

### Getting Your Keeper Secret
Expand Down Expand Up @@ -116,18 +118,11 @@ The `SOROBAN_RPC_URL` is unreachable. Check your `.env` and network.
npm start
```

You should see output like:
You should see JSON logs like:

```
Starting SoroTask Keeper...
Connected to Soroban RPC: https://rpc-futurenet.stellar.org
Keeper account: GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Poller initialized with max concurrent reads: 10
Starting polling loop with interval: 10000ms
[Keeper] Running initial poll...
[Keeper] Initial check: 100 tasks in registry
[Poller] Current ledger sequence: 12345
[Poller] Poll complete in 234ms | Checked: 5 | Due: 2 | Skipped: 1 | Errors: 0
{"level":"info","timestamp":"2026-01-01T12:00:00.000Z","service":"keeper","module":"keeper","message":"Starting SoroTask Keeper"}
{"level":"info","timestamp":"2026-01-01T12:00:01.000Z","service":"keeper","module":"poller","message":"Task is due","taskId":1}
```

## Step 5: Monitor Execution
Expand Down
7 changes: 7 additions & 0 deletions keeper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ MAX_TASK_ID=100

# Wait for transaction confirmation (default: true, set to 'false' to disable)
WAIT_FOR_CONFIRMATION=true

# Structured logging
LOG_LEVEL=info
# Optional: pretty console output for local development only
# LOG_FORMAT=pretty
```

### Explanation of Variables:
Expand All @@ -56,6 +61,8 @@ WAIT_FOR_CONFIRMATION=true
- **`MAX_TASK_ID`**: The keeper will check task IDs from 1 to this value. Alternatively, use `TASK_IDS` to specify exact task IDs.
- **`TASK_IDS`**: Optional comma-separated list of specific task IDs to monitor (e.g., "1,2,3,5"). If set, overrides `MAX_TASK_ID`.
- **`WAIT_FOR_CONFIRMATION`**: Whether to wait for transaction confirmation after submitting. Set to 'false' for fire-and-forget mode.
- **`LOG_LEVEL`**: Minimum log severity to emit (`trace`, `debug`, `info`, `warn`, `error`, `fatal`).
- **`LOG_FORMAT`**: Optional log renderer. Leave unset for JSON logs; set to `pretty` for local human-readable output.

## Setup Instructions

Expand Down
36 changes: 18 additions & 18 deletions keeper/__tests__/account.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,27 @@
const { initializeKeeperAccount } = require('../src/account');

describe('Keeper Account Module', () => {
const originalEnv = process.env;
const originalEnv = process.env;

beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});
afterEach(() => {
process.env = originalEnv;
});

it('should throw error when KEEPER_SECRET is missing', async () => {
delete process.env.KEEPER_SECRET;
it('should throw error when KEEPER_SECRET is missing', async () => {
delete process.env.KEEPER_SECRET;

// Just test that it throws when secret is missing
await expect(initializeKeeperAccount()).rejects.toThrow();
});
// Just test that it throws when secret is missing
await expect(initializeKeeperAccount()).rejects.toThrow();
});

it('should have KEEPER_SECRET defined', () => {
process.env.KEEPER_SECRET = 'test-secret';
// This test just verifies env is set correctly
expect(process.env.KEEPER_SECRET).toBe('test-secret');
});
it('should have KEEPER_SECRET defined', () => {
process.env.KEEPER_SECRET = 'test-secret';
// This test just verifies env is set correctly
expect(process.env.KEEPER_SECRET).toBe('test-secret');
});
});
76 changes: 30 additions & 46 deletions keeper/__tests__/executor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

// Create mock objects at module scope for jest.mock
const mockWithRetryImpl = jest.fn((fn, options) => fn().then(result => ({
const mockWithRetryImpl = jest.fn((fn, _options) => fn().then(result => ({
success: true,
result,
attempts: 1,
Expand Down Expand Up @@ -44,7 +44,7 @@ describe('Executor', () => {

beforeEach(() => {
jest.clearAllMocks();

mockConfig = {
maxRetries: 3,
retryBaseDelayMs: 1000,
Expand Down Expand Up @@ -72,9 +72,9 @@ describe('Executor', () => {
warn: jest.fn(),
error: jest.fn(),
};
const executorWithLogger = createExecutor({
const executorWithLogger = createExecutor({
logger: customLogger,
config: mockConfig
config: mockConfig,
});
expect(executorWithLogger).toBeDefined();
});
Expand All @@ -83,9 +83,9 @@ describe('Executor', () => {
describe('execute', () => {
it('should execute task successfully', async () => {
const task = { id: 1, name: 'test-task' };

const result = await executor.execute(task);

expect(result.success).toBe(true);
expect(result.result).toEqual({ taskId: 1, status: 'executed' });
expect(result.attempts).toBe(1);
Expand All @@ -94,17 +94,17 @@ describe('Executor', () => {

it('should include task ID in result', async () => {
const task = { id: 42 };

const result = await executor.execute(task);

expect(result.result.taskId).toBe(42);
});

it('should track execution attempts', async () => {
const task = { id: 1 };

const result = await executor.execute(task);

expect(result.attempts).toBeGreaterThanOrEqual(1);
expect(result.retries).toBeGreaterThanOrEqual(0);
});
Expand Down Expand Up @@ -153,23 +153,23 @@ describe('Executor Integration with Retry', () => {

it('should pass correct options to withRetry', async () => {
const { createExecutor } = require('../src/executor');
mockWithRetry.mockImplementation((fn, options) => fn().then(result => ({

mockWithRetry.mockImplementation((fn, _options) => fn().then(result => ({
success: true,
result,
attempts: 1,
retries: 0,
})));

const config = {
maxRetries: 5,
retryBaseDelayMs: 500,
maxRetryDelayMs: 10000,
};

const executor = createExecutor({ config });
await executor.execute({ id: 1 });

expect(mockWithRetry).toHaveBeenCalled();
const options = mockWithRetry.mock.calls[0][1];
expect(options.maxRetries).toBe(5);
Expand All @@ -179,17 +179,17 @@ describe('Executor Integration with Retry', () => {

it('should use default retry options when config not provided', async () => {
const { createExecutor } = require('../src/executor');
mockWithRetry.mockImplementation((fn, options) => fn().then(result => ({

mockWithRetry.mockImplementation((fn, _options) => fn().then(result => ({
success: true,
result,
attempts: 1,
retries: 0,
})));

const executor = createExecutor({});
await executor.execute({ id: 1 });

const options = mockWithRetry.mock.calls[0][1];
expect(options.maxRetries).toBe(3);
expect(options.baseDelayMs).toBe(1000);
Expand All @@ -198,7 +198,7 @@ describe('Executor Integration with Retry', () => {

it('should call onRetry callback on retry', async () => {
const { createExecutor } = require('../src/executor');

const onRetryMock = jest.fn();
mockWithRetry.mockImplementation((fn, options) => {
if (options.onRetry) {
Expand All @@ -211,16 +211,16 @@ describe('Executor Integration with Retry', () => {
retries: 0,
}));
});

const executor = createExecutor({ config: { maxRetries: 3, onRetry: onRetryMock } });
await executor.execute({ id: 1 });

expect(mockWithRetry).toHaveBeenCalled();
});

it('should call onMaxRetries callback when max retries exceeded', async () => {
const { createExecutor } = require('../src/executor');

mockWithRetry.mockImplementation((fn, options) => {
if (options.onMaxRetries) {
options.onMaxRetries(new Error('max retries'), 3);
Expand All @@ -232,16 +232,16 @@ describe('Executor Integration with Retry', () => {
retries: 0,
}));
});

const executor = createExecutor({ config: { maxRetries: 3 } });
await executor.execute({ id: 1 });

expect(mockWithRetry).toHaveBeenCalled();
});

it('should call onDuplicate callback for duplicate transactions', async () => {
const { createExecutor } = require('../src/executor');

mockWithRetry.mockImplementation((fn, options) => {
if (options.onDuplicate) {
options.onDuplicate();
Expand All @@ -253,10 +253,10 @@ describe('Executor Integration with Retry', () => {
retries: 0,
}));
});

const executor = createExecutor({ config: { maxRetries: 3 } });
await executor.execute({ id: 1 });

expect(mockWithRetry).toHaveBeenCalled();
});
});
Expand All @@ -282,22 +282,6 @@ describe('executeTask', () => {
it('executeTask should be callable with correct parameters', async () => {
// This test verifies that executeTask can be called
// In a real environment with actual SDK, this would test the full flow
const mockServer = {
simulateTransaction: jest.fn().mockResolvedValue({ results: [] }),
sendTransaction: jest.fn().mockResolvedValue({ hash: 'test123', status: 'PENDING' }),
getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }),
};

const mockKeypair = {
publicKey: jest.fn().mockReturnValue('GPUB123'),
sign: jest.fn(),
};

const mockAccount = {
accountId: jest.fn().mockReturnValue('GPUB123'),
sequenceNumber: jest.fn().mockReturnValue('1'),
};

// The function should accept these parameters without throwing
// Actual execution would require real SDK
expect(() => {
Expand All @@ -315,7 +299,7 @@ describe('executeTask', () => {
feePaid: 100,
error: null,
};

expect(mockResult).toMatchObject({
taskId: expect.any(Number),
txHash: expect.any(String),
Expand All @@ -329,7 +313,7 @@ describe('executeTask', () => {
// Verify the polling behavior is documented
const POLL_ATTEMPTS = 30;
const POLL_INTERVAL_MS = 2000;

expect(POLL_ATTEMPTS).toBe(30);
expect(POLL_INTERVAL_MS).toBe(2000);
});
Expand Down
46 changes: 23 additions & 23 deletions keeper/__tests__/gasMonitor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,33 @@
const { GasMonitor } = require('../src/gasMonitor');

describe('GasMonitor', () => {
let gasMonitor;
let gasMonitor;

beforeEach(() => {
gasMonitor = new GasMonitor();
});
beforeEach(() => {
gasMonitor = new GasMonitor();
});

it('should create GasMonitor instance', () => {
expect(gasMonitor).toBeDefined();
});
it('should create GasMonitor instance', () => {
expect(gasMonitor).toBeDefined();
});

it('should have default threshold', () => {
expect(gasMonitor.GAS_WARN_THRESHOLD).toBeDefined();
});
it('should have default threshold', () => {
expect(gasMonitor.GAS_WARN_THRESHOLD).toBeDefined();
});

it('should get low gas count', () => {
const count = gasMonitor.getLowGasCount();
expect(typeof count).toBe('number');
});
it('should get low gas count', () => {
const count = gasMonitor.getLowGasCount();
expect(typeof count).toBe('number');
});

it('should get config', () => {
const config = gasMonitor.getConfig();
expect(config).toBeDefined();
expect(config.gasWarnThreshold).toBeDefined();
});
it('should get config', () => {
const config = gasMonitor.getConfig();
expect(config).toBeDefined();
expect(config.gasWarnThreshold).toBeDefined();
});

it('should check gas balance without throwing', async () => {
const result = await gasMonitor.checkGasBalance('task1', 100);
expect(typeof result).toBe('boolean');
});
it('should check gas balance without throwing', async () => {
const result = await gasMonitor.checkGasBalance('task1', 100);
expect(typeof result).toBe('boolean');
});
});
Loading
Loading