Skip to content

Commit de297e4

Browse files
authored
[SDK] Feature: Track insufficient funds error across SDK (#7256)
1 parent 2114e5f commit de297e4

File tree

10 files changed

+390
-20
lines changed

10 files changed

+390
-20
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getErrorDetails, isInsufficientFundsError } from "./helpers.js";
3+
4+
describe("isInsufficientFundsError", () => {
5+
it("should detect basic insufficient funds error message", () => {
6+
const error = new Error("insufficient funds");
7+
expect(isInsufficientFundsError(error)).toBe(true);
8+
});
9+
10+
it("should detect insufficient funds for gas error", () => {
11+
const error = new Error("Insufficient funds for gas * price + value");
12+
expect(isInsufficientFundsError(error)).toBe(true);
13+
});
14+
15+
it("should detect insufficient funds for intrinsic transaction cost", () => {
16+
const error = new Error(
17+
"insufficient funds for intrinsic transaction cost",
18+
);
19+
expect(isInsufficientFundsError(error)).toBe(true);
20+
});
21+
22+
it("should detect insufficient balance error", () => {
23+
const error = new Error("insufficient balance");
24+
expect(isInsufficientFundsError(error)).toBe(true);
25+
});
26+
27+
it("should detect insufficient native funds error", () => {
28+
const error = new Error("Insufficient Native Funds");
29+
expect(isInsufficientFundsError(error)).toBe(true);
30+
});
31+
32+
it("should detect INSUFFICIENT_FUNDS error code", () => {
33+
const error = { code: "INSUFFICIENT_FUNDS", message: "Transaction failed" };
34+
expect(isInsufficientFundsError(error)).toBe(true);
35+
});
36+
37+
it("should detect reason field", () => {
38+
const error = {
39+
reason: "insufficient funds",
40+
message: "Transaction failed",
41+
};
42+
expect(isInsufficientFundsError(error)).toBe(true);
43+
});
44+
45+
it("should detect error in nested data.message", () => {
46+
const error = { data: { message: "insufficient funds for gas" } };
47+
expect(isInsufficientFundsError(error)).toBe(true);
48+
});
49+
50+
it("should handle string errors", () => {
51+
expect(isInsufficientFundsError("insufficient funds")).toBe(true);
52+
});
53+
54+
it("should return false for non-insufficient funds errors", () => {
55+
const error = new Error("User rejected transaction");
56+
expect(isInsufficientFundsError(error)).toBe(false);
57+
});
58+
59+
it("should return false for null/undefined", () => {
60+
expect(isInsufficientFundsError(null)).toBe(false);
61+
expect(isInsufficientFundsError(undefined)).toBe(false);
62+
});
63+
64+
it("should be case insensitive", () => {
65+
const error = new Error("INSUFFICIENT FUNDS FOR GAS");
66+
expect(isInsufficientFundsError(error)).toBe(true);
67+
});
68+
});
69+
70+
describe("getErrorDetails", () => {
71+
it("should extract message and code from Error object", () => {
72+
const error = new Error("Test error message");
73+
const details = getErrorDetails(error);
74+
expect(details.message).toBe("Test error message");
75+
expect(details.code).toBeUndefined();
76+
});
77+
78+
it("should extract message and code from error object", () => {
79+
const error = { message: "Test message", code: "TEST_CODE" };
80+
const details = getErrorDetails(error);
81+
expect(details.message).toBe("Test message");
82+
expect(details.code).toBe("TEST_CODE");
83+
});
84+
85+
it("should extract message from nested data", () => {
86+
const error = { data: { message: "Nested error message" } };
87+
const details = getErrorDetails(error);
88+
expect(details.message).toBe("Nested error message");
89+
});
90+
91+
it("should handle string errors", () => {
92+
const details = getErrorDetails("String error");
93+
expect(details.message).toBe("String error");
94+
expect(details.code).toBeUndefined();
95+
});
96+
97+
it("should handle null/undefined", () => {
98+
const details = getErrorDetails(null);
99+
expect(details.message).toBe("Unknown error");
100+
expect(details.code).toBeUndefined();
101+
});
102+
103+
it("should extract reason as code", () => {
104+
const error = { message: "Test message", reason: "test_reason" };
105+
const details = getErrorDetails(error);
106+
expect(details.message).toBe("Test message");
107+
expect(details.code).toBe("test_reason");
108+
});
109+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @internal
3+
*/
4+
export function isInsufficientFundsError(error: Error | unknown): boolean {
5+
if (!error) return false;
6+
7+
const errorMessage =
8+
typeof error === "string"
9+
? error
10+
: (error as Error)?.message ||
11+
(error as { data?: { message?: string } })?.data?.message ||
12+
"";
13+
14+
const message = errorMessage.toLowerCase();
15+
16+
// Common patterns for insufficient funds errors
17+
return (
18+
message.includes("insufficient funds") ||
19+
message.includes("insufficient balance") ||
20+
(message.includes("insufficient") &&
21+
(message.includes("native") || message.includes("gas"))) ||
22+
// Common error codes from various wallets/providers
23+
(error as { code?: string | number })?.code === "INSUFFICIENT_FUNDS" ||
24+
(error as { reason?: string })?.reason === "insufficient funds"
25+
);
26+
}
27+
28+
/**
29+
* @internal
30+
*/
31+
export function getErrorDetails(error: Error | unknown): {
32+
message: string;
33+
code?: string | number;
34+
} {
35+
if (!error) return { message: "Unknown error" };
36+
37+
const message =
38+
typeof error === "string"
39+
? error
40+
: (error as Error)?.message ||
41+
(error as { data?: { message?: string } })?.data?.message ||
42+
String(error);
43+
44+
const code =
45+
(error as { code?: string | number })?.code ||
46+
(error as { reason?: string })?.reason;
47+
48+
return { message, code };
49+
}

packages/thirdweb/src/analytics/track/transaction.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { http, HttpResponse } from "msw";
22
import { setupServer } from "msw/node";
33
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
44
import type { ThirdwebClient } from "../../client/client.js";
5-
import { trackTransaction } from "./transaction.js";
5+
import {
6+
trackInsufficientFundsError,
7+
trackTransaction,
8+
} from "./transaction.js";
69

710
const server = setupServer(
811
http.post("https://c.thirdweb.com/event", () => {
@@ -127,4 +130,110 @@ describe("transaction tracking", () => {
127130
"test-partner-id",
128131
);
129132
});
133+
134+
it("should track insufficient funds error with correct data", async () => {
135+
const mockClient: ThirdwebClient = {
136+
clientId: "test-client-id",
137+
secretKey: undefined,
138+
};
139+
140+
let requestBody: unknown;
141+
server.use(
142+
http.post("https://c.thirdweb.com/event", async (handler) => {
143+
requestBody = await handler.request.json();
144+
return HttpResponse.json({});
145+
}),
146+
);
147+
148+
const mockError = new Error("Insufficient funds for gas * price + value");
149+
150+
await trackInsufficientFundsError({
151+
client: mockClient,
152+
error: mockError,
153+
walletAddress: "0x1234567890123456789012345678901234567890",
154+
chainId: 1,
155+
contractAddress: "0xcontract",
156+
transactionValue: 1000000000000000000n,
157+
});
158+
159+
expect(requestBody).toEqual({
160+
source: "sdk",
161+
action: "transaction:insufficient_funds",
162+
clientId: "test-client-id",
163+
chainId: 1,
164+
walletAddress: "0x1234567890123456789012345678901234567890",
165+
contractAddress: "0xcontract",
166+
transactionValue: "1000000000000000000",
167+
requiredAmount: undefined,
168+
userBalance: undefined,
169+
errorMessage: "Insufficient funds for gas * price + value",
170+
errorCode: undefined,
171+
});
172+
});
173+
174+
it("should not throw an error if insufficient funds tracking request fails", async () => {
175+
const mockClient: ThirdwebClient = {
176+
clientId: "test-client-id",
177+
secretKey: undefined,
178+
};
179+
180+
server.use(
181+
http.post("https://c.thirdweb.com/event", () => {
182+
return HttpResponse.error();
183+
}),
184+
);
185+
186+
const mockError = new Error("insufficient funds");
187+
188+
expect(() =>
189+
trackInsufficientFundsError({
190+
client: mockClient,
191+
error: mockError,
192+
walletAddress: "0x1234567890123456789012345678901234567890",
193+
chainId: 137,
194+
}),
195+
).not.toThrowError();
196+
197+
// Wait for the asynchronous POST request to complete
198+
await new Promise((resolve) => setTimeout(resolve, 100));
199+
});
200+
201+
it("should track insufficient funds error during transaction preparation", async () => {
202+
const mockClient: ThirdwebClient = {
203+
clientId: "test-client-id",
204+
secretKey: undefined,
205+
};
206+
207+
let requestBody: unknown;
208+
server.use(
209+
http.post("https://c.thirdweb.com/event", async (handler) => {
210+
requestBody = await handler.request.json();
211+
return HttpResponse.json({});
212+
}),
213+
);
214+
215+
const mockError = new Error("insufficient funds for gas");
216+
217+
await trackInsufficientFundsError({
218+
client: mockClient,
219+
error: mockError,
220+
walletAddress: "0xabcdef1234567890abcdef1234567890abcdef12",
221+
chainId: 42,
222+
contractAddress: "0x0987654321098765432109876543210987654321",
223+
});
224+
225+
expect(requestBody).toEqual({
226+
source: "sdk",
227+
action: "transaction:insufficient_funds",
228+
clientId: "test-client-id",
229+
chainId: 42,
230+
walletAddress: "0xabcdef1234567890abcdef1234567890abcdef12",
231+
contractAddress: "0x0987654321098765432109876543210987654321",
232+
transactionValue: undefined,
233+
requiredAmount: undefined,
234+
userBalance: undefined,
235+
errorMessage: "insufficient funds for gas",
236+
errorCode: undefined,
237+
});
238+
});
130239
});

packages/thirdweb/src/analytics/track/transaction.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ThirdwebClient } from "../../client/client.js";
22
import { stringify } from "../../utils/json.js";
33
import type { Ecosystem } from "../../wallets/in-app/core/wallet/types.js";
44
import type { WalletId } from "../../wallets/wallet-types.js";
5+
import { getErrorDetails } from "./helpers.js";
56
import { track } from "./index.js";
67

78
type TransactionEvent = {
@@ -55,3 +56,39 @@ function trackTransactionEvent(
5556
},
5657
});
5758
}
59+
60+
/**
61+
* @internal
62+
*/
63+
export async function trackInsufficientFundsError(args: {
64+
client: ThirdwebClient;
65+
ecosystem?: Ecosystem;
66+
error: Error | unknown;
67+
walletAddress?: string;
68+
chainId?: number;
69+
contractAddress?: string;
70+
functionName?: string;
71+
transactionValue?: bigint;
72+
requiredAmount?: bigint;
73+
userBalance?: bigint;
74+
}) {
75+
const errorDetails = getErrorDetails(args.error);
76+
77+
return track({
78+
client: args.client,
79+
ecosystem: args.ecosystem,
80+
data: {
81+
action: "transaction:insufficient_funds",
82+
clientId: args.client.clientId,
83+
chainId: args.chainId,
84+
walletAddress: args.walletAddress,
85+
contractAddress: args.contractAddress,
86+
functionName: args.functionName,
87+
transactionValue: args.transactionValue?.toString(),
88+
requiredAmount: args.requiredAmount?.toString(),
89+
userBalance: args.userBalance?.toString(),
90+
errorMessage: errorDetails.message,
91+
errorCode: errorDetails.code ? stringify(errorDetails.code) : undefined,
92+
},
93+
});
94+
}

packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
2+
import { isInsufficientFundsError } from "../../../../analytics/track/helpers.js";
23
import { trackPayEvent } from "../../../../analytics/track/pay.js";
4+
import { trackInsufficientFundsError } from "../../../../analytics/track/transaction.js";
35
import * as Bridge from "../../../../bridge/index.js";
46
import type { Chain } from "../../../../chains/types.js";
57
import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js";
@@ -174,6 +176,18 @@ export function useSendTransactionCore(args: {
174176

175177
resolve(res);
176178
} catch (e) {
179+
// Track insufficient funds errors specifically
180+
if (isInsufficientFundsError(e)) {
181+
trackInsufficientFundsError({
182+
client: tx.client,
183+
error: e,
184+
walletAddress: account.address,
185+
chainId: tx.chain.id,
186+
contractAddress: await resolvePromisedValue(tx.to ?? undefined),
187+
transactionValue: await resolvePromisedValue(tx.value),
188+
});
189+
}
190+
177191
reject(e);
178192
}
179193
};

packages/thirdweb/src/transaction/actions/estimate-gas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export async function estimateGas(
9494
throw await extractError({
9595
error,
9696
contract: options.transaction.__contract,
97+
fromAddress,
9798
});
9899
}
99100
}
@@ -150,6 +151,7 @@ export async function estimateGas(
150151
throw await extractError({
151152
error,
152153
contract: options.transaction.__contract,
154+
fromAddress,
153155
});
154156
}
155157
})();

packages/thirdweb/src/transaction/actions/simulate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export async function simulateTransaction<
8989
throw await extractError({
9090
error,
9191
contract: options.transaction.__contract,
92+
fromAddress: from,
9293
});
9394
}
9495
}

0 commit comments

Comments
 (0)