Skip to content

fix(wallets-sdk): add default HTTP timeout + gate preAuth on prepareOnly#1802

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1776365429-fix-http-timeout-preauth-gate
Open

fix(wallets-sdk): add default HTTP timeout + gate preAuth on prepareOnly#1802
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1776365429-fix-http-timeout-preauth-gate

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 16, 2026

Description

Fixes two SDK-side bugs that, in combination, cause wallet.send() to hang indefinitely (reported via Pylon #6935 — Pálpito).

Bug 1 — No HTTP timeout on ApiClient: makeRequest() used bare fetch() with no AbortController. If a response was lost in transit, the Promise would never resolve or reject. Now applies AbortSignal.timeout(30_000) by default. Callers can still override by passing their own signal in RequestInit. Timeout rejections are caught and rethrown as a typed ApiRequestTimeoutError (extends Error, not ApiClientError, since there is no HTTP response to attach).

Bug 2 — preAuthIfNeeded() runs on prepareOnly sends: wallet.send() unconditionally called preAuthIfNeeded(), which triggers signer initialization/recovery and NonCustodialSigner.ensureAuthenticated(). When prepareOnly: true, the caller only wants to persist the transaction without broadcasting — signer pre-auth is unnecessary and can itself stall. Now gated behind if (!options?.prepareOnly). The else branch still awaits this.#signerInitialization so that requireSigner() on the next line never races against initDefaultSigner().

Audit note (out of scope)

The same preAuth-before-prepareOnly pattern exists in EVMWallet.sendTransaction, EVMWallet.signMessage, EVMWallet.signTypedData, SolanaWallet.sendTransaction, and StellarWallet.sendTransaction. These are not fixed in this PR to keep scope tight — follow-up ticket needed. wallet.approve() also calls preAuthIfNeeded() but approval inherently requires signer auth, so no gate is needed there.

Human review checklist

  • ApiRequestTimeoutError extends plain Error rather than ApiClientError — intentional since there's no HTTP status/body to attach. Confirm this is the right hierarchy.
  • The 30s default is hardcoded. Acceptable for now? (A defaultTimeoutMs property was suggested but deferred to keep this PR minimal.)
  • The #signerInitialization race fix (else { await this.#signerInitialization; }) was flagged by Greptile and addressed — but note the existing tests use createMockWallet which calls wallet.useSigner() directly, bypassing initDefaultSigner(). The race path is not directly exercised in unit tests. Worth a manual sanity check or integration test follow-up.
  • Non-blocking smoke test failure in CI appears pre-existing (1 of 5 tests failing, not related to changed packages).

Test plan

  • Bug 1 tests (ApiClient.test.ts — new file, 7 cases): makeRequest() rejects with ApiRequestTimeoutError after mocked timeout; error includes the request path; caller-supplied signal takes precedence over default; AbortSignal.timeout(30_000) is applied when no signal provided; non-timeout errors (network failures, AbortError DOMExceptions) pass through unchanged; 5xx still throws ApiClientError.
  • Bug 2 tests (wallet.test.ts — new preAuthIfNeeded gating describe block, 2 cases): wallet.send({ prepareOnly: true }) does not call preAuthIfNeeded; wallet.send() without the flag does call it.
  • Full suite passes locally and in CI: pnpm test:vitest — all 11 test suites green, no regressions. Required CI checks (lint, build & test) pass.

Package updates

  • @crossmint/common-sdk-base: patch (new ApiRequestTimeoutError export, makeRequest timeout logic)
  • @crossmint/wallets-sdk: patch (prepareOnly gate in wallet.send())
  • Changeset added: .changeset/fix-http-timeout-preauth-gate.md

Link to Devin session: https://crossmint.devinenterprise.com/sessions/f411cdd969cd4f07988fc20d3d08a850
Requested by: @jorge2393

Co-Authored-By: jorge@paella.dev <jorge@paella.dev>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Original prompt from jorge@paella.dev

# Task: Open a PR to fix two SDK bugs causing wallet.send() to hang indefinitely

#``# Repo
crossbit-main (Crossmint monorepo). Work in the wallets-sdk packages.

#``# Context
Customer Pálpito (Pylon #6935) reported wallet.send({ experimental_prepareOnly: true })
hanging indefinitely in production. Investigation confirmed two SDK-side bugs that,
in combination, cause the hang:

  1. The base HTTP client uses bare fetch() with no AbortController, so if a
    response is lost in transit, the Promise never resolves or rejects.
  2. wallet.send() still runs preAuthIfNeeded() even when experimental_prepareOnly
    is true, which can itself stall waiting on signer session state before the
    HTTP request ever fires.

Target release: @crossmint/wallets-sdk v0.21.2

#``# Bug 1: Add default timeout + AbortController to ApiClient

File: packages/common/base/src/apiClient/ApiClient.ts (around lines 17-43)

Current code (makeRequest method):
const response = await fetch(this.buildUrl(path), {
...init,
headers: { ...this.commonHeaders, ...init.headers },
});

Change to:
const response = await fetch(this.buildUrl(path), {
...init,
headers: { ...this.commonHeaders, ...init.headers },
signal: init.signal ?? AbortSignal.timeout(30_000),
});

Requirements:

  • Default timeout is 30 seconds.
  • Callers can override by passing their own AbortSignal in init.signal.
  • When the timeout fires, the resulting DOMException("TimeoutError") should
    be caught and rethrown as a typed error class (e.g., ApiRequestTimeoutError)
    with a clear message referencing the URL path. Follow the existing error-class
    conventions used elsewhere in the package.
  • Confirm AbortSignal.timeout() is available in the package's target runtimes
    (Node 18+ and all evergreen browsers). If a polyfill is needed for any
    published target, add it.

#``# Bug 2: Gate preAuthIfNeeded() behind !experimental_prepa... (2835 chars truncated...)

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 16, 2026

🦋 Changeset detected

Latest commit: 9726fd4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@crossmint/wallets-sdk Patch
@crossmint/common-sdk-base Patch
@crossmint/wallets-quickstart-devkit Patch
@crossmint/wallets-playground-react Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-react-ui Patch
@crossmint/client-sdk-auth Patch
@crossmint/client-sdk-base Patch
@crossmint/client-sdk-verifiable-credentials Patch
@crossmint/client-sdk-smart-wallet Patch
@crossmint/client-sdk-walletconnect Patch
@crossmint/common-sdk-auth Patch
@crossmint/server-sdk Patch
@crossmint/wallets-playground-expo Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/client-sdk-nextjs-starter Patch
crossmint-auth-node Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 16, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 511-514

Comment:
**`#signerInitialization` race skipped for `prepareOnly`**

`preAuthIfNeeded()` starts with `await this.#signerInitialization`, which is the only place that waits for `initDefaultSigner()` to finish auto-assembling `this.#signer`. By skipping `preAuthIfNeeded()` entirely for `prepareOnly`, `requireSigner()` on line 514 can be reached while `initDefaultSigner()` is still running — leaving `this.#signer === undefined` and causing `requireSigner()` to throw "This wallet is read-only" even though the signer would be available moments later. The tests don't expose this because `createMockWallet` calls `await wallet.useSigner(signer)` before any test runs, bypassing `initDefaultSigner()` entirely.

A minimal fix is to await initialization in the `prepareOnly` branch before calling `requireSigner()`:

```
if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization; // ensure auto-assembly completes
}
const walletSigner = this.requireSigner();
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/common/base/src/apiClient/ApiClient.ts
Line: 30

Comment:
**Hardcoded timeout with no override at the client level**

The 30-second timeout is hardcoded with no way to adjust it per `ApiClient` instance. Callers can override it per-request by passing their own `signal`, but there is no way to say "this client should always time out in 10 s" without wrapping every call. Consider accepting an optional `defaultTimeoutMs` in the `ApiClient` constructor so subclasses can configure it without touching call sites.

```suggestion
                signal: init.signal ?? AbortSignal.timeout(this.defaultTimeoutMs ?? 30_000),
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(wallets-sdk): add default HTTP timeo..." | Re-trigger Greptile

Comment on lines +511 to 514
if (!options?.prepareOnly) {
await this.preAuthIfNeeded();
}
const walletSigner = this.requireSigner();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 #signerInitialization race skipped for prepareOnly

preAuthIfNeeded() starts with await this.#signerInitialization, which is the only place that waits for initDefaultSigner() to finish auto-assembling this.#signer. By skipping preAuthIfNeeded() entirely for prepareOnly, requireSigner() on line 514 can be reached while initDefaultSigner() is still running — leaving this.#signer === undefined and causing requireSigner() to throw "This wallet is read-only" even though the signer would be available moments later. The tests don't expose this because createMockWallet calls await wallet.useSigner(signer) before any test runs, bypassing initDefaultSigner() entirely.

A minimal fix is to await initialization in the prepareOnly branch before calling requireSigner():

if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization; // ensure auto-assembly completes
}
const walletSigner = this.requireSigner();
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 511-514

Comment:
**`#signerInitialization` race skipped for `prepareOnly`**

`preAuthIfNeeded()` starts with `await this.#signerInitialization`, which is the only place that waits for `initDefaultSigner()` to finish auto-assembling `this.#signer`. By skipping `preAuthIfNeeded()` entirely for `prepareOnly`, `requireSigner()` on line 514 can be reached while `initDefaultSigner()` is still running — leaving `this.#signer === undefined` and causing `requireSigner()` to throw "This wallet is read-only" even though the signer would be available moments later. The tests don't expose this because `createMockWallet` calls `await wallet.useSigner(signer)` before any test runs, bypassing `initDefaultSigner()` entirely.

A minimal fix is to await initialization in the `prepareOnly` branch before calling `requireSigner()`:

```
if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization; // ensure auto-assembly completes
}
const walletSigner = this.requireSigner();
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed this is a real race. preAuthIfNeeded() awaits this.#signerInitialization (line 1404), and by skipping the entire call for prepareOnly we'd let requireSigner() run before initDefaultSigner() completes.

Fixed in 9726fd4 — added await this.#signerInitialization in the else branch:

if (!options?.prepareOnly) {
    await this.preAuthIfNeeded();
} else {
    await this.#signerInitialization;
}
const walletSigner = this.requireSigner();

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),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded timeout with no override at the client level

The 30-second timeout is hardcoded with no way to adjust it per ApiClient instance. Callers can override it per-request by passing their own signal, but there is no way to say "this client should always time out in 10 s" without wrapping every call. Consider accepting an optional defaultTimeoutMs in the ApiClient constructor so subclasses can configure it without touching call sites.

Suggested change
signal: init.signal ?? AbortSignal.timeout(30_000),
signal: init.signal ?? AbortSignal.timeout(this.defaultTimeoutMs ?? 30_000),
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/common/base/src/apiClient/ApiClient.ts
Line: 30

Comment:
**Hardcoded timeout with no override at the client level**

The 30-second timeout is hardcoded with no way to adjust it per `ApiClient` instance. Callers can override it per-request by passing their own `signal`, but there is no way to say "this client should always time out in 10 s" without wrapping every call. Consider accepting an optional `defaultTimeoutMs` in the `ApiClient` constructor so subclasses can configure it without touching call sites.

```suggestion
                signal: init.signal ?? AbortSignal.timeout(this.defaultTimeoutMs ?? 30_000),
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree this would be a nice enhancement, but treating it as out of scope for this bug-fix PR to keep the change minimal. The per-request signal override is sufficient for callers who need a different timeout today. A defaultTimeoutMs property on ApiClient could be added in a follow-up if subclasses need instance-level configuration.

Co-Authored-By: jorge@paella.dev <jorge@paella.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 16, 2026

🔥 Smoke Test Results

Status: Failed

Statistics

  • Total Tests: 5
  • Passed: 3 ✅
  • Failed: 1 ❌
  • Skipped: 1 ⚠️
  • Duration: 4.73 min

Test Details


This is a non-blocking smoke test. Full regression tests run separately.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 16, 2026

Reviews (2): Last reviewed commit: "fix: await #signerInitialization in prep..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants