From ae7b9baf55ca2ec5944a3cc6f2a6ecb3a9088962 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:11:05 +0000 Subject: [PATCH 1/6] fix: cache auth status in NonCustodialSigner to prevent repeated OTP prompts handleAuthRequired() was sending get-status to the frame on every transaction, even when the signer was recently authenticated. If the frame's in-memory cache had expired or the JWT was refreshed, this could cascade into an unnecessary Relay round-trip and OTP re-prompt. Add a timestamp-based cache (10-min TTL) that short-circuits the get-status call when the signer was recently confirmed as ready. The timestamp is updated on every successful auth event (get-status ready, start-onboarding ready, complete-onboarding success). This is a defense-in-depth fix complementing the open-signer cache key fix, making the SDK resilient to frame cache misses. Fixes ticket 6574 - repeated OTP verification on React Native. Co-Authored-By: max@paella.dev --- .../src/signers/non-custodial/ncs-signer.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/wallets/src/signers/non-custodial/ncs-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-signer.ts index 16216d783..1abc5e91f 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-signer.ts @@ -13,9 +13,15 @@ import { validateAPIKey, WithLoggerContext } from "@crossmint/common-sdk-base"; import type { SignerOutputEvent } from "@crossmint/client-signers"; import { walletsLogger } from "../../logger"; +// Client-side TTL for caching a successful "ready" status from the frame. +// Avoids redundant get-status round-trips that can trigger unnecessary OTP +// prompts when the frame's own cache has expired or the JWT was refreshed. +const AUTH_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + export abstract class NonCustodialSigner implements SignerAdapter { public readonly type: "email" | "phone"; private _needsAuth = true; + private _lastAuthSuccessTimestamp = 0; private _authPromise: { promise: Promise; resolve: () => void; @@ -124,6 +130,16 @@ export abstract class NonCustodialSigner implements SignerAdapter { ); } + // Skip the get-status round-trip if we recently confirmed the signer is ready. + // This prevents unnecessary OTP prompts caused by frame cache expiry or JWT refresh. + const timeSinceLastAuth = Date.now() - this._lastAuthSuccessTimestamp; + if (!this._needsAuth && timeSinceLastAuth < AUTH_CACHE_TTL_MS) { + walletsLogger.info("get-status: skipping, recently authenticated", { + timeSinceLastAuthMs: timeSinceLastAuth, + }); + return; + } + // Determine if we need to authenticate the user via OTP or not walletsLogger.info("get-status: sending request"); const startTime = Date.now(); @@ -156,9 +172,11 @@ export abstract class NonCustodialSigner implements SignerAdapter { if (signerResponse.signerStatus === "ready") { this._needsAuth = false; + this._lastAuthSuccessTimestamp = Date.now(); return; } else { this._needsAuth = true; + this._lastAuthSuccessTimestamp = 0; } walletsLogger.info("Auth required, initiating OTP flow", { needsAuth: this._needsAuth }); @@ -258,6 +276,7 @@ export abstract class NonCustodialSigner implements SignerAdapter { if (response?.status === "success" && response.signerStatus === "ready") { this._needsAuth = false; + this._lastAuthSuccessTimestamp = Date.now(); return; } @@ -307,6 +326,7 @@ export abstract class NonCustodialSigner implements SignerAdapter { if (response?.status === "success") { this._needsAuth = false; + this._lastAuthSuccessTimestamp = Date.now(); // We call onAuthRequired again so the needsAuth state is updated for the dev if (this.config.onAuthRequired != null) { await this.config.onAuthRequired( From 58e3a22104ce9c9a4a014574c5435b30830d105b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:11:30 +0000 Subject: [PATCH 2/6] chore: add changeset for wallets-sdk patch Co-Authored-By: max@paella.dev --- .changeset/fix-otp-reverification-rn.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/fix-otp-reverification-rn.md diff --git a/.changeset/fix-otp-reverification-rn.md b/.changeset/fix-otp-reverification-rn.md new file mode 100644 index 000000000..ee1d728f9 --- /dev/null +++ b/.changeset/fix-otp-reverification-rn.md @@ -0,0 +1,9 @@ +--- +"@crossmint/wallets-sdk": patch +--- + +fix: cache auth status in NonCustodialSigner to prevent repeated OTP prompts on React Native + +Added a timestamp-based cache (10-min TTL) in `handleAuthRequired()` that short-circuits +the `get-status` round-trip to the frame when the signer was recently confirmed as ready. +This prevents unnecessary OTP re-prompts caused by frame cache expiry or JWT token refresh. From e07c352a38845fb51b26dcf20d5c937f032bb841 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:17:03 +0000 Subject: [PATCH 3/6] fix: add invalidateAuthCache() helper for subclass sign failure recovery Exposes a protected method that subclasses can call when they receive an auth-related error during signing (e.g. frame silently reloaded). This resets the client-side cache so the next operation re-checks signer status instead of short-circuiting for the remaining TTL. Co-Authored-By: max@paella.dev --- .../wallets/src/signers/non-custodial/ncs-signer.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/wallets/src/signers/non-custodial/ncs-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-signer.ts index 1abc5e91f..30483f9f8 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-signer.ts @@ -22,6 +22,17 @@ export abstract class NonCustodialSigner implements SignerAdapter { public readonly type: "email" | "phone"; private _needsAuth = true; private _lastAuthSuccessTimestamp = 0; + + /** + * Resets the client-side auth cache so the next operation will re-check + * signer status with the frame. Subclasses should call this when they + * receive an auth-related error during signing (e.g. frame was silently + * reloaded and lost its master secret). + */ + protected invalidateAuthCache(): void { + this._needsAuth = true; + this._lastAuthSuccessTimestamp = 0; + } private _authPromise: { promise: Promise; resolve: () => void; From cefc913ee9b2cb6d446c66d5ffa55c017ae7eaf7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:20:09 +0000 Subject: [PATCH 4/6] fix: reset _lastAuthSuccessTimestamp on verifyOtp error paths Keep _needsAuth and _lastAuthSuccessTimestamp in sync on all failure paths for consistency with the get-status non-ready branch. Co-Authored-By: max@paella.dev --- packages/wallets/src/signers/non-custodial/ncs-signer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wallets/src/signers/non-custodial/ncs-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-signer.ts index 30483f9f8..c955a6a66 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-signer.ts @@ -331,6 +331,7 @@ export abstract class NonCustodialSigner implements SignerAdapter { } catch (err) { walletsLogger.error("complete-onboarding: error", { error: err }); this._needsAuth = true; + this._lastAuthSuccessTimestamp = 0; this._authPromise?.reject(err as Error); throw err; } @@ -355,6 +356,7 @@ export abstract class NonCustodialSigner implements SignerAdapter { walletsLogger.error("complete-onboarding: OTP validation failed", { status: response?.status }); this._needsAuth = true; + this._lastAuthSuccessTimestamp = 0; const errorMessage = response?.status === "error" ? response.error : "Failed to validate encrypted OTP"; const error = new Error(errorMessage); this._authPromise?.reject(error); From a89e580ba0cff2ed690e1c90b0c218860330a79a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:23:34 +0000 Subject: [PATCH 5/6] fix: call invalidateAuthCache() on sign failure in all chain signers Co-Authored-By: max@paella.dev --- packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts | 1 + packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts | 1 + packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts index 2e0945e20..630ee462d 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts @@ -49,6 +49,7 @@ export class EVMNonCustodialSigner extends NonCustodialSigner { }); if (res?.status === "error") { + this.invalidateAuthCache(); throw new Error(res.error); } diff --git a/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts index 0f1f27256..cd5b242b1 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-solana-signer.ts @@ -45,6 +45,7 @@ export class SolanaNonCustodialSigner extends NonCustodialSigner { }); if (res?.status === "error") { + this.invalidateAuthCache(); throw new Error(res.error); } diff --git a/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts index 9b0856576..b72080c2e 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-stellar-signer.ts @@ -39,6 +39,7 @@ export class StellarNonCustodialSigner extends NonCustodialSigner { }); if (res?.status === "error") { + this.invalidateAuthCache(); throw new Error(res.error); } From e9a8e56d142f668d49a6dd7327f9af19c37c4429 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:26:24 +0000 Subject: [PATCH 6/6] fix: invalidate auth cache on _exportPrivateKey error Co-Authored-By: max@paella.dev --- packages/wallets/src/signers/non-custodial/ncs-signer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wallets/src/signers/non-custodial/ncs-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-signer.ts index c955a6a66..a1bcb2f8b 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-signer.ts @@ -390,6 +390,7 @@ export abstract class NonCustodialSigner implements SignerAdapter { }); if (response?.status === "error") { + this.invalidateAuthCache(); throw new Error(response.error || "Failed to export private key"); } }