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