Skip to content

Commit 9c82b9e

Browse files
Merge pull request #125 from OpadijoIdris/feat/issue-121-plugin-architecture
docs: propose plugin system architecture for keeper resolvers (issue …
2 parents 38a31df + 5021da9 commit 9c82b9e

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

keeper/RESOLVER_PLUGINS.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Keeper Plugin System Architecture (Resolvers)
2+
3+
## Overview
4+
5+
The SoroTask Keeper currently determines task readiness based on time intervals and on-chain state (e.g., executing a task when `last_run + interval` has elapsed). To support more complex, dynamic, and off-chain conditional logic, this document proposes a **Plugin Architecture for Custom Resolvers**.
6+
7+
A **Resolver** is a modular component that can:
8+
1. Perform off-chain checks (e.g., API calls, price feeds, subgraph queries) to determine if a task is "ready".
9+
2. Dynamically construct execution payloads or arguments required by the task on-chain.
10+
11+
This architecture enables Keepers to flexibly support diverse, advanced task automations without requiring forks or modifications to the core Keeper codebase.
12+
13+
---
14+
15+
## 1. Interface Definition
16+
17+
To ensure uniformity and ease of use, all custom resolvers must conform to a standardized interface. Since the SoroTask Keeper is built on Node.js, plugins will be structured as Javascript classes that implement the `ResolverPlugin` interface.
18+
19+
```javascript
20+
/**
21+
* Interface that all SoroTask Keeper Resolver Plugins must implement.
22+
*/
23+
class BaseResolverPlugin {
24+
/**
25+
* Initializes the plugin with any necessary configuration.
26+
* This is called once during Keeper startup.
27+
*
28+
* @param {Object} config - The plugin-specific configuration.
29+
* @param {Object} context - Global keeper context (logger, RPC provider, etc).
30+
*/
31+
async init(config, context) {
32+
// Implement setup logic here (e.g., DB connections, websocket subscriptions)
33+
}
34+
35+
/**
36+
* Evaluates if a specific task is ready for execution, and optionally
37+
* provides execution payload/arguments.
38+
*
39+
* @param {string} taskId - The ID of the task being evaluated.
40+
* @param {Object} taskConfig - The current configuration of the task from the contract.
41+
* @returns {Promise<ResolverResult>}
42+
*/
43+
async resolve(taskId, taskConfig) {
44+
throw new Error('resolve() must be implemented by the plugin');
45+
}
46+
47+
/**
48+
* Cleans up resources upon Keeper shutdown.
49+
*/
50+
async destroy() {
51+
// Implement teardown logic here
52+
}
53+
}
54+
55+
/**
56+
* Expected return structure from resolve()
57+
*
58+
* @typedef {Object} ResolverResult
59+
* @property {boolean} isReady - True if the task should be executed.
60+
* @property {Array<any>} [args] - Optional list of arguments to pass into the transaction.
61+
* @property {string} [reason] - Optional reason for skipping (useful for Keeper logging).
62+
*/
63+
module.exports = BaseResolverPlugin;
64+
```
65+
66+
---
67+
68+
## 2. Dynamic Loading & Registration
69+
70+
Keepers can dynamically register and load these module-based plugins via a configuration file, allowing for extreme portability. Node operators will simply provide the location of the plugin (either a local directory or an `npm` package).
71+
72+
### Configuration (`plugins.json`)
73+
74+
A new `plugins.json` file will be supported by the Keeper to declare which resolvers to load on startup.
75+
76+
```json
77+
{
78+
"resolvers": {
79+
"price-monitor": {
80+
"path": "./plugins/local-price-monitor", // Local path
81+
"options": {
82+
"threshold": 2000,
83+
"assetPair": "XLM/USD"
84+
}
85+
},
86+
"custom-api": {
87+
"path": "sorotask-resolver-custom-api", // npm package
88+
"options": {
89+
"endpoint": "https://api.example.com/check"
90+
}
91+
}
92+
}
93+
}
94+
```
95+
96+
### PluginManager
97+
98+
A new `PluginManager` internal module will be responsible for instantiating the classes. When the Keeper starts (`index.js`), it will initialize the PluginManager, passing it the configuration map.
99+
100+
```javascript
101+
// Internal pseudo-implementation for PluginManager
102+
class PluginManager {
103+
constructor() {
104+
this.resolvers = new Map();
105+
}
106+
107+
async loadPlugins(pluginConfig, context) {
108+
for (const [name, config] of Object.entries(pluginConfig.resolvers)) {
109+
try {
110+
const PluginClass = require(config.path);
111+
const pluginInstance = new PluginClass();
112+
await pluginInstance.init(config.options, context);
113+
114+
this.resolvers.set(name, pluginInstance);
115+
context.logger.info(`Successfully loaded plugin: ${name}`);
116+
} catch (err) {
117+
context.logger.error(`Failed to load plugin ${name}: ${err.message}`);
118+
}
119+
}
120+
}
121+
122+
getResolver(name) {
123+
return this.resolvers.get(name);
124+
}
125+
}
126+
```
127+
128+
---
129+
130+
## 3. Integration with Poller
131+
132+
The current `TaskPoller` logic handles time-based and gas-based constraints. We will insert the Resolver capability as an optional, final step before placing a task into the execution queue.
133+
134+
### Task to Resolver Mapping
135+
To know *which* off-chain resolver a task requires, the node operator can declare a mapping file (e.g., `task_resolvers.json`), mapping a `taskId` to a `resolver_name`.
136+
137+
*(Future Iteration: The SoroTask smart contract might include a `resolver` identifier field natively in the `TaskConfig` struct).*
138+
139+
### Control Flow
140+
Inside `poller.js` -> `pollDueTasks(taskIds)`:
141+
142+
1. **Time Check**: Ensure `current_ledger_timestamp >= task.last_run + task.interval`.
143+
2. **Gas Check**: Ensure `task.gas_balance > 0`.
144+
3. **[NEW] Resolver Check**:
145+
- Check if `taskId` maps to a registered resolver.
146+
- If yes, invoke `PluginManager.getResolver(name).resolve(taskId, taskConfig)`.
147+
- If `isReady === false`, skip execution, log `result.reason`.
148+
- If `isReady === true`, push the task to `dueTaskIds`. If `result.args` are provided, attach them to the execution queue payload.
149+
150+
```javascript
151+
// Pseudo-code in poller.js
152+
if (taskNeedsResolver(taskId)) {
153+
const resolverName = getResolverName(taskId);
154+
const resolver = pluginManager.getResolver(resolverName);
155+
156+
if (resolver) {
157+
const result = await resolver.resolve(taskId, taskConfig);
158+
if (!result.isReady) {
159+
logger.debug(`Task ${taskId} not ready. Reason: ${result.reason}`);
160+
continue; // Skip execution
161+
}
162+
163+
// Push task for execution with dynamic args
164+
dueTasks.push({ taskId, args: result.args || [] });
165+
}
166+
} else {
167+
// Normal execution
168+
dueTasks.push({ taskId, args: [] });
169+
}
170+
```
171+
172+
---
173+
174+
## 4. Security & Considerations
175+
176+
1. **Trust Model**: The Keeper executes arbitrary code in the form of plugins. It is assumed that the Node Operator strictly vets and trusts any plugin running alongside their Keeper, as plugins run in the same node process.
177+
2. **Error Boundaries**: If a plugin's `resolve()` method throws an exception or times out, the `TaskPoller` must catch the error, log a warning, and skip the task for that cycle, ensuring the main Keeper polling loop doesn't crash.
178+
3. **Performance**: Off-chain API calls during `resolve()` must be performant. The Keeper should enforce an upper limit timeout (e.g., `5000ms`) on custom `resolve()` invocations to prevent the entire cycle from halting.

0 commit comments

Comments
 (0)