From 90980df0cb0d789f4e819f66a89ef533ff0e0b1d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:54:12 +0000 Subject: [PATCH 1/2] fix(wallets-sdk): add default HTTP timeout + gate preAuth on prepareOnly Co-Authored-By: jorge@paella.dev --- .changeset/fix-http-timeout-preauth-gate.md | 9 ++ .../base/src/apiClient/ApiClient.test.ts | 107 ++++++++++++++++++ .../common/base/src/apiClient/ApiClient.ts | 24 +++- packages/wallets/src/wallets/wallet.test.ts | 45 ++++++++ packages/wallets/src/wallets/wallet.ts | 4 +- 5 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-http-timeout-preauth-gate.md create mode 100644 packages/common/base/src/apiClient/ApiClient.test.ts diff --git a/.changeset/fix-http-timeout-preauth-gate.md b/.changeset/fix-http-timeout-preauth-gate.md new file mode 100644 index 000000000..2e99b8e68 --- /dev/null +++ b/.changeset/fix-http-timeout-preauth-gate.md @@ -0,0 +1,9 @@ +--- +"@crossmint/wallets-sdk": patch +"@crossmint/common-sdk-base": patch +--- + +fix: add default 30s HTTP timeout to ApiClient and gate preAuthIfNeeded on prepareOnly in wallet.send() + +- ApiClient.makeRequest() now uses `AbortSignal.timeout(30_000)` by default so requests that never receive a response will reject after 30 seconds instead of hanging indefinitely. Callers can override by passing their own `signal` in `RequestInit`. Timeout rejections are wrapped in a typed `ApiRequestTimeoutError`. +- `wallet.send()` no longer calls `preAuthIfNeeded()` when `prepareOnly` is `true`, preventing unnecessary signer pre-authorization that could stall the call before the HTTP request fires. diff --git a/packages/common/base/src/apiClient/ApiClient.test.ts b/packages/common/base/src/apiClient/ApiClient.test.ts new file mode 100644 index 000000000..bbb44c68a --- /dev/null +++ b/packages/common/base/src/apiClient/ApiClient.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiClient, ApiClientError, ApiRequestTimeoutError } from "./ApiClient"; + +class TestApiClient extends ApiClient { + get commonHeaders(): HeadersInit { + return { "x-test": "true" }; + } + get baseUrl(): string { + return "https://api.example.com"; + } + + // Expose makeRequest indirectly through the public HTTP methods +} + +describe("ApiClient - timeout handling", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("should reject with ApiRequestTimeoutError when fetch times out", async () => { + const timeoutError = new DOMException("The operation was aborted due to timeout", "TimeoutError"); + globalThis.fetch = vi.fn().mockRejectedValue(timeoutError); + + const client = new TestApiClient(); + + await expect(client.get("/slow-endpoint", {})).rejects.toThrow(ApiRequestTimeoutError); + await expect(client.get("/slow-endpoint", {})).rejects.toThrow('API request to "/slow-endpoint" timed out'); + }); + + it("should include the path in the ApiRequestTimeoutError", async () => { + const timeoutError = new DOMException("The operation was aborted due to timeout", "TimeoutError"); + globalThis.fetch = vi.fn().mockRejectedValue(timeoutError); + + const client = new TestApiClient(); + + try { + await client.get("/my/custom/path", {}); + expect.fail("Expected ApiRequestTimeoutError"); + } catch (error) { + expect(error).toBeInstanceOf(ApiRequestTimeoutError); + expect((error as ApiRequestTimeoutError).path).toBe("/my/custom/path"); + } + }); + + it("should use caller-supplied signal instead of the default timeout", async () => { + const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const client = new TestApiClient(); + const customController = new AbortController(); + + await client.get("/endpoint", { signal: customController.signal }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signal: customController.signal, + }) + ); + }); + + it("should apply a default AbortSignal.timeout(30_000) when no signal is provided", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const client = new TestApiClient(); + await client.get("/endpoint", {}); + + expect(timeoutSpy).toHaveBeenCalledWith(30_000); + }); + + it("should re-throw non-timeout errors as-is", async () => { + const networkError = new Error("Network failure"); + globalThis.fetch = vi.fn().mockRejectedValue(networkError); + + const client = new TestApiClient(); + + await expect(client.get("/endpoint", {})).rejects.toThrow("Network failure"); + await expect(client.get("/endpoint", {})).rejects.not.toBeInstanceOf(ApiRequestTimeoutError); + }); + + it("should re-throw non-timeout DOMExceptions as-is", async () => { + const abortError = new DOMException("The operation was aborted", "AbortError"); + globalThis.fetch = vi.fn().mockRejectedValue(abortError); + + const client = new TestApiClient(); + + await expect(client.get("/endpoint", {})).rejects.toThrow(DOMException); + await expect(client.get("/endpoint", {})).rejects.not.toBeInstanceOf(ApiRequestTimeoutError); + }); + + it("should still throw ApiClientError on 5xx responses", async () => { + const mockResponse = new Response("Internal Server Error", { + status: 500, + statusText: "Internal Server Error", + }); + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + + const client = new TestApiClient(); + + await expect(client.get("/endpoint", {})).rejects.toThrow(ApiClientError); + }); +}); diff --git a/packages/common/base/src/apiClient/ApiClient.ts b/packages/common/base/src/apiClient/ApiClient.ts index a54e0cd11..c4f9c6b9c 100644 --- a/packages/common/base/src/apiClient/ApiClient.ts +++ b/packages/common/base/src/apiClient/ApiClient.ts @@ -10,15 +10,31 @@ export class ApiClientError extends Error { } } +export class ApiRequestTimeoutError extends Error { + constructor(public readonly path: string) { + super(`API request to "${path}" timed out`); + this.name = "ApiRequestTimeoutError"; + } +} + export abstract class ApiClient { abstract get commonHeaders(): HeadersInit; abstract get baseUrl(): string; private async makeRequest(path: string, init: RequestInit) { - const response = await fetch(this.buildUrl(path), { - ...init, - headers: { ...this.commonHeaders, ...init.headers }, // commonHeaders intentionally first, in case sub class wants to override - }); + let response: Response; + try { + response = await fetch(this.buildUrl(path), { + ...init, + headers: { ...this.commonHeaders, ...init.headers }, // commonHeaders intentionally first, in case sub class wants to override + signal: init.signal ?? AbortSignal.timeout(30_000), + }); + } catch (error: unknown) { + if (error instanceof DOMException && error.name === "TimeoutError") { + throw new ApiRequestTimeoutError(path); + } + throw error; + } // Only throw on server errors (5xx) where the response body is likely // non-JSON (e.g. HTML 502 from load balancer). 4xx responses are passed diff --git a/packages/wallets/src/wallets/wallet.test.ts b/packages/wallets/src/wallets/wallet.test.ts index 9eee1bb03..4c96a0c72 100644 --- a/packages/wallets/src/wallets/wallet.test.ts +++ b/packages/wallets/src/wallets/wallet.test.ts @@ -473,6 +473,51 @@ describe("Wallet - send()", () => { expect(mockApiClient.send).not.toHaveBeenCalled(); }); }); + + describe("preAuthIfNeeded gating", () => { + it("should NOT call preAuthIfNeeded when prepareOnly is true", async () => { + const mockSendResponse = { + id: "txn-prep-1", + } as unknown as SendResponse; + + mockApiClient.send.mockResolvedValue(mockSendResponse); + + const preAuthSpy = vi.spyOn(wallet as any, "preAuthIfNeeded"); + + await wallet.send("0x1111111111111111111111111111111111111111", "usdc", "10.0", { + prepareOnly: true, + }); + + expect(preAuthSpy).not.toHaveBeenCalled(); + expect(mockApiClient.send).toHaveBeenCalled(); + }); + + it("should call preAuthIfNeeded when prepareOnly is not set", async () => { + const mockSendResponse = { + id: "txn-normal-1", + } as unknown as SendResponse; + + const mockTransactionResponse = { + id: "txn-normal-1", + status: "success", + onChain: { + txId: "0xabc", + explorerLink: "https://explorer.example.com/tx/0xabc", + }, + }; + + mockApiClient.send.mockResolvedValue(mockSendResponse); + mockApiClient.getTransaction.mockResolvedValue(mockTransactionResponse as any); + + const preAuthSpy = vi.spyOn(wallet as any, "preAuthIfNeeded").mockResolvedValue(undefined); + + const sendPromise = wallet.send("0x1111111111111111111111111111111111111111", "usdc", "10.0"); + await vi.runAllTimersAsync(); + await sendPromise; + + expect(preAuthSpy).toHaveBeenCalled(); + }); + }); }); describe("Wallet - approve()", () => { diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index 37ce0c69c..348400528 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -508,7 +508,9 @@ export class Wallet { ...(options?.transactionType != null ? { transactionType: options.transactionType } : {}), }); - await this.preAuthIfNeeded(); + if (!options?.prepareOnly) { + await this.preAuthIfNeeded(); + } const walletSigner = this.requireSigner(); let signer: string; From 9726fd4efe44e677ff6ebb59f4c2c6e3b8fa7745 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:00:09 +0000 Subject: [PATCH 2/2] fix: await #signerInitialization in prepareOnly branch to prevent race Co-Authored-By: jorge@paella.dev --- packages/wallets/src/wallets/wallet.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index 348400528..4188b8693 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -510,6 +510,8 @@ export class Wallet { if (!options?.prepareOnly) { await this.preAuthIfNeeded(); + } else { + await this.#signerInitialization; } const walletSigner = this.requireSigner();