From ed12adc9c50af8a4c032d7ff7768f1e0a9db6587 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 20 May 2026 17:53:31 +0300 Subject: [PATCH 01/10] Add pending wallet selection flow --- API.md | 73 +++- README.md | 20 +- src/clients/walletClient.ts | 371 +++++++++++++++++---- src/errors.ts | 15 + src/index.ts | 4 +- tests/oidcRedirectAuth.test.ts | 120 ++++++- tests/walletSession.test.ts | 570 +++++++++++++++++++++++++++++++- type-tests/oidcProviderTypes.ts | 5 +- 8 files changed, 1090 insertions(+), 88 deletions(-) diff --git a/API.md b/API.md index 582ac8d..1b06b05 100644 --- a/API.md +++ b/API.md @@ -41,6 +41,8 @@ - [StorageManager](#storagemanager) - [CredentialSigner](#credentialsigner) - [OmsWallet](#omswallet) + - [PendingWalletSelection](#pendingwalletselection) + - [WalletSelectionBehavior](#walletselectionbehavior) - [WalletCredential](#walletcredential) - [AccessGrant](#accessgrant) - [ListAccessParams](#listaccessparams) @@ -180,16 +182,16 @@ await oms.wallet.startEmailAuth({ email: 'user@example.com' }) completeEmailAuth(params: { code: string walletType?: WalletType - autoActivate?: boolean + walletSelection?: 'automatic' | 'manual' }): Promise< | { walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } - | { wallets: OmsWallet[]; credential: WalletCredential } + | PendingWalletSelection > ``` Verifies the OTP code and activates a wallet. Must be called after [`startEmailAuth`](#startemailauth). -This method verifies the code with a one-week WaaS session lifetime, loads all wallet pages, then automatically selects an existing wallet matching `walletType`, or creates a new one if none exists. Wallet metadata is persisted to storage. Pass `autoActivate: false` to return `{ wallets, credential }` without selecting or creating a wallet; then call [`useWallet`](#usewallet) or [`createWallet`](#createwallet). +This method verifies the code with a one-week WaaS session lifetime, loads all wallet pages, then automatically selects an existing wallet matching `walletType`, or creates a new one if none exists. Wallet metadata is persisted to storage. Pass `walletSelection: 'manual'` to return a [`PendingWalletSelection`](#pendingwalletselection) bound to the verified auth flow; complete selection through that object. **Parameters** @@ -197,9 +199,9 @@ This method verifies the code with a one-week WaaS session lifetime, loads all w |---|---|---|---| | `code` | `string` | Yes | The one-time passcode entered by the user. | | `walletType` | `WalletType` | No | The wallet type to load or create. Defaults to `WalletType.Ethereum`. | -| `autoActivate` | `boolean` | No | Defaults to `true`. Set to `false` to let the app choose an existing wallet or create a new one. | +| `walletSelection` | `'automatic' \| 'manual'` | No | Defaults to `'automatic'`. Set to `'manual'` to let the app choose an existing wallet or create one through the returned pending selection. | -**Returns** `Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential }>` by default, or `Promise<{ wallets: OmsWallet[]; credential: WalletCredential }>` when `autoActivate` is `false`. +**Returns** `Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential }>` by default, or `Promise` when `walletSelection` is `'manual'`. **Throws** if the code is incorrect, expired, or the network request fails. @@ -214,6 +216,20 @@ try { } ``` +Manual selection: + +```typescript +const selection = await oms.wallet.completeEmailAuth({ + code: '123456', + walletType: WalletType.Ethereum, + walletSelection: 'manual', +}) + +await selection.selectWallet({ walletId: selection.wallets[0].id }) +// or: +await selection.createAndSelectWallet({ reference: 'main' }) +``` + --- ### startOidcRedirectAuth @@ -252,14 +268,14 @@ completeOidcRedirectAuth(params: { callbackUrl: string cleanUrl?: boolean replaceUrl?: (url: string) => void - autoActivate?: boolean + walletSelection?: 'automatic' | 'manual' }): Promise< | { walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } - | { wallets: OmsWallet[]; credential: WalletCredential } + | PendingWalletSelection > ``` -Completes an OIDC redirect flow by validating the persisted state nonce, exchanging the authorization code with WaaS using a one-week session lifetime, and activating an existing wallet or creating one. Pass `autoActivate: false` to return `{ wallets, credential }` for app-driven wallet selection. `cleanUrl` removes OAuth query parameters after successful completion; outside a browser, pass `replaceUrl`. +Completes an OIDC redirect flow by validating the persisted state nonce, exchanging the authorization code with WaaS using a one-week session lifetime, and activating an existing wallet or creating one. Pass `walletSelection: 'manual'` to return a [`PendingWalletSelection`](#pendingwalletselection) for app-driven wallet selection. `cleanUrl` removes OAuth query parameters after successful completion; outside a browser, pass `replaceUrl`. ```typescript const { walletAddress, credential } = await oms.wallet.completeOidcRedirectAuth({ @@ -277,14 +293,14 @@ signInWithOidcRedirect(params: { provider: string | OidcProviderConfig redirectUri?: string walletType?: WalletType - autoActivate?: boolean + walletSelection?: 'automatic' | 'manual' relayRedirectUri?: string authorizeParams?: Record cleanUrl?: boolean currentUrl?: string assignUrl?: (url: string) => void replaceUrl?: (url: string) => void -}): Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } | { wallets: OmsWallet[]; credential: WalletCredential } | void> +}): Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } | PendingWalletSelection | void> ``` Browser convenience method for regular web apps. If the current URL has OIDC callback params, it completes auth and returns the same result as [`completeOidcRedirectAuth`](#completeoidcredirectauth). Otherwise it starts auth, redirects with `window.location.assign`, and returns `void`. For router-driven apps, prefer [`startOidcRedirectAuth`](#startoidcredirectauth) and [`completeOidcRedirectAuth`](#completeoidcredirectauth). @@ -319,7 +335,7 @@ await oms.wallet.signOut() listWallets(): Promise ``` -Returns all wallets available to the authenticated credential. This can be used after completing auth with `autoActivate: false` to show a wallet picker. +Returns all wallets available to an authenticated active or pending wallet-selection session. --- @@ -329,7 +345,7 @@ Returns all wallets available to the authenticated credential. This can be used useWallet(params: { walletId: string }): Promise<{ walletAddress: Address; wallet: OmsWallet }> ``` -Activates an existing wallet by server-side wallet id and persists it as the current wallet session. +Activates an existing wallet by server-side wallet id and persists it as the current wallet session. Manual auth flows should prefer [`PendingWalletSelection.selectWallet`](#pendingwalletselection). --- @@ -339,7 +355,7 @@ Activates an existing wallet by server-side wallet id and persists it as the cur createWallet(params?: { type?: WalletType; reference?: string }): Promise<{ walletAddress: Address; wallet: OmsWallet }> ``` -Creates a new wallet, activates it, and persists it as the current wallet session. `type` defaults to `WalletType.Ethereum`. +Creates a new wallet, activates it, and persists it as the current wallet session. `type` defaults to `WalletType.Ethereum`. Manual auth flows should prefer [`PendingWalletSelection.createAndSelectWallet`](#pendingwalletselection), which uses the auth-requested wallet type automatically. --- @@ -718,6 +734,9 @@ type OmsSdkErrorCode = | 'OMS_REQUEST_FAILED' | 'OMS_AUTH_COMMITMENT_CONSUMED' | 'OMS_SESSION_MISSING' + | 'OMS_WALLET_SELECTION_STALE' + | 'OMS_WALLET_SELECTION_UNAVAILABLE' + | 'OMS_WALLET_SELECTION_IN_FLIGHT' | 'OMS_TRANSACTION_STATUS_LOOKUP_FAILED' | 'OMS_VALIDATION_ERROR' ``` @@ -730,6 +749,7 @@ type OmsSdkErrorCode = | `OmsRequestError` | Network, fetch, or non-2xx HTTP failures. | | `OmsResponseError` | Invalid JSON or malformed API responses. | | `OmsTransactionError` | Transaction was submitted but status polling failed; includes `txnId`. | +| `OmsWalletSelectionError` | Manual wallet selection is stale, invalid, or already processing an action. | | `OmsValidationError` | SDK-side validation failures before a request is sent. | Use `isOmsSdkError(err)` or `err instanceof OmsSdkError` to branch on structured error fields. @@ -886,6 +906,33 @@ Wallet metadata returned by auth and wallet listing APIs. --- +### PendingWalletSelection + +```typescript +interface PendingWalletSelection { + walletType: WalletType + wallets: OmsWallet[] + credential: WalletCredential + + selectWallet(params: { walletId: string }): Promise + createAndSelectWallet(params?: { reference?: string }): Promise +} +``` + +Returned by manual email or OIDC auth completion. The selection is bound to the verified auth flow and signer that created it. It can be used once to select one of the returned `wallets` or to create and select a new wallet of `walletType`. + +--- + +### WalletSelectionBehavior + +```typescript +type WalletSelectionBehavior = 'automatic' | 'manual' +``` + +Controls whether auth completion immediately activates a wallet or returns a [`PendingWalletSelection`](#pendingwalletselection). + +--- + ### WalletCredential ```typescript diff --git a/README.md b/README.md index 2fc54dc..33c6d89 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ pnpm dev:example ## Quick Start ```typescript -import { Networks, OMSClient } from '@0xsequence/typescript-sdk' +import { Networks, OMSClient, WalletType } from '@0xsequence/typescript-sdk' import { parseUnits } from 'viem' const oms = new OMSClient({ @@ -121,6 +121,22 @@ Email OTP is a two-step flow: 1. **`startEmailAuth({ email })`** — clears any active session and sends a one-time code to the user's inbox. 2. **`completeEmailAuth({ code })`** — verifies the code, then automatically loads an existing wallet or creates a new one if none exists. Returns `{ walletAddress, wallet, wallets, credential }`. +Use manual wallet selection when the app needs to present wallet choices: + +```typescript +const selection = await oms.wallet.completeEmailAuth({ + code: '123456', + walletType: WalletType.Ethereum, + walletSelection: 'manual', +}) + +await selection.selectWallet({ walletId: selection.wallets[0].id }) +// or: +await selection.createAndSelectWallet({ reference: 'main' }) +``` + +The returned pending selection is bound to the verified auth flow and signer. Hold that object and complete selection through it instead of saving `{ wallets }` and later calling global wallet activation methods. + ### OIDC Redirect Auth Google redirect auth is configured on the default environment. The redirect auth APIs are provider-neutral, so custom environments can add or replace providers. @@ -149,6 +165,8 @@ const { walletAddress, wallet, wallets, credential } = await oms.wallet.complete }) ``` +OIDC redirect auth also supports manual wallet selection by passing `walletSelection: 'manual'` to `completeOidcRedirectAuth`. + For simple browser apps, use the one-call convenience method from a sign-in action and from the callback page: ```typescript diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index d59e613..7b49805 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -10,7 +10,11 @@ import {createDefaultStorage, SessionStorageManager, StorageManager} from "../st import {createSignedFetch} from "../signedFetch.js"; import {Constants} from "../utils/constants.js"; import {RequestUtils} from "../utils/requestUtils.js"; -import {CredentialSigner, WebCryptoP256CredentialSigner} from "../credentialSigner.js"; +import { + CredentialSigner, + type CredentialSigningAlgorithm, + WebCryptoP256CredentialSigner, +} from "../credentialSigner.js"; import { buildOidcAuthorizationUrl, cleanOidcCallbackUrl, @@ -20,7 +24,7 @@ import { parseOidcCallbackUrl, redirectUriFromCurrentUrl, } from "../utils/oidcRedirect.js"; -import {OmsSessionError, OmsTransactionError, toOmsSdkError} from "../errors.js"; +import {OmsSessionError, OmsTransactionError, OmsWalletSelectionError, toOmsSdkError} from "../errors.js"; import { Wallet as Walletclient, @@ -96,17 +100,21 @@ export interface CompleteOidcRedirectAuthParams { callbackUrl: string; cleanUrl?: boolean; replaceUrl?: (url: string) => void; - autoActivate?: boolean; + walletSelection?: WalletSelectionBehavior; } export interface CompleteEmailAuthParams { code: string; walletType?: WalletType; - autoActivate?: boolean; + walletSelection?: WalletSelectionBehavior; } -type AutoActivateParams = Omit & {autoActivate?: true} -type ManualActivateParams = Omit & {autoActivate: false} +export type WalletSelectionBehavior = "automatic" | "manual"; + +type AutomaticWalletSelectionParams = + Omit & {walletSelection?: "automatic"} +type ManualWalletSelectionParams = + Omit & {walletSelection: "manual"} export interface OmsWallet { id: string; @@ -134,9 +142,13 @@ export interface CompleteOidcRedirectAuthResult { credential: WalletCredential; } -export interface CompleteAuthWalletSelectionResult { +export interface PendingWalletSelection { + walletType: WalletType; wallets: Array; credential: WalletCredential; + + selectWallet(params: {walletId: string}): Promise; + createAndSelectWallet(params?: {reference?: string}): Promise; } export type OMSClientSessionLoginType = 'email' | 'google-auth' | 'oidc'; @@ -178,7 +190,7 @@ export interface SignInWithOidcRedirectParams; redirectUri?: string; walletType?: WalletType; - autoActivate?: boolean; + walletSelection?: WalletSelectionBehavior; relayRedirectUri?: string; authorizeParams?: Record; cleanUrl?: boolean; @@ -192,6 +204,8 @@ interface PendingOidcRedirectAuth { nonce: string; provider: string | null; walletType: WalletType; + signerCredentialId: string; + signerKeyType: CredentialSigningAlgorithm; redirectUri: string; issuer: string; projectId: string; @@ -208,6 +222,71 @@ interface WalletSessionMetadata { sessionEmail?: string; } +interface ActivePendingWalletSelection { + id: string; + signerCredentialId: string; + signerKeyType: CredentialSigningAlgorithm; + walletType: WalletType; + metadata: WalletSessionMetadata; +} + +type WalletActivationContext = + | {kind: "pending"; session: ActivePendingWalletSelection; metadata: WalletSessionMetadata} + | {kind: "active"; metadata?: WalletSessionMetadata} + +class PendingWalletSelectionImpl implements PendingWalletSelection { + private readonly availableWalletIds: Set; + private inFlight = false; + + constructor( + public readonly walletType: WalletType, + public readonly wallets: Array, + public readonly credential: WalletCredential, + private readonly selectWalletAction: (walletId: string) => Promise, + private readonly createAndSelectWalletAction: (reference?: string) => Promise, + ) { + this.availableWalletIds = new Set(wallets.map(wallet => wallet.id)); + } + + async selectWallet(params: {walletId: string}): Promise { + return this.runExclusive("wallet.pendingWalletSelection.selectWallet", async () => { + if (!this.availableWalletIds.has(params.walletId)) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_UNAVAILABLE", + operation: "wallet.pendingWalletSelection.selectWallet", + message: "Selected wallet is not one of the available options", + }); + } + return this.selectWalletAction(params.walletId); + }); + } + + async createAndSelectWallet(params: {reference?: string} = {}): Promise { + return this.runExclusive("wallet.pendingWalletSelection.createAndSelectWallet", () => + this.createAndSelectWalletAction(params.reference), + ); + } + + private async runExclusive(operation: string, action: () => Promise): Promise { + if (this.inFlight) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_IN_FLIGHT", + operation, + message: "Pending wallet selection already has an action in flight", + }); + } + + this.inFlight = true; + try { + return await action(); + } catch (error) { + throw toOmsSdkError(error, operation); + } finally { + this.inFlight = false; + } + } +} + export class WalletClient { private readonly client: Walletclient private readonly publicClient: WalletPublicclient @@ -227,7 +306,8 @@ export class WalletClient { private sessionExpiresAt: string | undefined private sessionLoginType: OMSClientSessionLoginType | undefined private sessionEmail: string | undefined - private pendingSessionMetadata: WalletSessionMetadata | undefined + private activePendingWalletSelection: ActivePendingWalletSelection | undefined + private nextPendingWalletSelectionId = 1 private walletId: string private verifier = '' @@ -324,10 +404,10 @@ export class WalletClient { * existing wallet or creates a new one and returns the wallet address plus * the credential added by WaaS. */ - async completeEmailAuth(params: ManualActivateParams): Promise - async completeEmailAuth(params: AutoActivateParams): Promise - async completeEmailAuth(params: CompleteEmailAuthParams): Promise - async completeEmailAuth(params: CompleteEmailAuthParams): Promise { + async completeEmailAuth(params: ManualWalletSelectionParams): Promise + async completeEmailAuth(params: AutomaticWalletSelectionParams): Promise + async completeEmailAuth(params: CompleteEmailAuthParams): Promise + async completeEmailAuth(params: CompleteEmailAuthParams): Promise { return this.runOperation("wallet.completeEmailAuth", async () => { const walletType = params.walletType ?? WalletType.Ethereum; const answer = await RequestUtils.hashEmailAuthAnswer(this.challenge, params.code); @@ -340,7 +420,7 @@ export class WalletClient { lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, } const response = await this.client.completeAuth(request) - return this.completeWalletAuth(response, walletType, params.autoActivate ?? true) + return this.completeWalletAuth(response, walletType, params.walletSelection ?? "automatic") }) } @@ -369,6 +449,7 @@ export class WalletClient { }, } const response = await this.client.commitVerifier(request) + const signerCredentialId = await this.credentialSigner.credentialId() const nonce = generateOidcNonce() const state = encodeOidcState({ nonce, @@ -381,6 +462,8 @@ export class WalletClient { nonce, provider: provider.name, walletType: params.walletType ?? WalletType.Ethereum, + signerCredentialId, + signerKeyType: this.credentialSigner.signingAlgorithm, redirectUri: params.redirectUri, issuer: provider.config.issuer, projectId: this.projectId, @@ -416,17 +499,17 @@ export class WalletClient { * WaaS auth, and activates an existing wallet or creates a new one. */ async completeOidcRedirectAuth( - params: ManualActivateParams, - ): Promise + params: ManualWalletSelectionParams, + ): Promise async completeOidcRedirectAuth( - params: AutoActivateParams, + params: AutomaticWalletSelectionParams, ): Promise async completeOidcRedirectAuth( params: CompleteOidcRedirectAuthParams, - ): Promise + ): Promise async completeOidcRedirectAuth( params: CompleteOidcRedirectAuthParams, - ): Promise { + ): Promise { return this.runOperation("wallet.completeOidcRedirectAuth", async () => { const redirectAuthStorage = this.requireRedirectAuthStorage() @@ -445,6 +528,7 @@ export class WalletClient { const pending = this.loadPendingOidcRedirectAuth(redirectAuthStorage) this.validateOidcState(callback.state, pending) + await this.validatePendingOidcRedirectSigner(pending, "wallet.completeOidcRedirectAuth") const request: CompleteAuthRequest = { identityType: IdentityType.OIDC, @@ -454,9 +538,9 @@ export class WalletClient { lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, } const response = await this.client.completeAuth(request) - const result = await this.completeWalletAuth(response, pending.walletType, params.autoActivate ?? true) + const result = await this.completeWalletAuth(response, pending.walletType, params.walletSelection ?? "automatic") - if ((params.autoActivate ?? true) && !this.walletAddress) { + if ((params.walletSelection ?? "automatic") === "automatic" && !this.walletAddress) { throw new Error('OIDC auth completed without an active wallet') } @@ -473,10 +557,10 @@ export class WalletClient { * If the current URL contains an OIDC callback, it completes auth. Otherwise it * starts auth and redirects to the provider. */ - async signInWithOidcRedirect(params: ManualActivateParams>): Promise - async signInWithOidcRedirect(params: AutoActivateParams>): Promise - async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise - async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise { + async signInWithOidcRedirect(params: ManualWalletSelectionParams>): Promise + async signInWithOidcRedirect(params: AutomaticWalletSelectionParams>): Promise + async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise + async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise { return this.runOperation("wallet.signInWithOidcRedirect", async () => { const currentUrl = params.currentUrl ?? this.browserCurrentUrl() const callback = parseOidcCallbackUrl(currentUrl) @@ -485,7 +569,7 @@ export class WalletClient { callbackUrl: currentUrl, cleanUrl: params.cleanUrl ?? true, replaceUrl: params.replaceUrl, - autoActivate: params.autoActivate, + walletSelection: params.walletSelection, }) } @@ -507,23 +591,28 @@ export class WalletClient { } async listWallets(): Promise> { - return this.runOperation("wallet.listWallets", () => this.listAllWallets()) + return this.runOperation("wallet.listWallets", async () => { + await this.requireWalletSelectionOrActiveSession("wallet.listWallets") + return this.listAllWallets() + }) } async useWallet(params: {walletId: string}): Promise { - return this.runOperation("wallet.useWallet", () => - this.useWalletUnchecked(params.walletId, this.sessionMetadataForActivation()), - ) + return this.runOperation("wallet.useWallet", async () => { + const context = await this.walletActivationContext("wallet.useWallet") + const wallet = await this.requestUseWallet(params.walletId) + await this.requireWalletActivationContextStillActive(context, "wallet.useWallet") + return this.activateWallet(wallet, context.metadata) + }) } async createWallet(params: {type?: WalletType; reference?: string} = {}): Promise { - return this.runOperation("wallet.createWallet", () => - this.createWalletUnchecked( - params.type ?? WalletType.Ethereum, - this.sessionMetadataForActivation(), - params.reference, - ), - ) + return this.runOperation("wallet.createWallet", async () => { + const context = await this.walletActivationContext("wallet.createWallet") + const wallet = await this.requestCreateWallet(params.type ?? WalletType.Ethereum, params.reference) + await this.requireWalletActivationContextStillActive(context, "wallet.createWallet") + return this.activateWallet(wallet, context.metadata) + }) } private async clearSession(): Promise { @@ -537,7 +626,7 @@ export class WalletClient { this.sessionExpiresAt = undefined this.sessionLoginType = undefined this.sessionEmail = undefined - this.pendingSessionMetadata = undefined + this.activePendingWalletSelection = undefined this.verifier = '' this.challenge = '' this.redirectAuthStorage?.delete(Constants.redirectAuthStorageKey) @@ -710,17 +799,13 @@ export class WalletClient { * The wallet ID and address are persisted to storage so the session can be * restored when the configured credential signer is also available. */ - private async createWalletUnchecked( + private async requestCreateWallet( type: WalletType, - metadata?: WalletSessionMetadata, reference?: string, - ): Promise { + ): Promise { const params: CreateWalletRequest = { type, reference } const response = await this.client.createWallet(params) - this.persistSession(response.wallet.id, response.wallet.address, metadata) - this.pendingSessionMetadata = undefined - const wallet = this.toOmsWallet(response.wallet) - return {walletAddress: wallet.address, wallet} + return this.toOmsWallet(response.wallet) } /** @@ -728,33 +813,43 @@ export class WalletClient { * * The wallet ID and address are persisted to storage. */ - private async useWalletUnchecked(walletId: string, metadata?: WalletSessionMetadata): Promise { + private async requestUseWallet(walletId: string): Promise { const params: UseWalletRequest = { walletId } const response = await this.client.useWallet(params) - this.persistSession(response.wallet.id, response.wallet.address, metadata) - this.pendingSessionMetadata = undefined - const wallet = this.toOmsWallet(response.wallet) + return this.toOmsWallet(response.wallet) + } + + private activateWallet(wallet: OmsWallet, metadata?: WalletSessionMetadata): WalletActivationResult { + this.persistSession(wallet.id, wallet.address, metadata) + this.activePendingWalletSelection = undefined return {walletAddress: wallet.address, wallet} } private async completeWalletAuth( response: CompleteAuthResponse, walletType: WalletType, - autoActivate: boolean, - ): Promise { + walletSelection: WalletSelectionBehavior, + ): Promise { + this.activePendingWalletSelection = undefined + const metadata = this.sessionMetadataFromAuthResponse(response) const wallets = await this.listAllWalletsFromAuthResponse(response) const credential = this.toWalletCredential(response.credential) - - if (!autoActivate) { - this.pendingSessionMetadata = metadata - return {wallets, credential} + const candidateWallets = wallets.filter(wallet => wallet.type === walletType) + + if (walletSelection === "manual") { + return this.createPendingWalletSelection({ + walletType, + wallets: candidateWallets, + credential, + metadata, + }) } - const wallet = wallets.find(candidate => candidate.type === walletType) + const wallet = candidateWallets[0] const activated = wallet - ? await this.useWalletUnchecked(wallet.id, metadata) - : await this.createWalletUnchecked(walletType, metadata) + ? this.activateWallet(await this.requestUseWallet(wallet.id), metadata) + : this.activateWallet(await this.requestCreateWallet(walletType), metadata) const resultWallets = wallet ? wallets : [...wallets, activated.wallet] return { @@ -854,13 +949,8 @@ export class WalletClient { this.setOptionalStorageValue(Constants.sessionEmailStorageKey, this.sessionEmail) } - private sessionMetadataForActivation(): WalletSessionMetadata | undefined { - if (this.pendingSessionMetadata) { - return this.pendingSessionMetadata - } - if (!this.sessionExpiresAt) { - return undefined - } + private currentSessionMetadata(): WalletSessionMetadata | undefined { + if (!this.sessionExpiresAt) return undefined return { expiresAt: this.sessionExpiresAt, loginType: this.sessionLoginType, @@ -868,6 +958,126 @@ export class WalletClient { } } + private async walletActivationContext(operation: string): Promise { + const pendingSelection = this.activePendingWalletSelection + if (pendingSelection) { + await this.requireActivePendingWalletSelection(pendingSelection, operation) + return { + kind: "pending", + session: pendingSelection, + metadata: pendingSelection.metadata, + } + } + + if (!this.walletId) { + throw new OmsSessionError({ + operation, + message: 'No authenticated wallet session', + }) + } + + await this.requireActiveSession(operation) + return {kind: "active", metadata: this.currentSessionMetadata()} + } + + private async requireWalletActivationContextStillActive( + context: WalletActivationContext, + operation: string, + ): Promise { + if (context.kind === "pending") { + await this.requireActivePendingWalletSelection(context.session, operation) + } + } + + private async requireWalletSelectionOrActiveSession(operation: string): Promise { + if (this.activePendingWalletSelection) { + await this.requireActivePendingWalletSelection(this.activePendingWalletSelection, operation) + return + } + + if (this.walletId) { + await this.requireActiveSession(operation) + return + } + + throw new OmsSessionError({ + operation, + message: 'No authenticated wallet session', + }) + } + + private async createPendingWalletSelection(params: { + walletType: WalletType; + wallets: Array; + credential: WalletCredential; + metadata: WalletSessionMetadata; + }): Promise { + const selectionSession: ActivePendingWalletSelection = { + id: `pending-${this.nextPendingWalletSelectionId++}`, + signerCredentialId: await this.credentialSigner.credentialId(), + signerKeyType: this.credentialSigner.signingAlgorithm, + walletType: params.walletType, + metadata: params.metadata, + } + this.activePendingWalletSelection = selectionSession + + const wallets = params.wallets.map(wallet => ({...wallet})) + const credential = {...params.credential} + + return new PendingWalletSelectionImpl( + params.walletType, + wallets, + credential, + async walletId => { + const operation = "wallet.pendingWalletSelection.selectWallet" + await this.requireActivePendingWalletSelection(selectionSession, operation) + const wallet = await this.requestUseWallet(walletId) + await this.requireActivePendingWalletSelection(selectionSession, operation) + return this.activateWallet(wallet, selectionSession.metadata) + }, + async reference => { + const operation = "wallet.pendingWalletSelection.createAndSelectWallet" + await this.requireActivePendingWalletSelection(selectionSession, operation) + const wallet = await this.requestCreateWallet(selectionSession.walletType, reference) + await this.requireActivePendingWalletSelection(selectionSession, operation) + return this.activateWallet(wallet, selectionSession.metadata) + }, + ) + } + + private async requireActivePendingWalletSelection( + selectionSession: ActivePendingWalletSelection, + operation: string, + ): Promise { + if (this.activePendingWalletSelection?.id !== selectionSession.id) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_STALE", + operation, + message: "Pending wallet selection is no longer active", + }) + } + + if (this.credentialSigner.hasCredential && !(await this.credentialSigner.hasCredential())) { + this.activePendingWalletSelection = undefined + throw new OmsSessionError({ + operation, + message: 'No active credential', + }) + } + + const signerCredentialId = await this.credentialSigner.credentialId() + if ( + normalizeCredentialId(signerCredentialId) !== normalizeCredentialId(selectionSession.signerCredentialId) || + this.credentialSigner.signingAlgorithm !== selectionSession.signerKeyType + ) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_STALE", + operation, + message: "Pending wallet selection is no longer active", + }) + } + } + private sessionMetadataFromAuthResponse(response: CompleteAuthResponse): WalletSessionMetadata { return { expiresAt: response.credential.expiresAt, @@ -937,6 +1147,8 @@ export class WalletClient { if ( typeof parsed.verifier !== 'string' || typeof parsed.nonce !== 'string' || + typeof parsed.signerCredentialId !== 'string' || + typeof parsed.signerKeyType !== 'string' || typeof parsed.redirectUri !== 'string' || typeof parsed.issuer !== 'string' || typeof parsed.projectId !== 'string' @@ -949,6 +1161,8 @@ export class WalletClient { nonce: parsed.nonce, provider: typeof parsed.provider === 'string' ? parsed.provider : null, walletType: isWalletType(parsed.walletType) ? parsed.walletType : WalletType.Ethereum, + signerCredentialId: parsed.signerCredentialId, + signerKeyType: parsed.signerKeyType as CredentialSigningAlgorithm, redirectUri: parsed.redirectUri, issuer: parsed.issuer, projectId: parsed.projectId, @@ -971,6 +1185,29 @@ export class WalletClient { } } + private async validatePendingOidcRedirectSigner( + pending: PendingOidcRedirectAuth, + operation: string, + ): Promise { + if (this.credentialSigner.hasCredential && !(await this.credentialSigner.hasCredential())) { + throw new OmsSessionError({ + operation, + message: 'No active credential', + }) + } + + const signerCredentialId = await this.credentialSigner.credentialId() + if ( + normalizeCredentialId(signerCredentialId) !== normalizeCredentialId(pending.signerCredentialId) || + this.credentialSigner.signingAlgorithm !== pending.signerKeyType + ) { + throw new OmsSessionError({ + operation, + message: 'OIDC redirect auth signer mismatch', + }) + } + } + private replaceOidcCallbackUrl(callbackUrl: string, replaceUrl?: (url: string) => void): void { const cleanUrl = cleanOidcCallbackUrl(callbackUrl) if (replaceUrl) { @@ -1273,6 +1510,10 @@ function isWalletType(value: unknown): value is WalletType { return typeof value === 'string' && Object.values(WalletType).includes(value as WalletType) } +function normalizeCredentialId(value: string): string { + return value.trim().toLowerCase() +} + function parseSessionLoginType(value: string | null): OMSClientSessionLoginType | undefined { return value === 'email' || value === 'google-auth' || value === 'oidc' ? value diff --git a/src/errors.ts b/src/errors.ts index 5d5276f..bafacc7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,6 +4,9 @@ export type OmsSdkErrorCode = | "OMS_REQUEST_FAILED" | "OMS_AUTH_COMMITMENT_CONSUMED" | "OMS_SESSION_MISSING" + | "OMS_WALLET_SELECTION_STALE" + | "OMS_WALLET_SELECTION_UNAVAILABLE" + | "OMS_WALLET_SELECTION_IN_FLIGHT" | "OMS_TRANSACTION_STATUS_LOOKUP_FAILED" | "OMS_VALIDATION_ERROR" @@ -67,6 +70,18 @@ export class OmsTransactionError extends OmsSdkError { } } +export class OmsWalletSelectionError extends OmsSdkError { + constructor(params: Omit & { + code: + | "OMS_WALLET_SELECTION_STALE" + | "OMS_WALLET_SELECTION_UNAVAILABLE" + | "OMS_WALLET_SELECTION_IN_FLIGHT" + }) { + super(params) + this.name = "OmsWalletSelectionError" + } +} + export class OmsValidationError extends OmsSdkError { constructor(params: Omit & { code?: OmsSdkErrorCode }) { super({code: params.code ?? "OMS_VALIDATION_ERROR", ...params}) diff --git a/src/index.ts b/src/index.ts index f8ed44b..36e6b60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,13 +45,13 @@ export { OmsSdkError, OmsSessionError, OmsTransactionError, + OmsWalletSelectionError, OmsValidationError, isOmsSdkError, type OmsSdkErrorCode, type OmsSdkErrorParams, } from './errors.js' export type { - CompleteAuthWalletSelectionResult, CompleteEmailAuthParams, CompleteEmailAuthResult, CompleteOidcRedirectAuthParams, @@ -63,12 +63,14 @@ export type { OmsWallet, OidcProviderInput, OidcProviderName, + PendingWalletSelection, SignMessageParams, SignInWithOidcRedirectParams, SignTypedDataParams, StartOidcRedirectAuthParams, StartOidcRedirectAuthResult, WalletActivationResult, + WalletSelectionBehavior, } from './clients/walletClient.js' export type { TokenContractInfo, diff --git a/tests/oidcRedirectAuth.test.ts b/tests/oidcRedirectAuth.test.ts index 3c0a31c..58203ab 100644 --- a/tests/oidcRedirectAuth.test.ts +++ b/tests/oidcRedirectAuth.test.ts @@ -17,8 +17,14 @@ class MockSigner implements CredentialSigner { readonly signingAlgorithm = "ecdsa-p256-sha256"; readonly preimages: string[] = []; + constructor(private credential = "0x04" + "11".repeat(64)) {} + async credentialId(): Promise { - return "0x04" + "11".repeat(64); + return this.credential; + } + + setCredential(credential: string): void { + this.credential = credential; } async nextNonce(): Promise { @@ -331,6 +337,118 @@ describe("WalletClient OIDC redirect auth", () => { expect(replaceUrl).toHaveBeenCalledWith("https://app.example/auth/callback"); }); + it("can complete an OIDC callback with a pending wallet selection", async () => { + const redirectAuthStorage = new MemoryStorageManager(); + const otherType = "future-wallet" as WalletType; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + } + + if (url.endsWith("/CompleteAuth")) { + expect(body).toEqual({ + identityType: "oidc", + authMode: "auth-code-pkce", + verifier: "verifier-1", + answer: "auth-code", + lifetime: 604_800, + }); + return jsonResponse({ + identity: {type: "oidc", iss: "https://accounts.google.com", sub: "user-1"}, + wallets: [ + { + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }, + { + id: "wallet-other", + type: otherType, + address: "0x2222222222222222222222222222222222222222", + }, + ], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet") || url.endsWith("/CreateWallet")) { + throw new Error("OIDC manual auth should not activate a wallet"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletClient({redirectAuthStorage}); + const started = await wallet.startOidcRedirectAuth({ + provider: "google", + redirectUri: "https://app.example/auth/callback", + }); + + const selection = await wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, + walletSelection: "manual", + }); + + expect(selection).toMatchObject({ + walletType: WalletType.Ethereum, + wallets: [{ + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }], + credential: testCredential(), + }); + expect(selection.selectWallet).toEqual(expect.any(Function)); + expect(selection.createAndSelectWallet).toEqual(expect.any(Function)); + expect(wallet.walletAddress).toBeUndefined(); + expect(redirectAuthStorage.get(Constants.redirectAuthStorageKey)).toBeNull(); + }); + + it("rejects OIDC callbacks when the signer changed after starting redirect auth", async () => { + const redirectAuthStorage = new MemoryStorageManager(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + } + + if (url.endsWith("/CompleteAuth")) { + throw new Error("CompleteAuth should not be called after signer mismatch"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const signer = new MockSigner(); + const wallet = createWalletClient({redirectAuthStorage, credentialSigner: signer}); + const started = await wallet.startOidcRedirectAuth({ + provider: "google", + redirectUri: "https://app.example/auth/callback", + }); + signer.setCredential("0x04" + "99".repeat(64)); + + await expect(wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, + })).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "OIDC redirect auth signer mismatch", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(redirectAuthStorage.get(Constants.redirectAuthStorageKey)).toBeNull(); + }); + it("cleans the callback URL when OIDC completion fails", async () => { const redirectAuthStorage = new MemoryStorageManager(); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { diff --git a/tests/walletSession.test.ts b/tests/walletSession.test.ts index aa92521..57fe266 100644 --- a/tests/walletSession.test.ts +++ b/tests/walletSession.test.ts @@ -6,6 +6,7 @@ import {Networks} from "../src/networks"; import {OMSClient} from "../src/omsClient"; import {MemoryStorageManager} from "../src/storageManager"; import {Constants} from "../src/utils/constants"; +import {RequestUtils} from "../src/utils/requestUtils"; import {WalletType} from "../src/generated/waas.gen"; class MockSigner implements CredentialSigner { @@ -318,7 +319,8 @@ describe("WalletClient session storage", () => { ]); }); - it("returns all wallets without activating when autoActivate is false", async () => { + it("returns a pending wallet selection with filtered wallets when wallet selection is manual", async () => { + const otherType = "future-wallet" as WalletType; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const body = JSON.parse(init?.body as string); @@ -327,7 +329,10 @@ describe("WalletClient session storage", () => { return jsonResponse({ identity: {type: "email", sub: "user-1"}, email: "user@example.com", - wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], + wallets: [ + testWallet("wallet-1", WalletType.Ethereum, "11"), + testWallet("wallet-other", otherType, "33"), + ], page: {cursor: "cursor-2"}, credential: testCredential(), }); @@ -365,18 +370,27 @@ describe("WalletClient session storage", () => { (wallet as any).verifier = "verifier-1"; (wallet as any).challenge = "challenge-1"; - const result = await wallet.completeEmailAuth({code: "123456", autoActivate: false}); + const result = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); - expect(result).toEqual({ + expect(result).toMatchObject({ + walletType: WalletType.Ethereum, wallets: [ testWallet("wallet-1", WalletType.Ethereum, "11"), testWallet("wallet-2", WalletType.Ethereum, "22"), ], credential: testCredential(), }); + expect(result.selectWallet).toEqual(expect.any(Function)); + expect(result.createAndSelectWallet).toEqual(expect.any(Function)); expect(wallet.walletAddress).toBeUndefined(); - const activated = await wallet.useWallet({walletId: "wallet-2"}); + await expect(result.selectWallet({walletId: "wallet-other"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_UNAVAILABLE", + operation: "wallet.pendingWalletSelection.selectWallet", + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(0); + + const activated = await result.selectWallet({walletId: "wallet-2"}); expect(activated.walletAddress).toBe("0x2222222222222222222222222222222222222222"); expect(wallet.session).toEqual({ @@ -388,7 +402,532 @@ describe("WalletClient session storage", () => { expect(storage.get(Constants.sessionEmailStorageKey)).toBe("user@example.com"); }); - it("can explicitly create and activate a new wallet", async () => { + it("pending create uses the requested wallet type", async () => { + const requestedType = "future-wallet" as WalletType; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + expect(body).toEqual({ + type: requestedType, + reference: "fresh", + }); + return jsonResponse({wallet: testWallet("wallet-new", requestedType, "33", "fresh")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + + const selection = await wallet.completeEmailAuth({ + code: "123456", + walletType: requestedType, + walletSelection: "manual", + }); + + const result = await selection.createAndSelectWallet({reference: "fresh"}); + + expect(result).toEqual({ + walletAddress: "0x3333333333333333333333333333333333333333", + wallet: testWallet("wallet-new", requestedType, "33", "fresh"), + }); + expect(wallet.walletAddress).toBe("0x3333333333333333333333333333333333333333"); + }); + + it("stale pending wallet selections fail before network after newer manual auth", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: [testWallet(body.answer === "first" ? "wallet-first" : "wallet-second", WalletType.Ethereum, body.answer === "first" ? "11" : "22")], + credential: testCredential(), + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + + const staleSelection = await wallet.completeEmailAuth({ + code: "111111", + walletSelection: "manual", + }); + await wallet.completeEmailAuth({ + code: "222222", + walletSelection: "manual", + }); + const requestCountBeforeStaleSelection = fetchMock.mock.calls.length; + + await expect(staleSelection.selectWallet({walletId: "wallet-first"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + await expect(staleSelection.createAndSelectWallet({reference: "stale"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(fetchMock.mock.calls.length).toBe(requestCountBeforeStaleSelection); + expect(wallet.walletAddress).toBeUndefined(); + }); + + it("stale pending wallet selections fail before network after newer automatic auth", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: [testWallet(body.answer === "first" ? "wallet-first" : "wallet-second", WalletType.Ethereum, body.answer === "first" ? "11" : "22")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-second", WalletType.Ethereum, "22")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + + const staleSelection = await wallet.completeEmailAuth({ + code: "111111", + walletSelection: "manual", + }); + await wallet.completeEmailAuth({code: "222222"}); + const requestCountBeforeStaleSelection = fetchMock.mock.calls.length; + + await expect(staleSelection.createAndSelectWallet({reference: "stale"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(fetchMock.mock.calls.length).toBe(requestCountBeforeStaleSelection); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + }); + + it("reused pending wallet selections fail before network after success", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-1", WalletType.Ethereum, "11")}); + } + + if (url.endsWith("/CreateWallet")) { + throw new Error("CreateWallet should not be called"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + + const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + await selection.selectWallet({walletId: "wallet-1"}); + const requestCountAfterSelection = fetchMock.mock.calls.length; + + await expect(selection.createAndSelectWallet({reference: "again"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(fetchMock.mock.calls.length).toBe(requestCountAfterSelection); + }); + + it("concurrent pending create calls send only one create wallet request", async () => { + let resolveCreate!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + expect(body).toEqual({type: WalletType.Ethereum, reference: "fresh"}); + return new Promise(resolve => { + resolveCreate = resolve; + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + + const firstCreate = selection.createAndSelectWallet({reference: "fresh"}); + const secondCreate = selection.createAndSelectWallet({reference: "fresh"}); + + await expect(secondCreate).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_IN_FLIGHT", + }); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(1); + + resolveCreate(jsonResponse({wallet: testWallet("wallet-new", WalletType.Ethereum, "33", "fresh")})); + await expect(firstCreate).resolves.toMatchObject({ + wallet: {id: "wallet-new"}, + }); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(1); + }); + + it("failed pending create can be retried when the selection was not consumed", async () => { + let createAttempts = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + createAttempts += 1; + if (createAttempts === 1) { + throw new Error("network failed"); + } + return jsonResponse({wallet: testWallet("wallet-new", WalletType.Ethereum, "33", "fresh")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + + await expect(selection.createAndSelectWallet({reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_REQUEST_FAILED", + }); + + await expect(selection.createAndSelectWallet({reference: "fresh"})).resolves.toMatchObject({ + wallet: {id: "wallet-new"}, + }); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(2); + }); + + it("pending create invalidated while in flight does not persist the stale result", async () => { + let resolveCreate!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: body.answer === "first" + ? [] + : [testWallet("wallet-second", WalletType.Ethereum, "22")], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + return new Promise(resolve => { + resolveCreate = resolve; + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-second", WalletType.Ethereum, "22")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + const selection = await wallet.completeEmailAuth({code: "111111", walletSelection: "manual"}); + + const staleCreate = selection.createAndSelectWallet({reference: "stale"}); + await wallet.completeEmailAuth({code: "222222"}); + resolveCreate(jsonResponse({wallet: testWallet("wallet-stale", WalletType.Ethereum, "44", "stale")})); + + await expect(staleCreate).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + }); + + it("public pending create invalidated while in flight does not persist the stale result", async () => { + let resolveCreate!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: body.answer === "first" + ? [] + : [testWallet("wallet-second", WalletType.Ethereum, "22")], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + return new Promise(resolve => { + resolveCreate = resolve; + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-second", WalletType.Ethereum, "22")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + await wallet.completeEmailAuth({code: "111111", walletSelection: "manual"}); + + const staleCreate = wallet.createWallet({reference: "stale"}); + await waitForRequest(fetchMock, "/CreateWallet"); + await wallet.completeEmailAuth({code: "222222"}); + resolveCreate(jsonResponse({wallet: testWallet("wallet-stale", WalletType.Ethereum, "44", "stale")})); + + await expect(staleCreate).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + operation: "wallet.createWallet", + }); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + }); + + it("public pending use invalidated while in flight by sign-out does not persist the stale result", async () => { + const storage = new MemoryStorageManager(); + let resolveUse!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return new Promise(resolve => { + resolveUse = resolve; + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage, + credentialSigner: new MockSigner(), + }); + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + + const staleUse = wallet.useWallet({walletId: "wallet-1"}); + await waitForRequest(fetchMock, "/UseWallet"); + await wallet.signOut(); + resolveUse(jsonResponse({wallet: testWallet("wallet-1", WalletType.Ethereum, "11")})); + + await expect(staleUse).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + operation: "wallet.useWallet", + }); + expect(wallet.walletAddress).toBeUndefined(); + expect(storage.get(Constants.walletIdStorageKey)).toBeNull(); + expect(storage.get(Constants.walletAddressStorageKey)).toBeNull(); + }); + + it("public wallet activation methods require an authenticated active or pending session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : {}; + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/ListWallets")) { + return jsonResponse({wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], page: {}}); + } + + if (url.endsWith("/UseWallet")) { + expect(body).toEqual({walletId: "wallet-1"}); + return jsonResponse({wallet: testWallet("wallet-1", WalletType.Ethereum, "11")}); + } + + if (url.endsWith("/CreateWallet")) { + expect(body).toEqual({type: WalletType.Ethereum, reference: "fresh"}); + return jsonResponse({wallet: testWallet("wallet-new", WalletType.Ethereum, "33", "fresh")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + + await expect(wallet.listWallets()).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No authenticated wallet session", + }); + await expect(wallet.useWallet({walletId: "wallet-1"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No authenticated wallet session", + }); + await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No authenticated wallet session", + }); + expect(fetchMock).not.toHaveBeenCalled(); + + (wallet as any).verifier = "verifier-1"; + (wallet as any).challenge = "challenge-1"; + await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + + await expect(wallet.listWallets()).resolves.toEqual([testWallet("wallet-1", WalletType.Ethereum, "11")]); + await expect(wallet.useWallet({walletId: "wallet-1"})).resolves.toMatchObject({ + wallet: {id: "wallet-1"}, + }); + await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).resolves.toMatchObject({ + wallet: {id: "wallet-new"}, + }); + }); + + it("can explicitly create and activate a new wallet from an active session", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const body = JSON.parse(init?.body as string); @@ -412,6 +951,11 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); + (wallet as any).persistSession("wallet-id", "0x1111111111111111111111111111111111111111", { + expiresAt: "2026-01-01T00:00:00Z", + loginType: "email", + sessionEmail: "user@example.com", + }); const result = await wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"}); @@ -453,3 +997,17 @@ function testWallet(id: string, type: WalletType, seed: string, reference?: stri ...(reference ? {reference} : {}), }; } + +function requestCount(fetchMock: ReturnType, endpoint: string): number { + return fetchMock.mock.calls.filter(([input]) => input.toString().endsWith(endpoint)).length; +} + +async function waitForRequest(fetchMock: ReturnType, endpoint: string): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + if (requestCount(fetchMock, endpoint) > 0) { + return; + } + await Promise.resolve(); + } + throw new Error(`Expected ${endpoint} request`); +} diff --git a/type-tests/oidcProviderTypes.ts b/type-tests/oidcProviderTypes.ts index f35e373..a2d1779 100644 --- a/type-tests/oidcProviderTypes.ts +++ b/type-tests/oidcProviderTypes.ts @@ -50,8 +50,11 @@ if (false) { }); void (async () => { - const manualAuth = await wallet.completeEmailAuth({code: "123456", autoActivate: false}); + const manualAuth = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + void manualAuth.walletType; void manualAuth.wallets; + void manualAuth.selectWallet({walletId: manualAuth.wallets[0].id}); + void manualAuth.createAndSelectWallet({reference: "main"}); // @ts-expect-error manual auth does not activate a wallet. void manualAuth.walletAddress; From 9a51d3c5efa4c889eb17698c1793612b4dbe2d4a Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 20 May 2026 17:53:43 +0300 Subject: [PATCH 02/10] Update React example wallet selection UI --- examples/react/src/main.tsx | 165 ++++++++++++++++++++++++++++++++-- examples/react/src/styles.css | 90 ++++++++++++++++++- 2 files changed, 242 insertions(+), 13 deletions(-) diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index b4f43cf..b547590 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -8,10 +8,13 @@ import { type FeeOptionWithBalance, type Network, type OMSClientSessionLoginType, + type OmsWallet, + type PendingWalletSelection, + type WalletActivationResult, } from '@0xsequence/typescript-sdk' import './styles.css' -type Step = 'email' | 'code' | 'wallet' +type Step = 'email' | 'code' | 'wallet-selection' | 'wallet' type FeeSelectionController = { resolve: (selection: FeeOptionSelection) => void reject: (error: Error) => void @@ -21,6 +24,7 @@ const DEFAULT_MESSAGE = 'test' const DEFAULT_TX_TO = '0xE5E8B483FfC05967FcFed58cc98D053265af6D99' const PUBLIC_API_KEY = requiredEnv('VITE_OMS_PUBLIC_API_KEY', import.meta.env.VITE_OMS_PUBLIC_API_KEY) const PROJECT_ID = requiredEnv('VITE_OMS_PROJECT_ID', import.meta.env.VITE_OMS_PROJECT_ID) +const MANUAL_WALLET_SELECTION_KEY = 'oms-demo-manual-wallet-selection' function requiredEnv(name: string, value: string | undefined): string { if (!value) { @@ -42,6 +46,8 @@ function App() { const [lastTransactionHash, setLastTransactionHash] = useState('') const [lastTransactionExplorerUrl, setLastTransactionExplorerUrl] = useState('') const [feeOptions, setFeeOptions] = useState([]) + const [useManualWalletSelection, setUseManualWalletSelection] = useState(readManualWalletSelectionPreference) + const [pendingWalletSelection, setPendingWalletSelection] = useState(null) const [emailAuthStatus, setEmailAuthStatus] = useState('Enter an email to start.') const [redirectStatus, setRedirectStatus] = useState('') const [walletStatus, setWalletStatus] = useState('') @@ -86,6 +92,10 @@ function App() { } }, [selectedNetworkId, step]) + useEffect(() => { + window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') + }, [useManualWalletSelection]) + async function run( label: string, setActiveStatus: (message: string) => void, @@ -105,6 +115,7 @@ function App() { async function startEmailAuth() { if (!email.trim()) return await run('Sending code...', setEmailAuthStatus, async () => { + setPendingWalletSelection(null) await oms.wallet.startEmailAuth({ email: email.trim() }) setStep('code') setEmailAuthStatus('Code sent. Check your email.') @@ -114,25 +125,82 @@ function App() { async function completeEmailAuth() { if (!code.trim()) return await run('Completing sign-in...', setEmailAuthStatus, async () => { - const result = await oms.wallet.completeEmailAuth({ code: code.trim() }) - setWalletAddress(result.walletAddress) - setStep('wallet') - setWalletStatus('Wallet ready.') + const result = await oms.wallet.completeEmailAuth({ + code: code.trim(), + walletSelection: useManualWalletSelection ? 'manual' : 'automatic', + }) + handleAuthCompletion(result, 'Email login complete.') }) } async function startOidcRedirect() { await run('Redirecting to provider...', setRedirectStatus, async () => { + window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') + setPendingWalletSelection(null) await oms.wallet.signInWithOidcRedirect({ provider: 'google' }) }) } async function completeOidcRedirect() { await run('Completing redirect sign-in...', setRedirectStatus, async () => { - const result = await oms.wallet.signInWithOidcRedirect({ provider: 'google' }) - setWalletAddress(result?.walletAddress ?? oms.wallet.walletAddress ?? '') - setStep('wallet') - setWalletStatus('Wallet ready.') + const result = await oms.wallet.signInWithOidcRedirect({ + provider: 'google', + walletSelection: readManualWalletSelectionPreference() ? 'manual' : 'automatic', + }) + if (result) { + handleAuthCompletion(result, 'Google login complete.') + return + } + + const restoredAddress = oms.wallet.walletAddress ?? '' + setWalletAddress(restoredAddress) + setStep(restoredAddress ? 'wallet' : 'email') + setWalletStatus(restoredAddress ? 'Wallet ready.' : '') + }) + } + + function handleAuthCompletion( + result: PendingWalletSelection | WalletActivationResult, + status: string, + ) { + if (isPendingWalletSelection(result)) { + setPendingWalletSelection(result) + setStep('wallet-selection') + setEmailAuthStatus('') + setRedirectStatus('') + return + } + + setPendingWalletSelection(null) + setWalletAddress(result.walletAddress) + setStep('wallet') + setWalletStatus(status) + } + + async function selectPendingWallet(wallet: OmsWallet) { + if (!pendingWalletSelection) return + await run('Selecting wallet...', setEmailAuthStatus, async () => { + const result = await pendingWalletSelection.selectWallet({ walletId: wallet.id }) + handleAuthCompletion(result, 'Wallet selected.') + }) + } + + async function createPendingWallet() { + if (!pendingWalletSelection) return + await run('Creating wallet...', setEmailAuthStatus, async () => { + const result = await pendingWalletSelection.createAndSelectWallet({ reference: 'main' }) + handleAuthCompletion(result, 'Wallet created.') + }) + } + + async function cancelPendingWalletSelection() { + await run('Cancelling wallet selection...', setEmailAuthStatus, async () => { + await oms.wallet.signOut() + setPendingWalletSelection(null) + setWalletAddress('') + setCode('') + setStep('email') + setEmailAuthStatus('Enter an email to start.') }) } @@ -193,6 +261,7 @@ function App() { await run('Signing out...', setWalletStatus, async () => { await oms.wallet.signOut() setCode('') + setPendingWalletSelection(null) setWalletAddress('') setLastSignature('') setLastTransactionHash('') @@ -211,6 +280,17 @@ function App() {

OMS Client Typescript SDK

Wallet Demo

+ {step === 'email' && ( + + )}
{step === 'email' && ( @@ -218,6 +298,7 @@ function App() { event.preventDefault() void startEmailAuth() }}> +

Login Options

+ ))} +
+ ) : ( +

No existing {formatWalletType(pendingWalletSelection.walletType)} wallets.

+ )} + +

Create new wallet

+ + + + + {emailAuthStatus && {emailAuthStatus}} + + )} + {step === 'wallet' && (
@@ -445,3 +575,20 @@ function formatSessionExpiry(expiresAt: string | undefined): string { const date = new Date(expiresAt) return Number.isNaN(date.getTime()) ? expiresAt : date.toLocaleString() } + +function formatWalletType(walletType: string): string { + return walletType + .split(/[-_]/) + .map(part => part ? part[0].toUpperCase() + part.slice(1) : part) + .join(' ') +} + +function isPendingWalletSelection( + result: PendingWalletSelection | WalletActivationResult, +): result is PendingWalletSelection { + return 'selectWallet' in result +} + +function readManualWalletSelectionPreference(): boolean { + return window.sessionStorage.getItem(MANUAL_WALLET_SELECTION_KEY) === 'true' +} diff --git a/examples/react/src/styles.css b/examples/react/src/styles.css index 6fe051e..ea67551 100644 --- a/examples/react/src/styles.css +++ b/examples/react/src/styles.css @@ -52,6 +52,13 @@ h1 { line-height: 1.15; } +.section-title { + margin: 0; + color: #1f2937; + font-size: 17px; + line-height: 1.25; +} + .stack { display: grid; gap: 16px; @@ -65,6 +72,32 @@ label { font-weight: 650; } +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #e1e6ee; + border-radius: 6px; + background: #fbfcfe; +} + +.checkbox-row input { + width: 18px; + min-height: 18px; + margin: 0; + accent-color: #1d4ed8; +} + +.checkbox-row span { + min-width: 0; +} + +.header-option { + margin-top: 14px; +} + .field-stack { display: grid; gap: 10px; @@ -114,6 +147,12 @@ button.secondary { background: #e6ebf2; } +button.subtle { + color: #475467; + background: transparent; + border: 1px solid #d8dee8; +} + button:disabled { cursor: not-allowed; background: #a7b1c2; @@ -188,6 +227,19 @@ button:disabled { text-align: center; } +.metadata-pill { + min-width: 48px; + padding: 4px 8px; + border: 1px solid #d8dee8; + border-radius: 6px; + color: #475467; + background: #eef2f7; + font-size: 12px; + font-weight: 800; + line-height: 1.2; + text-align: center; +} + select:disabled { color: #667085; background: #eef2f7; @@ -203,7 +255,8 @@ select:disabled { gap: 8px; } -.fee-option { +.fee-option, +.wallet-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; @@ -215,7 +268,13 @@ select:disabled { text-align: left; } -.fee-option span { +.wallet-option { + grid-template-columns: minmax(0, 1fr) auto; + row-gap: 6px; +} + +.fee-option span, +.wallet-option span { min-width: 0; } @@ -226,17 +285,40 @@ select:disabled { } .fee-option strong, -.fee-option small { +.fee-option small, +.wallet-option strong, +.wallet-option small { display: block; overflow-wrap: anywhere; } -.fee-option small { +.fee-option small, +.wallet-option small { margin-top: 3px; color: #667085; font-size: 12px; } +.wallet-option code { + grid-column: 1 / -1; + min-width: 0; + overflow-wrap: anywhere; + color: #344054; + font-size: 12px; +} + +.wallet-option-action { + justify-self: end; + color: #1d4ed8; + font-size: 13px; + font-weight: 800; +} + +.wallet-option-list { + display: grid; + gap: 8px; +} + output { min-height: 42px; padding: 11px 12px; From 2fccfd0eea30fb4169697b522ccf0d111759efca Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 20 May 2026 23:24:39 +0300 Subject: [PATCH 03/10] Remove unused utility code --- src/types/evmTypes.ts | 1 - src/utils/byteUtils.ts | 11 +---------- src/utils/timeUtils.ts | 5 ----- 3 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 src/types/evmTypes.ts delete mode 100644 src/utils/timeUtils.ts diff --git a/src/types/evmTypes.ts b/src/types/evmTypes.ts deleted file mode 100644 index 5ec00e1..0000000 --- a/src/types/evmTypes.ts +++ /dev/null @@ -1 +0,0 @@ -export type {Network} from "../networks.js"; diff --git a/src/utils/byteUtils.ts b/src/utils/byteUtils.ts index 92247ee..5f913b8 100644 --- a/src/utils/byteUtils.ts +++ b/src/utils/byteUtils.ts @@ -1,14 +1,5 @@ export class ByteUtils { - static hexToBytes(hex: string): Uint8Array { - const h = hex.startsWith('0x') ? hex.slice(2) : hex - const bytes = new Uint8Array(h.length / 2) - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16) - } - return bytes - } - static bytesToHex(bytes: Uint8Array): string { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') } -} \ No newline at end of file +} diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts deleted file mode 100644 index f22b6b9..0000000 --- a/src/utils/timeUtils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class TimeUtils { - static currentTimestampInSecondsString(): string { - return Math.floor(Date.now()).toString() - } -} \ No newline at end of file From 80c2de52c51cdb748730c5eba4ee58719b3ac3de Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 00:10:45 +0300 Subject: [PATCH 04/10] Harden auth flow and operation labels --- src/clients/indexerClient.ts | 9 +- src/clients/walletClient.ts | 251 +++++++++++++++++++++++++---------- src/errors.ts | 4 +- src/operations.ts | 35 +++++ tests/walletErrors.test.ts | 13 ++ tests/walletSession.test.ts | 222 ++++++++++++++++++++++++++----- 6 files changed, 428 insertions(+), 106 deletions(-) create mode 100644 src/operations.ts diff --git a/src/clients/indexerClient.ts b/src/clients/indexerClient.ts index dd8d875..85cb0a9 100644 --- a/src/clients/indexerClient.ts +++ b/src/clients/indexerClient.ts @@ -3,6 +3,7 @@ import {HttpClient} from "../httpClient.js"; import {errorMessage, OmsRequestError, OmsResponseError} from "../errors.js"; import type {Network} from "../networks.js"; +import {IndexerOperation} from "../operations.js"; export interface TokenBalancesPage { page: number; @@ -193,7 +194,7 @@ export class IndexerClient { const bodyString = JSON.stringify(request); const baseUrl = this.indexerUrl(params.network); - const response = await this.postJson("indexer.getTokenBalances", { + const response = await this.postJson(IndexerOperation.getTokenBalances, { baseUrl, path: "/GetTokenBalances", body: bodyString, @@ -211,7 +212,7 @@ export class IndexerClient { network: Network walletAddress: string }): Promise { - const response = await this.postJson("indexer.getNativeTokenBalance", { + const response = await this.postJson(IndexerOperation.getNativeTokenBalance, { baseUrl: this.indexerUrl(params.network), path: "/GetNativeTokenBalance", body: JSON.stringify({ accountAddress: params.walletAddress }), @@ -235,7 +236,7 @@ export class IndexerClient { } private async postJson( - operation: string, + operation: IndexerOperation, args: Parameters[0], ): Promise<{statusCode: number, payload: T}> { let response; @@ -325,7 +326,7 @@ function mapTokenMetadata(raw: TokenMetadataRaw): TokenMetadata { }; } -function responseErrorMessage(payload: unknown, operation: string, status: number): string { +function responseErrorMessage(payload: unknown, operation: IndexerOperation, status: number): string { if (payload && typeof payload === "object" && "message" in payload) { const message = (payload as {message?: unknown}).message; if (typeof message === "string" && message) { diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index 7b49805..599a3fd 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -75,6 +75,7 @@ import { WalletCredential, } from "../types/accessGrant.js"; import {IndexerClient, TokenBalance} from "./indexerClient.js"; +import {WalletOperation} from "../operations.js"; export type OidcProviderName = keyof NonNullable['oidcProviders']> & string; @@ -234,6 +235,21 @@ type WalletActivationContext = | {kind: "pending"; session: ActivePendingWalletSelection; metadata: WalletSessionMetadata} | {kind: "active"; metadata?: WalletSessionMetadata} +interface EmailAuthCompletionParams { + code: string; + walletType: WalletType; + walletSelection: WalletSelectionBehavior; +} + +interface ActiveEmailAuthAttempt { + verifier: string; + challenge: string; + completion?: { + params: EmailAuthCompletionParams; + promise: Promise; + }; +} + class PendingWalletSelectionImpl implements PendingWalletSelection { private readonly availableWalletIds: Set; private inFlight = false; @@ -249,11 +265,11 @@ class PendingWalletSelectionImpl implements PendingWalletSelection { } async selectWallet(params: {walletId: string}): Promise { - return this.runExclusive("wallet.pendingWalletSelection.selectWallet", async () => { + return this.runExclusive(WalletOperation.pendingWalletSelectionSelectWallet, async () => { if (!this.availableWalletIds.has(params.walletId)) { throw new OmsWalletSelectionError({ code: "OMS_WALLET_SELECTION_UNAVAILABLE", - operation: "wallet.pendingWalletSelection.selectWallet", + operation: WalletOperation.pendingWalletSelectionSelectWallet, message: "Selected wallet is not one of the available options", }); } @@ -262,12 +278,12 @@ class PendingWalletSelectionImpl implements PendingWalletSelection { } async createAndSelectWallet(params: {reference?: string} = {}): Promise { - return this.runExclusive("wallet.pendingWalletSelection.createAndSelectWallet", () => + return this.runExclusive(WalletOperation.pendingWalletSelectionCreateAndSelectWallet, () => this.createAndSelectWalletAction(params.reference), ); } - private async runExclusive(operation: string, action: () => Promise): Promise { + private async runExclusive(operation: WalletOperation, action: () => Promise): Promise { if (this.inFlight) { throw new OmsWalletSelectionError({ code: "OMS_WALLET_SELECTION_IN_FLIGHT", @@ -307,11 +323,10 @@ export class WalletClient { private sessionLoginType: OMSClientSessionLoginType | undefined private sessionEmail: string | undefined private activePendingWalletSelection: ActivePendingWalletSelection | undefined + private activeEmailAuthAttempt: ActiveEmailAuthAttempt | undefined private nextPendingWalletSelectionId = 1 private walletId: string - private verifier = '' - private challenge = '' constructor(params: { publicApiKey: string, @@ -383,7 +398,7 @@ export class WalletClient { async startEmailAuth(params: { email: string }): Promise { - return this.runOperation("wallet.startEmailAuth", async () => { + return this.runOperation(WalletOperation.startEmailAuth, async () => { await this.clearSession() const request: CommitVerifierRequest = { identityType: IdentityType.Email, @@ -392,8 +407,10 @@ export class WalletClient { handle: params.email, } const response = await this.client.commitVerifier(request) - this.verifier = response.verifier - this.challenge = response.challenge + this.activeEmailAuthAttempt = { + verifier: response.verifier, + challenge: response.challenge, + } }) } @@ -408,19 +425,38 @@ export class WalletClient { async completeEmailAuth(params: AutomaticWalletSelectionParams): Promise async completeEmailAuth(params: CompleteEmailAuthParams): Promise async completeEmailAuth(params: CompleteEmailAuthParams): Promise { - return this.runOperation("wallet.completeEmailAuth", async () => { - const walletType = params.walletType ?? WalletType.Ethereum; - const answer = await RequestUtils.hashEmailAuthAnswer(this.challenge, params.code); - - const request: CompleteAuthRequest = { - identityType: IdentityType.Email, - authMode: AuthMode.OTP, - verifier: this.verifier, - answer, - lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, + return this.runOperation(WalletOperation.completeEmailAuth, async () => { + const completionParams: EmailAuthCompletionParams = { + code: params.code, + walletType: params.walletType ?? WalletType.Ethereum, + walletSelection: params.walletSelection ?? "automatic", } - const response = await this.client.completeAuth(request) - return this.completeWalletAuth(response, walletType, params.walletSelection ?? "automatic") + const attempt = this.currentEmailAuthAttempt(WalletOperation.completeEmailAuth) + const completion = attempt.completion + if (completion) { + if (!sameEmailAuthCompletionParams(completion.params, completionParams)) { + throw new OmsSessionError({ + operation: WalletOperation.completeEmailAuth, + message: "Email auth completion is already in flight", + }) + } + return completion.promise + } + + let promise: Promise + promise = this.completeEmailAuthAttempt(attempt, completionParams).catch(error => { + const sdkError = toOmsSdkError(error, WalletOperation.completeEmailAuth) + if (this.activeEmailAuthAttempt === attempt && attempt.completion?.promise === promise) { + if (sdkError.code === "OMS_AUTH_COMMITMENT_CONSUMED") { + this.activeEmailAuthAttempt = undefined + } else { + attempt.completion = undefined + } + } + throw sdkError + }) + attempt.completion = {params: completionParams, promise} + return promise }) } @@ -433,7 +469,7 @@ export class WalletClient { async startOidcRedirectAuth( params: StartOidcRedirectAuthParams, ): Promise { - return this.runOperation("wallet.startOidcRedirectAuth", async () => { + return this.runOperation(WalletOperation.startOidcRedirectAuth, async () => { await this.clearSession() const redirectAuthStorage = this.requireRedirectAuthStorage() const provider = this.resolveOidcProvider(params.provider) @@ -510,7 +546,7 @@ export class WalletClient { async completeOidcRedirectAuth( params: CompleteOidcRedirectAuthParams, ): Promise { - return this.runOperation("wallet.completeOidcRedirectAuth", async () => { + return this.runOperation(WalletOperation.completeOidcRedirectAuth, async () => { const redirectAuthStorage = this.requireRedirectAuthStorage() try { @@ -528,7 +564,7 @@ export class WalletClient { const pending = this.loadPendingOidcRedirectAuth(redirectAuthStorage) this.validateOidcState(callback.state, pending) - await this.validatePendingOidcRedirectSigner(pending, "wallet.completeOidcRedirectAuth") + await this.validatePendingOidcRedirectSigner(pending, WalletOperation.completeOidcRedirectAuth) const request: CompleteAuthRequest = { identityType: IdentityType.OIDC, @@ -561,7 +597,7 @@ export class WalletClient { async signInWithOidcRedirect(params: AutomaticWalletSelectionParams>): Promise async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise { - return this.runOperation("wallet.signInWithOidcRedirect", async () => { + return this.runOperation(WalletOperation.signInWithOidcRedirect, async () => { const currentUrl = params.currentUrl ?? this.browserCurrentUrl() const callback = parseOidcCallbackUrl(currentUrl) if (callback.code || callback.state || callback.error) { @@ -587,30 +623,30 @@ export class WalletClient { } async signOut(): Promise { - return this.runOperation("wallet.signOut", () => this.clearSession()) + return this.runOperation(WalletOperation.signOut, () => this.clearSession()) } async listWallets(): Promise> { - return this.runOperation("wallet.listWallets", async () => { - await this.requireWalletSelectionOrActiveSession("wallet.listWallets") + return this.runOperation(WalletOperation.listWallets, async () => { + await this.requireWalletSelectionOrActiveSession(WalletOperation.listWallets) return this.listAllWallets() }) } async useWallet(params: {walletId: string}): Promise { - return this.runOperation("wallet.useWallet", async () => { - const context = await this.walletActivationContext("wallet.useWallet") + return this.runOperation(WalletOperation.useWallet, async () => { + const context = await this.walletActivationContext(WalletOperation.useWallet) const wallet = await this.requestUseWallet(params.walletId) - await this.requireWalletActivationContextStillActive(context, "wallet.useWallet") + await this.requireWalletActivationContextStillActive(context, WalletOperation.useWallet) return this.activateWallet(wallet, context.metadata) }) } async createWallet(params: {type?: WalletType; reference?: string} = {}): Promise { - return this.runOperation("wallet.createWallet", async () => { - const context = await this.walletActivationContext("wallet.createWallet") + return this.runOperation(WalletOperation.createWallet, async () => { + const context = await this.walletActivationContext(WalletOperation.createWallet) const wallet = await this.requestCreateWallet(params.type ?? WalletType.Ethereum, params.reference) - await this.requireWalletActivationContextStillActive(context, "wallet.createWallet") + await this.requireWalletActivationContextStillActive(context, WalletOperation.createWallet) return this.activateWallet(wallet, context.metadata) }) } @@ -627,15 +663,14 @@ export class WalletClient { this.sessionLoginType = undefined this.sessionEmail = undefined this.activePendingWalletSelection = undefined - this.verifier = '' - this.challenge = '' + this.activeEmailAuthAttempt = undefined this.redirectAuthStorage?.delete(Constants.redirectAuthStorageKey) await this.credentialSigner.clear?.() } async signMessage(params: SignMessageParams): Promise { - return this.runOperation("wallet.signMessage", async () => { - await this.requireActiveSession("wallet.signMessage") + return this.runOperation(WalletOperation.signMessage, async () => { + await this.requireActiveSession(WalletOperation.signMessage) const request: SignMessageRequest = { network: params.network.id.toString(), walletId: this.walletId, @@ -647,8 +682,8 @@ export class WalletClient { } async signTypedData(params: SignTypedDataParams): Promise { - return this.runOperation("wallet.signTypedData", async () => { - await this.requireActiveSession("wallet.signTypedData") + return this.runOperation(WalletOperation.signTypedData, async () => { + await this.requireActiveSession(WalletOperation.signTypedData) const request: SignTypedDataRequest = { network: params.network.id.toString(), walletId: this.walletId, @@ -660,7 +695,7 @@ export class WalletClient { } async isValidMessageSignature(params: IsValidMessageSignatureParams): Promise { - return this.runOperation("wallet.isValidMessageSignature", async () => { + return this.runOperation(WalletOperation.isValidMessageSignature, async () => { const request: IsValidMessageSignatureRequest = { network: params.network?.id.toString(), walletAddress: params.walletAddress, @@ -674,7 +709,7 @@ export class WalletClient { } async isValidTypedDataSignature(params: IsValidTypedDataSignatureParams): Promise { - return this.runOperation("wallet.isValidTypedDataSignature", async () => { + return this.runOperation(WalletOperation.isValidTypedDataSignature, async () => { const request: IsValidTypedDataSignatureRequest = { network: params.network?.id.toString(), walletAddress: params.walletAddress, @@ -694,8 +729,8 @@ export class WalletClient { functionName extends ContractFunctionName | undefined = ContractFunctionName, >(params: SendContractTransactionParams): Promise async sendTransaction(params: SendTransactionParams): Promise { - return this.runOperation("wallet.sendTransaction", async () => { - await this.requireActiveSession("wallet.sendTransaction") + return this.runOperation(WalletOperation.sendTransaction, async () => { + await this.requireActiveSession(WalletOperation.sendTransaction) const data = 'abi' in params ? encodeFunctionData(params as EncodeFunctionDataParameters) @@ -731,8 +766,8 @@ export class WalletClient { waitForStatus?: boolean statusPolling?: TransactionStatusPollingOptions }): Promise { - return this.runOperation("wallet.callContract", async () => { - await this.requireActiveSession("wallet.callContract") + return this.runOperation(WalletOperation.callContract, async () => { + await this.requireActiveSession(WalletOperation.callContract) const request: PrepareEthereumContractCallRequest = { network: params.network.id.toString(), walletId: this.walletId, @@ -756,15 +791,15 @@ export class WalletClient { async getTransactionStatus(params: { txnId: string }): Promise { - return this.runOperation("wallet.getTransactionStatus", () => + return this.runOperation(WalletOperation.getTransactionStatus, () => this.client.transactionStatus({txnId: params.txnId} as TransactionStatusRequest), ) } async listAccess(params: ListAccessParams = {}): Promise { - return this.runOperation("wallet.listAccess", async () => { + return this.runOperation(WalletOperation.listAccess, async () => { const grants: AccessGrant[] = [] - for await (const page of this.listAccessPagesUnchecked(params, "wallet.listAccess")) { + for await (const page of this.listAccessPagesUnchecked(params, WalletOperation.listAccess)) { grants.push(...page.grants) } return grants @@ -773,17 +808,17 @@ export class WalletClient { async *listAccessPages(params: ListAccessParams = {}): AsyncIterable { try { - yield* this.listAccessPagesUnchecked(params, "wallet.listAccessPages") + yield* this.listAccessPagesUnchecked(params, WalletOperation.listAccessPages) } catch (error) { - throw toOmsSdkError(error, "wallet.listAccessPages") + throw toOmsSdkError(error, WalletOperation.listAccessPages) } } async revokeAccess(params: { targetCredentialId: string }): Promise { - return this.runOperation("wallet.revokeAccess", async () => { - await this.requireActiveSession("wallet.revokeAccess") + return this.runOperation(WalletOperation.revokeAccess, async () => { + await this.requireActiveSession(WalletOperation.revokeAccess) const request: RevokeAccessRequest = { targetCredentialId: params.targetCredentialId, walletId: this.walletId @@ -829,6 +864,7 @@ export class WalletClient { response: CompleteAuthResponse, walletType: WalletType, walletSelection: WalletSelectionBehavior, + emailAuthAttempt?: ActiveEmailAuthAttempt, ): Promise { this.activePendingWalletSelection = undefined @@ -836,20 +872,33 @@ export class WalletClient { const wallets = await this.listAllWalletsFromAuthResponse(response) const credential = this.toWalletCredential(response.credential) const candidateWallets = wallets.filter(wallet => wallet.type === walletType) + const operation = WalletOperation.completeEmailAuth + const requireCurrentEmailAuth = () => { + if (emailAuthAttempt) { + this.requireActiveEmailAuthAttempt(emailAuthAttempt, operation) + } + } + + requireCurrentEmailAuth() if (walletSelection === "manual") { - return this.createPendingWalletSelection({ + const selection = await this.createPendingWalletSelection({ walletType, wallets: candidateWallets, credential, metadata, - }) + }, requireCurrentEmailAuth) + this.clearEmailAuthAttempt(emailAuthAttempt) + return selection } const wallet = candidateWallets[0] - const activated = wallet - ? this.activateWallet(await this.requestUseWallet(wallet.id), metadata) - : this.activateWallet(await this.requestCreateWallet(walletType), metadata) + const selectedWallet = wallet + ? await this.requestUseWallet(wallet.id) + : await this.requestCreateWallet(walletType) + requireCurrentEmailAuth() + const activated = this.activateWallet(selectedWallet, metadata) + this.clearEmailAuthAttempt(emailAuthAttempt) const resultWallets = wallet ? wallets : [...wallets, activated.wallet] return { @@ -860,9 +909,29 @@ export class WalletClient { } } + private async completeEmailAuthAttempt( + attempt: ActiveEmailAuthAttempt, + params: EmailAuthCompletionParams, + ): Promise { + this.requireActiveEmailAuthAttempt(attempt, WalletOperation.completeEmailAuth) + const answer = await RequestUtils.hashEmailAuthAnswer(attempt.challenge, params.code) + this.requireActiveEmailAuthAttempt(attempt, WalletOperation.completeEmailAuth) + + const request: CompleteAuthRequest = { + identityType: IdentityType.Email, + authMode: AuthMode.OTP, + verifier: attempt.verifier, + answer, + lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, + } + const response = await this.client.completeAuth(request) + this.requireActiveEmailAuthAttempt(attempt, WalletOperation.completeEmailAuth) + return this.completeWalletAuth(response, params.walletType, params.walletSelection, attempt) + } + private async *listAccessPagesUnchecked( params: ListAccessParams, - operation: string, + operation: WalletOperation, ): AsyncIterable { await this.requireActiveSession(operation) @@ -958,7 +1027,7 @@ export class WalletClient { } } - private async walletActivationContext(operation: string): Promise { + private async walletActivationContext(operation: WalletOperation): Promise { const pendingSelection = this.activePendingWalletSelection if (pendingSelection) { await this.requireActivePendingWalletSelection(pendingSelection, operation) @@ -982,14 +1051,14 @@ export class WalletClient { private async requireWalletActivationContextStillActive( context: WalletActivationContext, - operation: string, + operation: WalletOperation, ): Promise { if (context.kind === "pending") { await this.requireActivePendingWalletSelection(context.session, operation) } } - private async requireWalletSelectionOrActiveSession(operation: string): Promise { + private async requireWalletSelectionOrActiveSession(operation: WalletOperation): Promise { if (this.activePendingWalletSelection) { await this.requireActivePendingWalletSelection(this.activePendingWalletSelection, operation) return @@ -1006,15 +1075,46 @@ export class WalletClient { }) } + private currentEmailAuthAttempt(operation: WalletOperation): ActiveEmailAuthAttempt { + if (!this.activeEmailAuthAttempt) { + throw new OmsSessionError({ + operation, + message: "No pending email auth attempt", + }) + } + + return this.activeEmailAuthAttempt + } + + private requireActiveEmailAuthAttempt( + attempt: ActiveEmailAuthAttempt, + operation: WalletOperation, + ): void { + if (this.activeEmailAuthAttempt !== attempt) { + throw new OmsSessionError({ + operation, + message: "Email auth attempt is no longer active", + }) + } + } + + private clearEmailAuthAttempt(attempt: ActiveEmailAuthAttempt | undefined): void { + if (attempt && this.activeEmailAuthAttempt === attempt) { + this.activeEmailAuthAttempt = undefined + } + } + private async createPendingWalletSelection(params: { walletType: WalletType; wallets: Array; credential: WalletCredential; metadata: WalletSessionMetadata; - }): Promise { + }, beforeCommit?: () => void): Promise { + const signerCredentialId = await this.credentialSigner.credentialId() + beforeCommit?.() const selectionSession: ActivePendingWalletSelection = { id: `pending-${this.nextPendingWalletSelectionId++}`, - signerCredentialId: await this.credentialSigner.credentialId(), + signerCredentialId, signerKeyType: this.credentialSigner.signingAlgorithm, walletType: params.walletType, metadata: params.metadata, @@ -1029,14 +1129,14 @@ export class WalletClient { wallets, credential, async walletId => { - const operation = "wallet.pendingWalletSelection.selectWallet" + const operation = WalletOperation.pendingWalletSelectionSelectWallet await this.requireActivePendingWalletSelection(selectionSession, operation) const wallet = await this.requestUseWallet(walletId) await this.requireActivePendingWalletSelection(selectionSession, operation) return this.activateWallet(wallet, selectionSession.metadata) }, async reference => { - const operation = "wallet.pendingWalletSelection.createAndSelectWallet" + const operation = WalletOperation.pendingWalletSelectionCreateAndSelectWallet await this.requireActivePendingWalletSelection(selectionSession, operation) const wallet = await this.requestCreateWallet(selectionSession.walletType, reference) await this.requireActivePendingWalletSelection(selectionSession, operation) @@ -1047,7 +1147,7 @@ export class WalletClient { private async requireActivePendingWalletSelection( selectionSession: ActivePendingWalletSelection, - operation: string, + operation: WalletOperation, ): Promise { if (this.activePendingWalletSelection?.id !== selectionSession.id) { throw new OmsWalletSelectionError({ @@ -1187,7 +1287,7 @@ export class WalletClient { private async validatePendingOidcRedirectSigner( pending: PendingOidcRedirectAuth, - operation: string, + operation: WalletOperation, ): Promise { if (this.credentialSigner.hasCredential && !(await this.credentialSigner.hasCredential())) { throw new OmsSessionError({ @@ -1271,7 +1371,7 @@ export class WalletClient { ) } catch (error) { throw new OmsTransactionError({ - operation: "wallet.transactionStatus", + operation: WalletOperation.transactionStatus, txnId: params.prepared.txnId, retryable: true, cause: error, @@ -1425,7 +1525,7 @@ export class WalletClient { : options.intervalMs ?? this.transactionStatusPollIntervalMs } - private async requireActiveSession(operation: string): Promise { + private async requireActiveSession(operation: WalletOperation): Promise { if (!this.walletId) { throw new OmsSessionError({ operation, @@ -1479,7 +1579,7 @@ export class WalletClient { return new Promise(resolve => setTimeout(resolve, ms)) } - private async runOperation(operation: string, action: () => Promise): Promise { + private async runOperation(operation: WalletOperation, action: () => Promise): Promise { try { return await action() } catch (error) { @@ -1510,6 +1610,15 @@ function isWalletType(value: unknown): value is WalletType { return typeof value === 'string' && Object.values(WalletType).includes(value as WalletType) } +function sameEmailAuthCompletionParams( + left: EmailAuthCompletionParams, + right: EmailAuthCompletionParams, +): boolean { + return left.code === right.code && + left.walletType === right.walletType && + left.walletSelection === right.walletSelection +} + function normalizeCredentialId(value: string): string { return value.trim().toLowerCase() } diff --git a/src/errors.ts b/src/errors.ts index bafacc7..86b4acd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,5 @@ +import type {OmsSdkOperation} from "./operations.js"; + export type OmsSdkErrorCode = | "OMS_HTTP_ERROR" | "OMS_INVALID_RESPONSE" @@ -93,7 +95,7 @@ export function isOmsSdkError(error: unknown): error is OmsSdkError { return error instanceof OmsSdkError } -export function toOmsSdkError(error: unknown, operation: string): OmsSdkError { +export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSdkError { if (isOmsSdkError(error)) { return error } diff --git a/src/operations.ts b/src/operations.ts new file mode 100644 index 0000000..a8168b7 --- /dev/null +++ b/src/operations.ts @@ -0,0 +1,35 @@ +export const WalletOperation = { + pendingWalletSelectionSelectWallet: "wallet.pendingWalletSelection.selectWallet", + pendingWalletSelectionCreateAndSelectWallet: "wallet.pendingWalletSelection.createAndSelectWallet", + startEmailAuth: "wallet.startEmailAuth", + completeEmailAuth: "wallet.completeEmailAuth", + startOidcRedirectAuth: "wallet.startOidcRedirectAuth", + completeOidcRedirectAuth: "wallet.completeOidcRedirectAuth", + signInWithOidcRedirect: "wallet.signInWithOidcRedirect", + signOut: "wallet.signOut", + listWallets: "wallet.listWallets", + useWallet: "wallet.useWallet", + createWallet: "wallet.createWallet", + signMessage: "wallet.signMessage", + signTypedData: "wallet.signTypedData", + isValidMessageSignature: "wallet.isValidMessageSignature", + isValidTypedDataSignature: "wallet.isValidTypedDataSignature", + sendTransaction: "wallet.sendTransaction", + callContract: "wallet.callContract", + getTransactionStatus: "wallet.getTransactionStatus", + listAccess: "wallet.listAccess", + listAccessPages: "wallet.listAccessPages", + revokeAccess: "wallet.revokeAccess", + transactionStatus: "wallet.transactionStatus", +} as const + +export type WalletOperation = typeof WalletOperation[keyof typeof WalletOperation] + +export const IndexerOperation = { + getTokenBalances: "indexer.getTokenBalances", + getNativeTokenBalance: "indexer.getNativeTokenBalance", +} as const + +export type IndexerOperation = typeof IndexerOperation[keyof typeof IndexerOperation] + +export type OmsSdkOperation = WalletOperation | IndexerOperation diff --git a/tests/walletErrors.test.ts b/tests/walletErrors.test.ts index 9a85076..0c51e3e 100644 --- a/tests/walletErrors.test.ts +++ b/tests/walletErrors.test.ts @@ -90,6 +90,7 @@ describe("WalletClient errors", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); + seedEmailAuthAttempt(wallet); await expect(wallet.completeEmailAuth({code: "123456"})).rejects.toMatchObject({ code: "OMS_AUTH_COMMITMENT_CONSUMED", @@ -97,9 +98,21 @@ describe("WalletClient errors", () => { status: 400, retryable: false, }); + expect(activeEmailAuthAttempt(wallet)).toBeUndefined(); }); }); +function seedEmailAuthAttempt(wallet: WalletClient): void { + (wallet as any).activeEmailAuthAttempt = { + verifier: "verifier-1", + challenge: "challenge-1", + }; +} + +function activeEmailAuthAttempt(wallet: WalletClient): unknown { + return (wallet as any).activeEmailAuthAttempt; +} + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, diff --git a/tests/walletSession.test.ts b/tests/walletSession.test.ts index 57fe266..ad9ffca 100644 --- a/tests/walletSession.test.ts +++ b/tests/walletSession.test.ts @@ -37,6 +37,18 @@ afterEach(() => { vi.unstubAllGlobals(); }); +function seedEmailAuthAttempt( + wallet: WalletClient, + verifier = "verifier-1", + challenge = "challenge-1", +): void { + (wallet as any).activeEmailAuthAttempt = {verifier, challenge}; +} + +function activeEmailAuthAttempt(wallet: WalletClient): unknown { + return (wallet as any).activeEmailAuthAttempt; +} + describe("WalletClient session storage", () => { it("falls back to memory storage when localStorage is unavailable", () => { vi.stubGlobal("localStorage", undefined); @@ -98,13 +110,11 @@ describe("WalletClient session storage", () => { loginType: "email", sessionEmail: "user@example.com", }); - (wallet as any).verifier = "old-verifier"; - (wallet as any).challenge = "old-challenge"; + seedEmailAuthAttempt(wallet, "old-verifier", "old-challenge"); await wallet.signOut(); expect(wallet.walletAddress).toBeUndefined(); - expect((wallet as any).verifier).toBe(""); - expect((wallet as any).challenge).toBe(""); + expect(activeEmailAuthAttempt(wallet)).toBeUndefined(); expect(wallet.session).toEqual({ walletAddress: undefined, expiresAt: undefined, @@ -234,8 +244,7 @@ describe("WalletClient session storage", () => { storage, credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const result = await wallet.completeEmailAuth({code: "123456"}); @@ -264,6 +273,157 @@ describe("WalletClient session storage", () => { expect(storage.get(Constants.sessionEmailStorageKey)).toBe("user@example.com"); }); + it("deduplicates concurrent email auth completion for the same auth attempt", async () => { + const completeAuth = deferred(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return completeAuth.promise; + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-id", WalletType.Ethereum, "11")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer").mockResolvedValue("answer"); + + const first = wallet.completeEmailAuth({code: "123456"}); + const second = wallet.completeEmailAuth({code: "123456"}); + await waitForRequest(fetchMock, "/CompleteAuth"); + + expect(requestCount(fetchMock, "/CompleteAuth")).toBe(1); + completeAuth.resolve(jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-id", WalletType.Ethereum, "11")], + credential: testCredential(), + })); + + await expect(first).resolves.toMatchObject({ + wallet: {id: "wallet-id"}, + }); + await expect(second).resolves.toMatchObject({ + wallet: {id: "wallet-id"}, + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(1); + }); + + it("does not persist stale automatic email auth after a newer email auth starts", async () => { + const completeAuth = deferred(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return completeAuth.promise; + } + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-2", + challenge: "challenge-2", + }); + } + + if (url.endsWith("/UseWallet")) { + throw new Error("UseWallet should not be called for stale auth"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer").mockResolvedValue("old-answer"); + + const staleCompletion = wallet.completeEmailAuth({code: "111111"}); + await waitForRequest(fetchMock, "/CompleteAuth"); + await wallet.startEmailAuth({email: "new@example.com"}); + completeAuth.resolve(jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "old@example.com", + wallets: [testWallet("wallet-old", WalletType.Ethereum, "44")], + credential: testCredential(), + })); + + await expect(staleCompletion).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.completeEmailAuth", + message: "Email auth attempt is no longer active", + }); + expect(wallet.walletAddress).toBeUndefined(); + expect(activeEmailAuthAttempt(wallet)).toMatchObject({ + verifier: "verifier-2", + challenge: "challenge-2", + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(0); + }); + + it("allows email auth completion retry after a failed completion request", async () => { + let completeAuthCalls = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + completeAuthCalls += 1; + if (completeAuthCalls === 1) { + throw new Error("temporary CompleteAuth failure"); + } + + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-id", WalletType.Ethereum, "11")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-id", WalletType.Ethereum, "11")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer").mockResolvedValue("answer"); + + await expect(wallet.completeEmailAuth({code: "123456"})).rejects.toMatchObject({ + operation: "wallet.completeEmailAuth", + }); + await expect(wallet.completeEmailAuth({code: "123456"})).resolves.toMatchObject({ + wallet: {id: "wallet-id"}, + }); + expect(requestCount(fetchMock, "/CompleteAuth")).toBe(2); + }); + it("loads remaining auth wallet pages before creating a wallet", async () => { const requestedType = "future-wallet" as WalletType; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -307,8 +467,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const result = await wallet.completeEmailAuth({code: "123456", walletType: requestedType}); @@ -367,8 +526,7 @@ describe("WalletClient session storage", () => { storage, credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const result = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); @@ -436,8 +594,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const selection = await wallet.completeEmailAuth({ code: "123456", @@ -479,8 +636,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); vi.spyOn(RequestUtils, "hashEmailAuthAnswer") .mockResolvedValueOnce("first") .mockResolvedValueOnce("second"); @@ -489,6 +645,7 @@ describe("WalletClient session storage", () => { code: "111111", walletSelection: "manual", }); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); await wallet.completeEmailAuth({ code: "222222", walletSelection: "manual", @@ -534,8 +691,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); vi.spyOn(RequestUtils, "hashEmailAuthAnswer") .mockResolvedValueOnce("first") .mockResolvedValueOnce("second"); @@ -544,6 +700,7 @@ describe("WalletClient session storage", () => { code: "111111", walletSelection: "manual", }); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); await wallet.completeEmailAuth({code: "222222"}); const requestCountBeforeStaleSelection = fetchMock.mock.calls.length; @@ -586,8 +743,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); await selection.selectWallet({walletId: "wallet-1"}); @@ -632,8 +788,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); const firstCreate = selection.createAndSelectWallet({reference: "fresh"}); @@ -684,8 +839,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); await expect(selection.createAndSelectWallet({reference: "fresh"})).rejects.toMatchObject({ @@ -736,14 +890,14 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); vi.spyOn(RequestUtils, "hashEmailAuthAnswer") .mockResolvedValueOnce("first") .mockResolvedValueOnce("second"); const selection = await wallet.completeEmailAuth({code: "111111", walletSelection: "manual"}); const staleCreate = selection.createAndSelectWallet({reference: "stale"}); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); await wallet.completeEmailAuth({code: "222222"}); resolveCreate(jsonResponse({wallet: testWallet("wallet-stale", WalletType.Ethereum, "44", "stale")})); @@ -791,8 +945,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); vi.spyOn(RequestUtils, "hashEmailAuthAnswer") .mockResolvedValueOnce("first") .mockResolvedValueOnce("second"); @@ -800,6 +953,7 @@ describe("WalletClient session storage", () => { const staleCreate = wallet.createWallet({reference: "stale"}); await waitForRequest(fetchMock, "/CreateWallet"); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); await wallet.completeEmailAuth({code: "222222"}); resolveCreate(jsonResponse({wallet: testWallet("wallet-stale", WalletType.Ethereum, "44", "stale")})); @@ -842,8 +996,7 @@ describe("WalletClient session storage", () => { storage, credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); const staleUse = wallet.useWallet({walletId: "wallet-1"}); @@ -914,8 +1067,7 @@ describe("WalletClient session storage", () => { }); expect(fetchMock).not.toHaveBeenCalled(); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); await expect(wallet.listWallets()).resolves.toEqual([testWallet("wallet-1", WalletType.Ethereum, "11")]); @@ -1002,6 +1154,16 @@ function requestCount(fetchMock: ReturnType, endpoint: string): nu return fetchMock.mock.calls.filter(([input]) => input.toString().endsWith(endpoint)).length; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return {promise, resolve, reject}; +} + async function waitForRequest(fetchMock: ReturnType, endpoint: string): Promise { for (let attempt = 0; attempt < 10; attempt += 1) { if (requestCount(fetchMock, endpoint) > 0) { From 6be9126ab3edbd3491fd5fa558128b04dcccf452 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 00:10:55 +0300 Subject: [PATCH 05/10] Trim root public exports --- src/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 36e6b60..cbd791f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,6 @@ export { type OmsEnvironment, } from './omsEnvironment.js' export { - defaultGoogleClientId, - defaultRelayRedirectUri, googleOidcProvider, type GoogleOidcProviderParams, } from './oidc.js' @@ -35,7 +33,6 @@ export { export { TransactionMode, TransactionStatus, - SigningAlgorithm, WalletType, type TransactionStatusResponse, } from './generated/waas.gen.js' @@ -49,7 +46,6 @@ export { OmsValidationError, isOmsSdkError, type OmsSdkErrorCode, - type OmsSdkErrorParams, } from './errors.js' export type { CompleteEmailAuthParams, From 0096093e4bf1aaa8063dea32dadda417753283ae Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 12:07:16 +0300 Subject: [PATCH 06/10] Cover wallet switching after activation --- tests/walletSession.test.ts | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/walletSession.test.ts b/tests/walletSession.test.ts index ad9ffca..2b23276 100644 --- a/tests/walletSession.test.ts +++ b/tests/walletSession.test.ts @@ -1079,6 +1079,58 @@ describe("WalletClient session storage", () => { }); }); + it("can switch to an existing wallet from an active session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/UseWallet")) { + expect(body).toEqual({walletId: "wallet-2"}); + return jsonResponse({wallet: testWallet("wallet-2", WalletType.Ethereum, "22")}); + } + + if (url.endsWith("/SignMessage")) { + expect(body).toEqual({ + network: Networks.polygon.id.toString(), + walletId: "wallet-2", + message: "hello", + }); + return jsonResponse({signature: "0xsigned"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const storage = new MemoryStorageManager(); + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage, + credentialSigner: new MockSigner(), + }); + (wallet as any).persistSession("wallet-1", "0x1111111111111111111111111111111111111111", { + expiresAt: "2026-01-01T00:00:00Z", + loginType: "email", + sessionEmail: "user@example.com", + }); + + const result = await wallet.useWallet({walletId: "wallet-2"}); + + expect(result).toEqual({ + walletAddress: "0x2222222222222222222222222222222222222222", + wallet: testWallet("wallet-2", WalletType.Ethereum, "22"), + }); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + expect(storage.get(Constants.walletIdStorageKey)).toBe("wallet-2"); + expect(storage.get(Constants.walletAddressStorageKey)).toBe("0x2222222222222222222222222222222222222222"); + expect(requestCount(fetchMock, "/UseWallet")).toBe(1); + + await expect(wallet.signMessage({network: Networks.polygon, message: "hello"})).resolves.toBe("0xsigned"); + expect(requestCount(fetchMock, "/SignMessage")).toBe(1); + }); + it("can explicitly create and activate a new wallet from an active session", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); From 18f08e4980dc7ef53513cee16c7937d6dea4894c Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 12:30:33 +0300 Subject: [PATCH 07/10] Require active session for public wallet activation --- API.md | 4 +- src/clients/walletClient.ts | 51 +++++------ tests/walletSession.test.ts | 165 +++++++++++++----------------------- 3 files changed, 83 insertions(+), 137 deletions(-) diff --git a/API.md b/API.md index 1b06b05..3061a03 100644 --- a/API.md +++ b/API.md @@ -345,7 +345,7 @@ Returns all wallets available to an authenticated active or pending wallet-selec useWallet(params: { walletId: string }): Promise<{ walletAddress: Address; wallet: OmsWallet }> ``` -Activates an existing wallet by server-side wallet id and persists it as the current wallet session. Manual auth flows should prefer [`PendingWalletSelection.selectWallet`](#pendingwalletselection). +Activates an existing wallet by server-side wallet id and persists it as the current wallet session. Requires an active wallet session; pending manual auth flows must use [`PendingWalletSelection.selectWallet`](#pendingwalletselection). --- @@ -355,7 +355,7 @@ Activates an existing wallet by server-side wallet id and persists it as the cur createWallet(params?: { type?: WalletType; reference?: string }): Promise<{ walletAddress: Address; wallet: OmsWallet }> ``` -Creates a new wallet, activates it, and persists it as the current wallet session. `type` defaults to `WalletType.Ethereum`. Manual auth flows should prefer [`PendingWalletSelection.createAndSelectWallet`](#pendingwalletselection), which uses the auth-requested wallet type automatically. +Creates a new wallet, activates it, and persists it as the current wallet session. Requires an active wallet session. `type` defaults to `WalletType.Ethereum`. Pending manual auth flows must use [`PendingWalletSelection.createAndSelectWallet`](#pendingwalletselection), which uses the auth-requested wallet type automatically. --- diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index 599a3fd..e8c86df 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -231,9 +231,10 @@ interface ActivePendingWalletSelection { metadata: WalletSessionMetadata; } -type WalletActivationContext = - | {kind: "pending"; session: ActivePendingWalletSelection; metadata: WalletSessionMetadata} - | {kind: "active"; metadata?: WalletSessionMetadata} +interface ActiveWalletActivationContext { + walletId: string; + metadata?: WalletSessionMetadata; +} interface EmailAuthCompletionParams { code: string; @@ -635,18 +636,18 @@ export class WalletClient { async useWallet(params: {walletId: string}): Promise { return this.runOperation(WalletOperation.useWallet, async () => { - const context = await this.walletActivationContext(WalletOperation.useWallet) + const context = await this.activeWalletActivationContext(WalletOperation.useWallet) const wallet = await this.requestUseWallet(params.walletId) - await this.requireWalletActivationContextStillActive(context, WalletOperation.useWallet) + await this.requireActiveWalletActivationContextStillActive(context, WalletOperation.useWallet) return this.activateWallet(wallet, context.metadata) }) } async createWallet(params: {type?: WalletType; reference?: string} = {}): Promise { return this.runOperation(WalletOperation.createWallet, async () => { - const context = await this.walletActivationContext(WalletOperation.createWallet) + const context = await this.activeWalletActivationContext(WalletOperation.createWallet) const wallet = await this.requestCreateWallet(params.type ?? WalletType.Ethereum, params.reference) - await this.requireWalletActivationContextStillActive(context, WalletOperation.createWallet) + await this.requireActiveWalletActivationContextStillActive(context, WalletOperation.createWallet) return this.activateWallet(wallet, context.metadata) }) } @@ -1027,34 +1028,24 @@ export class WalletClient { } } - private async walletActivationContext(operation: WalletOperation): Promise { - const pendingSelection = this.activePendingWalletSelection - if (pendingSelection) { - await this.requireActivePendingWalletSelection(pendingSelection, operation) - return { - kind: "pending", - session: pendingSelection, - metadata: pendingSelection.metadata, - } - } - - if (!this.walletId) { - throw new OmsSessionError({ - operation, - message: 'No authenticated wallet session', - }) - } - + private async activeWalletActivationContext(operation: WalletOperation): Promise { await this.requireActiveSession(operation) - return {kind: "active", metadata: this.currentSessionMetadata()} + return { + walletId: this.walletId, + metadata: this.currentSessionMetadata(), + } } - private async requireWalletActivationContextStillActive( - context: WalletActivationContext, + private async requireActiveWalletActivationContextStillActive( + context: ActiveWalletActivationContext, operation: WalletOperation, ): Promise { - if (context.kind === "pending") { - await this.requireActivePendingWalletSelection(context.session, operation) + await this.requireActiveSession(operation) + if (this.walletId !== context.walletId) { + throw new OmsSessionError({ + operation, + message: "Active wallet session changed", + }) } } diff --git a/tests/walletSession.test.ts b/tests/walletSession.test.ts index 2b23276..53a0f93 100644 --- a/tests/walletSession.test.ts +++ b/tests/walletSession.test.ts @@ -907,31 +907,27 @@ describe("WalletClient session storage", () => { expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); }); - it("public pending create invalidated while in flight does not persist the stale result", async () => { - let resolveCreate!: (response: Response) => void; - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + it("public wallet activation methods reject while manual selection is pending", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); - const body = JSON.parse(init?.body as string); if (url.endsWith("/CompleteAuth")) { return jsonResponse({ identity: {type: "email", sub: "user-1"}, - email: body.answer === "first" ? "first@example.com" : "second@example.com", - wallets: body.answer === "first" - ? [] - : [testWallet("wallet-second", WalletType.Ethereum, "22")], + email: "user@example.com", + wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], credential: testCredential(), }); } - if (url.endsWith("/CreateWallet")) { - return new Promise(resolve => { - resolveCreate = resolve; - }); + if (url.endsWith("/ListWallets")) { + return jsonResponse({wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], page: {}}); } - if (url.endsWith("/UseWallet")) { - return jsonResponse({wallet: testWallet("wallet-second", WalletType.Ethereum, "22")}); + if (url.endsWith("/UseWallet") || url.endsWith("/CreateWallet")) { + throw new Error( + "Public activation methods should not be used while manual wallet selection is pending; complete selection through PendingWalletSelection", + ); } throw new Error(`Unexpected request: ${url}`); @@ -946,46 +942,26 @@ describe("WalletClient session storage", () => { credentialSigner: new MockSigner(), }); seedEmailAuthAttempt(wallet); - vi.spyOn(RequestUtils, "hashEmailAuthAnswer") - .mockResolvedValueOnce("first") - .mockResolvedValueOnce("second"); await wallet.completeEmailAuth({code: "111111", walletSelection: "manual"}); - const staleCreate = wallet.createWallet({reference: "stale"}); - await waitForRequest(fetchMock, "/CreateWallet"); - seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); - await wallet.completeEmailAuth({code: "222222"}); - resolveCreate(jsonResponse({wallet: testWallet("wallet-stale", WalletType.Ethereum, "44", "stale")})); - - await expect(staleCreate).rejects.toMatchObject({ - code: "OMS_WALLET_SELECTION_STALE", + await expect(wallet.listWallets()).resolves.toEqual([testWallet("wallet-1", WalletType.Ethereum, "11")]); + await expect(wallet.useWallet({walletId: "wallet-1"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.useWallet", + message: "No active wallet session", + }); + await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", operation: "wallet.createWallet", + message: "No active wallet session", }); - expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + expect(requestCount(fetchMock, "/UseWallet")).toBe(0); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(0); }); - it("public pending use invalidated while in flight by sign-out does not persist the stale result", async () => { - const storage = new MemoryStorageManager(); - let resolveUse!: (response: Response) => void; + it("public wallet activation methods require an active session", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = input.toString(); - - if (url.endsWith("/CompleteAuth")) { - return jsonResponse({ - identity: {type: "email", sub: "user-1"}, - email: "user@example.com", - wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], - credential: testCredential(), - }); - } - - if (url.endsWith("/UseWallet")) { - return new Promise(resolve => { - resolveUse = resolve; - }); - } - - throw new Error(`Unexpected request: ${url}`); + throw new Error(`Unexpected request: ${input.toString()}`); }); vi.stubGlobal("fetch", fetchMock); @@ -993,52 +969,37 @@ describe("WalletClient session storage", () => { publicApiKey: "public-api-key", projectId: "project-id", environment: testEnvironment(), - storage, + storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - seedEmailAuthAttempt(wallet); - await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); - const staleUse = wallet.useWallet({walletId: "wallet-1"}); - await waitForRequest(fetchMock, "/UseWallet"); - await wallet.signOut(); - resolveUse(jsonResponse({wallet: testWallet("wallet-1", WalletType.Ethereum, "11")})); - - await expect(staleUse).rejects.toMatchObject({ - code: "OMS_WALLET_SELECTION_STALE", - operation: "wallet.useWallet", + await expect(wallet.listWallets()).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No authenticated wallet session", }); - expect(wallet.walletAddress).toBeUndefined(); - expect(storage.get(Constants.walletIdStorageKey)).toBeNull(); - expect(storage.get(Constants.walletAddressStorageKey)).toBeNull(); + await expect(wallet.useWallet({walletId: "wallet-1"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No active wallet session", + }); + await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No active wallet session", + }); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("public wallet activation methods require an authenticated active or pending session", async () => { + it("active wallet switch invalidated while in flight by sign-out does not persist the stale result", async () => { + const storage = new MemoryStorageManager(); + let resolveUse!: (response: Response) => void; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); - const body = init?.body ? JSON.parse(init.body as string) : {}; - - if (url.endsWith("/CompleteAuth")) { - return jsonResponse({ - identity: {type: "email", sub: "user-1"}, - email: "user@example.com", - wallets: [], - credential: testCredential(), - }); - } - - if (url.endsWith("/ListWallets")) { - return jsonResponse({wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], page: {}}); - } + const body = JSON.parse(init?.body as string); if (url.endsWith("/UseWallet")) { - expect(body).toEqual({walletId: "wallet-1"}); - return jsonResponse({wallet: testWallet("wallet-1", WalletType.Ethereum, "11")}); - } - - if (url.endsWith("/CreateWallet")) { - expect(body).toEqual({type: WalletType.Ethereum, reference: "fresh"}); - return jsonResponse({wallet: testWallet("wallet-new", WalletType.Ethereum, "33", "fresh")}); + expect(body).toEqual({walletId: "wallet-2"}); + return new Promise(resolve => { + resolveUse = resolve; + }); } throw new Error(`Unexpected request: ${url}`); @@ -1049,34 +1010,28 @@ describe("WalletClient session storage", () => { publicApiKey: "public-api-key", projectId: "project-id", environment: testEnvironment(), - storage: new MemoryStorageManager(), + storage, credentialSigner: new MockSigner(), }); - - await expect(wallet.listWallets()).rejects.toMatchObject({ - code: "OMS_SESSION_MISSING", - message: "No authenticated wallet session", - }); - await expect(wallet.useWallet({walletId: "wallet-1"})).rejects.toMatchObject({ - code: "OMS_SESSION_MISSING", - message: "No authenticated wallet session", - }); - await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).rejects.toMatchObject({ - code: "OMS_SESSION_MISSING", - message: "No authenticated wallet session", + (wallet as any).persistSession("wallet-1", "0x1111111111111111111111111111111111111111", { + expiresAt: "2026-01-01T00:00:00Z", + loginType: "email", + sessionEmail: "user@example.com", }); - expect(fetchMock).not.toHaveBeenCalled(); - seedEmailAuthAttempt(wallet); - await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + const staleUse = wallet.useWallet({walletId: "wallet-2"}); + await waitForRequest(fetchMock, "/UseWallet"); + await wallet.signOut(); + resolveUse(jsonResponse({wallet: testWallet("wallet-2", WalletType.Ethereum, "22")})); - await expect(wallet.listWallets()).resolves.toEqual([testWallet("wallet-1", WalletType.Ethereum, "11")]); - await expect(wallet.useWallet({walletId: "wallet-1"})).resolves.toMatchObject({ - wallet: {id: "wallet-1"}, - }); - await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).resolves.toMatchObject({ - wallet: {id: "wallet-new"}, + await expect(staleUse).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.useWallet", + message: "No active wallet session", }); + expect(wallet.walletAddress).toBeUndefined(); + expect(storage.get(Constants.walletIdStorageKey)).toBeNull(); + expect(storage.get(Constants.walletAddressStorageKey)).toBeNull(); }); it("can switch to an existing wallet from an active session", async () => { From a36e8577d5c2e9538744161674b5ef7c175e4e8a Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 13:26:01 +0300 Subject: [PATCH 08/10] Expose wallet ID token retrieval --- API.md | 23 +++++++++++++++++++++++ README.md | 2 ++ examples/react/src/main.tsx | 25 +++++++++++++++++++++++++ examples/react/src/styles.css | 26 ++++++++++++++++++++++++++ src/clients/walletClient.ts | 19 +++++++++++++++++++ src/index.ts | 1 + src/operations.ts | 1 + tests/walletSigning.test.ts | 31 +++++++++++++++++++++++++++++++ type-tests/oidcProviderTypes.ts | 3 +++ 9 files changed, 131 insertions(+) diff --git a/API.md b/API.md index 3061a03..81582dd 100644 --- a/API.md +++ b/API.md @@ -17,6 +17,7 @@ - [listWallets](#listwallets) - [useWallet](#usewallet) - [createWallet](#createwallet) + - [getIdToken](#getidtoken) - [signMessage](#signmessage) - [signTypedData](#signtypeddata) - [isValidMessageSignature](#isvalidmessagesignature) @@ -359,6 +360,28 @@ Creates a new wallet, activates it, and persists it as the current wallet sessio --- +### getIdToken + +```typescript +getIdToken(params?: { + ttlSeconds?: number + customClaims?: Record +}): Promise +``` + +Requests an ID token for the active wallet session. The SDK uses the active wallet id automatically. + +**Parameters** + +| Name | Type | Description | +|---|---|---| +| `ttlSeconds` | `number` | Optional token lifetime in seconds. | +| `customClaims` | `Record` | Optional custom claims to include in the token. | + +**Returns** `Promise` — the issued ID token. + +--- + ### signMessage ```typescript diff --git a/README.md b/README.md index 33c6d89..8f74e62 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ const walletAddress = oms.wallet.walletAddress const { expiresAt, loginType, sessionEmail } = oms.wallet.session ``` +Use `oms.wallet.getIdToken({ ttlSeconds, customClaims })` to request an ID token for the active wallet session. + Pending email OTP and OIDC redirect state are not exposed through `session`; use the auth method results to drive pending UI. To end the session, call: diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index b547590..6a94e15 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -43,6 +43,7 @@ function App() { const [transactionValue, setTransactionValue] = useState('0') const [walletAddress, setWalletAddress] = useState('') const [lastSignature, setLastSignature] = useState('') + const [lastIdToken, setLastIdToken] = useState('') const [lastTransactionHash, setLastTransactionHash] = useState('') const [lastTransactionExplorerUrl, setLastTransactionExplorerUrl] = useState('') const [feeOptions, setFeeOptions] = useState([]) @@ -85,6 +86,7 @@ function App() { feeSelection.current = null setFeeOptions([]) setLastSignature('') + setLastIdToken('') setLastTransactionHash('') setLastTransactionExplorerUrl('') if (step === 'wallet') { @@ -172,6 +174,7 @@ function App() { } setPendingWalletSelection(null) + setLastIdToken('') setWalletAddress(result.walletAddress) setStep('wallet') setWalletStatus(status) @@ -236,6 +239,17 @@ function App() { }) } + async function getIdToken() { + await run('Getting ID token...', setWalletStatus, async () => { + const idToken = await oms.wallet.getIdToken({ + ttlSeconds: 300, + customClaims: { demo: true }, + }) + setLastIdToken(idToken) + setWalletStatus('ID token issued.') + }) + } + function waitForFeeOptionSelection(options: FeeOptionWithBalance[]): Promise { setFeeOptions(options) setWalletStatus('Choose a fee token to continue.') @@ -264,6 +278,7 @@ function App() { setPendingWalletSelection(null) setWalletAddress('') setLastSignature('') + setLastIdToken('') setLastTransactionHash('') setLastTransactionExplorerUrl('') setFeeOptions([]) @@ -526,6 +541,16 @@ function App() { )} +
+ Other operations +
+ + {lastIdToken && {lastIdToken}} +
+
+ diff --git a/examples/react/src/styles.css b/examples/react/src/styles.css index ea67551..2e959a8 100644 --- a/examples/react/src/styles.css +++ b/examples/react/src/styles.css @@ -204,6 +204,32 @@ button:disabled { line-height: 1.25; } +.collapsible-tool { + gap: 0; + padding: 0; + overflow: hidden; +} + +.collapsible-tool summary { + min-height: 52px; + padding: 16px; + color: #1f2937; + font-size: 17px; + font-weight: 800; + line-height: 1.25; + cursor: pointer; +} + +.collapsible-tool summary::marker { + color: #667085; +} + +.collapsible-content { + display: grid; + gap: 12px; + padding: 0 16px 16px; +} + .network-tool { gap: 8px; } diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index e8c86df..d2b0f37 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -44,6 +44,7 @@ import { RevokeAccessRequest, SignMessageRequest, SignTypedDataRequest, + GetIDTokenRequest, IsValidMessageSignatureRequest, IsValidTypedDataSignatureRequest, PrepareEthereumTransactionRequest, @@ -171,6 +172,11 @@ export interface SignTypedDataParams { typedData: any } +export interface GetIdTokenParams { + ttlSeconds?: number + customClaims?: Record +} + export interface IsValidMessageSignatureParams { network?: Network walletAddress?: Address @@ -652,6 +658,19 @@ export class WalletClient { }) } + async getIdToken(params: GetIdTokenParams = {}): Promise { + return this.runOperation(WalletOperation.getIdToken, async () => { + await this.requireActiveSession(WalletOperation.getIdToken) + const request: GetIDTokenRequest = { + walletId: this.walletId, + ttlSeconds: params.ttlSeconds, + customClaims: params.customClaims, + } + const response = await this.client.getIDToken(request) + return response.idToken + }) + } + private async clearSession(): Promise { this.storage.delete(Constants.walletIdStorageKey) this.storage.delete(Constants.walletAddressStorageKey) diff --git a/src/index.ts b/src/index.ts index cbd791f..0877c34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ export type { CompleteEmailAuthResult, CompleteOidcRedirectAuthParams, CompleteOidcRedirectAuthResult, + GetIdTokenParams, IsValidMessageSignatureParams, IsValidTypedDataSignatureParams, OMSClientSessionLoginType, diff --git a/src/operations.ts b/src/operations.ts index a8168b7..905e146 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -10,6 +10,7 @@ export const WalletOperation = { listWallets: "wallet.listWallets", useWallet: "wallet.useWallet", createWallet: "wallet.createWallet", + getIdToken: "wallet.getIdToken", signMessage: "wallet.signMessage", signTypedData: "wallet.signTypedData", isValidMessageSignature: "wallet.isValidMessageSignature", diff --git a/tests/walletSigning.test.ts b/tests/walletSigning.test.ts index da75979..822da27 100644 --- a/tests/walletSigning.test.ts +++ b/tests/walletSigning.test.ts @@ -31,6 +31,37 @@ afterEach(() => { }); describe("WalletClient signing", () => { + it("gets an ID token for the active wallet session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + const headers = init?.headers as Record; + + expect(headers["X-Access-Key"]).toBe("public-api-key"); + expect(headers["OMS-Wallet-Signature"]).toContain('alg="ecdsa-p256-sha256"'); + expect(headers.Authorization).toBeUndefined(); + + if (url.endsWith("/GetIDToken")) { + expect(body).toEqual({ + walletId: "wallet-id", + ttlSeconds: 300, + customClaims: {role: "admin"}, + }); + return jsonResponse({idToken: "jwt-token"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession("0x1111111111111111111111111111111111111111"); + + await expect(wallet.getIdToken({ + ttlSeconds: 300, + customClaims: {role: "admin"}, + })).resolves.toBe("jwt-token"); + }); + it("signs typed data through the generated wallet client", async () => { const typedData = { domain: {name: "Test", chainId: 137n}, diff --git a/type-tests/oidcProviderTypes.ts b/type-tests/oidcProviderTypes.ts index a2d1779..e6b8e95 100644 --- a/type-tests/oidcProviderTypes.ts +++ b/type-tests/oidcProviderTypes.ts @@ -6,6 +6,7 @@ import { findNetworkByName, supportedNetworks, type Network, + type GetIdTokenParams, type OMSClientSessionLoginType, type OMSClientSessionState, type TokenBalance, @@ -77,6 +78,8 @@ new OMSClient({projectAccessKey: "public-api-key", projectId: "project-id"}); // @ts-expect-error old authorizationScope initializer name is not supported. new OMSClient({publicApiKey: "public-api-key", authorizationScope: "project-id"}); const session: OMSClientSessionState = defaultClient.wallet.session; +const idTokenParams: GetIdTokenParams = {ttlSeconds: 300, customClaims: {role: "admin"}}; +const idToken: Promise = defaultClient.wallet.getIdToken(idTokenParams); const loginType: OMSClientSessionLoginType | undefined = defaultClient.wallet.session.loginType; const polygonNetwork: Network = Networks.polygon; const amoyNetwork: Network | undefined = findNetworkById(80002); From 38357336ac96e35a7d41e8dd4ee30a65b29ad90d Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 13:43:46 +0300 Subject: [PATCH 09/10] Simplify example getIdToken call --- examples/react/src/main.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 6a94e15..823653c 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -241,10 +241,7 @@ function App() { async function getIdToken() { await run('Getting ID token...', setWalletStatus, async () => { - const idToken = await oms.wallet.getIdToken({ - ttlSeconds: 300, - customClaims: { demo: true }, - }) + const idToken = await oms.wallet.getIdToken() setLastIdToken(idToken) setWalletStatus('ID token issued.') }) From 484389619e2548479df1c48c7a55576496b0d3fe Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 21 May 2026 15:36:24 +0300 Subject: [PATCH 10/10] Add network display names --- API.md | 40 +++++++++++++++++---------------- README.md | 38 +++++++++++++++---------------- examples/react/src/main.tsx | 10 +-------- src/networks.ts | 17 ++++++++++++++ tests/networks.test.ts | 20 +++++++++++++++++ type-tests/oidcProviderTypes.ts | 1 + 6 files changed, 79 insertions(+), 47 deletions(-) diff --git a/API.md b/API.md index 81582dd..7a1578e 100644 --- a/API.md +++ b/API.md @@ -116,7 +116,7 @@ new OMSClient(params: { oms.supportedNetworks: readonly Network[] ``` -Returns the supported network registry. Each entry has `id`, `name`, `nativeTokenSymbol`, and `explorerUrl`. +Returns the supported network registry. Each entry has `id`, `name`, `nativeTokenSymbol`, `explorerUrl`, and `displayName`. ## WalletClient @@ -789,34 +789,36 @@ interface Network { readonly name: string readonly nativeTokenSymbol: string readonly explorerUrl: string + readonly displayName: string } ``` A supported OMS network entry. The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)`. +`name` is the registry/routing slug for indexer URLs, while `displayName` is the user-facing label. ```typescript findNetworkById(id: number): Network | undefined findNetworkByName(name: string): Network | undefined ``` -| Key | id | name | nativeTokenSymbol | explorerUrl | -|---|---:|---|---|---| -| `Networks.mainnet` | 1 | `mainnet` | `ETH` | `https://etherscan.io` | -| `Networks.sepolia` | 11155111 | `sepolia` | `ETH` | `https://sepolia.etherscan.io` | -| `Networks.polygon` | 137 | `polygon` | `POL` | `https://polygonscan.com` | -| `Networks.amoy` | 80002 | `amoy` | `POL` | `https://amoy.polygonscan.com` | -| `Networks.arbitrum` | 42161 | `arbitrum` | `ETH` | `https://arbiscan.io` | -| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | `ETH` | `https://sepolia.arbiscan.io` | -| `Networks.optimism` | 10 | `optimism` | `ETH` | `https://optimistic.etherscan.io` | -| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | `ETH` | `https://sepolia-optimism.etherscan.io` | -| `Networks.base` | 8453 | `base` | `ETH` | `https://basescan.org` | -| `Networks.baseSepolia` | 84532 | `base-sepolia` | `ETH` | `https://sepolia.basescan.org` | -| `Networks.bsc` | 56 | `bsc` | `BNB` | `https://bscscan.com` | -| `Networks.bscTestnet` | 97 | `bsc-testnet` | `BNB` | `https://testnet.bscscan.com` | -| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | `ETH` | `https://nova.arbiscan.io` | -| `Networks.avalanche` | 43114 | `avalanche` | `AVAX` | `https://subnets.avax.network/c-chain` | -| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | `AVAX` | `https://subnets-test.avax.network/c-chain` | -| `Networks.katana` | 747474 | `katana` | `ETH` | `https://katanascan.com` | +| Key | id | name | displayName | nativeTokenSymbol | explorerUrl | +|---|---:|---|---|---|---| +| `Networks.mainnet` | 1 | `mainnet` | `Ethereum` | `ETH` | `https://etherscan.io` | +| `Networks.sepolia` | 11155111 | `sepolia` | `Sepolia` | `ETH` | `https://sepolia.etherscan.io` | +| `Networks.polygon` | 137 | `polygon` | `Polygon` | `POL` | `https://polygonscan.com` | +| `Networks.amoy` | 80002 | `amoy` | `Polygon Amoy` | `POL` | `https://amoy.polygonscan.com` | +| `Networks.arbitrum` | 42161 | `arbitrum` | `Arbitrum` | `ETH` | `https://arbiscan.io` | +| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | `Arbitrum Sepolia` | `ETH` | `https://sepolia.arbiscan.io` | +| `Networks.optimism` | 10 | `optimism` | `Optimism` | `ETH` | `https://optimistic.etherscan.io` | +| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | `Optimism Sepolia` | `ETH` | `https://sepolia-optimism.etherscan.io` | +| `Networks.base` | 8453 | `base` | `Base` | `ETH` | `https://basescan.org` | +| `Networks.baseSepolia` | 84532 | `base-sepolia` | `Base Sepolia` | `ETH` | `https://sepolia.basescan.org` | +| `Networks.bsc` | 56 | `bsc` | `BSC` | `BNB` | `https://bscscan.com` | +| `Networks.bscTestnet` | 97 | `bsc-testnet` | `BSC Testnet` | `BNB` | `https://testnet.bscscan.com` | +| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | `Arbitrum Nova` | `ETH` | `https://nova.arbiscan.io` | +| `Networks.avalanche` | 43114 | `avalanche` | `Avalanche` | `AVAX` | `https://subnets.avax.network/c-chain` | +| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | `Avalanche Testnet` | `AVAX` | `https://subnets-test.avax.network/c-chain` | +| `Networks.katana` | 747474 | `katana` | `Katana` | `ETH` | `https://katanascan.com` | ### OmsEnvironment diff --git a/README.md b/README.md index 8f74e62..69eeb3e 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ await oms.wallet.signOut() ## Networks -The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)` for the networks currently configured by OMS. Each network has `id`, `name`, `nativeTokenSymbol`, and `explorerUrl`. +The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)` for the networks currently configured by OMS. Each network has `id`, `name`, `nativeTokenSymbol`, `explorerUrl`, and `displayName`. `name` is the registry/routing slug, while `displayName` is the user-facing label. The `network` parameter on all transaction and signing methods accepts a `Network` from the SDK registry: @@ -211,24 +211,24 @@ console.log(supportedNetworks) console.log(findNetworkById(80002)) // Networks.amoy ``` -| Key | id | name | native token | explorerUrl | -|---|---:|---|---|---| -| `Networks.mainnet` | 1 | `mainnet` | ETH | `https://etherscan.io` | -| `Networks.sepolia` | 11155111 | `sepolia` | ETH | `https://sepolia.etherscan.io` | -| `Networks.polygon` | 137 | `polygon` | POL | `https://polygonscan.com` | -| `Networks.amoy` | 80002 | `amoy` | POL | `https://amoy.polygonscan.com` | -| `Networks.arbitrum` | 42161 | `arbitrum` | ETH | `https://arbiscan.io` | -| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | ETH | `https://sepolia.arbiscan.io` | -| `Networks.optimism` | 10 | `optimism` | ETH | `https://optimistic.etherscan.io` | -| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | ETH | `https://sepolia-optimism.etherscan.io` | -| `Networks.base` | 8453 | `base` | ETH | `https://basescan.org` | -| `Networks.baseSepolia` | 84532 | `base-sepolia` | ETH | `https://sepolia.basescan.org` | -| `Networks.bsc` | 56 | `bsc` | BNB | `https://bscscan.com` | -| `Networks.bscTestnet` | 97 | `bsc-testnet` | BNB | `https://testnet.bscscan.com` | -| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | ETH | `https://nova.arbiscan.io` | -| `Networks.avalanche` | 43114 | `avalanche` | AVAX | `https://subnets.avax.network/c-chain` | -| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | AVAX | `https://subnets-test.avax.network/c-chain` | -| `Networks.katana` | 747474 | `katana` | ETH | `https://katanascan.com` | +| Key | id | name | display name | native token | explorerUrl | +|---|---:|---|---|---|---| +| `Networks.mainnet` | 1 | `mainnet` | Ethereum | ETH | `https://etherscan.io` | +| `Networks.sepolia` | 11155111 | `sepolia` | Sepolia | ETH | `https://sepolia.etherscan.io` | +| `Networks.polygon` | 137 | `polygon` | Polygon | POL | `https://polygonscan.com` | +| `Networks.amoy` | 80002 | `amoy` | Polygon Amoy | POL | `https://amoy.polygonscan.com` | +| `Networks.arbitrum` | 42161 | `arbitrum` | Arbitrum | ETH | `https://arbiscan.io` | +| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | Arbitrum Sepolia | ETH | `https://sepolia.arbiscan.io` | +| `Networks.optimism` | 10 | `optimism` | Optimism | ETH | `https://optimistic.etherscan.io` | +| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | Optimism Sepolia | ETH | `https://sepolia-optimism.etherscan.io` | +| `Networks.base` | 8453 | `base` | Base | ETH | `https://basescan.org` | +| `Networks.baseSepolia` | 84532 | `base-sepolia` | Base Sepolia | ETH | `https://sepolia.basescan.org` | +| `Networks.bsc` | 56 | `bsc` | BSC | BNB | `https://bscscan.com` | +| `Networks.bscTestnet` | 97 | `bsc-testnet` | BSC Testnet | BNB | `https://testnet.bscscan.com` | +| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | Arbitrum Nova | ETH | `https://nova.arbiscan.io` | +| `Networks.avalanche` | 43114 | `avalanche` | Avalanche | AVAX | `https://subnets.avax.network/c-chain` | +| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | Avalanche Testnet | AVAX | `https://subnets-test.avax.network/c-chain` | +| `Networks.katana` | 747474 | `katana` | Katana | ETH | `https://katanascan.com` | ## Sending Transactions diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 823653c..b2bcb21 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -457,7 +457,7 @@ function App() { > {supportedNetworks.map(network => ( ))} @@ -570,14 +570,6 @@ function transactionExplorerUrl(network: Network, txnHash: string): string { return `${network.explorerUrl.replace(/\/+$/, '')}/tx/${txnHash}` } -function networkLabel(network: Network): string { - const label = network.name - .split('-') - .map(part => part.toUpperCase() === 'BSC' ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1)) - .join(' ') - return `${label} (${network.id})` -} - function formatLoginType(loginType: OMSClientSessionLoginType | undefined): string { switch (loginType) { case 'email': diff --git a/src/networks.ts b/src/networks.ts index 2471957..297d138 100644 --- a/src/networks.ts +++ b/src/networks.ts @@ -3,6 +3,7 @@ export interface Network { readonly name: string readonly nativeTokenSymbol: string readonly explorerUrl: string + readonly displayName: string } export const Networks = { @@ -11,96 +12,112 @@ export const Networks = { name: 'mainnet', nativeTokenSymbol: 'ETH', explorerUrl: 'https://etherscan.io', + displayName: 'Ethereum', }, sepolia: { id: 11155111, name: 'sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia.etherscan.io', + displayName: 'Sepolia', }, polygon: { id: 137, name: 'polygon', nativeTokenSymbol: 'POL', explorerUrl: 'https://polygonscan.com', + displayName: 'Polygon', }, amoy: { id: 80002, name: 'amoy', nativeTokenSymbol: 'POL', explorerUrl: 'https://amoy.polygonscan.com', + displayName: 'Polygon Amoy', }, arbitrum: { id: 42161, name: 'arbitrum', nativeTokenSymbol: 'ETH', explorerUrl: 'https://arbiscan.io', + displayName: 'Arbitrum', }, arbitrumSepolia: { id: 421614, name: 'arbitrum-sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia.arbiscan.io', + displayName: 'Arbitrum Sepolia', }, optimism: { id: 10, name: 'optimism', nativeTokenSymbol: 'ETH', explorerUrl: 'https://optimistic.etherscan.io', + displayName: 'Optimism', }, optimismSepolia: { id: 11155420, name: 'optimism-sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia-optimism.etherscan.io', + displayName: 'Optimism Sepolia', }, base: { id: 8453, name: 'base', nativeTokenSymbol: 'ETH', explorerUrl: 'https://basescan.org', + displayName: 'Base', }, baseSepolia: { id: 84532, name: 'base-sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia.basescan.org', + displayName: 'Base Sepolia', }, bsc: { id: 56, name: 'bsc', nativeTokenSymbol: 'BNB', explorerUrl: 'https://bscscan.com', + displayName: 'BSC', }, bscTestnet: { id: 97, name: 'bsc-testnet', nativeTokenSymbol: 'BNB', explorerUrl: 'https://testnet.bscscan.com', + displayName: 'BSC Testnet', }, arbitrumNova: { id: 42170, name: 'arbitrum-nova', nativeTokenSymbol: 'ETH', explorerUrl: 'https://nova.arbiscan.io', + displayName: 'Arbitrum Nova', }, avalanche: { id: 43114, name: 'avalanche', nativeTokenSymbol: 'AVAX', explorerUrl: 'https://subnets.avax.network/c-chain', + displayName: 'Avalanche', }, avalancheTestnet: { id: 43113, name: 'avalanche-testnet', nativeTokenSymbol: 'AVAX', explorerUrl: 'https://subnets-test.avax.network/c-chain', + displayName: 'Avalanche Testnet', }, katana: { id: 747474, name: 'katana', nativeTokenSymbol: 'ETH', explorerUrl: 'https://katanascan.com', + displayName: 'Katana', }, } as const satisfies Record; diff --git a/tests/networks.test.ts b/tests/networks.test.ts index 1fae63c..6b8f6a4 100644 --- a/tests/networks.test.ts +++ b/tests/networks.test.ts @@ -33,13 +33,33 @@ describe("Networks", () => { name: "katana", nativeTokenSymbol: "ETH", explorerUrl: "https://katanascan.com", + displayName: "Katana", }); + expect(supportedNetworks.map(network => network.displayName)).toEqual([ + "Ethereum", + "Sepolia", + "Polygon", + "Polygon Amoy", + "Arbitrum", + "Arbitrum Sepolia", + "Optimism", + "Optimism Sepolia", + "Base", + "Base Sepolia", + "BSC", + "BSC Testnet", + "Arbitrum Nova", + "Avalanche", + "Avalanche Testnet", + "Katana", + ]); }); it("looks up networks by id or name", () => { expect(findNetworkById(43113)).toBe(Networks.avalancheTestnet); expect(findNetworkById(421614)).toBe(Networks.arbitrumSepolia); expect(findNetworkByName("base-sepolia")).toBe(Networks.baseSepolia); + expect(findNetworkByName("Ethereum")).toBeUndefined(); }); it("is available from OMSClient", () => { diff --git a/type-tests/oidcProviderTypes.ts b/type-tests/oidcProviderTypes.ts index e6b8e95..6951636 100644 --- a/type-tests/oidcProviderTypes.ts +++ b/type-tests/oidcProviderTypes.ts @@ -82,6 +82,7 @@ const idTokenParams: GetIdTokenParams = {ttlSeconds: 300, customClaims: {role: " const idToken: Promise = defaultClient.wallet.getIdToken(idTokenParams); const loginType: OMSClientSessionLoginType | undefined = defaultClient.wallet.session.loginType; const polygonNetwork: Network = Networks.polygon; +const polygonDisplayName: string = Networks.polygon.displayName; const amoyNetwork: Network | undefined = findNetworkById(80002); const baseNetwork: Network | undefined = findNetworkByName("base"); const allNetworks: readonly Network[] = supportedNetworks;