Skip to content

Commit 8c12183

Browse files
authored
fix: Missing low wallet balance webhook (#737)
* fix: Missing low wallet balance webhook * undo file * fix build * fix throttle logic
1 parent 63a3db2 commit 8c12183

File tree

5 files changed

+119
-80
lines changed

5 files changed

+119
-80
lines changed

src/schema/webhooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ export enum WebhooksEventTypes {
1010
CONTRACT_SUBSCRIPTION = "contract_subscription",
1111
}
1212

13-
export interface WalletBalanceWebhookSchema {
13+
export type BackendWalletBalanceWebhookParams = {
1414
walletAddress: string;
1515
minimumBalance: string;
1616
currentBalance: string;
1717
chainId: number;
1818
message: string;
19-
}
19+
};

src/utils/webhook.ts

Lines changed: 8 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
import { Webhooks } from "@prisma/client";
2-
import crypto from "crypto";
3-
import {
4-
WalletBalanceWebhookSchema,
5-
WebhooksEventTypes,
6-
} from "../schema/webhooks";
7-
import { getWebhooksByEventType } from "./cache/getWebhook";
8-
import { logger } from "./logger";
9-
10-
let balanceNotificationLastSentAt = -1;
1+
import type { Webhooks } from "@prisma/client";
2+
import crypto from "node:crypto";
3+
import { prettifyError } from "./error";
114

125
export const generateSignature = (
13-
body: Record<string, any>,
6+
body: Record<string, unknown>,
147
timestamp: string,
158
secret: string,
169
): string => {
@@ -21,7 +14,7 @@ export const generateSignature = (
2114

2215
export const createWebhookRequestHeaders = async (
2316
webhook: Webhooks,
24-
body: Record<string, any>,
17+
body: Record<string, unknown>,
2518
): Promise<HeadersInit> => {
2619
const headers: {
2720
Accept: string;
@@ -54,7 +47,7 @@ export interface WebhookResponse {
5447

5548
export const sendWebhookRequest = async (
5649
webhook: Webhooks,
57-
body: Record<string, any>,
50+
body: Record<string, unknown>,
5851
): Promise<WebhookResponse> => {
5952
try {
6053
const headers = await createWebhookRequestHeaders(webhook, body);
@@ -69,67 +62,11 @@ export const sendWebhookRequest = async (
6962
status: resp.status,
7063
body: await resp.text(),
7164
};
72-
} catch (e: any) {
65+
} catch (e) {
7366
return {
7467
ok: false,
7568
status: 500,
76-
body: e.toString(),
69+
body: prettifyError(e),
7770
};
7871
}
7972
};
80-
81-
// TODO: Add retry logic upto
82-
export const sendBalanceWebhook = async (
83-
data: WalletBalanceWebhookSchema,
84-
): Promise<void> => {
85-
try {
86-
const elaspsedTime = Date.now() - balanceNotificationLastSentAt;
87-
if (elaspsedTime < 30000) {
88-
logger({
89-
service: "server",
90-
level: "warn",
91-
message: `[sendBalanceWebhook] Low wallet balance notification sent within last 30 Seconds. Skipping.`,
92-
});
93-
return;
94-
}
95-
96-
const webhooks = await getWebhooksByEventType(
97-
WebhooksEventTypes.BACKEND_WALLET_BALANCE,
98-
);
99-
100-
if (webhooks.length === 0) {
101-
logger({
102-
service: "server",
103-
level: "debug",
104-
message: "No webhook set, skipping webhook send",
105-
});
106-
107-
return;
108-
}
109-
110-
webhooks.map(async (config) => {
111-
if (!config || config.revokedAt) {
112-
logger({
113-
service: "server",
114-
level: "debug",
115-
message: "No webhook set or active, skipping webhook send",
116-
});
117-
118-
return;
119-
}
120-
121-
const success = await sendWebhookRequest(config, data);
122-
123-
if (success) {
124-
balanceNotificationLastSentAt = Date.now();
125-
}
126-
});
127-
} catch (error) {
128-
logger({
129-
service: "server",
130-
level: "error",
131-
message: `Failed to send balance webhook`,
132-
error,
133-
});
134-
}
135-
};

src/worker/queues/sendWebhookQueue.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import {
1+
import type {
22
ContractEventLogs,
33
ContractTransactionReceipts,
44
Webhooks,
55
} from "@prisma/client";
66
import { Queue } from "bullmq";
77
import SuperJSON from "superjson";
8-
import { WebhooksEventTypes } from "../../schema/webhooks";
8+
import {
9+
WebhooksEventTypes,
10+
type BackendWalletBalanceWebhookParams,
11+
} from "../../schema/webhooks";
912
import { getWebhooksByEventType } from "../../utils/cache/getWebhook";
1013
import { logger } from "../../utils/logger";
1114
import { redis } from "../../utils/redis/redis";
@@ -27,10 +30,16 @@ export type EnqueueTransactionWebhookData = {
2730
queueId: string;
2831
};
2932

33+
export type EnqueueLowBalanceWebhookData = {
34+
type: WebhooksEventTypes.BACKEND_WALLET_BALANCE;
35+
body: BackendWalletBalanceWebhookParams;
36+
};
37+
3038
// Add other webhook event types here.
3139
type EnqueueWebhookData =
3240
| EnqueueContractSubscriptionWebhookData
33-
| EnqueueTransactionWebhookData;
41+
| EnqueueTransactionWebhookData
42+
| EnqueueLowBalanceWebhookData;
3443

3544
export interface WebhookJob {
3645
data: EnqueueWebhookData;
@@ -56,6 +65,8 @@ export class SendWebhookQueue {
5665
case WebhooksEventTypes.ERRORED_TX:
5766
case WebhooksEventTypes.CANCELLED_TX:
5867
return this._enqueueTransactionWebhook(data);
68+
case WebhooksEventTypes.BACKEND_WALLET_BALANCE:
69+
return this._enqueueBackendWalletBalanceWebhook(data);
5970
default:
6071
logger({
6172
service: "worker",
@@ -105,7 +116,8 @@ export class SendWebhookQueue {
105116

106117
if (eventLog) {
107118
return `${webhook.url}.${eventLog.transactionHash}.${eventLog.logIndex}`;
108-
} else if (transactionReceipt) {
119+
}
120+
if (transactionReceipt) {
109121
return `${webhook.url}.${transactionReceipt.transactionHash}`;
110122
}
111123
throw 'Must provide "eventLog" or "transactionReceipt".';
@@ -140,4 +152,20 @@ export class SendWebhookQueue {
140152
eventType: WebhooksEventTypes;
141153
queueId: string;
142154
}) => `${args.webhook.url}.${args.eventType}.${args.queueId}`;
155+
156+
private static _enqueueBackendWalletBalanceWebhook = async (
157+
data: EnqueueLowBalanceWebhookData,
158+
) => {
159+
const webhooks = await getWebhooksByEventType(
160+
WebhooksEventTypes.BACKEND_WALLET_BALANCE,
161+
);
162+
for (const webhook of webhooks) {
163+
const job: WebhookJob = { data, webhook };
164+
const serialized = SuperJSON.stringify(job);
165+
await this.q.add(
166+
`${data.type}:${data.body.chainId}:${data.body.walletAddress}`,
167+
serialized,
168+
);
169+
}
170+
};
143171
}

src/worker/tasks/mineTransactionWorker.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,26 @@ import { Worker, type Job, type Processor } from "bullmq";
22
import assert from "node:assert";
33
import superjson from "superjson";
44
import {
5+
eth_getBalance,
56
eth_getTransactionByHash,
67
eth_getTransactionReceipt,
78
getAddress,
89
getRpcClient,
10+
toTokens,
911
type Address,
1012
} from "thirdweb";
1113
import { stringify } from "thirdweb/utils";
1214
import { getUserOpReceipt, getUserOpReceiptRaw } from "thirdweb/wallets/smart";
1315
import { TransactionDB } from "../../db/transactions/db";
1416
import { recycleNonce, removeSentNonce } from "../../db/wallets/walletNonce";
17+
import { WebhooksEventTypes } from "../../schema/webhooks";
1518
import { getBlockNumberish } from "../../utils/block";
1619
import { getConfig } from "../../utils/cache/getConfig";
20+
import { getWebhooksByEventType } from "../../utils/cache/getWebhook";
1721
import { getChain } from "../../utils/chain";
1822
import { msSince } from "../../utils/date";
1923
import { env } from "../../utils/env";
24+
import { prettifyError } from "../../utils/error";
2025
import { logger } from "../../utils/logger";
2126
import { recordMetrics } from "../../utils/prometheus";
2227
import { redis } from "../../utils/redis/redis";
@@ -33,6 +38,7 @@ import {
3338
type MineTransactionData,
3439
} from "../queues/mineTransactionQueue";
3540
import { SendTransactionQueue } from "../queues/sendTransactionQueue";
41+
import { SendWebhookQueue } from "../queues/sendWebhookQueue";
3642

3743
/**
3844
* Check if the submitted transaction or userOp is mined onchain.
@@ -66,6 +72,7 @@ const handler: Processor<any, void, string> = async (job: Job<string>) => {
6672
if (resultTransaction.status === "mined") {
6773
await TransactionDB.set(resultTransaction);
6874
await enqueueTransactionWebhook(resultTransaction);
75+
await _notifyIfLowBalance(resultTransaction);
6976
await _reportUsageSuccess(resultTransaction);
7077
recordMetrics({
7178
event: "transaction_mined",
@@ -257,6 +264,65 @@ const _mineUserOp = async (
257264
};
258265
};
259266

267+
const _notifyIfLowBalance = async (transaction: MinedTransaction) => {
268+
const { isUserOp, chainId, from } = transaction;
269+
if (isUserOp) {
270+
// Skip for userOps since they may not use the wallet's gas balance.
271+
return;
272+
}
273+
274+
try {
275+
const webhooks = await getWebhooksByEventType(
276+
WebhooksEventTypes.BACKEND_WALLET_BALANCE,
277+
);
278+
if (webhooks.length === 0) {
279+
// Skip if no webhooks configured.
280+
return;
281+
}
282+
283+
// Set a key with 5min TTL if it doesn't exist.
284+
// This effectively throttles this check once every 5min.
285+
const throttleKey = `webhook:${WebhooksEventTypes.BACKEND_WALLET_BALANCE}:${chainId}:${from}`;
286+
const isThrottled =
287+
(await redis.set(throttleKey, "", "EX", 5 * 60, "NX")) === null;
288+
if (isThrottled) {
289+
return;
290+
}
291+
292+
// Get the current wallet balance.
293+
const rpcRequest = getRpcClient({
294+
client: thirdwebClient,
295+
chain: await getChain(chainId),
296+
});
297+
const currentBalance = await eth_getBalance(rpcRequest, {
298+
address: from,
299+
});
300+
301+
const config = await getConfig();
302+
if (currentBalance >= BigInt(config.minWalletBalance)) {
303+
// Skip if the balance is above the alert threshold.
304+
return;
305+
}
306+
307+
await SendWebhookQueue.enqueueWebhook({
308+
type: WebhooksEventTypes.BACKEND_WALLET_BALANCE,
309+
body: {
310+
chainId,
311+
walletAddress: from,
312+
minimumBalance: config.minWalletBalance,
313+
currentBalance: currentBalance.toString(),
314+
message: `LowBalance: The backend wallet ${from} on chain ${chainId} has ${toTokens(currentBalance, 18)} gas remaining.`,
315+
},
316+
});
317+
} catch (e) {
318+
logger({
319+
level: "warn",
320+
message: `[mineTransactionWorker] Error sending low balance notification: ${prettifyError(e)}`,
321+
service: "worker",
322+
});
323+
}
324+
};
325+
260326
// Must be explicitly called for the worker to run on this host.
261327
export const initMineTransactionWorker = () => {
262328
const _worker = new Worker(MineTransactionQueue.q.name, handler, {

src/worker/tasks/sendWebhookWorker.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { Static } from "@sinclair/typebox";
22
import { Worker, type Job, type Processor } from "bullmq";
33
import superjson from "superjson";
44
import { TransactionDB } from "../../db/transactions/db";
5-
import { WebhooksEventTypes } from "../../schema/webhooks";
5+
import {
6+
WebhooksEventTypes,
7+
type BackendWalletBalanceWebhookParams,
8+
} from "../../schema/webhooks";
69
import { toEventLogSchema } from "../../server/schemas/eventLog";
710
import {
811
toTransactionSchema,
@@ -57,10 +60,15 @@ const handler: Processor<any, void, string> = async (job: Job<string>) => {
5760
resp = await sendWebhookRequest(webhook, webhookBody);
5861
break;
5962
}
63+
64+
case WebhooksEventTypes.BACKEND_WALLET_BALANCE: {
65+
const webhookBody: BackendWalletBalanceWebhookParams = data.body;
66+
resp = await sendWebhookRequest(webhook, webhookBody);
67+
break;
68+
}
6069
}
6170

62-
const shouldRetry = resp && resp.status >= 500 && resp.status <= 599;
63-
if (shouldRetry) {
71+
if (resp && resp.status >= 500) {
6472
// Throw on 5xx so it remains in the queue to retry later.
6573
throw new Error(
6674
`Received status ${resp.status} from webhook ${webhook.url}.`,

0 commit comments

Comments
 (0)