Skip to content

Commit 06a68c7

Browse files
authored
Merge pull request #1132 from BoltzExchange/interval-amount-batch-claim
Interval and amount batch claim
2 parents a498be7 + 12713b7 commit 06a68c7

File tree

11 files changed

+516
-20
lines changed

11 files changed

+516
-20
lines changed

lib/Config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ type SwapConfig = {
182182
cltvDelta: number;
183183
sweepAmountTrigger?: number;
184184

185+
scheduleAmountTrigger?: {
186+
interval: string;
187+
threshold: number;
188+
};
189+
185190
minSwapSizeMultipliers?: MinSwapSizeMultipliersConfig;
186191

187192
overpayment?: OverPaymentConfig;

lib/service/cooperative/DeferredClaimer.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import AsyncLock from 'async-lock';
2-
import type { Job } from 'node-schedule';
3-
import { scheduleJob } from 'node-schedule';
42
import type { SwapConfig } from '../../Config';
53
import type { ClaimDetails, LiquidClaimDetails } from '../../Core';
64
import { calculateTransactionFee, constructClaimTransaction } from '../../Core';
@@ -50,6 +48,8 @@ import CoopSignerBase, {
5048
} from './CoopSignerBase';
5149
import AmountTrigger from './triggers/AmountTrigger';
5250
import ExpiryTrigger from './triggers/ExpiryTrigger';
51+
import IntervalTrigger from './triggers/IntervalTrigger';
52+
import ScheduledAmountTrigger from './triggers/ScheduledAmountTrigger';
5353
import type SweepTrigger from './triggers/SweepTrigger';
5454

5555
type AnySwapWithPreimage<T extends AnySwap> = SwapToClaim<T> & {
@@ -88,7 +88,6 @@ class DeferredClaimer extends CoopSignerBase<{
8888
>();
8989
private readonly sweepTriggers: SweepTrigger[];
9090

91-
private batchClaimSchedule?: Job;
9291
private disableCooperative = false;
9392

9493
constructor(
@@ -116,6 +115,21 @@ class DeferredClaimer extends CoopSignerBase<{
116115
this.pendingSweepsValues,
117116
this.config.sweepAmountTrigger,
118117
),
118+
new IntervalTrigger(
119+
this.logger,
120+
this.config.batchClaimInterval,
121+
async () => {
122+
await this.sweep();
123+
},
124+
),
125+
new ScheduledAmountTrigger(
126+
this.logger,
127+
this.config.scheduleAmountTrigger,
128+
this.pendingSweepsValues,
129+
async (symbol: string) => {
130+
await this.sweepSymbol(symbol);
131+
},
132+
),
119133
];
120134
}
121135

@@ -128,26 +142,20 @@ class DeferredClaimer extends CoopSignerBase<{
128142
`Using deferred claims for: ${this.config.deferredClaimSymbols.join(', ')}`,
129143
);
130144
this.logger.verbose(
131-
`Batch claim interval: ${this.config.batchClaimInterval} with expiry tolerance of ${this.config.expiryTolerance} minutes`,
145+
`Expiry tolerance: ${this.config.expiryTolerance} minutes`,
132146
);
133147

134148
try {
135149
await this.batchClaimLeftovers();
136150
} catch (e) {
137151
this.logger.error(`Could not sweep leftovers: ${formatError(e)}`);
138152
}
139-
140-
this.batchClaimSchedule = scheduleJob(
141-
this.config.batchClaimInterval,
142-
async () => {
143-
await this.sweep();
144-
},
145-
);
146153
};
147154

148155
public close = () => {
149-
this.batchClaimSchedule?.cancel();
150-
this.batchClaimSchedule = undefined;
156+
for (const trigger of this.sweepTriggers) {
157+
trigger.close();
158+
}
151159
};
152160

153161
public pendingSweeps = () => {

lib/service/cooperative/triggers/AmountTrigger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class AmountTrigger extends SweepTrigger {
4343
);
4444
return toSweep >= this.sweepAmountTrigger;
4545
};
46+
47+
public close = () => {};
4648
}
4749

4850
export default AmountTrigger;

lib/service/cooperative/triggers/ExpiryTrigger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class ExpiryTrigger extends SweepTrigger {
3030
return minutesLeft <= this.expiryTolerance;
3131
};
3232

33+
public close = () => {};
34+
3335
private getBlockHeight = async (chainCurrency: string) => {
3436
const currency = this.currencies.get(chainCurrency);
3537
if (!currency) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Job } from 'node-schedule';
2+
import { scheduleJob } from 'node-schedule';
3+
import type Logger from '../../../Logger';
4+
import { formatError } from '../../../Utils';
5+
import SweepTrigger from './SweepTrigger';
6+
7+
class IntervalTrigger extends SweepTrigger {
8+
private batchClaimSchedule?: Job;
9+
10+
constructor(
11+
private readonly logger: Logger,
12+
private readonly interval: string,
13+
callback: () => Promise<void>,
14+
) {
15+
super();
16+
17+
this.logger.verbose(`Batch claim interval: ${this.interval}`);
18+
19+
this.batchClaimSchedule = scheduleJob(this.interval, async () => {
20+
this.logger.verbose('Batch claim interval triggered');
21+
try {
22+
await callback();
23+
} catch (error) {
24+
this.logger.error(
25+
`Error in batch claim interval callback: ${formatError(error)}`,
26+
);
27+
}
28+
});
29+
}
30+
31+
public check = async (): Promise<boolean> => {
32+
// Interval trigger doesn't check per-swap conditions
33+
// It triggers on a schedule instead
34+
return false;
35+
};
36+
37+
public close = () => {
38+
this.batchClaimSchedule?.cancel();
39+
this.batchClaimSchedule = undefined;
40+
};
41+
}
42+
43+
export default IntervalTrigger;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Job } from 'node-schedule';
2+
import { scheduleJob } from 'node-schedule';
3+
import type { SwapConfig } from '../../../Config';
4+
import type Logger from '../../../Logger';
5+
import { formatError } from '../../../Utils';
6+
import type DeferredClaimer from '../DeferredClaimer';
7+
import SweepTrigger from './SweepTrigger';
8+
9+
class ScheduledAmountTrigger extends SweepTrigger {
10+
private readonly threshold?: number;
11+
12+
private batchClaimSchedule?: Job;
13+
14+
constructor(
15+
private readonly logger: Logger,
16+
config: SwapConfig['scheduleAmountTrigger'] | undefined,
17+
private readonly pendingValues: typeof DeferredClaimer.prototype.pendingSweepsValues,
18+
private readonly onTrigger: (symbol: string) => Promise<void>,
19+
) {
20+
super();
21+
22+
if (config === undefined) {
23+
this.logger.warn('Scheduled amount trigger not set');
24+
return;
25+
}
26+
27+
if (config.threshold === undefined) {
28+
throw new Error('scheduleAmountTrigger.threshold is required');
29+
}
30+
31+
if (config.interval === undefined) {
32+
throw new Error('scheduleAmountTrigger.interval is required');
33+
}
34+
35+
this.threshold = config.threshold;
36+
this.logger.verbose(
37+
`Scheduled amount trigger: >= ${this.threshold} every ${config.interval}`,
38+
);
39+
40+
this.batchClaimSchedule = scheduleJob(config.interval, async () => {
41+
try {
42+
await this.checkAndTrigger();
43+
} catch (error) {
44+
this.logger.error(
45+
`Error in scheduled amount trigger check: ${formatError(error)}`,
46+
);
47+
}
48+
});
49+
}
50+
51+
public check = async (): Promise<boolean> => {
52+
// Scheduled amount trigger doesn't check per-swap conditions
53+
// It triggers on a schedule instead
54+
return false;
55+
};
56+
57+
public close = () => {
58+
this.batchClaimSchedule?.cancel();
59+
this.batchClaimSchedule = undefined;
60+
};
61+
62+
private checkAndTrigger = async () => {
63+
if (this.threshold === undefined) {
64+
return;
65+
}
66+
67+
for (const [symbol, pendingValues] of this.pendingValues()) {
68+
const totalAmount = pendingValues.reduce(
69+
(acc, value) => acc + value.onchainAmount,
70+
0,
71+
);
72+
73+
if (totalAmount >= this.threshold) {
74+
this.logger.verbose(
75+
`Scheduled amount trigger for ${symbol}: ${totalAmount} >= ${this.threshold}`,
76+
);
77+
try {
78+
await this.onTrigger(symbol);
79+
} catch (triggerError) {
80+
this.logger.error(
81+
`Error triggering scheduled amount sweep for ${symbol}: ${formatError(triggerError)}`,
82+
);
83+
}
84+
}
85+
}
86+
};
87+
}
88+
89+
export default ScheduledAmountTrigger;

lib/service/cooperative/triggers/SweepTrigger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ abstract class SweepTrigger {
66
chainCurrency: string,
77
swap: Swap | ChainSwapInfo,
88
): Promise<boolean>;
9+
10+
public abstract close(): void;
911
}
1012

1113
export default SweepTrigger;

test/integration/service/cooperative/DeferredClaimer.spec.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,6 @@ describe('DeferredClaimer', () => {
455455
expect(ChainSwapRepository.getChainSwapsClaimable).toHaveBeenCalledTimes(
456456
1,
457457
);
458-
expect(claimer['batchClaimSchedule']).not.toBeUndefined();
459458
});
460459

461460
test('should not crash when batch claim of leftovers fails', async () => {
@@ -470,12 +469,6 @@ describe('DeferredClaimer', () => {
470469
});
471470
});
472471

473-
test('should close', () => {
474-
expect(claimer['batchClaimSchedule']).not.toBeUndefined();
475-
claimer.close();
476-
expect(claimer['batchClaimSchedule']).toBeUndefined();
477-
});
478-
479472
describe('deferClaim', () => {
480473
test('should defer claim transactions of Submarine Swaps', async () => {
481474
const swap = {

test/integration/swap/UtxoNursery.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ describe('UtxoNursery', () => {
186186
] as ClnPendingPaymentTracker
187187
).stop();
188188

189+
swapManager.deferredClaimer.close();
190+
189191
sidecar.disconnect();
190192
await Sidecar.stop();
191193

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { scheduleJob } from 'node-schedule';
2+
import type Logger from '../../../../../lib/Logger';
3+
import IntervalTrigger from '../../../../../lib/service/cooperative/triggers/IntervalTrigger';
4+
5+
jest.mock('node-schedule');
6+
7+
describe('IntervalTrigger', () => {
8+
const mockLogger = {
9+
verbose: jest.fn(),
10+
} as unknown as Logger;
11+
12+
const mockCallback = jest.fn().mockResolvedValue(undefined);
13+
const interval = '*/5 * * * *';
14+
15+
let mockJob: any;
16+
let trigger: IntervalTrigger;
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
21+
mockJob = {
22+
cancel: jest.fn(),
23+
};
24+
25+
(scheduleJob as jest.Mock).mockReturnValue(mockJob);
26+
});
27+
28+
describe('constructor', () => {
29+
test('should log the batch claim interval', () => {
30+
trigger = new IntervalTrigger(mockLogger, interval, mockCallback);
31+
32+
expect(mockLogger.verbose).toHaveBeenCalledTimes(1);
33+
expect(mockLogger.verbose).toHaveBeenCalledWith(
34+
`Batch claim interval: ${interval}`,
35+
);
36+
});
37+
38+
test('should schedule a job with the provided interval and callback', () => {
39+
trigger = new IntervalTrigger(mockLogger, interval, mockCallback);
40+
41+
expect(scheduleJob).toHaveBeenCalledTimes(1);
42+
expect(scheduleJob).toHaveBeenCalledWith(interval, expect.any(Function));
43+
});
44+
45+
test('should execute the callback when the scheduled job runs', async () => {
46+
trigger = new IntervalTrigger(mockLogger, interval, mockCallback);
47+
48+
const scheduledFunction = (scheduleJob as jest.Mock).mock.calls[0][1];
49+
50+
await scheduledFunction();
51+
52+
expect(mockCallback).toHaveBeenCalledTimes(1);
53+
});
54+
55+
test('should work with different interval formats', () => {
56+
const cronInterval = '0 */2 * * *';
57+
trigger = new IntervalTrigger(mockLogger, cronInterval, mockCallback);
58+
59+
expect(scheduleJob).toHaveBeenCalledWith(
60+
cronInterval,
61+
expect.any(Function),
62+
);
63+
});
64+
});
65+
66+
describe('check', () => {
67+
beforeEach(() => {
68+
trigger = new IntervalTrigger(mockLogger, interval, mockCallback);
69+
});
70+
71+
test('should always return false', async () => {
72+
const result = await trigger.check();
73+
expect(result).toBe(false);
74+
});
75+
});
76+
77+
describe('close', () => {
78+
beforeEach(() => {
79+
trigger = new IntervalTrigger(mockLogger, interval, mockCallback);
80+
});
81+
82+
test('should cancel the scheduled job', () => {
83+
trigger.close();
84+
85+
expect(mockJob.cancel).toHaveBeenCalledTimes(1);
86+
});
87+
88+
test('should handle close being called multiple times', () => {
89+
trigger.close();
90+
trigger.close();
91+
92+
expect(mockJob.cancel).toHaveBeenCalledTimes(1);
93+
});
94+
});
95+
});

0 commit comments

Comments
 (0)