Skip to content

fix(wallets): use live JWT getter and retry stale JWT in NCS signer - #2006

Open
devin-ai-integration[bot] wants to merge 10 commits into
mainfrom
devin/ncs-signer-stale-jwt-retry
Open

fix(wallets): use live JWT getter and retry stale JWT in NCS signer#2006
devin-ai-integration[bot] wants to merge 10 commits into
mainfrom
devin/ncs-signer-stale-jwt-retry

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Description

This fixes the non-custodial signer TEE path so that JWTs refreshed while a get-status, start-onboarding, complete-onboarding, or export-signer request is in flight are picked up by retries instead of the request continuing with a stale token.

The signer TEE can be initialized with a JWT that is valid when the request starts, but if setJwt updates the token while the request is still pending, the previous code passed the original JWT string into the TEE sendAction args. WebViewParent/EventEmitter retries the same args object, so the old JWT could reach the Crossmint backend and result in a 401 ERROR_JWT_EXPIRED surfaced as a generic SignerStatusError: HTTP 401.

Changes:

  • createAuthData() returns an authData object whose jwt property is a getter. JSON serialization (React Native) and structured clone (browser) evaluate getters at serialization time, so every retry reads the latest crossmint.jwt even if setJwt is called while the request is pending.
  • sendActionWithRetry() wraps the TEE sendAction calls and retries once when the response is an error and crossmint.jwt has changed since the request started.
  • throwIfCrossmintApiAuthError() is called before falling back to SignerStatusError/OtpValidationError/KeyExportError so JWTExpiredError is surfaced when the signer frame reports ERROR_JWT_EXPIRED.
  • Auth errors in sendMessageWithOtp/verifyOtp are routed through handleOnboardingVerificationFailure() so _authPromise is always settled, and the OTP reissue path is skipped for NotAuthorizedErrors (re-issuing an OTP cannot fix an expired JWT).
  • throwIfCrossmintApiAuthError() now reads expiredAt/identifierKey from the nested data field as well as the top-level response, so it tolerates both response shapes.

A companion PR in Crossmint/open-signer propagates the backend ERROR_JWT_EXPIRED code through CrossmintHttpError to the SDK event response; this SDK change is defensive and already handles the retry via the live getter.

Test plan

  • Added unit tests in ncs-signer.test.ts:
    • authData.jwt reads the latest value from crossmint.jwt after setJwt.
    • get-status/export-signer retries once when the JWT is refreshed while the request is pending.
    • ensureAuthenticated/_exportPrivateKey throws JWTExpiredError when the signer frame returns code: "ERROR_JWT_EXPIRED".
    • sendActionWithRetry logs a walletsLogger.info line when it retries after a JWT refresh.
    • verifyOtp rejects _authPromise with JWTExpiredError even when onAuthRequired does not await/propagate the verify callback.
  • Added unit test in utils/errors.test.ts for reading expiredAt from response.data.
  • Ran pnpm test:vitest in packages/wallets — all 670 tests pass (68 skipped).
  • Ran pnpm lint on the modified files — clean.

Package updates

  • Added .changeset/stale-jwt-retry.md for @crossmint/wallets-sdk patch.

Link to Devin session: https://crossmint.devinenterprise.com/sessions/9c62083f8c3b45c1aeb0001fb1325120
Requested by: @jcurbelo

-  is now a getter that reads the latest  at
  serialization time, so / retries pick up JWTs
  refreshed by  while a request is in flight.
- Added one-time retry in ,  and
   when the request returns an error and  has changed.
- Wire  in  before falling back to
  / so  is surfaced when
  the signer frame reports .
- Extend  to read / from
  the nested  field as well as the top-level response.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@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, CI, and merge conflict monitoring

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 540f500

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

This PR includes changesets to release 9 packages
Name Type
@crossmint/wallets-sdk 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/wallets-playground-expo Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/client-sdk-nextjs-starter 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

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
packages/wallets/src/signers/non-custodial/ncs-signer.ts:455-479
**`_exportPrivateKey` still captures a static JWT snapshot**

`_exportPrivateKey` calls `getJwtOrThrow()` before sending to `exportTEEConnection`, so the JWT is snapshotted at call time rather than at serialization time. If the device is backgrounded while this request is in flight (the same scenario described in the PR), `setJwt` will update `crossmint.jwt` but the request will still carry the old value, and the response will be a silent `KeyExportError` masking a JWT-expiry underneath. The other three action paths (`get-status`, `start-onboarding`, `complete-onboarding`) have been migrated to `sendActionWithFreshJwt` / `createAuthData`, but this one was not.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "chore: add changeset for stale JWT retry..." | Re-trigger Greptile

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Slytherin spotted that _exportPrivateKey was still snapshotting the JWT — fixed in 666214db to use the live authData getter, sendActionWithFreshJwt, and throwIfCrossmintApiAuthError, plus unit tests for live getter and JWTExpiredError on the export path. 🐍

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "fix(wallets): apply live JWT getter and ..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (3): Last reviewed commit: "refactor(wallets): rename sendActionWith..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🔥 Smoke Test Results

Status: Failed

Statistics

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

Test Details


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

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (4): Last reviewed commit: "refactor(wallets): rename sendWithFreshJ..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (5): Last reviewed commit: "test(wallets): add logging test for JWT ..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
packages/wallets/src/signers/non-custodial/ncs-signer.ts:392-395
**`_authPromise` can hang when `JWTExpiredError` bypasses `handleOnboardingVerificationFailure`**

For `OtpValidationError`, `handleOnboardingVerificationFailure` always calls `this._authPromise?.reject(error)` directly, so `_authPromise` is settled regardless of what the `onAuthRequired` callback does. The new `JWTExpiredError` path throws before reaching `handleOnboardingVerificationFailure`, so `_authPromise` is only rejected if `onAuthRequired` propagates the error all the way back to `handleAuthRequired`'s try/catch. If an `onAuthRequired` implementation swallows the error (or doesn't `await` `verifyOtp`), `_authPromise` will never settle, and `await promise` in `handleAuthRequired` will hang indefinitely — breaking the invariant that all auth-failure paths explicitly reject the promise.

Route JWT auth errors through `handleOnboardingVerificationFailure` as well to preserve the invariant.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (6): Last reviewed commit: "chore(wallets): remove customer-specific..." | Re-trigger Greptile

Comment thread packages/wallets/src/signers/non-custodial/ncs-signer.ts Outdated
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (7): Last reviewed commit: "fix(wallets): route auth errors through ..." | 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.

1 participant