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. 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-signer.ts b/packages/wallets/src/signers/non-custodial/ncs-signer.ts index 16216d783..a1bcb2f8b 100644 --- a/packages/wallets/src/signers/non-custodial/ncs-signer.ts +++ b/packages/wallets/src/signers/non-custodial/ncs-signer.ts @@ -13,9 +13,26 @@ 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; + + /** + * 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; @@ -124,6 +141,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 +183,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 +287,7 @@ export abstract class NonCustodialSigner implements SignerAdapter { if (response?.status === "success" && response.signerStatus === "ready") { this._needsAuth = false; + this._lastAuthSuccessTimestamp = Date.now(); return; } @@ -301,12 +331,14 @@ 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; } 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( @@ -324,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); @@ -357,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"); } } 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); }