Skip to content

Commit 8744689

Browse files
committed
refactor: extract transaction clearing to shared bot-utils module
Move clearStuckTransactions and related helpers to bot-utils/transactionClearing.ts so both transaction-clearer and bot-oo can reuse the same logic. Signed-off-by: Pablo Maldonado <pablo@umaproject.org>
1 parent dd0c8b7 commit 8744689

3 files changed

Lines changed: 211 additions & 187 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { BigNumber } from "ethers";
2+
import type { Logger as LoggerType } from "winston";
3+
import type { Provider } from "@ethersproject/abstract-provider";
4+
import type { Signer } from "ethers";
5+
import { GasEstimator } from "@uma/financial-templates-lib";
6+
7+
export interface NonceBacklogConfig {
8+
// Minimum nonce difference (pending - latest) to trigger clearing
9+
nonceBacklogThreshold: number;
10+
// Fee bump percentage per attempt (e.g., 20 means 20% increase)
11+
feeBumpPercent: number;
12+
// Max attempts to replace a stuck transaction with increasing fees
13+
replacementAttempts: number;
14+
}
15+
16+
export interface TransactionClearingParams {
17+
provider: Provider;
18+
signer: Signer;
19+
nonceBacklogConfig: NonceBacklogConfig;
20+
}
21+
22+
type FeeData = { maxFeePerGas: BigNumber; maxPriorityFeePerGas: BigNumber } | { gasPrice: BigNumber };
23+
24+
function isLondonFeeData(feeData: FeeData): feeData is { maxFeePerGas: BigNumber; maxPriorityFeePerGas: BigNumber } {
25+
return "maxFeePerGas" in feeData;
26+
}
27+
28+
export const parsePositiveInt = (value: string | undefined, defaultValue: number, name: string): number => {
29+
if (value === undefined) return defaultValue;
30+
const parsed = Number(value);
31+
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
32+
throw new Error(`${name} must be a positive integer, got: ${value}`);
33+
}
34+
return parsed;
35+
};
36+
37+
export const getNonceBacklogConfig = (env: NodeJS.ProcessEnv): NonceBacklogConfig => {
38+
return {
39+
nonceBacklogThreshold: parsePositiveInt(env.NONCE_BACKLOG_THRESHOLD, 1, "NONCE_BACKLOG_THRESHOLD"),
40+
feeBumpPercent: parsePositiveInt(env.NONCE_REPLACEMENT_BUMP_PERCENT, 20, "NONCE_REPLACEMENT_BUMP_PERCENT"),
41+
replacementAttempts: parsePositiveInt(env.NONCE_REPLACEMENT_ATTEMPTS, 3, "NONCE_REPLACEMENT_ATTEMPTS"),
42+
};
43+
};
44+
45+
function bumpFeeData(baseFeeData: FeeData, attemptIndex: number, config: NonceBacklogConfig): FeeData {
46+
// Calculate multiplier: ((100 + percent) / 100)^(attemptIndex+1)
47+
// For attempt 0: 1.2x, attempt 1: 1.44x, attempt 2: 1.73x (with default 20%)
48+
const bumpValue = (value: BigNumber): BigNumber => {
49+
let bumped = value;
50+
for (let i = 0; i <= attemptIndex; i++) {
51+
bumped = bumped.mul(100 + config.feeBumpPercent).div(100);
52+
}
53+
return bumped;
54+
};
55+
56+
if (isLondonFeeData(baseFeeData)) {
57+
return {
58+
maxFeePerGas: bumpValue(baseFeeData.maxFeePerGas),
59+
maxPriorityFeePerGas: bumpValue(baseFeeData.maxPriorityFeePerGas),
60+
};
61+
} else {
62+
return {
63+
gasPrice: bumpValue(baseFeeData.gasPrice),
64+
};
65+
}
66+
}
67+
68+
async function getNonces(provider: Provider, address: string): Promise<{ latestNonce: number; pendingNonce: number }> {
69+
const [latestNonce, pendingNonce] = await Promise.all([
70+
provider.getTransactionCount(address, "latest"),
71+
provider.getTransactionCount(address, "pending"),
72+
]);
73+
return { latestNonce, pendingNonce };
74+
}
75+
76+
/**
77+
* Clears stuck transactions by sending self-transactions with higher gas fees.
78+
* @returns true if a nonce backlog was detected and clearing was attempted
79+
*/
80+
export async function clearStuckTransactions(
81+
logger: LoggerType,
82+
params: TransactionClearingParams,
83+
gasEstimator: GasEstimator
84+
): Promise<boolean> {
85+
const { provider, signer, nonceBacklogConfig } = params;
86+
const botAddress = await signer.getAddress();
87+
88+
const { latestNonce, pendingNonce } = await getNonces(provider, botAddress);
89+
const backlog = pendingNonce - latestNonce;
90+
91+
if (backlog < nonceBacklogConfig.nonceBacklogThreshold) {
92+
logger.debug({
93+
at: "TransactionClearer",
94+
message: "No nonce backlog detected",
95+
botAddress,
96+
latestNonce,
97+
pendingNonce,
98+
backlog,
99+
threshold: nonceBacklogConfig.nonceBacklogThreshold,
100+
});
101+
return false;
102+
}
103+
104+
logger.warn({
105+
at: "TransactionClearer",
106+
message: "Nonce backlog detected, attempting to clear stuck transactions",
107+
botAddress,
108+
latestNonce,
109+
pendingNonce,
110+
backlog,
111+
threshold: nonceBacklogConfig.nonceBacklogThreshold,
112+
});
113+
114+
// Get base fee data from gas estimator
115+
const baseFeeData = gasEstimator.getCurrentFastPriceEthers();
116+
117+
// Clear all stuck nonces from latestNonce to pendingNonce - 1
118+
for (let nonce = latestNonce; nonce < pendingNonce; nonce++) {
119+
let cleared = false;
120+
121+
for (let attempt = 0; attempt < nonceBacklogConfig.replacementAttempts; attempt++) {
122+
const feeData = bumpFeeData(baseFeeData, attempt, nonceBacklogConfig);
123+
124+
try {
125+
logger.info({
126+
at: "TransactionClearer",
127+
message: `Attempting to clear stuck transaction (nonce ${nonce}, attempt ${attempt + 1})`,
128+
botAddress,
129+
nonce,
130+
attempt: attempt + 1,
131+
feeData: isLondonFeeData(feeData)
132+
? {
133+
maxFeePerGas: feeData.maxFeePerGas.toString(),
134+
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas.toString(),
135+
}
136+
: { gasPrice: feeData.gasPrice.toString() },
137+
});
138+
139+
const tx = await signer.sendTransaction({
140+
to: botAddress, // Self-transaction
141+
value: 0,
142+
nonce,
143+
gasLimit: 21_000,
144+
...feeData,
145+
});
146+
147+
const receipt = await tx.wait(1);
148+
149+
logger.info({
150+
at: "TransactionClearer",
151+
message: `Successfully cleared stuck transaction (nonce ${nonce})`,
152+
botAddress,
153+
nonce,
154+
transactionHash: receipt.transactionHash,
155+
gasUsed: receipt.gasUsed.toString(),
156+
});
157+
158+
cleared = true;
159+
break;
160+
} catch (error) {
161+
logger.warn({
162+
at: "TransactionClearer",
163+
message: `Failed to clear stuck transaction (nonce ${nonce}, attempt ${attempt + 1})`,
164+
botAddress,
165+
nonce,
166+
attempt: attempt + 1,
167+
error: error instanceof Error ? error.message : String(error),
168+
});
169+
}
170+
}
171+
172+
if (!cleared) {
173+
logger.error({
174+
at: "TransactionClearer",
175+
message: `Failed to clear stuck transaction after all attempts (nonce ${nonce})`,
176+
botAddress,
177+
nonce,
178+
maxAttempts: nonceBacklogConfig.replacementAttempts,
179+
});
180+
}
181+
}
182+
183+
// Verify final state
184+
const { latestNonce: finalLatestNonce, pendingNonce: finalPendingNonce } = await getNonces(provider, botAddress);
185+
const finalBacklog = finalPendingNonce - finalLatestNonce;
186+
187+
if (finalBacklog < nonceBacklogConfig.nonceBacklogThreshold) {
188+
logger.info({
189+
at: "TransactionClearer",
190+
message: "Successfully cleared nonce backlog",
191+
botAddress,
192+
previousBacklog: backlog,
193+
finalBacklog,
194+
});
195+
} else {
196+
logger.warn({
197+
at: "TransactionClearer",
198+
message: "Nonce backlog still present after clearing attempt",
199+
botAddress,
200+
previousBacklog: backlog,
201+
finalBacklog,
202+
});
203+
}
204+
205+
return true;
206+
}
Lines changed: 3 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,171 +1,12 @@
1-
import { BigNumber } from "ethers";
21
import type { Logger as LoggerType } from "winston";
3-
import type { Provider } from "@ethersproject/abstract-provider";
42
import { GasEstimator } from "@uma/financial-templates-lib";
5-
import { MonitoringParams, NonceBacklogConfig } from "./common";
6-
7-
type FeeData = { maxFeePerGas: BigNumber; maxPriorityFeePerGas: BigNumber } | { gasPrice: BigNumber };
8-
9-
function isLondonFeeData(feeData: FeeData): feeData is { maxFeePerGas: BigNumber; maxPriorityFeePerGas: BigNumber } {
10-
return "maxFeePerGas" in feeData;
11-
}
12-
13-
function bumpFeeData(baseFeeData: FeeData, attemptIndex: number, config: NonceBacklogConfig): FeeData {
14-
// Calculate multiplier: ((100 + percent) / 100)^(attemptIndex+1)
15-
// For attempt 0: 1.2x, attempt 1: 1.44x, attempt 2: 1.73x (with default 20%)
16-
const bumpValue = (value: BigNumber): BigNumber => {
17-
let bumped = value;
18-
for (let i = 0; i <= attemptIndex; i++) {
19-
bumped = bumped.mul(100 + config.feeBumpPercent).div(100);
20-
}
21-
return bumped;
22-
};
23-
24-
if (isLondonFeeData(baseFeeData)) {
25-
return {
26-
maxFeePerGas: bumpValue(baseFeeData.maxFeePerGas),
27-
maxPriorityFeePerGas: bumpValue(baseFeeData.maxPriorityFeePerGas),
28-
};
29-
} else {
30-
return {
31-
gasPrice: bumpValue(baseFeeData.gasPrice),
32-
};
33-
}
34-
}
35-
36-
async function getNonces(
37-
provider: Provider,
38-
address: string
39-
): Promise<{ latestNonce: number; pendingNonce: number }> {
40-
const [latestNonce, pendingNonce] = await Promise.all([
41-
provider.getTransactionCount(address, "latest"),
42-
provider.getTransactionCount(address, "pending"),
43-
]);
44-
return { latestNonce, pendingNonce };
45-
}
3+
import { MonitoringParams } from "./common";
4+
import { clearStuckTransactions as clearStuckTransactionsImpl } from "../bot-utils/transactionClearing";
465

476
export async function clearStuckTransactions(
487
logger: LoggerType,
498
params: MonitoringParams,
509
gasEstimator: GasEstimator
5110
): Promise<void> {
52-
const { provider, signer, nonceBacklogConfig } = params;
53-
const botAddress = await signer.getAddress();
54-
55-
const { latestNonce, pendingNonce } = await getNonces(provider, botAddress);
56-
const backlog = pendingNonce - latestNonce;
57-
58-
if (backlog < nonceBacklogConfig.nonceBacklogThreshold) {
59-
logger.debug({
60-
at: "TransactionClearer",
61-
message: "No nonce backlog detected",
62-
botAddress,
63-
latestNonce,
64-
pendingNonce,
65-
backlog,
66-
threshold: nonceBacklogConfig.nonceBacklogThreshold,
67-
});
68-
return;
69-
}
70-
71-
logger.warn({
72-
at: "TransactionClearer",
73-
message: "Nonce backlog detected, attempting to clear stuck transactions",
74-
botAddress,
75-
latestNonce,
76-
pendingNonce,
77-
backlog,
78-
threshold: nonceBacklogConfig.nonceBacklogThreshold,
79-
});
80-
81-
// Get base fee data from gas estimator
82-
const baseFeeData = gasEstimator.getCurrentFastPriceEthers();
83-
84-
// Clear all stuck nonces from latestNonce to pendingNonce - 1
85-
for (let nonce = latestNonce; nonce < pendingNonce; nonce++) {
86-
let cleared = false;
87-
88-
for (let attempt = 0; attempt < nonceBacklogConfig.replacementAttempts; attempt++) {
89-
const feeData = bumpFeeData(baseFeeData, attempt, nonceBacklogConfig);
90-
91-
try {
92-
logger.info({
93-
at: "TransactionClearer",
94-
message: `Attempting to clear stuck transaction (nonce ${nonce}, attempt ${attempt + 1})`,
95-
botAddress,
96-
nonce,
97-
attempt: attempt + 1,
98-
feeData: isLondonFeeData(feeData)
99-
? {
100-
maxFeePerGas: feeData.maxFeePerGas.toString(),
101-
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas.toString(),
102-
}
103-
: { gasPrice: feeData.gasPrice.toString() },
104-
});
105-
106-
const tx = await signer.sendTransaction({
107-
to: botAddress, // Self-transaction
108-
value: 0,
109-
nonce,
110-
gasLimit: 21_000,
111-
...feeData,
112-
});
113-
114-
const receipt = await tx.wait(1);
115-
116-
logger.info({
117-
at: "TransactionClearer",
118-
message: `Successfully cleared stuck transaction (nonce ${nonce})`,
119-
botAddress,
120-
nonce,
121-
transactionHash: receipt.transactionHash,
122-
gasUsed: receipt.gasUsed.toString(),
123-
});
124-
125-
cleared = true;
126-
break;
127-
} catch (error) {
128-
logger.warn({
129-
at: "TransactionClearer",
130-
message: `Failed to clear stuck transaction (nonce ${nonce}, attempt ${attempt + 1})`,
131-
botAddress,
132-
nonce,
133-
attempt: attempt + 1,
134-
error: error instanceof Error ? error.message : String(error),
135-
});
136-
}
137-
}
138-
139-
if (!cleared) {
140-
logger.error({
141-
at: "TransactionClearer",
142-
message: `Failed to clear stuck transaction after all attempts (nonce ${nonce})`,
143-
botAddress,
144-
nonce,
145-
maxAttempts: nonceBacklogConfig.replacementAttempts,
146-
});
147-
}
148-
}
149-
150-
// Verify final state
151-
const { latestNonce: finalLatestNonce, pendingNonce: finalPendingNonce } = await getNonces(provider, botAddress);
152-
const finalBacklog = finalPendingNonce - finalLatestNonce;
153-
154-
if (finalBacklog < nonceBacklogConfig.nonceBacklogThreshold) {
155-
logger.info({
156-
at: "TransactionClearer",
157-
message: "Successfully cleared nonce backlog",
158-
botAddress,
159-
previousBacklog: backlog,
160-
finalBacklog,
161-
});
162-
} else {
163-
logger.warn({
164-
at: "TransactionClearer",
165-
message: "Nonce backlog still present after clearing attempt",
166-
botAddress,
167-
previousBacklog: backlog,
168-
finalBacklog,
169-
});
170-
}
11+
await clearStuckTransactionsImpl(logger, params, gasEstimator);
17112
}

0 commit comments

Comments
 (0)