@cfxdevkit/executor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +860 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +425 -0
- package/dist/index.d.ts +425 -0
- package/dist/index.js +831 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
DEFAULT_SAFETY_CONFIG: () => DEFAULT_SAFETY_CONFIG,
|
|
24
|
+
Executor: () => Executor,
|
|
25
|
+
KeeperClientImpl: () => KeeperClientImpl,
|
|
26
|
+
PriceChecker: () => PriceChecker,
|
|
27
|
+
RetryQueue: () => RetryQueue,
|
|
28
|
+
SafetyGuard: () => SafetyGuard,
|
|
29
|
+
noopLogger: () => noopLogger
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(src_exports);
|
|
32
|
+
|
|
33
|
+
// src/executor.ts
|
|
34
|
+
var import_core = require("@cfxdevkit/core");
|
|
35
|
+
var Executor = class {
|
|
36
|
+
priceChecker;
|
|
37
|
+
safetyGuard;
|
|
38
|
+
retryQueue;
|
|
39
|
+
keeperClient;
|
|
40
|
+
jobStore;
|
|
41
|
+
dryRun;
|
|
42
|
+
constructor(priceChecker, safetyGuard, retryQueue, keeperClient, jobStore, options = {}) {
|
|
43
|
+
this.priceChecker = priceChecker;
|
|
44
|
+
this.safetyGuard = safetyGuard;
|
|
45
|
+
this.retryQueue = retryQueue;
|
|
46
|
+
this.keeperClient = keeperClient;
|
|
47
|
+
this.jobStore = jobStore;
|
|
48
|
+
this.dryRun = options.dryRun ?? false;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Process a single job tick.
|
|
52
|
+
*/
|
|
53
|
+
async processTick(job) {
|
|
54
|
+
if (job.expiresAt !== null && Date.now() >= job.expiresAt) {
|
|
55
|
+
import_core.logger.info(`[Executor] job ${job.id} expired`);
|
|
56
|
+
await this.jobStore.markExpired(job.id);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (job.status === "pending") {
|
|
61
|
+
await this.jobStore.markActive(job.id);
|
|
62
|
+
}
|
|
63
|
+
if (job.type === "limit_order") {
|
|
64
|
+
await this._processLimitOrder(job);
|
|
65
|
+
} else if (job.type === "dca") {
|
|
66
|
+
await this._processDCA(job);
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
+
if (message.includes("PriceConditionNotMet")) {
|
|
71
|
+
import_core.logger.debug(
|
|
72
|
+
`[Executor] job ${job.id}: price condition no longer met at execution time \u2014 will retry next tick`
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (message.includes("DCAIntervalNotReached")) {
|
|
77
|
+
import_core.logger.debug(
|
|
78
|
+
`[Executor] job ${job.id}: DCA interval not yet reached at execution time \u2014 will retry next tick`
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (message.includes("could not be found") || message.includes("TransactionReceiptNotFoundError")) {
|
|
83
|
+
import_core.logger.debug(
|
|
84
|
+
`[Executor] job ${job.id}: receipt not yet indexed \u2014 will retry next tick`
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (message.includes("Slippage exceeded")) {
|
|
89
|
+
import_core.logger.debug(
|
|
90
|
+
`[Executor] job ${job.id}: slippage exceeded at execution time \u2014 will retry next tick`
|
|
91
|
+
);
|
|
92
|
+
await this.jobStore.incrementRetry(job.id);
|
|
93
|
+
await this.jobStore.updateLastError(job.id, message);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (err instanceof Error && err.cause) {
|
|
97
|
+
import_core.logger.error(
|
|
98
|
+
{ cause: err.cause },
|
|
99
|
+
"[Executor] raw error cause"
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (message.includes("JobNotFound")) {
|
|
103
|
+
const ocId = job.onChainJobId;
|
|
104
|
+
import_core.logger.warn(
|
|
105
|
+
{ jobId: job.id, onChainJobId: ocId },
|
|
106
|
+
"[Executor] JobNotFound \u2014 on_chain_job_id not found on current contract (likely left over from an old deployment) \u2014 marking cancelled"
|
|
107
|
+
);
|
|
108
|
+
await this.jobStore.markCancelled(job.id);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (message.includes("JobNotActive")) {
|
|
112
|
+
const ocId = job.onChainJobId;
|
|
113
|
+
let onChainStatus = "cancelled";
|
|
114
|
+
if (ocId) {
|
|
115
|
+
try {
|
|
116
|
+
onChainStatus = await this.keeperClient.getOnChainStatus(ocId);
|
|
117
|
+
} catch (statusErr) {
|
|
118
|
+
import_core.logger.warn(
|
|
119
|
+
{ jobId: job.id, err: statusErr },
|
|
120
|
+
"[Executor] could not read on-chain status \u2014 defaulting to cancelled"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (onChainStatus === "executed") {
|
|
125
|
+
import_core.logger.warn(
|
|
126
|
+
{ jobId: job.id, onChainJobId: ocId },
|
|
127
|
+
"[Executor] job EXECUTED on-chain but DB was out of sync \u2014 marking executed"
|
|
128
|
+
);
|
|
129
|
+
await this.jobStore.markExecuted(job.id, "chain-sync");
|
|
130
|
+
} else {
|
|
131
|
+
import_core.logger.warn(
|
|
132
|
+
{ jobId: job.id, onChainJobId: ocId, onChainStatus },
|
|
133
|
+
"[Executor] job is CANCELLED/EXPIRED on-chain \u2014 marking cancelled in DB"
|
|
134
|
+
);
|
|
135
|
+
await this.jobStore.markCancelled(job.id);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
import_core.logger.error(`[Executor] job ${job.id} failed: ${message}`);
|
|
140
|
+
const nextRetries = job.retries + 1;
|
|
141
|
+
if (job.retries < job.maxRetries) {
|
|
142
|
+
await this.jobStore.incrementRetry(job.id);
|
|
143
|
+
}
|
|
144
|
+
if (nextRetries < job.maxRetries) {
|
|
145
|
+
this.retryQueue.enqueue({ ...job, retries: nextRetries });
|
|
146
|
+
}
|
|
147
|
+
await this.jobStore.markFailed(job.id, message);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Process all active jobs + due retries in one tick.
|
|
152
|
+
*/
|
|
153
|
+
async runAllTicks() {
|
|
154
|
+
const activeJobs = await this.jobStore.getActiveJobs();
|
|
155
|
+
const retries = this.retryQueue.drainDue();
|
|
156
|
+
const all = [...activeJobs, ...retries];
|
|
157
|
+
import_core.logger.info(
|
|
158
|
+
`[Executor] tick \u2013 ${activeJobs.length} active, ${retries.length} retries`
|
|
159
|
+
);
|
|
160
|
+
await Promise.allSettled(all.map((job) => this.processTick(job)));
|
|
161
|
+
}
|
|
162
|
+
// ─── Private ────────────────────────────────────────────────────────────────
|
|
163
|
+
async _processLimitOrder(job) {
|
|
164
|
+
const priceResult = await this.priceChecker.checkLimitOrder(job);
|
|
165
|
+
if (!priceResult.conditionMet) {
|
|
166
|
+
import_core.logger.info(
|
|
167
|
+
{
|
|
168
|
+
jobId: job.id,
|
|
169
|
+
currentPrice: priceResult.currentPrice.toString(),
|
|
170
|
+
targetPrice: priceResult.targetPrice.toString(),
|
|
171
|
+
direction: job.params.direction
|
|
172
|
+
},
|
|
173
|
+
`[Executor] limit-order ${job.id}: condition not met \u2013 waiting`
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const safetyResult = this.safetyGuard.check(job, {
|
|
178
|
+
swapUsd: priceResult.swapUsd
|
|
179
|
+
});
|
|
180
|
+
if (!safetyResult.ok) {
|
|
181
|
+
import_core.logger.warn(
|
|
182
|
+
{ violation: safetyResult.violation },
|
|
183
|
+
`[Executor] limit-order ${job.id} blocked by safety guard`
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (this.dryRun) {
|
|
188
|
+
import_core.logger.info(`[Executor][dryRun] would execute limit-order ${job.id}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
import_core.logger.info(`[Executor] executing limit-order ${job.id}`);
|
|
192
|
+
const onChainJobId = job.onChainJobId;
|
|
193
|
+
if (!onChainJobId) {
|
|
194
|
+
import_core.logger.warn(
|
|
195
|
+
`[Executor] limit-order ${job.id} has no onChainJobId \u2013 skipping until registered on-chain`
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const { txHash, amountOut } = await this.keeperClient.executeLimitOrder(
|
|
200
|
+
onChainJobId,
|
|
201
|
+
job.owner,
|
|
202
|
+
job.params
|
|
203
|
+
);
|
|
204
|
+
await this.jobStore.markExecuted(job.id, txHash, amountOut);
|
|
205
|
+
import_core.logger.info(`[Executor] limit-order ${job.id} executed \u2013 tx ${txHash}`);
|
|
206
|
+
}
|
|
207
|
+
async _processDCA(job) {
|
|
208
|
+
const priceResult = await this.priceChecker.checkDCA(job);
|
|
209
|
+
if (!priceResult.conditionMet) {
|
|
210
|
+
import_core.logger.info(
|
|
211
|
+
{
|
|
212
|
+
jobId: job.id,
|
|
213
|
+
nextExecution: job.params.nextExecution,
|
|
214
|
+
now: Date.now(),
|
|
215
|
+
secsRemaining: Math.round(
|
|
216
|
+
(job.params.nextExecution - Date.now()) / 1e3
|
|
217
|
+
)
|
|
218
|
+
},
|
|
219
|
+
`[Executor] DCA ${job.id}: interval not reached`
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const safetyResult = this.safetyGuard.check(job, {
|
|
224
|
+
swapUsd: priceResult.swapUsd
|
|
225
|
+
});
|
|
226
|
+
if (!safetyResult.ok) {
|
|
227
|
+
import_core.logger.warn(
|
|
228
|
+
{ violation: safetyResult.violation },
|
|
229
|
+
`[Executor] DCA ${job.id} blocked by safety guard`
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (this.dryRun) {
|
|
234
|
+
import_core.logger.info(`[Executor][dryRun] would execute DCA tick for ${job.id}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
import_core.logger.info(`[Executor] executing DCA tick ${job.id}`);
|
|
238
|
+
const onChainJobId = job.onChainJobId;
|
|
239
|
+
if (!onChainJobId) {
|
|
240
|
+
import_core.logger.warn(
|
|
241
|
+
`[Executor] DCA job ${job.id} has no onChainJobId \u2013 skipping until registered on-chain`
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const { txHash, amountOut, nextExecutionSec } = await this.keeperClient.executeDCATick(
|
|
246
|
+
onChainJobId,
|
|
247
|
+
job.owner,
|
|
248
|
+
job.params
|
|
249
|
+
);
|
|
250
|
+
const newSwapsCompleted = job.params.swapsCompleted + 1;
|
|
251
|
+
const nextExecutionMs = nextExecutionSec * 1e3;
|
|
252
|
+
await this.jobStore.markDCATick(
|
|
253
|
+
job.id,
|
|
254
|
+
txHash,
|
|
255
|
+
newSwapsCompleted,
|
|
256
|
+
nextExecutionMs,
|
|
257
|
+
amountOut
|
|
258
|
+
);
|
|
259
|
+
import_core.logger.info(
|
|
260
|
+
{
|
|
261
|
+
jobId: job.id,
|
|
262
|
+
txHash,
|
|
263
|
+
swapsCompleted: newSwapsCompleted,
|
|
264
|
+
total: job.params.totalSwaps
|
|
265
|
+
},
|
|
266
|
+
`[Executor] DCA tick executed \u2013 ${newSwapsCompleted}/${job.params.totalSwaps}`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// src/keeper-client.ts
|
|
272
|
+
var import_core2 = require("@cfxdevkit/core");
|
|
273
|
+
var import_protocol = require("@cfxdevkit/protocol");
|
|
274
|
+
var import_viem = require("viem");
|
|
275
|
+
var import_accounts = require("viem/accounts");
|
|
276
|
+
function decodeAmountOut(logs, owner) {
|
|
277
|
+
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
278
|
+
const ownerLower = owner.toLowerCase();
|
|
279
|
+
for (let i = logs.length - 1; i >= 0; i--) {
|
|
280
|
+
const log = logs[i];
|
|
281
|
+
if (log.topics[0]?.toLowerCase() === TRANSFER_TOPIC && log.topics[2] && `0x${log.topics[2].slice(26)}`.toLowerCase() === ownerLower) {
|
|
282
|
+
try {
|
|
283
|
+
return BigInt(log.data).toString();
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
var KeeperClientImpl = class {
|
|
292
|
+
walletClient;
|
|
293
|
+
publicClient;
|
|
294
|
+
contractAddress;
|
|
295
|
+
swappiRouter;
|
|
296
|
+
maxGasPriceGwei;
|
|
297
|
+
rpcTimeoutMs;
|
|
298
|
+
account;
|
|
299
|
+
constructor(config) {
|
|
300
|
+
this.account = (0, import_accounts.privateKeyToAccount)(config.privateKey);
|
|
301
|
+
this.contractAddress = config.contractAddress;
|
|
302
|
+
this.swappiRouter = config.swappiRouter;
|
|
303
|
+
this.maxGasPriceGwei = config.maxGasPriceGwei ?? 1000n;
|
|
304
|
+
this.rpcTimeoutMs = config.rpcTimeoutMs ?? 12e4;
|
|
305
|
+
this.publicClient = (0, import_viem.createPublicClient)({
|
|
306
|
+
chain: config.chain,
|
|
307
|
+
transport: (0, import_viem.http)(config.rpcUrl)
|
|
308
|
+
});
|
|
309
|
+
this.walletClient = (0, import_viem.createWalletClient)({
|
|
310
|
+
chain: config.chain,
|
|
311
|
+
transport: (0, import_viem.http)(config.rpcUrl),
|
|
312
|
+
account: this.account
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
withTimeout(fn, timeoutMs = this.rpcTimeoutMs) {
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
318
|
+
return fn(controller.signal).finally(() => clearTimeout(id));
|
|
319
|
+
}
|
|
320
|
+
// --------------------------------------------------------------------------
|
|
321
|
+
// Helpers
|
|
322
|
+
// --------------------------------------------------------------------------
|
|
323
|
+
/**
|
|
324
|
+
* Query the on-chain status of a job.
|
|
325
|
+
* Returns one of: 'active' | 'executed' | 'cancelled' | 'expired'.
|
|
326
|
+
* Throws if the contract call fails (e.g. job not found).
|
|
327
|
+
*/
|
|
328
|
+
async getOnChainStatus(onChainJobId) {
|
|
329
|
+
const job = await this.withTimeout(
|
|
330
|
+
(_signal) => this.publicClient.readContract({
|
|
331
|
+
address: this.contractAddress,
|
|
332
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
333
|
+
functionName: "getJob",
|
|
334
|
+
args: [onChainJobId]
|
|
335
|
+
})
|
|
336
|
+
);
|
|
337
|
+
switch (job.status) {
|
|
338
|
+
case 0:
|
|
339
|
+
return "active";
|
|
340
|
+
case 1:
|
|
341
|
+
return "executed";
|
|
342
|
+
case 2:
|
|
343
|
+
return "cancelled";
|
|
344
|
+
case 3:
|
|
345
|
+
return "expired";
|
|
346
|
+
default:
|
|
347
|
+
return "cancelled";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Guard: abort if chain is paused or gas price is too high */
|
|
351
|
+
async preflightCheck() {
|
|
352
|
+
const isPaused = await this.withTimeout(
|
|
353
|
+
(_signal) => this.publicClient.readContract({
|
|
354
|
+
address: this.contractAddress,
|
|
355
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
356
|
+
functionName: "paused"
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
if (isPaused) {
|
|
360
|
+
throw new Error("AutomationManager is paused on-chain");
|
|
361
|
+
}
|
|
362
|
+
const gasPrice = await Promise.race([
|
|
363
|
+
this.publicClient.getGasPrice(),
|
|
364
|
+
new Promise(
|
|
365
|
+
(_, rej) => setTimeout(
|
|
366
|
+
() => rej(new Error("getGasPrice timeout")),
|
|
367
|
+
this.rpcTimeoutMs
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
]);
|
|
371
|
+
const gasPriceGwei = gasPrice / 1000000000n;
|
|
372
|
+
if (gasPriceGwei > this.maxGasPriceGwei) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Gas price ${gasPriceGwei} gwei exceeds cap ${this.maxGasPriceGwei} gwei`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Build minimal swap calldata for a token-in/token-out pair via Swappi.
|
|
380
|
+
*
|
|
381
|
+
* In a production keeper the calldata would be built via a DEX aggregator
|
|
382
|
+
* (e.g. OKX DEX API) to get optimal routing. For the MVP we encode the
|
|
383
|
+
* simplest Swappi path: `swapExactTokensForTokens(amountIn, minOut, path, to, deadline)`.
|
|
384
|
+
*
|
|
385
|
+
* NOTE: The AutomationManager does NOT use this calldata for custody;
|
|
386
|
+
* it only uses it to call the router on behalf of the user's pre-approved allowance.
|
|
387
|
+
*/
|
|
388
|
+
buildSwapCalldata(tokenIn, tokenOut, amountIn, minAmountOut, recipient) {
|
|
389
|
+
const selector = "38ed1739";
|
|
390
|
+
const deadline = BigInt(Math.floor(Date.now() / 1e3) + 1800);
|
|
391
|
+
const encode256 = (n) => n.toString(16).padStart(64, "0");
|
|
392
|
+
const encodeAddr = (a) => a.slice(2).toLowerCase().padStart(64, "0");
|
|
393
|
+
const pathOffset = (5 * 32).toString(16).padStart(64, "0");
|
|
394
|
+
const pathLength = encode256(2n);
|
|
395
|
+
const pathData = encodeAddr(tokenIn) + encodeAddr(tokenOut);
|
|
396
|
+
const calldata = "0x" + selector + encode256(amountIn) + encode256(minAmountOut) + pathOffset + encodeAddr(recipient) + encode256(deadline) + pathLength + pathData;
|
|
397
|
+
return calldata;
|
|
398
|
+
}
|
|
399
|
+
// --------------------------------------------------------------------------
|
|
400
|
+
// IKeeperClient interface implementation
|
|
401
|
+
// --------------------------------------------------------------------------
|
|
402
|
+
async executeLimitOrder(jobId, owner, params) {
|
|
403
|
+
await this.preflightCheck();
|
|
404
|
+
const amountIn = BigInt(params.amountIn);
|
|
405
|
+
const minAmountOut = 0n;
|
|
406
|
+
const swapCalldata = this.buildSwapCalldata(
|
|
407
|
+
params.tokenIn,
|
|
408
|
+
params.tokenOut,
|
|
409
|
+
amountIn,
|
|
410
|
+
minAmountOut,
|
|
411
|
+
owner
|
|
412
|
+
);
|
|
413
|
+
import_core2.logger.info(
|
|
414
|
+
{
|
|
415
|
+
jobId,
|
|
416
|
+
tokenIn: params.tokenIn,
|
|
417
|
+
tokenOut: params.tokenOut,
|
|
418
|
+
amountIn: params.amountIn
|
|
419
|
+
},
|
|
420
|
+
"[KeeperClient] executeLimitOrder"
|
|
421
|
+
);
|
|
422
|
+
try {
|
|
423
|
+
await this.withTimeout(
|
|
424
|
+
(_signal) => this.publicClient.simulateContract({
|
|
425
|
+
address: this.contractAddress,
|
|
426
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
427
|
+
functionName: "executeLimitOrder",
|
|
428
|
+
args: [jobId, this.swappiRouter, swapCalldata],
|
|
429
|
+
account: this.account.address
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
} catch (simErr) {
|
|
433
|
+
const msg = simErr instanceof Error ? simErr.message : String(simErr);
|
|
434
|
+
import_core2.logger.error(
|
|
435
|
+
{ jobId, error: msg.slice(0, 500) },
|
|
436
|
+
"[KeeperClient] executeLimitOrder simulation reverted"
|
|
437
|
+
);
|
|
438
|
+
throw simErr;
|
|
439
|
+
}
|
|
440
|
+
const hash = await this.withTimeout(
|
|
441
|
+
(_signal) => this.walletClient.writeContract({
|
|
442
|
+
address: this.contractAddress,
|
|
443
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
444
|
+
functionName: "executeLimitOrder",
|
|
445
|
+
args: [jobId, this.swappiRouter, swapCalldata],
|
|
446
|
+
chain: void 0,
|
|
447
|
+
account: this.account
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
import_core2.logger.info({ jobId, hash }, "[KeeperClient] limitOrder tx submitted");
|
|
451
|
+
const receipt = await Promise.race([
|
|
452
|
+
this.publicClient.waitForTransactionReceipt({ hash }),
|
|
453
|
+
new Promise(
|
|
454
|
+
(_, rej) => setTimeout(
|
|
455
|
+
() => rej(new Error("waitForTransactionReceipt timeout")),
|
|
456
|
+
this.rpcTimeoutMs
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
]);
|
|
460
|
+
if (receipt.status !== "success") {
|
|
461
|
+
let reason = hash ?? "unknown";
|
|
462
|
+
try {
|
|
463
|
+
await this.withTimeout(
|
|
464
|
+
(_signal) => this.publicClient.simulateContract({
|
|
465
|
+
address: this.contractAddress,
|
|
466
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
467
|
+
functionName: "executeLimitOrder",
|
|
468
|
+
args: [jobId, this.swappiRouter, swapCalldata],
|
|
469
|
+
account: this.account.address
|
|
470
|
+
})
|
|
471
|
+
);
|
|
472
|
+
} catch (simErr) {
|
|
473
|
+
if (simErr instanceof Error) reason = simErr.message;
|
|
474
|
+
}
|
|
475
|
+
throw new Error(`Limit order tx reverted: ${reason}`);
|
|
476
|
+
}
|
|
477
|
+
const amountOut = decodeAmountOut(receipt.logs, owner);
|
|
478
|
+
import_core2.logger.info(
|
|
479
|
+
{ jobId, hash, blockNumber: receipt.blockNumber.toString(), amountOut },
|
|
480
|
+
"[KeeperClient] limitOrder confirmed"
|
|
481
|
+
);
|
|
482
|
+
return { txHash: hash, amountOut };
|
|
483
|
+
}
|
|
484
|
+
async executeDCATick(jobId, owner, params) {
|
|
485
|
+
await this.preflightCheck();
|
|
486
|
+
const onChainParams = await this.publicClient.readContract({
|
|
487
|
+
address: this.contractAddress,
|
|
488
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
489
|
+
functionName: "getDCAJob",
|
|
490
|
+
args: [jobId]
|
|
491
|
+
});
|
|
492
|
+
const nowSec = BigInt(Math.floor(Date.now() / 1e3));
|
|
493
|
+
if (nowSec < onChainParams.nextExecution) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`DCAIntervalNotReached(${onChainParams.nextExecution}) \u2014 on-chain interval not reached yet (now=${nowSec}, next=${onChainParams.nextExecution}, remaining=${onChainParams.nextExecution - nowSec}s)`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const amountPerTick = BigInt(params.amountPerSwap);
|
|
499
|
+
const minAmountOut = 0n;
|
|
500
|
+
const swapCalldata = this.buildSwapCalldata(
|
|
501
|
+
params.tokenIn,
|
|
502
|
+
params.tokenOut,
|
|
503
|
+
amountPerTick,
|
|
504
|
+
minAmountOut,
|
|
505
|
+
owner
|
|
506
|
+
);
|
|
507
|
+
import_core2.logger.info(
|
|
508
|
+
{
|
|
509
|
+
jobId,
|
|
510
|
+
tokenIn: params.tokenIn,
|
|
511
|
+
tokenOut: params.tokenOut,
|
|
512
|
+
amountPerTick: params.amountPerSwap
|
|
513
|
+
},
|
|
514
|
+
"[KeeperClient] executeDCATick"
|
|
515
|
+
);
|
|
516
|
+
try {
|
|
517
|
+
await this.withTimeout(
|
|
518
|
+
(_signal) => this.publicClient.simulateContract({
|
|
519
|
+
address: this.contractAddress,
|
|
520
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
521
|
+
functionName: "executeDCATick",
|
|
522
|
+
args: [jobId, this.swappiRouter, swapCalldata],
|
|
523
|
+
account: this.account.address
|
|
524
|
+
})
|
|
525
|
+
);
|
|
526
|
+
} catch (simErr) {
|
|
527
|
+
const msg = simErr instanceof Error ? simErr.message : String(simErr);
|
|
528
|
+
import_core2.logger.error(
|
|
529
|
+
{ jobId, error: msg.slice(0, 500) },
|
|
530
|
+
"[KeeperClient] executeDCATick simulation reverted"
|
|
531
|
+
);
|
|
532
|
+
throw simErr;
|
|
533
|
+
}
|
|
534
|
+
const hash = await this.withTimeout(
|
|
535
|
+
(_signal) => this.walletClient.writeContract({
|
|
536
|
+
address: this.contractAddress,
|
|
537
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
538
|
+
functionName: "executeDCATick",
|
|
539
|
+
args: [jobId, this.swappiRouter, swapCalldata],
|
|
540
|
+
chain: void 0,
|
|
541
|
+
account: this.account
|
|
542
|
+
})
|
|
543
|
+
);
|
|
544
|
+
import_core2.logger.info({ jobId, hash }, "[KeeperClient] dca tx submitted");
|
|
545
|
+
const receipt = await Promise.race([
|
|
546
|
+
this.publicClient.waitForTransactionReceipt({ hash }),
|
|
547
|
+
new Promise(
|
|
548
|
+
(_, rej) => setTimeout(
|
|
549
|
+
() => rej(new Error("waitForTransactionReceipt timeout")),
|
|
550
|
+
this.rpcTimeoutMs
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
]);
|
|
554
|
+
if (receipt.status !== "success") {
|
|
555
|
+
let reason = hash ?? "unknown";
|
|
556
|
+
try {
|
|
557
|
+
await this.withTimeout(
|
|
558
|
+
(_signal) => this.publicClient.simulateContract({
|
|
559
|
+
address: this.contractAddress,
|
|
560
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
561
|
+
functionName: "executeDCATick",
|
|
562
|
+
args: [jobId, this.swappiRouter, swapCalldata],
|
|
563
|
+
account: this.account.address
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
} catch (simErr) {
|
|
567
|
+
if (simErr instanceof Error) reason = simErr.message;
|
|
568
|
+
}
|
|
569
|
+
throw new Error(`DCA tick tx reverted: ${reason}`);
|
|
570
|
+
}
|
|
571
|
+
const amountOut = decodeAmountOut(receipt.logs, owner);
|
|
572
|
+
const postTickParams = await this.publicClient.readContract({
|
|
573
|
+
address: this.contractAddress,
|
|
574
|
+
abi: import_protocol.AUTOMATION_MANAGER_ABI,
|
|
575
|
+
functionName: "getDCAJob",
|
|
576
|
+
args: [jobId]
|
|
577
|
+
});
|
|
578
|
+
const nextExecutionSec = Number(postTickParams.nextExecution);
|
|
579
|
+
import_core2.logger.info(
|
|
580
|
+
{
|
|
581
|
+
jobId,
|
|
582
|
+
hash,
|
|
583
|
+
blockNumber: receipt.blockNumber.toString(),
|
|
584
|
+
amountOut,
|
|
585
|
+
nextExecutionSec
|
|
586
|
+
},
|
|
587
|
+
"[KeeperClient] dca confirmed"
|
|
588
|
+
);
|
|
589
|
+
return { txHash: hash, amountOut, nextExecutionSec };
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// src/logger.ts
|
|
594
|
+
var noopLogger = {
|
|
595
|
+
info: () => {
|
|
596
|
+
},
|
|
597
|
+
warn: () => {
|
|
598
|
+
},
|
|
599
|
+
debug: () => {
|
|
600
|
+
},
|
|
601
|
+
error: () => {
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/price-checker.ts
|
|
606
|
+
var defaultDecimalsResolver = async () => 18;
|
|
607
|
+
var PriceChecker = class {
|
|
608
|
+
source;
|
|
609
|
+
tokenPricesUsd;
|
|
610
|
+
/**
|
|
611
|
+
* Resolves a token's decimal count. Called lazily when _estimateUsd needs
|
|
612
|
+
* to convert raw wei amounts into human-readable values. The resolver may
|
|
613
|
+
* query the chain, a static map, or simply return 18.
|
|
614
|
+
*/
|
|
615
|
+
getDecimals;
|
|
616
|
+
log;
|
|
617
|
+
constructor(source, tokenPricesUsd = /* @__PURE__ */ new Map(), logger3 = noopLogger, getDecimals = defaultDecimalsResolver) {
|
|
618
|
+
this.source = source;
|
|
619
|
+
this.tokenPricesUsd = tokenPricesUsd;
|
|
620
|
+
this.getDecimals = getDecimals;
|
|
621
|
+
this.log = logger3;
|
|
622
|
+
}
|
|
623
|
+
async checkLimitOrder(job) {
|
|
624
|
+
const params = job.params;
|
|
625
|
+
const currentPrice = await this.source.getPrice(
|
|
626
|
+
params.tokenIn,
|
|
627
|
+
params.tokenOut
|
|
628
|
+
);
|
|
629
|
+
const targetPrice = BigInt(params.targetPrice);
|
|
630
|
+
let conditionMet;
|
|
631
|
+
if (params.direction === "gte") {
|
|
632
|
+
conditionMet = currentPrice >= targetPrice;
|
|
633
|
+
} else {
|
|
634
|
+
conditionMet = currentPrice <= targetPrice;
|
|
635
|
+
}
|
|
636
|
+
const swapUsd = await this._estimateUsd(params.tokenIn, params.amountIn);
|
|
637
|
+
this.log.debug(
|
|
638
|
+
{
|
|
639
|
+
jobId: job.id,
|
|
640
|
+
currentPrice: currentPrice.toString(),
|
|
641
|
+
targetPrice: targetPrice.toString(),
|
|
642
|
+
conditionMet,
|
|
643
|
+
swapUsd
|
|
644
|
+
},
|
|
645
|
+
"[PriceChecker] limit-order check"
|
|
646
|
+
);
|
|
647
|
+
return { conditionMet, currentPrice, targetPrice, swapUsd };
|
|
648
|
+
}
|
|
649
|
+
async checkDCA(job) {
|
|
650
|
+
const params = job.params;
|
|
651
|
+
const DCA_EXECUTION_BUFFER_MS = 15e3;
|
|
652
|
+
const conditionMet = Date.now() >= params.nextExecution + DCA_EXECUTION_BUFFER_MS;
|
|
653
|
+
const currentPrice = await this.source.getPrice(
|
|
654
|
+
params.tokenIn,
|
|
655
|
+
params.tokenOut
|
|
656
|
+
);
|
|
657
|
+
const swapUsd = await this._estimateUsd(
|
|
658
|
+
params.tokenIn,
|
|
659
|
+
params.amountPerSwap
|
|
660
|
+
);
|
|
661
|
+
this.log.debug(
|
|
662
|
+
{
|
|
663
|
+
jobId: job.id,
|
|
664
|
+
nextExecution: params.nextExecution,
|
|
665
|
+
conditionMet,
|
|
666
|
+
swapUsd
|
|
667
|
+
},
|
|
668
|
+
"[PriceChecker] DCA check"
|
|
669
|
+
);
|
|
670
|
+
return { conditionMet, currentPrice, targetPrice: 0n, swapUsd };
|
|
671
|
+
}
|
|
672
|
+
updateTokenPrice(token, usdPrice) {
|
|
673
|
+
this.tokenPricesUsd.set(token.toLowerCase(), usdPrice);
|
|
674
|
+
}
|
|
675
|
+
async _estimateUsd(token, amountWei) {
|
|
676
|
+
const usdPerToken = this.tokenPricesUsd.get(token.toLowerCase()) ?? 0;
|
|
677
|
+
const decimals = await this.getDecimals(token);
|
|
678
|
+
const divisor = 10 ** decimals;
|
|
679
|
+
const amount = Number(BigInt(amountWei)) / divisor;
|
|
680
|
+
return amount * usdPerToken;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// src/retry-queue.ts
|
|
685
|
+
var RetryQueue = class {
|
|
686
|
+
baseDelayMs;
|
|
687
|
+
maxDelayMs;
|
|
688
|
+
jitter;
|
|
689
|
+
log;
|
|
690
|
+
queue = /* @__PURE__ */ new Map();
|
|
691
|
+
constructor(options, logger3 = noopLogger) {
|
|
692
|
+
this.baseDelayMs = options?.baseDelayMs ?? 5e3;
|
|
693
|
+
this.maxDelayMs = options?.maxDelayMs ?? 3e5;
|
|
694
|
+
this.jitter = options?.jitter ?? 0.2;
|
|
695
|
+
this.log = logger3;
|
|
696
|
+
}
|
|
697
|
+
/** Enqueue a job for retry after a calculated backoff delay. */
|
|
698
|
+
enqueue(job) {
|
|
699
|
+
const existing = this.queue.get(job.id);
|
|
700
|
+
const attempt = existing ? existing.attempt + 1 : 0;
|
|
701
|
+
const delay = this._backoffDelay(attempt);
|
|
702
|
+
const nextRetryAt = Date.now() + delay;
|
|
703
|
+
this.queue.set(job.id, { job, nextRetryAt, attempt });
|
|
704
|
+
this.log.info(
|
|
705
|
+
`[RetryQueue] job ${job.id} enqueued, attempt=${attempt}, delay=${delay}ms`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
/** Remove a job from the queue (e.g. after success or manual cancel). */
|
|
709
|
+
remove(jobId) {
|
|
710
|
+
this.queue.delete(jobId);
|
|
711
|
+
}
|
|
712
|
+
/** Return all jobs whose retry time has arrived; removes them from the queue. */
|
|
713
|
+
drainDue(now = Date.now()) {
|
|
714
|
+
const due = [];
|
|
715
|
+
for (const [jobId, entry] of this.queue) {
|
|
716
|
+
if (now >= entry.nextRetryAt) {
|
|
717
|
+
due.push(entry.job);
|
|
718
|
+
this.queue.delete(jobId);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return due;
|
|
722
|
+
}
|
|
723
|
+
size() {
|
|
724
|
+
return this.queue.size;
|
|
725
|
+
}
|
|
726
|
+
// ─── Private ──────────────────────────────────────────────────────
|
|
727
|
+
_backoffDelay(attempt) {
|
|
728
|
+
const exponential = this.baseDelayMs * 2 ** attempt;
|
|
729
|
+
const capped = Math.min(exponential, this.maxDelayMs);
|
|
730
|
+
const withJitter = capped * (1 + this.jitter * Math.random());
|
|
731
|
+
return Math.floor(withJitter);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// src/safety-guard.ts
|
|
736
|
+
var DEFAULT_SAFETY_CONFIG = {
|
|
737
|
+
maxSwapUsd: 1e4,
|
|
738
|
+
maxSlippageBps: 500,
|
|
739
|
+
maxRetries: 5,
|
|
740
|
+
minExecutionIntervalSeconds: 30,
|
|
741
|
+
globalPause: false
|
|
742
|
+
};
|
|
743
|
+
var SafetyGuard = class {
|
|
744
|
+
config;
|
|
745
|
+
violations = [];
|
|
746
|
+
log;
|
|
747
|
+
constructor(config = {}, logger3 = noopLogger) {
|
|
748
|
+
this.config = { ...DEFAULT_SAFETY_CONFIG, ...config };
|
|
749
|
+
this.log = logger3;
|
|
750
|
+
this.log.info(
|
|
751
|
+
{
|
|
752
|
+
maxSwapUsd: this.config.maxSwapUsd,
|
|
753
|
+
maxSlippageBps: this.config.maxSlippageBps,
|
|
754
|
+
maxRetries: this.config.maxRetries,
|
|
755
|
+
globalPause: this.config.globalPause
|
|
756
|
+
},
|
|
757
|
+
"[SafetyGuard] initialized"
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
// ─── Core check ───────────────────────────────────────────────────
|
|
761
|
+
/**
|
|
762
|
+
* Run all configured safety checks against a job.
|
|
763
|
+
* Returns `{ ok: true }` if all pass, or `{ ok: false, violation }` on first failure.
|
|
764
|
+
*/
|
|
765
|
+
check(job, context) {
|
|
766
|
+
const now = context.currentTime ?? Date.now();
|
|
767
|
+
if (this.config.globalPause) {
|
|
768
|
+
return this._fail(
|
|
769
|
+
job.id,
|
|
770
|
+
"globalPause",
|
|
771
|
+
"Global pause is active \u2014 all execution halted"
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
if (job.status !== "active" && job.status !== "pending") {
|
|
775
|
+
return this._fail(
|
|
776
|
+
job.id,
|
|
777
|
+
"globalPause",
|
|
778
|
+
`Job ${job.id} cannot be executed (status: ${job.status})`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (job.retries >= job.maxRetries) {
|
|
782
|
+
return this._fail(
|
|
783
|
+
job.id,
|
|
784
|
+
"maxRetries",
|
|
785
|
+
`Job ${job.id} has exhausted retries (${job.retries}/${job.maxRetries})`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (context.swapUsd > this.config.maxSwapUsd) {
|
|
789
|
+
return this._fail(
|
|
790
|
+
job.id,
|
|
791
|
+
"maxSwapUsd",
|
|
792
|
+
`Swap value $${context.swapUsd.toFixed(2)} exceeds limit $${this.config.maxSwapUsd}`
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
if (job.expiresAt !== null && now >= job.expiresAt) {
|
|
796
|
+
return this._fail(job.id, "globalPause", `Job ${job.id} has expired`);
|
|
797
|
+
}
|
|
798
|
+
if (job.type === "dca") {
|
|
799
|
+
const dcaParams = job.params;
|
|
800
|
+
if (now < dcaParams.nextExecution) {
|
|
801
|
+
const waitSec = Math.ceil((dcaParams.nextExecution - now) / 1e3);
|
|
802
|
+
return this._fail(
|
|
803
|
+
job.id,
|
|
804
|
+
"minExecutionIntervalSeconds",
|
|
805
|
+
`DCA interval not yet reached (${waitSec}s remaining)`
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { ok: true };
|
|
810
|
+
}
|
|
811
|
+
// ─── Config management ────────────────────────────────────────────
|
|
812
|
+
updateConfig(patch) {
|
|
813
|
+
this.config = { ...this.config, ...patch };
|
|
814
|
+
this.log.info(patch, "[SafetyGuard] config updated");
|
|
815
|
+
}
|
|
816
|
+
getConfig() {
|
|
817
|
+
return { ...this.config };
|
|
818
|
+
}
|
|
819
|
+
pauseAll() {
|
|
820
|
+
this.config.globalPause = true;
|
|
821
|
+
this.log.warn("[SafetyGuard] GLOBAL PAUSE ACTIVATED");
|
|
822
|
+
}
|
|
823
|
+
resumeAll() {
|
|
824
|
+
this.config.globalPause = false;
|
|
825
|
+
this.log.info("[SafetyGuard] global pause lifted");
|
|
826
|
+
}
|
|
827
|
+
isPaused() {
|
|
828
|
+
return this.config.globalPause;
|
|
829
|
+
}
|
|
830
|
+
// ─── Audit log ────────────────────────────────────────────────────
|
|
831
|
+
getViolations() {
|
|
832
|
+
return this.violations;
|
|
833
|
+
}
|
|
834
|
+
clearViolations() {
|
|
835
|
+
this.violations = [];
|
|
836
|
+
}
|
|
837
|
+
// ─── Private ──────────────────────────────────────────────────────
|
|
838
|
+
_fail(jobId, rule, detail) {
|
|
839
|
+
const violation = {
|
|
840
|
+
jobId,
|
|
841
|
+
rule,
|
|
842
|
+
detail,
|
|
843
|
+
timestamp: Date.now()
|
|
844
|
+
};
|
|
845
|
+
this.violations.push(violation);
|
|
846
|
+
this.log.warn({ jobId, detail }, `[SafetyGuard] violation \u2013 ${rule}`);
|
|
847
|
+
return { ok: false, violation };
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
851
|
+
0 && (module.exports = {
|
|
852
|
+
DEFAULT_SAFETY_CONFIG,
|
|
853
|
+
Executor,
|
|
854
|
+
KeeperClientImpl,
|
|
855
|
+
PriceChecker,
|
|
856
|
+
RetryQueue,
|
|
857
|
+
SafetyGuard,
|
|
858
|
+
noopLogger
|
|
859
|
+
});
|
|
860
|
+
//# sourceMappingURL=index.cjs.map
|