From b3607c3e1074760374f3a3795c7eb83eab1895de Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 01:31:13 +0300 Subject: [PATCH 1/9] Add Apple OIDC defaults and redirect auth cleanup --- API.md | 98 +++++++-- README.md | 34 ++- src/clients/walletClient.ts | 133 +++++++---- src/index.ts | 3 + src/oidc.ts | 31 ++- src/omsEnvironment.ts | 7 +- src/utils/oidcRedirect.ts | 16 +- tests/oidcRedirectAuth.test.ts | 378 +++++++++++++++++++++++++++++++- type-tests/oidcProviderTypes.ts | 48 +++- 9 files changed, 653 insertions(+), 95 deletions(-) diff --git a/API.md b/API.md index 06d5ff6..c9086c5 100644 --- a/API.md +++ b/API.md @@ -84,6 +84,7 @@ - [TokenMetadata](#tokenmetadata) - [TokenMetadataAsset](#tokenmetadataasset) - [AbiArg](#abiarg) + - [AuthMode](#authmode) - [WalletType](#wallettype) --- @@ -117,7 +118,7 @@ new OMSClient(params: { | Name | Type | Required | Description | |---|---|---|---| | `publishableKey` | `string` | Yes | Your OMS publishable key. | -| `auth` | `OmsAuthConfig` | No | OIDC provider configuration. Defaults to the built-in Google provider. | +| `auth` | `OmsAuthConfig` | No | OIDC provider configuration. Defaults to the built-in Google and Apple providers. | | `storage` | `StorageManager` | No | Storage backend for wallet metadata. Defaults to `LocalStorageManager` when browser `localStorage` is available, otherwise `MemoryStorageManager`. | | `redirectAuthStorage` | `StorageManager` | No | Transient storage for OIDC redirect auth state. Defaults to `sessionStorage` when available. | | `credentialSigner` | `CredentialSigner` | No | Request credential signer. Defaults to a non-extractable WebCrypto P-256 signer (`ecdsa-p256-sha256`) where WebCrypto is available. | @@ -277,20 +278,26 @@ await selection.createAndSelectWallet({ reference: 'main' }) ```typescript startOidcRedirectAuth(params: { provider: string | OidcProviderConfig - redirectUri: string + redirectUri?: string walletType?: WalletType + walletSelection?: 'automatic' | 'manual' + sessionLifetimeSeconds?: number relayRedirectUri?: string authorizeParams?: Record loginHint?: string }): Promise<{ url: string; state: string; challenge: string }> ``` -Starts an OIDC authorization-code PKCE flow and returns the provider authorization URL. If a wallet session is already active, it is cleared before the new auth attempt starts. The SDK stores transient redirect auth state so the callback can complete after a full-page redirect. +Starts an OIDC authorization-code redirect flow and returns the provider authorization URL. If a wallet session is already active, it is cleared before the new auth attempt starts. The SDK stores transient redirect auth state so the callback can complete after a full-page redirect. If `provider` is a string, it must match a configured `auth.oidcProviders` key. Passing an `OidcProviderConfig` object directly is also supported. +In browser environments, `redirectUri` defaults to the current page URL without query or hash. Outside a browser, pass `redirectUri`. + When `relayRedirectUri` is set, the provider redirects through that relay before returning to the app `redirectUri`. +Pass `walletSelection` or `sessionLifetimeSeconds` at start to store completion preferences in the pending redirect state. `completeOidcRedirectAuth` uses those stored values after the provider redirects back unless completion params override them. + Pass `loginHint` for Google redirect flows to set the Google `login_hint` authorization parameter, which can prefill or select the expected account. The SDK only sends `login_hint` for providers whose issuer is `https://accounts.google.com`. If omitted, the SDK falls back to the previous active session email when one exists before the redirect auth attempt starts. After `signOut()`, that previous session email is cleared. To force no `login_hint` for a call, pass `loginHint: ''`. ```typescript @@ -308,24 +315,29 @@ window.location.assign(url) ```typescript completeOidcRedirectAuth(params: { - callbackUrl: string + callbackUrl?: string cleanUrl?: boolean replaceUrl?: (url: string) => void walletSelection?: 'automatic' | 'manual' sessionLifetimeSeconds?: number -}): Promise< +} = {}): Promise< | { walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } | PendingWalletSelection + | void > ``` -Completes an OIDC redirect flow by validating the callback, completing auth with a one-week session lifetime by default, and activating an existing wallet or creating one. Pass `sessionLifetimeSeconds` to request a shorter or longer session lifetime. 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`. +Completes an OIDC redirect flow by validating the callback, completing auth with a one-week session lifetime by default, and activating an existing wallet or creating one. In browser environments, `callbackUrl` defaults to `window.location.href`; if the current URL has no OIDC callback params, the method returns `undefined` without requiring pending redirect storage. + +Pass `sessionLifetimeSeconds` to request a shorter or longer session lifetime. Pass `walletSelection: 'manual'` to return a [`PendingWalletSelection`](#pendingwalletselection) for app-driven wallet selection. If omitted, completion uses values stored by `startOidcRedirectAuth` or `signInWithOidcRedirect`, then falls back to automatic wallet selection and the default one-week lifetime. + +When `callbackUrl` is omitted, OAuth query parameters are cleaned by default. Explicit `callbackUrl` calls clean only when `cleanUrl: true`; outside a browser, pass `replaceUrl` when cleaning. ```typescript -const { walletAddress, credential } = await oms.wallet.completeOidcRedirectAuth({ - callbackUrl: window.location.href, - cleanUrl: true, -}) +const result = await oms.wallet.completeOidcRedirectAuth() +if (result) { + console.log(result.walletAddress) +} ``` --- @@ -342,17 +354,23 @@ signInWithOidcRedirect(params: { authorizeParams?: Record loginHint?: string sessionLifetimeSeconds?: number - cleanUrl?: boolean currentUrl?: string assignUrl?: (url: string) => void - replaceUrl?: (url: string) => void -}): Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } | PendingWalletSelection | void> +}): Promise ``` -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`. `loginHint` is passed through to the started redirect flow; `sessionLifetimeSeconds` is used when completing the callback and defaults to one week when omitted. For router-driven apps, prefer [`startOidcRedirectAuth`](#startoidcredirectauth) and [`completeOidcRedirectAuth`](#completeoidcredirectauth). +Browser convenience method for regular web apps. It starts OIDC redirect auth, stores pending redirect state, redirects with `window.location.assign`, and returns `void`. Use [`completeOidcRedirectAuth`](#completeoidcredirectauth) on the callback page to finish auth. + +`redirectUri` defaults to the current page URL without query or hash. Pass `currentUrl` to derive that value from a specific URL, and pass `assignUrl` outside a browser or when testing. `walletSelection` and `sessionLifetimeSeconds` are stored with the pending redirect state and used by `completeOidcRedirectAuth` after the provider redirects back. ```typescript void oms.wallet.signInWithOidcRedirect({ provider: 'google' }) +void oms.wallet.signInWithOidcRedirect({ provider: 'apple' }) + +const result = await oms.wallet.completeOidcRedirectAuth() +if (result) { + console.log(result.walletAddress) +} ``` --- @@ -940,7 +958,7 @@ interface OmsAuthConfig { |---|---|---| | `oidcProviders` | `Record` | OIDC provider configurations addressable by provider key. | -When `auth` is omitted, the SDK configures the built-in `google` provider. Passing `auth` replaces the configured provider set. +When `auth` is omitted, the SDK configures the built-in `google` and `apple` providers. Passing `auth` replaces the configured provider set. Use `defineOmsAuthConfig` to preserve typed custom OIDC provider keys: @@ -969,9 +987,12 @@ type OidcProviderConfig = { scopes?: string[] relayRedirectUri?: string authorizeParams?: Record + authMode?: AuthMode.AuthCode | AuthMode.AuthCodePKCE } ``` +Provider configs are the source of truth for authorization scopes. If `scopes` is omitted or empty, the SDK does not send a `scope` authorization parameter. `authMode` defaults to `AuthMode.AuthCodePKCE`. + Google can be configured with the `googleOidcProvider` helper: ```typescript @@ -985,6 +1006,19 @@ googleOidcProvider({ }) ``` +Apple can be configured with the `appleOidcProvider` helper: + +```typescript +// Uses the SDK default Apple Services ID (`service.oms.polygon.technology`) and relay redirect URI. +appleOidcProvider() + +// Override defaults when needed. +appleOidcProvider({ + clientId: 'your-apple-services-id', + relayRedirectUri: 'https://app.example/auth/callback', +}) +``` + --- ### OIDC Provider Helpers @@ -998,12 +1032,21 @@ interface GoogleOidcProviderParams { relayRedirectUri?: string scopes?: string[] authorizeParams?: Record + authMode?: AuthMode.AuthCode | AuthMode.AuthCodePKCE +} + +interface AppleOidcProviderParams { + clientId?: string + relayRedirectUri?: string + scopes?: string[] + authorizeParams?: Record + authMode?: AuthMode.AuthCode | AuthMode.AuthCodePKCE } const defaultOmsAuthConfig: OmsAuthConfig ``` -`OidcProviderName` is narrowed from the configured `auth.oidcProviders` keys when `OMSClient` is constructed with `defineOmsAuthConfig`. `googleOidcProvider(params)` accepts `GoogleOidcProviderParams` and returns an `OidcProviderConfig`. `defaultOmsAuthConfig` contains the SDK's built-in Google provider configuration. +`OidcProviderName` is narrowed from the configured `auth.oidcProviders` keys when `OMSClient` is constructed with `defineOmsAuthConfig`. `googleOidcProvider(params)` and `appleOidcProvider(params)` return `OidcProviderConfig` values. `defaultOmsAuthConfig` contains the SDK's built-in Google and Apple provider configuration. --- @@ -1026,8 +1069,10 @@ interface CompleteEmailAuthResult { interface StartOidcRedirectAuthParams { provider: OidcProviderInput - redirectUri: string + redirectUri?: string walletType?: WalletType + walletSelection?: WalletSelectionBehavior + sessionLifetimeSeconds?: number relayRedirectUri?: string authorizeParams?: Record loginHint?: string @@ -1040,7 +1085,7 @@ interface StartOidcRedirectAuthResult { } interface CompleteOidcRedirectAuthParams { - callbackUrl: string + callbackUrl?: string cleanUrl?: boolean replaceUrl?: (url: string) => void walletSelection?: WalletSelectionBehavior @@ -1063,10 +1108,8 @@ interface SignInWithOidcRedirectParams { authorizeParams?: Record loginHint?: string sessionLifetimeSeconds?: number - cleanUrl?: boolean currentUrl?: string assignUrl?: (url: string) => void - replaceUrl?: (url: string) => void } ``` @@ -1856,6 +1899,21 @@ A loosely-typed ABI argument used by [`callContract`](#callcontract). For fully- --- +### AuthMode + +```typescript +enum AuthMode { + OTP = 'otp', + IDToken = 'id-token', + AuthCode = 'auth-code', + AuthCodePKCE = 'auth-code-pkce' +} +``` + +OIDC provider configs support `AuthMode.AuthCode` and `AuthMode.AuthCodePKCE`. Redirect auth defaults to `AuthMode.AuthCodePKCE` when a provider does not specify `authMode`. + +--- + ### WalletType ```typescript diff --git a/README.md b/README.md index 24503b9..794f175 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ console.log(tx.txnHash ?? tx.txnId) ## Authentication Flow -OMS supports email-based OTP and OIDC authorization-code PKCE redirect auth. +OMS supports email-based OTP and OIDC authorization-code redirect auth. ### Email OTP Auth @@ -202,7 +202,7 @@ The returned pending selection is bound to the verified auth flow and signer. Ho ### OIDC Redirect Auth -Google redirect auth is configured by default. The redirect auth APIs are provider-neutral, so `auth.oidcProviders` can replace the configured provider set. +Google and Apple redirect auth are configured by default. The redirect auth APIs are provider-neutral, so `auth.oidcProviders` can replace the configured provider set when you need custom providers. ```typescript const oms = new OMSClient({ @@ -210,40 +210,49 @@ const oms = new OMSClient({ }) ``` -For routers such as React Router or Next.js, use the explicit start/complete methods: +For router-driven apps, use the explicit start/complete methods: ```typescript const { url } = await oms.wallet.startOidcRedirectAuth({ provider: 'google', - redirectUri: `${window.location.origin}/auth/callback`, + redirectUri: `${window.location.origin}/auth/callback`, // optional in browser apps }) window.location.assign(url) // On the callback route: -const { walletAddress, wallet, wallets, credential } = await oms.wallet.completeOidcRedirectAuth({ - callbackUrl: window.location.href, - cleanUrl: true, -}) +const result = await oms.wallet.completeOidcRedirectAuth() +if (result) { + console.log('Wallet address:', result.walletAddress) +} ``` -OIDC redirect auth also supports manual wallet selection by passing `walletSelection: 'manual'` to `completeOidcRedirectAuth`. +OIDC redirect auth also supports manual wallet selection by passing `walletSelection: 'manual'` to `startOidcRedirectAuth` or `completeOidcRedirectAuth`. Options passed at start are stored with the pending redirect state and used after the provider redirects back. -For simple browser apps, use the one-call convenience method from a sign-in action and from the callback page: +For simple browser apps, use `signInWithOidcRedirect` from a sign-in action. It calls `startOidcRedirectAuth`, derives the current page as `redirectUri`, and navigates with `window.location.assign`: ```typescript void oms.wallet.signInWithOidcRedirect({ provider: 'google' }) +void oms.wallet.signInWithOidcRedirect({ provider: 'apple' }) + +// On the callback page: +const result = await oms.wallet.completeOidcRedirectAuth() +if (result) { + console.log('Wallet address:', result.walletAddress) +} ``` Pass `loginHint` only when you want to prefill or select a specific Google account, such as during session-expiry reauth. The SDK only sends `login_hint` for Google providers. When omitted, the SDK falls back to the previous active session email when one exists before the redirect auth attempt starts. After `signOut()`, that previous session email is cleared. To force no `login_hint` for a call, pass `loginHint: ''`. Pending redirect state is stored in `sessionStorage` by default. Final wallet session metadata continues to use the configured SDK storage. +`appleOidcProvider()` uses the SDK default Apple Services ID (`service.oms.polygon.technology`), the SDK relay redirect URI, `response_mode=query`, no scopes, and PKCE auth-code mode by default. Requesting Apple `email` or `name` scopes requires Apple `response_mode=form_post`, which is not handled by the SDK's URL-query callback parser without a relay that converts the callback. + ### Session State Email and OIDC auth both persist the active wallet session in the configured SDK storage. Browser storage defaults to `localStorage` when available; non-browser runtimes fall back to in-memory storage unless you provide a custom `StorageManager`. Browser signing defaults to a non-extractable WebCrypto P-256 credential using `ecdsa-p256-sha256`, so the private session key is not written to `localStorage`. Completed auth requests ask WaaS for a one-week session lifetime. -Pass `sessionLifetimeSeconds` to `completeEmailAuth`, `completeOidcRedirectAuth`, or `signInWithOidcRedirect` to request a different session lifetime for that auth completion. +Pass `sessionLifetimeSeconds` to `completeEmailAuth`, `startOidcRedirectAuth`, `completeOidcRedirectAuth`, or `signInWithOidcRedirect` to request a different session lifetime. For OIDC redirects, values passed at start are stored with the pending redirect state and used on callback completion unless completion overrides them. Use `oms.wallet.walletAddress` when you only need the active wallet address. Use `oms.wallet.session` when you also need credential expiry, login type, or the email returned by the wallet API. @@ -459,12 +468,15 @@ const oms = new OMSClient({ clientId: 'custom-client-id', issuer: 'https://issuer.example', authorizationUrl: 'https://issuer.example/oauth/authorize', + scopes: ['openid', 'email', 'profile'], }, }, }, }) ``` +Provider configs are the source of truth for OIDC scopes. If `scopes` is omitted or empty, the SDK does not send a `scope` authorization parameter. OIDC auth mode defaults to PKCE; pass `authMode` when a provider needs a different WaaS auth-code mode. + ### Custom Storage and Signing The default storage backend is browser `localStorage` when available, otherwise in-memory storage for wallet metadata only. The default browser signer stores its non-extractable key reference separately through WebCrypto-compatible browser storage. Provide a custom `StorageManager` for persistent Node.js, React Native, or testing sessions: diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index 694e333..36fd13a 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -5,7 +5,7 @@ import { Address, EncodeFunctionDataParameters} from 'viem' -import {OidcProviderConfig, OmsEnvironment} from "../omsEnvironment.js"; +import {type OidcAuthMode, OidcProviderConfig, OmsEnvironment} from "../omsEnvironment.js"; import {createDefaultStorage, SessionStorageManager, StorageManager} from "../storageManager.js"; import {createSignedFetch} from "../signedFetch.js"; import {Constants} from "../utils/constants.js"; @@ -88,8 +88,10 @@ export type OidcProviderInput = export interface StartOidcRedirectAuthParams { provider: OidcProviderInput; - redirectUri: string; + redirectUri?: string; walletType?: WalletType; + walletSelection?: WalletSelectionBehavior; + sessionLifetimeSeconds?: number; relayRedirectUri?: string; authorizeParams?: Record; loginHint?: string; @@ -102,7 +104,7 @@ export interface StartOidcRedirectAuthResult { } export interface CompleteOidcRedirectAuthParams { - callbackUrl: string; + callbackUrl?: string; cleanUrl?: boolean; replaceUrl?: (url: string) => void; walletSelection?: WalletSelectionBehavior; @@ -210,21 +212,22 @@ export interface SignInWithOidcRedirectParams; loginHint?: string; - sessionLifetimeSeconds?: number; - cleanUrl?: boolean; currentUrl?: string; assignUrl?: (url: string) => void; - replaceUrl?: (url: string) => void; } interface PendingOidcRedirectAuth { verifier: string; nonce: string; + authMode: OidcAuthMode; provider: string | null; walletType: WalletType; + walletSelection?: WalletSelectionBehavior; + sessionLifetimeSeconds?: number; signerCredentialId: string; signerKeyType: CredentialSigningAlgorithm; redirectUri: string; @@ -521,7 +524,7 @@ export class WalletClient { } /** - * Starts an OIDC authorization-code PKCE flow and returns the provider URL. + * Starts an OIDC authorization-code redirect flow and returns the provider URL. * * Store or navigate to the returned `url`, then call `completeOidcRedirectAuth` * after the provider redirects back to your application. @@ -531,14 +534,18 @@ export class WalletClient { ): Promise { return this.runOperation(WalletOperation.startOidcRedirectAuth, async () => { const previousSession = this.session - await this.clearSession() const redirectAuthStorage = this.requireRedirectAuthStorage() const provider = this.resolveOidcProvider(params.provider) - const oauthRedirectUri = params.relayRedirectUri ?? provider.config.relayRedirectUri ?? params.redirectUri + const authMode = this.resolveOidcAuthMode(provider.config) + const redirectUri = params.redirectUri ?? + redirectUriFromCurrentUrl(this.browserCurrentUrl('startOidcRedirectAuth', 'redirectUri')) + const oauthRedirectUri = params.relayRedirectUri ?? provider.config.relayRedirectUri ?? redirectUri + + await this.clearSession() const request: CommitVerifierRequest = { identityType: IdentityType.OIDC, - authMode: AuthMode.AuthCodePKCE, + authMode, metadata: { iss: provider.config.issuer, aud: provider.config.clientId, @@ -551,17 +558,20 @@ export class WalletClient { const state = encodeOidcState({ nonce, scope: this.projectId, - ...(oauthRedirectUri !== params.redirectUri ? {redirect_uri: params.redirectUri} : {}), + ...(oauthRedirectUri !== redirectUri ? {redirect_uri: redirectUri} : {}), }) this.savePendingOidcRedirectAuth(redirectAuthStorage, { verifier: response.verifier, nonce, + authMode, provider: provider.name, walletType: params.walletType ?? WalletType.Ethereum, + walletSelection: params.walletSelection, + sessionLifetimeSeconds: params.sessionLifetimeSeconds, signerCredentialId, signerKeyType: this.credentialSigner.signingAlgorithm, - redirectUri: params.redirectUri, + redirectUri, issuer: provider.config.issuer, projectId: this.projectId, }) @@ -577,6 +587,7 @@ export class WalletClient { scopes: this.resolveOidcScopes(provider.config), state, challenge: response.challenge, + usePkce: authMode === AuthMode.AuthCodePKCE, authorizeParams, loginHint: this.loginHintForProvider( provider.config, @@ -593,30 +604,39 @@ export class WalletClient { } /** - * Completes an OIDC authorization-code PKCE redirect flow. + * Completes an OIDC authorization-code redirect flow. * * This validates the state nonce persisted by `startOidcRedirectAuth`, completes * WaaS auth, and activates an existing wallet or creates a new one. */ + async completeOidcRedirectAuth(): Promise async completeOidcRedirectAuth( params: ManualWalletSelectionParams, - ): Promise + ): Promise async completeOidcRedirectAuth( params: AutomaticWalletSelectionParams, - ): Promise + ): Promise async completeOidcRedirectAuth( params: CompleteOidcRedirectAuthParams, - ): Promise + ): Promise async completeOidcRedirectAuth( - params: CompleteOidcRedirectAuthParams, - ): Promise { + params: CompleteOidcRedirectAuthParams = {}, + ): Promise { return this.runOperation(WalletOperation.completeOidcRedirectAuth, async () => { + const callbackUrl = params.callbackUrl ?? + this.browserCurrentUrl('completeOidcRedirectAuth', 'callbackUrl') + const callback = parseOidcCallbackUrl(callbackUrl) + const hasCallbackParams = !!(callback.code || callback.state || callback.error) + + if (!hasCallbackParams && params.callbackUrl === undefined) { + return undefined + } + const redirectAuthStorage = this.requireRedirectAuthStorage() try { - const callback = parseOidcCallbackUrl(params.callbackUrl) - if (params.cleanUrl) { - this.replaceOidcCallbackUrl(params.callbackUrl, params.replaceUrl) + if (params.cleanUrl ?? (params.callbackUrl === undefined)) { + this.replaceOidcCallbackUrl(callbackUrl, params.replaceUrl) } if (callback.error) { @@ -629,18 +649,22 @@ export class WalletClient { const pending = this.loadPendingOidcRedirectAuth(redirectAuthStorage) this.validateOidcState(callback.state, pending) await this.validatePendingOidcRedirectSigner(pending, WalletOperation.completeOidcRedirectAuth) + const walletSelection = params.walletSelection ?? pending.walletSelection ?? "automatic" + const sessionLifetimeSeconds = params.sessionLifetimeSeconds ?? + pending.sessionLifetimeSeconds ?? + DEFAULT_SESSION_LIFETIME_SECONDS const request: CompleteAuthRequest = { identityType: IdentityType.OIDC, - authMode: AuthMode.AuthCodePKCE, + authMode: pending.authMode, verifier: pending.verifier, answer: callback.code, - lifetime: params.sessionLifetimeSeconds ?? DEFAULT_SESSION_LIFETIME_SECONDS, + lifetime: sessionLifetimeSeconds, } const response = await this.client.completeAuth(request) - const result = await this.completeWalletAuth(response, pending.walletType, params.walletSelection ?? "automatic") + const result = await this.completeWalletAuth(response, pending.walletType, walletSelection) - if ((params.walletSelection ?? "automatic") === "automatic" && !this.walletAddress) { + if (walletSelection === "automatic" && !this.walletAddress) { throw new Error('OIDC auth completed without an active wallet') } @@ -652,33 +676,24 @@ export class WalletClient { } /** - * Browser convenience wrapper for regular web apps. - * - * If the current URL contains an OIDC callback, it completes auth. Otherwise it - * starts auth and redirects to the provider. + * Browser convenience wrapper that starts an OIDC redirect and navigates to the provider. */ - async signInWithOidcRedirect(params: ManualWalletSelectionParams>): Promise - async signInWithOidcRedirect(params: AutomaticWalletSelectionParams>): Promise - async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise - async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise { + async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise + async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise { return this.runOperation(WalletOperation.signInWithOidcRedirect, async () => { - const currentUrl = params.currentUrl ?? this.browserCurrentUrl() - const callback = parseOidcCallbackUrl(currentUrl) - if (callback.code || callback.state || callback.error) { - return this.completeOidcRedirectAuth({ - callbackUrl: currentUrl, - cleanUrl: params.cleanUrl ?? true, - replaceUrl: params.replaceUrl, - walletSelection: params.walletSelection, - sessionLifetimeSeconds: params.sessionLifetimeSeconds, - }) + if (!hasOidcRedirectStartProvider(params)) { + throw new Error('signInWithOidcRedirect requires provider to start auth') } - const redirectUri = params.redirectUri ?? redirectUriFromCurrentUrl(currentUrl) + const redirectUri = params.redirectUri ?? redirectUriFromCurrentUrl( + params.currentUrl ?? this.browserCurrentUrl('signInWithOidcRedirect', 'currentUrl'), + ) const result = await this.startOidcRedirectAuth({ provider: params.provider, redirectUri, walletType: params.walletType, + walletSelection: params.walletSelection, + sessionLifetimeSeconds: params.sessionLifetimeSeconds, relayRedirectUri: params.relayRedirectUri, authorizeParams: params.authorizeParams, loginHint: params.loginHint, @@ -1312,7 +1327,11 @@ export class WalletClient { } private resolveOidcScopes(provider: OidcProviderConfig): string[] { - return provider.scopes?.length ? provider.scopes : ['openid', 'email', 'profile'] + return provider.scopes ?? [] + } + + private resolveOidcAuthMode(provider: OidcProviderConfig): OidcAuthMode { + return provider.authMode ?? AuthMode.AuthCodePKCE } private requireRedirectAuthStorage(): StorageManager { @@ -1340,6 +1359,7 @@ export class WalletClient { if ( typeof parsed.verifier !== 'string' || typeof parsed.nonce !== 'string' || + !isOidcAuthMode(parsed.authMode) || typeof parsed.signerCredentialId !== 'string' || typeof parsed.signerKeyType !== 'string' || typeof parsed.redirectUri !== 'string' || @@ -1352,8 +1372,13 @@ export class WalletClient { return { verifier: parsed.verifier, nonce: parsed.nonce, + authMode: parsed.authMode, provider: typeof parsed.provider === 'string' ? parsed.provider : null, walletType: isWalletType(parsed.walletType) ? parsed.walletType : WalletType.Ethereum, + walletSelection: isWalletSelectionBehavior(parsed.walletSelection) ? parsed.walletSelection : undefined, + sessionLifetimeSeconds: typeof parsed.sessionLifetimeSeconds === 'number' + ? parsed.sessionLifetimeSeconds + : undefined, signerCredentialId: parsed.signerCredentialId, signerKeyType: parsed.signerKeyType as CredentialSigningAlgorithm, redirectUri: parsed.redirectUri, @@ -1415,9 +1440,9 @@ export class WalletClient { window.history.replaceState({}, '', cleanUrl) } - private browserCurrentUrl(): string { + private browserCurrentUrl(operation: string, requiredParam: string): string { if (typeof window === 'undefined') { - throw new Error('signInWithOidcRedirect requires currentUrl outside a browser') + throw new Error(`${operation} requires ${requiredParam} outside a browser`) } return window.location.href } @@ -1858,6 +1883,20 @@ function isWalletType(value: unknown): value is WalletType { return typeof value === 'string' && Object.values(WalletType).includes(value as WalletType) } +function isOidcAuthMode(value: unknown): value is OidcAuthMode { + return value === AuthMode.AuthCode || value === AuthMode.AuthCodePKCE +} + +function hasOidcRedirectStartProvider( + params: SignInWithOidcRedirectParams | undefined, +): params is SignInWithOidcRedirectParams { + return !!params && 'provider' in params && params.provider !== undefined +} + +function isWalletSelectionBehavior(value: unknown): value is WalletSelectionBehavior { + return value === "automatic" || value === "manual" +} + function sameEmailAuthCompletionParams( left: EmailAuthCompletionParams, right: EmailAuthCompletionParams, diff --git a/src/index.ts b/src/index.ts index 41c1321..4afa935 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,9 @@ export { type OmsAuthConfig, } from './omsEnvironment.js' export { + appleOidcProvider, googleOidcProvider, + type AppleOidcProviderParams, type GoogleOidcProviderParams, } from './oidc.js' export { @@ -30,6 +32,7 @@ export { type Network, } from './networks.js' export { + AuthMode, TransactionMode, TransactionStatus, WalletType, diff --git a/src/oidc.ts b/src/oidc.ts index 259d6a9..0fc48b3 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -1,13 +1,24 @@ -import type {OidcProviderConfig} from "./omsEnvironment.js"; +import {AuthMode} from "./generated/waas.gen.js"; +import type {OidcAuthMode, OidcProviderConfig} from "./omsEnvironment.js"; export interface GoogleOidcProviderParams { clientId?: string; relayRedirectUri?: string; scopes?: string[]; authorizeParams?: Record; + authMode?: OidcAuthMode; } -export const defaultGoogleClientId = "970987756660-0dh5gubqfiugm452raf7mm39qaq639hn.apps.googleusercontent.com"; +export interface AppleOidcProviderParams { + clientId?: string; + relayRedirectUri?: string; + scopes?: string[]; + authorizeParams?: Record; + authMode?: OidcAuthMode; +} + +export const defaultGoogleClientId = "913882656162-7l4ofa0ou2hqo90umlkenhdop1f5inba.apps.googleusercontent.com"; +export const defaultAppleClientId = "service.oms.polygon.technology"; export const defaultRelayRedirectUri = "https://waas-cf-relay-staging.0xsequence.workers.dev/callback"; export function googleOidcProvider(params: GoogleOidcProviderParams = {}): OidcProviderConfig { @@ -17,6 +28,7 @@ export function googleOidcProvider(params: GoogleOidcProviderParams = {}): OidcP authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', scopes: params.scopes ?? ['openid', 'email', 'profile'], relayRedirectUri: params.relayRedirectUri || defaultRelayRedirectUri, + authMode: params.authMode ?? AuthMode.AuthCodePKCE, authorizeParams: { access_type: 'offline', prompt: 'consent', @@ -24,3 +36,18 @@ export function googleOidcProvider(params: GoogleOidcProviderParams = {}): OidcP }, }; } + +export function appleOidcProvider(params: AppleOidcProviderParams = {}): OidcProviderConfig { + return { + clientId: params.clientId || defaultAppleClientId, + issuer: 'https://appleid.apple.com', + authorizationUrl: 'https://appleid.apple.com/auth/authorize', + scopes: params.scopes ?? [], + relayRedirectUri: params.relayRedirectUri || defaultRelayRedirectUri, + authMode: params.authMode ?? AuthMode.AuthCodePKCE, + authorizeParams: { + response_mode: 'query', + ...params.authorizeParams, + }, + }; +} diff --git a/src/omsEnvironment.ts b/src/omsEnvironment.ts index 4080ca6..7b3b9ac 100644 --- a/src/omsEnvironment.ts +++ b/src/omsEnvironment.ts @@ -1,5 +1,8 @@ -import {googleOidcProvider} from "./oidc.js"; +import {appleOidcProvider, googleOidcProvider} from "./oidc.js"; import {parsePublishableKey} from "./publishableKey.js"; +import type {AuthMode} from "./generated/waas.gen.js"; + +export type OidcAuthMode = AuthMode.AuthCode | AuthMode.AuthCodePKCE; export interface OidcProviderConfig { clientId: string; @@ -8,6 +11,7 @@ export interface OidcProviderConfig { scopes?: string[]; relayRedirectUri?: string; authorizeParams?: Record; + authMode?: OidcAuthMode; } export interface OmsAuthConfig< @@ -26,6 +30,7 @@ export interface OmsEnvironment< const defaultOidcProviders = { google: googleOidcProvider(), + apple: appleOidcProvider(), }; export const defaultOmsAuthConfig = { diff --git a/src/utils/oidcRedirect.ts b/src/utils/oidcRedirect.ts index 4fed290..aea71b1 100644 --- a/src/utils/oidcRedirect.ts +++ b/src/utils/oidcRedirect.ts @@ -18,6 +18,7 @@ export interface BuildOidcAuthorizationUrlParams { scopes: string[]; state: string; challenge: string; + usePkce?: boolean; authorizeParams?: Record; loginHint?: string; } @@ -85,10 +86,19 @@ export function buildOidcAuthorizationUrl(params: BuildOidcAuthorizationUrlParam url.searchParams.set('client_id', params.clientId); url.searchParams.set('redirect_uri', params.redirectUri); url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', params.scopes.join(' ')); + if (params.scopes.length > 0) { + url.searchParams.set('scope', params.scopes.join(' ')); + } else { + url.searchParams.delete('scope'); + } url.searchParams.set('state', params.state); - url.searchParams.set('code_challenge', params.challenge); - url.searchParams.set('code_challenge_method', 'S256'); + if (params.usePkce) { + url.searchParams.set('code_challenge', params.challenge); + url.searchParams.set('code_challenge_method', 'S256'); + } else { + url.searchParams.delete('code_challenge'); + url.searchParams.delete('code_challenge_method'); + } return url.toString(); } diff --git a/tests/oidcRedirectAuth.test.ts b/tests/oidcRedirectAuth.test.ts index 9bc6561..de1b89c 100644 --- a/tests/oidcRedirectAuth.test.ts +++ b/tests/oidcRedirectAuth.test.ts @@ -2,10 +2,10 @@ import {afterEach, describe, expect, it, vi} from "vitest"; import {WalletClient} from "../src/clients/walletClient"; import type {CredentialSigner} from "../src/credentialSigner"; -import {type OidcProviderConfig, type OmsEnvironment} from "../src/omsEnvironment"; -import {googleOidcProvider} from "../src/oidc"; +import {defaultOmsAuthConfig, type OidcProviderConfig, type OmsEnvironment} from "../src/omsEnvironment"; +import {appleOidcProvider, googleOidcProvider} from "../src/oidc"; import {MemoryStorageManager} from "../src/storageManager"; -import {WalletType} from "../src/generated/waas.gen"; +import {AuthMode, WalletType} from "../src/generated/waas.gen"; import {Constants} from "../src/utils/constants"; import { decodeOidcState, @@ -13,7 +13,8 @@ import { redirectUriFromCurrentUrl, } from "../src/utils/oidcRedirect"; -const expectedDefaultGoogleClientId = "970987756660-0dh5gubqfiugm452raf7mm39qaq639hn.apps.googleusercontent.com"; +const expectedDefaultGoogleClientId = "913882656162-7l4ofa0ou2hqo90umlkenhdop1f5inba.apps.googleusercontent.com"; +const expectedDefaultAppleClientId = "service.oms.polygon.technology"; const expectedDefaultRelayRedirectUri = "https://waas-cf-relay-staging.0xsequence.workers.dev/callback"; class MockSigner implements CredentialSigner { @@ -94,6 +95,28 @@ describe("WalletClient OIDC redirect auth", () => { expect(redirectAuthStorage.get(Constants.redirectAuthStorageKey)).toContain("verifier-1"); }); + it("defaults start redirectUri from the current browser URL", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + })); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", { + location: { + href: "https://app.example/login?from=home#section", + }, + }); + + const wallet = createWalletClient({redirectAuthStorage: new MemoryStorageManager()}); + + const result = await wallet.startOidcRedirectAuth({ + provider: "google", + }); + + const state = decodeOidcState(result.state); + expect(state.redirect_uri).toBe("https://app.example/login"); + }); + it("uses an explicit login hint", async () => { const fetchMock = vi.fn(async () => jsonResponse({ verifier: "verifier-1", @@ -164,6 +187,32 @@ describe("WalletClient OIDC redirect auth", () => { expect(storage.get(Constants.walletIdStorageKey)).toBeNull(); }); + it("allows callers to suppress the previous session login hint", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + })); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletClient({ + redirectAuthStorage: new MemoryStorageManager(), + }); + (wallet as any).persistSession("wallet-id", "0x1111111111111111111111111111111111111111", { + expiresAt: "2099-01-01T00:00:00Z", + loginType: "google-auth", + sessionEmail: "last@example.com", + }); + + const result = await wallet.startOidcRedirectAuth({ + provider: "google", + redirectUri: "https://app.example/auth/callback", + loginHint: "", + }); + + const authorizeUrl = new URL(result.url); + expect(authorizeUrl.searchParams.get("login_hint")).toBeNull(); + }); + it("does not add login hints for non-Google providers", async () => { const fetchMock = vi.fn(async () => jsonResponse({ verifier: "verifier-1", @@ -317,9 +366,68 @@ describe("WalletClient OIDC redirect auth", () => { issuer: "https://accounts.google.com", authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", scopes: ["openid", "email", "profile"], + authMode: AuthMode.AuthCodePKCE, + }); + }); + + it("uses Apple provider defaults", () => { + expect(appleOidcProvider()).toMatchObject({ + clientId: expectedDefaultAppleClientId, + relayRedirectUri: expectedDefaultRelayRedirectUri, + issuer: "https://appleid.apple.com", + authorizationUrl: "https://appleid.apple.com/auth/authorize", + scopes: [], + authMode: AuthMode.AuthCodePKCE, + authorizeParams: { + response_mode: "query", + }, }); }); + it("starts an Apple query redirect flow from the default auth config", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(init?.body as string); + expect(body).toMatchObject({ + identityType: "oidc", + authMode: "auth-code-pkce", + metadata: { + iss: "https://appleid.apple.com", + aud: expectedDefaultAppleClientId, + redirect_uri: expectedDefaultRelayRedirectUri, + }, + }); + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletClient({ + redirectAuthStorage: new MemoryStorageManager(), + environment: { + walletApiUrl: "https://wallet.example", + indexerGatewayUrl: "https://indexer.example", + auth: defaultOmsAuthConfig, + }, + }); + + const result = await wallet.startOidcRedirectAuth({ + provider: "apple", + redirectUri: "https://app.example/auth/callback", + }); + + const authorizeUrl = new URL(result.url); + expect(authorizeUrl.origin + authorizeUrl.pathname).toBe("https://appleid.apple.com/auth/authorize"); + expect(authorizeUrl.searchParams.get("client_id")).toBe(expectedDefaultAppleClientId); + expect(authorizeUrl.searchParams.get("redirect_uri")).toBe(expectedDefaultRelayRedirectUri); + expect(authorizeUrl.searchParams.get("response_type")).toBe("code"); + expect(authorizeUrl.searchParams.get("response_mode")).toBe("query"); + expect(authorizeUrl.searchParams.get("scope")).toBeNull(); + expect(authorizeUrl.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(authorizeUrl.searchParams.get("code_challenge_method")).toBe("S256"); + }); + it("merges provider and method authorize params with method params taking precedence", async () => { vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ verifier: "verifier-1", @@ -361,6 +469,100 @@ describe("WalletClient OIDC redirect auth", () => { expect(authorizeUrl.searchParams.get("audience")).toBe("wallet"); }); + it("uses provider auth-code mode without PKCE authorization params", 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("/CommitVerifier")) { + expect(body).toMatchObject({ + identityType: "oidc", + authMode: "auth-code", + metadata: { + iss: "https://issuer.example", + aud: "custom-client", + redirect_uri: "https://app.example/callback", + }, + }); + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + } + + if (url.endsWith("/CompleteAuth")) { + expect(body).toEqual({ + identityType: "oidc", + authMode: "auth-code", + verifier: "verifier-1", + answer: "auth-code", + lifetime: 604_800, + }); + return jsonResponse({ + identity: {type: "oidc", iss: "https://issuer.example", sub: "user-1"}, + wallets: [{ + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + expect(body).toEqual({walletId: "wallet-id"}); + return jsonResponse({ + wallet: { + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }, + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletClient({ + redirectAuthStorage: new MemoryStorageManager(), + }); + const provider: OidcProviderConfig = { + clientId: "custom-client", + issuer: "https://issuer.example", + authorizationUrl: "https://issuer.example/oauth/authorize", + scopes: [], + authMode: AuthMode.AuthCode, + authorizeParams: { + response_mode: "query", + scope: "openid email", + code_challenge: "manual-challenge", + code_challenge_method: "plain", + }, + }; + + const started = await wallet.startOidcRedirectAuth({ + provider, + redirectUri: "https://app.example/callback", + }); + + const authorizeUrl = new URL(started.url); + expect(authorizeUrl.searchParams.get("scope")).toBeNull(); + expect(authorizeUrl.searchParams.get("code_challenge")).toBeNull(); + expect(authorizeUrl.searchParams.get("code_challenge_method")).toBeNull(); + + const completed = await wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/callback?code=auth-code&state=${started.state}`, + }); + + expect(completed).toMatchObject({ + walletAddress: "0x1111111111111111111111111111111111111111", + credential: testCredential(), + }); + expect(requestCount(fetchMock, "/CommitVerifier")).toBe(1); + expect(requestCount(fetchMock, "/CompleteAuth")).toBe(1); + }); + it("completes an OIDC callback, activates a wallet, cleans the URL, and clears pending state", async () => { const redirectAuthStorage = new MemoryStorageManager(); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -434,6 +636,77 @@ describe("WalletClient OIDC redirect auth", () => { expect(replaceUrl).toHaveBeenCalledWith("https://app.example/auth/callback"); }); + it("defaults callbackUrl from the current browser URL and cleans callback params", async () => { + const redirectAuthStorage = new MemoryStorageManager(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + } + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "oidc", iss: "https://accounts.google.com", sub: "user-1"}, + wallets: [{ + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({ + wallet: { + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }, + }); + } + + 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", + }); + vi.stubGlobal("window", { + location: { + href: `https://app.example/auth/callback?code=auth-code&state=${started.state}&scope=openid`, + }, + }); + const replaceUrl = vi.fn(); + + const completed = await wallet.completeOidcRedirectAuth({replaceUrl}); + + expect(completed).toMatchObject({ + walletAddress: "0x1111111111111111111111111111111111111111", + credential: testCredential(), + }); + expect(replaceUrl).toHaveBeenCalledWith("https://app.example/auth/callback"); + }); + + it("returns undefined when browser completion runs outside an OIDC callback URL", async () => { + vi.stubGlobal("window", { + location: { + href: "https://app.example/login", + }, + }); + + const wallet = createWalletClient(); + + await expect(wallet.completeOidcRedirectAuth()).resolves.toBeUndefined(); + }); + it("uses a requested OIDC auth session lifetime", async () => { const redirectAuthStorage = new MemoryStorageManager(); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -478,11 +751,11 @@ describe("WalletClient OIDC redirect auth", () => { const started = await wallet.startOidcRedirectAuth({ provider: "google", redirectUri: "https://app.example/auth/callback", + sessionLifetimeSeconds: 120, }); await wallet.completeOidcRedirectAuth({ callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, - sessionLifetimeSeconds: 120, }); expect(requestCount(fetchMock, "/CompleteAuth")).toBe(1); @@ -540,11 +813,11 @@ describe("WalletClient OIDC redirect auth", () => { const started = await wallet.startOidcRedirectAuth({ provider: "google", redirectUri: "https://app.example/auth/callback", + walletSelection: "manual", }); const selection = await wallet.completeOidcRedirectAuth({ callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, - walletSelection: "manual", }); expect(selection).toMatchObject({ @@ -725,7 +998,7 @@ describe("WalletClient OIDC redirect auth", () => { })).rejects.toThrow("No pending OIDC redirect auth found"); }); - it("starts and completes auth through the one-call browser convenience method", async () => { + it("starts auth through browser convenience and completes through completeOidcRedirectAuth", async () => { const redirectAuthStorage = new MemoryStorageManager(); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -740,6 +1013,7 @@ describe("WalletClient OIDC redirect auth", () => { } if (url.endsWith("/CompleteAuth")) { + expect(body.lifetime).toBe(120); return jsonResponse({ identity: {type: "oidc", sub: "user-1"}, wallets: [], @@ -766,6 +1040,8 @@ describe("WalletClient OIDC redirect auth", () => { await wallet.signInWithOidcRedirect({ provider: "google", currentUrl: "https://app.example/login?from=home#section", + walletSelection: "automatic", + sessionLifetimeSeconds: 120, assignUrl, }); @@ -774,9 +1050,9 @@ describe("WalletClient OIDC redirect auth", () => { expect(redirectUriFromCurrentUrl("https://app.example/login?from=home#section")).toBe("https://app.example/login"); const replaceUrl = vi.fn(); - const completed = await wallet.signInWithOidcRedirect({ - provider: "google", - currentUrl: `https://app.example/login?code=auth-code&state=${assignedUrl.searchParams.get("state")}`, + const completed = await wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/login?code=auth-code&state=${assignedUrl.searchParams.get("state")}`, + cleanUrl: true, replaceUrl, }); @@ -788,6 +1064,63 @@ describe("WalletClient OIDC redirect auth", () => { expect(replaceUrl).toHaveBeenCalledWith("https://app.example/login"); }); + it("starts Apple auth through the one-call browser convenience method", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + expect(url).toBe("https://wallet.example/v1/Waas/CommitVerifier"); + expect(body).toMatchObject({ + identityType: "oidc", + authMode: "auth-code-pkce", + metadata: { + iss: "https://appleid.apple.com", + aud: expectedDefaultAppleClientId, + redirect_uri: expectedDefaultRelayRedirectUri, + }, + }); + + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletClient({ + redirectAuthStorage: new MemoryStorageManager(), + environment: { + walletApiUrl: "https://wallet.example", + indexerGatewayUrl: "https://indexer.example", + auth: defaultOmsAuthConfig, + }, + }); + const assignUrl = vi.fn(); + + await wallet.signInWithOidcRedirect({ + provider: "apple", + currentUrl: "https://app.example/login", + assignUrl, + }); + + const assignedUrl = new URL(assignUrl.mock.calls[0][0]); + expect(assignedUrl.origin + assignedUrl.pathname).toBe("https://appleid.apple.com/auth/authorize"); + expect(assignedUrl.searchParams.get("client_id")).toBe(expectedDefaultAppleClientId); + expect(assignedUrl.searchParams.get("redirect_uri")).toBe(expectedDefaultRelayRedirectUri); + expect(assignedUrl.searchParams.get("response_mode")).toBe("query"); + expect(assignedUrl.searchParams.get("scope")).toBeNull(); + }); + + it("requires provider to start the one-call browser convenience method", async () => { + const wallet = createWalletClient({ + redirectAuthStorage: new MemoryStorageManager(), + }); + + await expect(wallet.signInWithOidcRedirect({ + currentUrl: "https://app.example/login", + })).rejects.toThrow("signInWithOidcRedirect requires provider to start auth"); + }); + it("rejects unknown configured provider names", async () => { const wallet = createWalletClient({ redirectAuthStorage: new MemoryStorageManager(), @@ -799,6 +1132,31 @@ describe("WalletClient OIDC redirect auth", () => { })).rejects.toThrow('OIDC provider "github" is not configured'); }); + it("does not clear an existing session when redirect storage preflight fails", async () => { + const storage = new MemoryStorageManager(); + const wallet = new WalletClient({ + publishableKey: "publishable-key", + projectId: "project-id", + environment: testEnvironment(), + storage, + credentialSigner: new MockSigner(), + }); + (wallet as any).persistSession("wallet-id", "0x1111111111111111111111111111111111111111", { + expiresAt: "2099-01-01T00:00:00Z", + loginType: "google-auth", + sessionEmail: "last@example.com", + }); + + await expect(wallet.startOidcRedirectAuth({ + provider: "google", + redirectUri: "https://app.example/auth/callback", + })).rejects.toThrow("OIDC redirect auth requires redirectAuthStorage or browser sessionStorage"); + + expect(wallet.walletAddress).toBe("0x1111111111111111111111111111111111111111"); + expect(storage.get(Constants.walletIdStorageKey)).toBe("wallet-id"); + expect(storage.get(Constants.walletAddressStorageKey)).toBe("0x1111111111111111111111111111111111111111"); + }); + }); function createWalletClient>(params: { diff --git a/type-tests/oidcProviderTypes.ts b/type-tests/oidcProviderTypes.ts index dd63b95..f3c852a 100644 --- a/type-tests/oidcProviderTypes.ts +++ b/type-tests/oidcProviderTypes.ts @@ -1,5 +1,6 @@ import {WalletClient, type OidcProviderName} from "../src/clients/walletClient"; import { + AuthMode, Networks, OMSClient, defineOmsAuthConfig, @@ -26,11 +27,12 @@ import { type TransactionHistoryResult, } from "../src/index"; import {omsEnvironmentFromPublishableKey} from "../src/omsEnvironment"; -import {googleOidcProvider} from "../src/oidc"; +import {appleOidcProvider, googleOidcProvider} from "../src/oidc"; const auth = defineOmsAuthConfig({ oidcProviders: { google: googleOidcProvider(), + apple: appleOidcProvider({authMode: AuthMode.AuthCode}), }, }); const environment = omsEnvironmentFromPublishableKey("pk_dev_sdbx_project_key", auth); @@ -39,6 +41,8 @@ type ProviderName = OidcProviderName; const configuredProvider: ProviderName = "google"; void configuredProvider; +const configuredAppleProvider: ProviderName = "apple"; +void configuredAppleProvider; // @ts-expect-error github is not configured in this static environment. const unknownProvider: ProviderName = "github"; @@ -50,6 +54,10 @@ if (false) { provider: "google", redirectUri: "https://app.example/auth/callback", }); + void wallet.startOidcRedirectAuth({ + provider: "apple", + redirectUri: "https://app.example/auth/callback", + }); void wallet.startOidcRedirectAuth({ // @ts-expect-error github is not configured in this static environment. @@ -193,11 +201,45 @@ void defaultClient.wallet.startOidcRedirectAuth({ provider: "google", redirectUri: "https://app.example/auth/callback", }); +void defaultClient.wallet.startOidcRedirectAuth({ + provider: "google", +}); +void defaultClient.wallet.startOidcRedirectAuth({ + provider: "apple", + redirectUri: "https://app.example/auth/callback", +}); +void defaultClient.wallet.completeOidcRedirectAuth(); +void defaultClient.wallet.completeOidcRedirectAuth({ + walletSelection: "manual", + sessionLifetimeSeconds: 120, +}); void defaultClient.wallet.startOidcRedirectAuth({ // @ts-expect-error github is not configured on the default auth config. provider: "github", redirectUri: "https://app.example/auth/callback", }); +void defaultClient.wallet.signInWithOidcRedirect({ + provider: "google", +}); +void defaultClient.wallet.signInWithOidcRedirect({ + provider: "apple", +}); +void defaultClient.wallet.signInWithOidcRedirect({ + provider: "google", + walletSelection: "manual", + sessionLifetimeSeconds: 120, +}); +// @ts-expect-error provider is required when starting redirect sign-in. +void defaultClient.wallet.signInWithOidcRedirect(); +// @ts-expect-error provider is required when starting redirect with a redirectUri override. +void defaultClient.wallet.signInWithOidcRedirect({ + redirectUri: "https://app.example/auth/callback", +}); +// @ts-expect-error provider is required when starting redirect with assignUrl. +void defaultClient.wallet.signInWithOidcRedirect({ + currentUrl: "https://app.example/login", + assignUrl: (url: string) => { void url; }, +}); const customClient = new OMSClient({ publishableKey: "pk_dev_sdbx_project_key", @@ -210,6 +252,10 @@ void customClient.wallet.startOidcRedirectAuth({ provider: "google", redirectUri: "https://app.example/auth/callback", }); +void customClient.wallet.startOidcRedirectAuth({ + provider: "apple", + redirectUri: "https://app.example/auth/callback", +}); const noProviderClient = new OMSClient({ publishableKey: "pk_dev_sdbx_project_key", From 39c48914ea62ae00ef5a7c9937a092d101b50993 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 01:31:49 +0300 Subject: [PATCH 2/9] Update browser examples for OIDC and shared UI --- examples/react/README.md | 2 +- examples/react/src/main.tsx | 410 +++--------- examples/react/src/styles.css | 548 +--------------- examples/react/tsconfig.json | 7 +- examples/react/vite.config.ts | 4 + examples/shared/example-components.tsx | 339 ++++++++++ examples/shared/example-utils.ts | 93 +++ examples/shared/oms-example-base.css | 522 +++++++++++++++ examples/shared/use-session-preferences.ts | 55 ++ examples/shared/vite-react-aliases.ts | 18 + examples/trails-actions/README.md | 1 + examples/trails-actions/src/App.tsx | 729 +++++++++------------ examples/trails-actions/src/styles.css | 559 +--------------- examples/trails-actions/tsconfig.json | 7 +- examples/trails-actions/vite.config.ts | 4 + examples/wagmi/README.md | 1 + examples/wagmi/src/App.tsx | 211 ++---- examples/wagmi/src/omsClient.ts | 2 - examples/wagmi/src/styles.css | 378 +---------- examples/wagmi/tsconfig.json | 7 +- examples/wagmi/vite.config.ts | 4 + 21 files changed, 1568 insertions(+), 2333 deletions(-) create mode 100644 examples/shared/example-components.tsx create mode 100644 examples/shared/example-utils.ts create mode 100644 examples/shared/oms-example-base.css create mode 100644 examples/shared/use-session-preferences.ts create mode 100644 examples/shared/vite-react-aliases.ts diff --git a/examples/react/README.md b/examples/react/README.md index b3cd483..04ac97c 100644 --- a/examples/react/README.md +++ b/examples/react/README.md @@ -30,7 +30,7 @@ cp examples/react/.env.example examples/react/.env.local The Amoy-only "ERC20 example" panel includes a WalletKit Dollar example using the demo WKUSD contract deployed on Polygon Amoy. -Google/OIDC redirect sign-in uses the SDK default Google client id. +Google redirect sign-in uses the SDK default Google client id. Apple redirect sign-in uses the SDK default Apple Services ID. Build it from the repository root: diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 7fd00cf..8273ff2 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -8,12 +8,33 @@ import { type AccessGrant, type Network, type OMSClientSessionExpiredEvent, - type OMSClientSessionLoginType, type OmsWallet, type PendingWalletSelection, type WalletActivationResult, } from '@0xsequence/typescript-sdk' import './styles.css' +import { + EmailCodeForm, + EmailLoginForm, + FeeOptionsPanel, + OidcButtons, + SessionExpiredDialog, + SessionOptions, + WalletSelectionPanel, +} from '../../shared/example-components' +import { + canAffordFeeOption, + formatCount, + formatLoginType, + formatOidcProvider, + formatSessionExpiry, + formatWalletType, + hasOidcCallbackParams, + isPendingWalletSelection, + sameAddress, + type OidcRedirectProvider, +} from '../../shared/example-utils' +import { useSessionPreferences } from '../../shared/use-session-preferences' import { TEST_SESSION_LIFETIME_SECONDS, oms } from './omsClient' import { WalletKitDollarExample } from './WalletKitDollarExample' @@ -45,8 +66,6 @@ function App() { const [managedWallets, setManagedWallets] = useState([]) const [newWalletReference, setNewWalletReference] = useState('') const [accessGrants, setAccessGrants] = useState([]) - const [useManualWalletSelection, setUseManualWalletSelection] = useState(readManualWalletSelectionPreference) - const [sessionLifetimeSeconds, setSessionLifetimeSeconds] = useState(readSessionLifetimePreference) const [pendingWalletSelection, setPendingWalletSelection] = useState(null) const [emailAuthStatus, setEmailAuthStatus] = useState('Enter an email to start.') const [redirectStatus, setRedirectStatus] = useState('') @@ -60,6 +79,18 @@ function App() { const selectedNetwork = supportedNetworks.find(network => network.id === selectedNetworkId) ?? Networks.amoy const session = oms.wallet.session + const { + useManualWalletSelection, + setUseManualWalletSelection, + sessionLifetimeSeconds, + updateSessionLifetime, + saveSessionPreferences, + walletSelection, + } = useSessionPreferences({ + manualWalletSelectionKey: MANUAL_WALLET_SELECTION_KEY, + sessionLifetimeSecondsKey: SESSION_LIFETIME_SECONDS_KEY, + defaultSessionLifetimeSeconds: TEST_SESSION_LIFETIME_SECONDS, + }) useEffect(() => { return oms.wallet.onSessionExpired(showSessionExpired) @@ -73,8 +104,7 @@ function App() { return } - const params = new URLSearchParams(window.location.search) - if (params.has('code') || params.has('state') || params.has('error')) { + if (hasOidcCallbackParams()) { if (oidcCallbackStarted.current) return oidcCallbackStarted.current = true void completeOidcRedirect() @@ -94,14 +124,6 @@ function App() { } }, [selectedNetworkId, step]) - useEffect(() => { - window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') - }, [useManualWalletSelection]) - - useEffect(() => { - window.sessionStorage.setItem(SESSION_LIFETIME_SECONDS_KEY, sessionLifetimeSeconds.toString()) - }, [sessionLifetimeSeconds]) - async function run( label: string, setActiveStatus: (message: string) => void, @@ -133,21 +155,21 @@ function App() { await run('Completing sign-in...', setEmailAuthStatus, async () => { const result = await oms.wallet.completeEmailAuth({ code: code.trim(), - walletSelection: useManualWalletSelection ? 'manual' : 'automatic', + walletSelection, sessionLifetimeSeconds, }) 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') - window.sessionStorage.setItem(SESSION_LIFETIME_SECONDS_KEY, sessionLifetimeSeconds.toString()) + async function startOidcRedirect(provider: OidcRedirectProvider) { + const label = formatOidcProvider(provider) + await run(`Redirecting to ${label}...`, setRedirectStatus, async () => { + saveSessionPreferences() setPendingWalletSelection(null) await oms.wallet.signInWithOidcRedirect({ - provider: 'google', - loginHint: email.trim() || oms.wallet.session.sessionEmail, + provider, + walletSelection, sessionLifetimeSeconds, }) }) @@ -155,13 +177,9 @@ function App() { async function completeOidcRedirect() { await run('Completing redirect sign-in...', setRedirectStatus, async () => { - const result = await oms.wallet.signInWithOidcRedirect({ - provider: 'google', - walletSelection: readManualWalletSelectionPreference() ? 'manual' : 'automatic', - sessionLifetimeSeconds: readSessionLifetimePreference(), - }) + const result = await oms.wallet.completeOidcRedirectAuth() if (result) { - handleAuthCompletion(result, 'Google login complete.') + handleAuthCompletion(result, 'Redirect login complete.') return } @@ -225,12 +243,25 @@ function App() { if (expiredSession.loginType === 'google-auth') { await run('Redirecting to Google...', setRedirectStatus, async () => { setSessionExpiredPrompt(null) - window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') - window.sessionStorage.setItem(SESSION_LIFETIME_SECONDS_KEY, sessionLifetimeSeconds.toString()) + saveSessionPreferences() setPendingWalletSelection(null) await oms.wallet.signInWithOidcRedirect({ provider: 'google', - loginHint: expiredSession.sessionEmail, + walletSelection, + sessionLifetimeSeconds, + }) + }) + return + } + + if (expiredSession.loginType === 'oidc') { + await run('Redirecting to Apple...', setRedirectStatus, async () => { + setSessionExpiredPrompt(null) + saveSessionPreferences() + setPendingWalletSelection(null) + await oms.wallet.signInWithOidcRedirect({ + provider: 'apple', + walletSelection, sessionLifetimeSeconds, }) }) @@ -259,12 +290,6 @@ function App() { setStep('email') } - function updateSessionLifetime(value: string) { - const next = Math.floor(Number(value)) - if (!Number.isFinite(next)) return - setSessionLifetimeSeconds(Math.max(1, next)) - } - async function selectPendingWallet(wallet: OmsWallet) { if (!pendingWalletSelection) return await run('Selecting wallet...', setEmailAuthStatus, async () => { @@ -464,152 +489,56 @@ function App() {

OMS Client Typescript SDK

Wallet Demo

{step === 'email' && ( -
- - -
+ )} {step === 'email' && ( -
{ - event.preventDefault() - void startEmailAuth() - }}> +

Login Options

-
- - {redirectStatus &&

{redirectStatus}

} -
+ void startOidcRedirect(provider)} + />
or
-
- -

{emailAuthStatus}

-
- - + void startEmailAuth()} + /> +
)} {step === 'code' && ( -
{ - event.preventDefault() - void completeEmailAuth() - }}> -
- -

{emailAuthStatus}

-
-
- - -
-
+ void completeEmailAuth()} + onBack={() => setStep('email')} + /> )} {step === 'wallet-selection' && pendingWalletSelection && ( -
-
-
-

Choose wallet

- {formatWalletType(pendingWalletSelection.walletType)} -
-

Existing wallets

- {pendingWalletSelection.wallets.length > 0 ? ( -
- {pendingWalletSelection.wallets.map(wallet => ( - - ))} -
- ) : ( -

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

- )} - -

Create new wallet

- - - -
- {emailAuthStatus && {emailAuthStatus}} -
+ void selectPendingWallet(wallet)} + onCreateWallet={() => void createPendingWallet()} + onCancel={() => void cancelPendingWalletSelection()} + /> )} {step === 'wallet' && ( @@ -858,86 +787,17 @@ function App() { )} {sessionExpiredPrompt && ( -
-
-

Session expired

-

- Your wallet session has expired. Reauthenticate to continue using this wallet. -

- {sessionExpiredPrompt.session.sessionEmail && ( -

- Account {sessionExpiredPrompt.session.sessionEmail} -

- )} -

- {sessionExpiredPrompt.session.loginType === 'google-auth' - ? 'You will be redirected to Google with the same account selected.' - : sessionExpiredPrompt.session.loginType === 'email' && sessionExpiredPrompt.session.sessionEmail - ? 'A new sign-in code will be sent to the same email address.' - : 'Sign in again to continue.'} -

-
- - -
-
-
+ void reauthenticateExpiredSession()} + onDismiss={dismissSessionExpiredPrompt} + /> )} ) } -function FeeOptionsPanel({ - feeOptions, - onCancel, - onChoose, -}: { - feeOptions: FeeOptionWithBalance[] - onCancel: () => void - onChoose: (option: FeeOptionWithBalance) => void -}) { - return ( -
-
-

Fee option

-
- {feeOptions.map((option) => { - const canAfford = canAffordFeeOption(option) - - return ( - - ) - })} -
- -
-
- ) -} - createRoot(document.getElementById('root')!).render( @@ -947,65 +807,3 @@ createRoot(document.getElementById('root')!).render( function transactionExplorerUrl(network: Network, txnHash: string): string { return `${network.explorerUrl.replace(/\/+$/, '')}/tx/${txnHash}` } - -function formatLoginType(loginType: OMSClientSessionLoginType | undefined): string { - switch (loginType) { - case 'email': - return 'Email' - case 'google-auth': - return 'Google' - case 'oidc': - return 'OIDC' - default: - return 'Unknown' - } -} - -function formatSessionExpiry(expiresAt: string | undefined): string { - if (!expiresAt) return 'Unknown' - - 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 formatCount(count: number, singular: string): string { - return `${count} ${singular}${count === 1 ? '' : 's'}` -} - -function sameAddress(left: string, right: string): boolean { - return left.toLowerCase() === right.toLowerCase() -} - -function isPendingWalletSelection( - result: PendingWalletSelection | WalletActivationResult, -): result is PendingWalletSelection { - return 'selectWallet' in result -} - -function canAffordFeeOption(option: FeeOptionWithBalance): boolean { - if (option.availableRaw === undefined) return false - - try { - return BigInt(option.availableRaw) >= BigInt(option.feeOption.value) - } catch { - return false - } -} - -function readManualWalletSelectionPreference(): boolean { - return window.sessionStorage.getItem(MANUAL_WALLET_SELECTION_KEY) === 'true' -} - -function readSessionLifetimePreference(): number { - const stored = Number(window.sessionStorage.getItem(SESSION_LIFETIME_SECONDS_KEY)) - return Number.isFinite(stored) && stored > 0 - ? Math.floor(stored) - : TEST_SESSION_LIFETIME_SECONDS -} diff --git a/examples/react/src/styles.css b/examples/react/src/styles.css index f3b8a4c..56cd883 100644 --- a/examples/react/src/styles.css +++ b/examples/react/src/styles.css @@ -1,257 +1,5 @@ -/* Design tokens live in one place — see examples/shared/oms-tokens.css. */ -@import url("../../shared/oms-tokens.css"); - -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -button, -input, -select { - font: inherit; -} - -.shell { - min-height: 100vh; - display: grid; - place-items: center; - padding: 32px; -} - -.panel { - width: min(100%, 560px); - display: grid; - gap: 18px; - padding: 28px; - border: 1px solid var(--oms-slate-200); - border-radius: 24px; - background: var(--oms-surface); - box-shadow: 0 24px 60px rgb(20 16 53 / 10%); -} - -.eyebrow { - margin: 0 0 6px; - color: var(--oms-brand); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -h1 { - margin: 0; - color: var(--oms-ink); - font-size: 28px; - font-weight: 700; - line-height: 1.15; -} - -.section-title { - margin: 0; - color: var(--oms-ink); - font-size: 17px; - font-weight: 700; - line-height: 1.25; -} - -.stack { - display: grid; - gap: 16px; -} - -label { - display: grid; - gap: 8px; - color: var(--oms-slate-900); - font-size: 14px; - font-weight: 600; -} - -.checkbox-row { - display: flex; - align-items: center; - gap: 10px; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-200); - border-radius: 12px; - background: var(--oms-slate-50); -} - -.checkbox-row input { - width: 18px; - min-height: 18px; - margin: 0; - accent-color: var(--oms-brand); -} - -.checkbox-row span { - min-width: 0; -} - -.header-options { - display: grid; - gap: 10px; - margin-top: 14px; -} - -.session-lifetime-option { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(104px, 150px); - align-items: center; - gap: 12px; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-200); - border-radius: 12px; - background: var(--oms-slate-50); -} - -.session-lifetime-option > span { - display: grid; -} - -.session-lifetime-copy { - gap: 3px; -} - -.session-lifetime-copy strong { - color: var(--oms-slate-900); - font-size: 14px; - line-height: 1.25; -} - -.session-lifetime-copy small { - color: var(--oms-muted-ink); - font-size: 12px; - font-weight: 600; - line-height: 1.35; -} - -.session-lifetime-option > span:last-child { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; -} - -.session-lifetime-option input { - min-height: 36px; - padding: 8px 10px; -} - -.session-lifetime-option input[type='number'] { - appearance: textfield; - -moz-appearance: textfield; -} - -.session-lifetime-option input[type='number']::-webkit-inner-spin-button, -.session-lifetime-option input[type='number']::-webkit-outer-spin-button { - margin: 0; - appearance: none; - -webkit-appearance: none; -} - -.session-lifetime-option small { - color: var(--oms-muted-ink); - font-size: 12px; - font-weight: 700; -} - -.field-stack { - display: grid; - gap: 10px; -} - -.field-hint { - min-height: 42px; - padding: 11px 12px; - border-radius: 12px; - margin: 0; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 14px; -} - -input, -select { - width: 100%; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-300); - border-radius: 12px; - color: var(--oms-ink); - background: var(--oms-surface); - transition: - border-color 160ms ease, - box-shadow 160ms ease; -} - -input:focus, -select:focus { - outline: none; - border-color: var(--oms-slate-400); - box-shadow: var(--oms-input-focus); -} - -button { - min-height: 44px; - border: 1px solid transparent; - border-radius: 12px; - padding: 10px 14px; - color: var(--oms-surface); - background: var(--oms-slate-950); - font-weight: 700; - cursor: pointer; - transition: - background-color 160ms ease, - border-color 160ms ease, - box-shadow 160ms ease; -} - -button:hover:not(:disabled) { - background: var(--oms-slate-800); -} - -button:active:not(:disabled) { - background: var(--oms-purple-700); -} - -button:focus-visible { - outline: none; - box-shadow: var(--oms-focus-ring); -} - -button.secondary { - color: var(--oms-slate-950); - background: transparent; - border-color: var(--oms-slate-500); -} - -button.secondary:hover:not(:disabled) { - background: transparent; - border-color: var(--oms-slate-950); -} - -button.secondary:active:not(:disabled) { - background: var(--oms-purple-100); - border-color: var(--oms-purple-200); -} - -button.subtle { - color: var(--oms-muted-ink); - background: transparent; - border: 1px solid var(--oms-slate-300); -} - -button.subtle:hover:not(:disabled) { - background: transparent; - border-color: var(--oms-slate-400); -} +/* Shared example structure and tokens live in examples/shared. */ +@import url("../../shared/oms-example-base.css"); button.danger { color: var(--oms-red-700); @@ -270,13 +18,6 @@ button.danger:active:not(:disabled) { border-color: var(--oms-red-500); } -button:disabled { - cursor: not-allowed; - color: var(--oms-slate-400); - background: var(--oms-slate-200); - border-color: transparent; -} - .burn-button { position: relative; overflow: hidden; @@ -345,54 +86,6 @@ button:disabled { transform: translateX(-102%); } -.actions { - display: grid; - grid-template-columns: 1fr auto; - gap: 10px; -} - -.divider { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 10px; - color: var(--oms-muted-ink); - font-size: 13px; - font-weight: 700; -} - -.divider::before, -.divider::after { - content: ""; - height: 1px; - background: var(--oms-slate-300); -} - -.tool { - display: grid; - gap: 12px; - padding: 16px; - border: 1px solid var(--oms-slate-200); - border-radius: 16px; - background: var(--oms-slate-50); -} - -.tool h2 { - margin: 0; - color: var(--oms-ink); - font-size: 17px; - font-weight: 700; - line-height: 1.25; -} - -.tool h3 { - margin: 0; - color: var(--oms-ink); - font-size: 15px; - font-weight: 700; - line-height: 1.25; -} - .collapsible-tool { gap: 0; padding: 0; @@ -450,147 +143,13 @@ button:disabled { gap: 8px; } -.tool-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - .network-meta { + justify-content: center; min-width: 48px; - padding: 4px 8px; - border-radius: 16px; color: var(--oms-surface); background: var(--oms-brand); - font-size: 12px; - font-weight: 800; - line-height: 1.2; - text-align: center; -} - -.metadata-pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 48px; - min-height: 24px; - padding: 3px 9px; - border: 1px solid var(--oms-slate-300); - border-radius: 16px; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 12px; font-weight: 800; - line-height: 1; text-align: center; - white-space: nowrap; -} - -select:disabled { - color: var(--oms-muted-ink); - background: var(--oms-slate-100); -} - -.fee-modal-backdrop { - position: fixed; - inset: 0; - z-index: 10; - display: grid; - place-items: center; - padding: 20px; - background: rgb(9 6 36 / 55%); -} - -.fee-options { - width: min(100%, 420px); - max-height: min(640px, calc(100vh - 40px)); - display: grid; - gap: 10px; - overflow: auto; - box-shadow: 0 30px 80px rgb(9 6 36 / 28%); -} - -.fee-option-list { - display: grid; - gap: 8px; -} - -.fee-option, -.wallet-option { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-height: 54px; - padding: 10px 12px; - border-radius: 12px; - color: var(--oms-ink); - background: var(--oms-slate-100); - text-align: left; -} - -.wallet-option { - grid-template-columns: minmax(0, 1fr) auto; - row-gap: 6px; -} - -/* Keep light-background option buttons readable; override the generic button hover/active. */ -.fee-option:hover:not(:disabled), -.wallet-option:hover:not(:disabled) { - background: var(--oms-slate-200); -} - -.fee-option:active:not(:disabled), -.wallet-option:active:not(:disabled) { - background: var(--oms-purple-100); -} - -.fee-option span, -.wallet-option span { - min-width: 0; -} - -.fee-option span:last-child { - color: var(--oms-slate-500); - font-size: 13px; - text-align: right; -} - -.fee-option strong, -.fee-option small, -.wallet-option strong, -.wallet-option small { - display: block; - overflow-wrap: anywhere; -} - -.fee-option small, -.wallet-option small { - margin-top: 3px; - color: var(--oms-muted-ink); - font-size: 12px; -} - -.wallet-option code { - grid-column: 1 / -1; - min-width: 0; - overflow-wrap: anywhere; - color: var(--oms-slate-800); - font-family: var(--oms-font-mono); - font-size: 12px; -} - -.wallet-option-action { - justify-self: end; - color: var(--oms-brand); - font-size: 13px; - font-weight: 800; -} - -.wallet-option-list { - display: grid; - gap: 8px; } .inline-field-action { @@ -662,13 +221,10 @@ select:disabled { .management-card code { display: block; min-width: 0; - overflow-wrap: anywhere; padding: 8px 10px; border-radius: var(--oms-radius-button); color: var(--oms-slate-800); background: var(--oms-slate-100); - font-family: var(--oms-font-mono); - font-size: 12px; } .management-meta { @@ -702,15 +258,6 @@ select:disabled { font-weight: 700; } -output { - min-height: 42px; - padding: 11px 12px; - border-radius: 12px; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 14px; -} - .wallet { display: grid; gap: 6px; @@ -725,12 +272,10 @@ output { .wallet code { min-width: 0; - overflow-wrap: anywhere; padding: 10px 12px; border-radius: 12px; background: var(--oms-slate-950); color: var(--oms-purple-100); - font-family: var(--oms-font-mono); } .session-info { @@ -807,94 +352,9 @@ output { text-decoration: underline; } -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: 20px; - background: rgb(9 6 36 / 52%); -} - -.modal { - width: min(100%, 420px); - display: grid; - gap: 14px; - padding: 24px; - border-radius: 24px; - background: var(--oms-surface); - box-shadow: 0 30px 80px rgb(9 6 36 / 28%); -} - -.modal h2, -.modal p { - margin: 0; -} - -.modal h2 { - color: var(--oms-ink); - font-size: 21px; - font-weight: 700; - line-height: 1.25; -} - -.modal p { - color: var(--oms-muted-ink); - font-size: 14px; - line-height: 1.5; -} - -.modal-detail { - padding: 10px 12px; - border: 1px solid var(--oms-slate-200); - border-radius: 12px; - background: var(--oms-slate-50); - overflow-wrap: anywhere; -} - -.modal-detail strong { - color: var(--oms-ink); -} - -.modal-hint { - padding: 10px 12px; - border-radius: 12px; - background: var(--oms-slate-100); -} - -.modal-actions { - display: grid; - grid-template-columns: 1fr auto; - gap: 10px; -} - @media (max-width: 520px) { - .shell { - padding: 16px; - } - - .panel { - padding: 20px; - } - - .actions { - grid-template-columns: 1fr; - } - - .session-lifetime-option { - grid-template-columns: 1fr; - } - - .session-info { - grid-template-columns: 1fr; - } - + .session-info, .inline-field-action { grid-template-columns: 1fr; } - - .modal-actions { - grid-template-columns: 1fr; - } } diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json index e8c909a..6cc1f89 100644 --- a/examples/react/tsconfig.json +++ b/examples/react/tsconfig.json @@ -9,12 +9,17 @@ "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, + "baseUrl": ".", "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "paths": { + "react": ["./node_modules/@types/react"], + "react/*": ["./node_modules/@types/react/*"] + } }, "include": ["src"], "references": [] diff --git a/examples/react/vite.config.ts b/examples/react/vite.config.ts index e85d558..4097172 100644 --- a/examples/react/vite.config.ts +++ b/examples/react/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { reactAliasesForExample } from '../shared/vite-react-aliases' export default defineConfig({ base: process.env.GITHUB_PAGES === 'true' ? '/typescript-sdk/react-example/' : '/', plugins: [react()], + resolve: { + alias: reactAliasesForExample(import.meta.url), + }, server: { port: 5173, strictPort: true, diff --git a/examples/shared/example-components.tsx b/examples/shared/example-components.tsx new file mode 100644 index 0000000..cc562f4 --- /dev/null +++ b/examples/shared/example-components.tsx @@ -0,0 +1,339 @@ +import type { + FeeOptionWithBalance, + OMSClientSessionExpiredEvent, + OmsWallet, + PendingWalletSelection, +} from '@0xsequence/typescript-sdk' +import { + canAffordFeeOption, + formatOidcProvider, + formatWalletType, + type OidcRedirectProvider, +} from './example-utils' + +export function SessionOptions({ + useManualWalletSelection, + sessionLifetimeSeconds, + disabled, + onManualWalletSelectionChange, + onSessionLifetimeChange, +}: { + useManualWalletSelection: boolean + sessionLifetimeSeconds: number + disabled: boolean + onManualWalletSelectionChange: (value: boolean) => void + onSessionLifetimeChange: (value: string) => void +}) { + return ( +
+ + +
+ ) +} + +export function OidcButtons({ + providers, + disabled, + status, + statusId = 'redirect-status', + buttonClassName = 'secondary', + onStart, +}: { + providers: OidcRedirectProvider[] + disabled: boolean + status?: string + statusId?: string + buttonClassName?: string + onStart: (provider: OidcRedirectProvider) => void +}) { + if (providers.length === 0) return null + + return ( +
+ {providers.map((provider) => ( + + ))} + {status ?

{status}

: null} +
+ ) +} + +export function EmailLoginForm({ + email, + disabled, + status, + onEmailChange, + onSubmit, +}: { + email: string + disabled: boolean + status?: string + onEmailChange: (value: string) => void + onSubmit: () => void +}) { + return ( +
{ + event.preventDefault() + onSubmit() + }}> +
+ + {status !== undefined ?

{status}

: null} +
+ +
+ ) +} + +export function EmailCodeForm({ + code, + disabled, + status, + onCodeChange, + onSubmit, + onBack, +}: { + code: string + disabled: boolean + status?: string + onCodeChange: (value: string) => void + onSubmit: () => void + onBack: () => void +}) { + return ( +
{ + event.preventDefault() + onSubmit() + }}> +
+ + {status !== undefined ?

{status}

: null} +
+
+ + +
+
+ ) +} + +export function WalletSelectionPanel({ + pendingWalletSelection, + authStatus, + disabled, + onSelectWallet, + onCreateWallet, + onCancel, +}: { + pendingWalletSelection: PendingWalletSelection + authStatus?: string + disabled: boolean + onSelectWallet: (wallet: OmsWallet) => void + onCreateWallet: () => void + onCancel: () => void +}) { + return ( +
+
+
+

Choose wallet

+ {formatWalletType(pendingWalletSelection.walletType)} +
+

Existing wallets

+ {pendingWalletSelection.wallets.length > 0 ? ( +
+ {pendingWalletSelection.wallets.map((wallet) => ( + + ))} +
+ ) : ( +

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

+ )} + +

Create new wallet

+ + + +
+ {authStatus ? {authStatus} : null} +
+ ) +} + +export function FeeOptionsPanel({ + feeOptions, + panelClassName = 'fee-options', + onCancel, + onChoose, +}: { + feeOptions: FeeOptionWithBalance[] + panelClassName?: string + onCancel: () => void + onChoose: (option: FeeOptionWithBalance) => void +}) { + return ( +
+
+

Fee option

+
+ {feeOptions.map((option) => { + const canAfford = canAffordFeeOption(option) + + return ( + + ) + })} +
+ +
+
+ ) +} + +export function SessionExpiredDialog({ + event, + disabled, + onReauthenticate, + onDismiss, +}: { + event: OMSClientSessionExpiredEvent + disabled: boolean + onReauthenticate: () => void + onDismiss: () => void +}) { + return ( +
+
+

Session expired

+

+ Your wallet session has expired. Reauthenticate to continue using this wallet. +

+ {event.session.sessionEmail && ( +

+ Account {event.session.sessionEmail} +

+ )} +

+ {event.session.loginType === 'google-auth' + ? 'You will be redirected to Google with the same account selected.' + : event.session.loginType === 'oidc' + ? 'You will be redirected to Apple.' + : event.session.loginType === 'email' && event.session.sessionEmail + ? 'A new sign-in code will be sent to the same email address.' + : 'Sign in again to continue.'} +

+
+ + +
+
+
+ ) +} diff --git a/examples/shared/example-utils.ts b/examples/shared/example-utils.ts new file mode 100644 index 0000000..9332058 --- /dev/null +++ b/examples/shared/example-utils.ts @@ -0,0 +1,93 @@ +import type { + FeeOptionWithBalance, + OMSClientSessionLoginType, + PendingWalletSelection, + WalletActivationResult, +} from '@0xsequence/typescript-sdk' + +export type OidcRedirectProvider = 'google' | 'apple' + +export function hasOidcCallbackParams(search: string = window.location.search): boolean { + const params = new URLSearchParams(search) + return params.has('code') || params.has('state') || params.has('error') +} + +export function formatOidcProvider(provider: OidcRedirectProvider): string { + return provider === 'google' ? 'Google' : 'Apple' +} + +export function formatLoginType( + loginType: OMSClientSessionLoginType | undefined, + fallback = 'Unknown', +): string { + switch (loginType) { + case 'email': + return 'Email' + case 'google-auth': + return 'Google' + case 'oidc': + return 'Apple' + default: + return fallback + } +} + +export function formatSessionExpiry(expiresAt: string | undefined): string { + if (!expiresAt) return 'Unknown' + + const date = new Date(expiresAt) + return Number.isNaN(date.getTime()) ? expiresAt : date.toLocaleString() +} + +export function formatWalletType(walletType: string): string { + return walletType + .split(/[-_]/) + .map((part) => part ? part[0].toUpperCase() + part.slice(1) : part) + .join(' ') +} + +export function formatCount(count: number, singular: string): string { + return `${count} ${singular}${count === 1 ? '' : 's'}` +} + +export function sameAddress(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase() +} + +export function shortAddress(address: string): string { + return `${address.slice(0, 6)}...${address.slice(-4)}` +} + +export function shortHash(hash: string): string { + return `${hash.slice(0, 10)}...${hash.slice(-8)}` +} + +export function canAffordFeeOption(option: FeeOptionWithBalance): boolean { + if (option.availableRaw === undefined) return false + + try { + return BigInt(option.availableRaw) >= BigInt(option.feeOption.value) + } catch { + return false + } +} + +export function isPendingWalletSelection( + result: PendingWalletSelection | WalletActivationResult, +): result is PendingWalletSelection { + return 'selectWallet' in result +} + +export function readStoredBoolean(key: string): boolean { + return window.sessionStorage.getItem(key) === 'true' +} + +export function readStoredPositiveInteger(key: string, fallback: number): number { + const stored = window.sessionStorage.getItem(key) + if (!stored) return fallback + + const parsed = Number(stored) + return Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : fallback +} diff --git a/examples/shared/oms-example-base.css b/examples/shared/oms-example-base.css new file mode 100644 index 0000000..80ef077 --- /dev/null +++ b/examples/shared/oms-example-base.css @@ -0,0 +1,522 @@ +/* Shared structure for browser examples. Design tokens live in oms-tokens.css. */ +@import url("./oms-tokens.css"); + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input, +select { + font: inherit; +} + +.shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.panel { + width: min(100%, 560px); + display: grid; + gap: 18px; + padding: 28px; + border: 1px solid var(--oms-slate-200); + border-radius: 24px; + background: var(--oms-surface); + box-shadow: 0 24px 60px rgb(20 16 53 / 10%); +} + +h1, +h2, +h3, +p { + margin: 0; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--oms-brand); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +h1 { + color: var(--oms-ink); + font-size: 28px; + font-weight: 700; + line-height: 1.15; +} + +h2, +.section-title { + color: var(--oms-ink); + font-size: 17px; + font-weight: 700; + line-height: 1.25; +} + +h3 { + color: var(--oms-ink); + font-size: 15px; + font-weight: 700; + line-height: 1.25; +} + +.stack { + display: grid; + gap: 16px; +} + +label { + display: grid; + gap: 8px; + color: var(--oms-slate-900); + font-size: 14px; + font-weight: 600; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--oms-slate-200); + border-radius: 12px; + background: var(--oms-slate-50); +} + +.checkbox-row input { + width: 18px; + min-height: 18px; + margin: 0; + accent-color: var(--oms-brand); +} + +.checkbox-row span { + min-width: 0; +} + +.header-options { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.session-lifetime-option { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(104px, 150px); + align-items: center; + gap: 12px; + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--oms-slate-200); + border-radius: 12px; + background: var(--oms-slate-50); +} + +.session-lifetime-option > span { + display: grid; +} + +.session-lifetime-copy { + gap: 3px; +} + +.session-lifetime-copy strong { + color: var(--oms-slate-900); + font-size: 14px; + line-height: 1.25; +} + +.session-lifetime-copy small { + color: var(--oms-muted-ink); + font-size: 12px; + font-weight: 600; + line-height: 1.35; +} + +.session-lifetime-option > span:last-child { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.session-lifetime-option input { + min-height: 36px; + padding: 8px 10px; +} + +.session-lifetime-option input[type='number'] { + appearance: textfield; + -moz-appearance: textfield; +} + +.session-lifetime-option input[type='number']::-webkit-inner-spin-button, +.session-lifetime-option input[type='number']::-webkit-outer-spin-button { + margin: 0; + appearance: none; + -webkit-appearance: none; +} + +.session-lifetime-option small { + color: var(--oms-muted-ink); + font-size: 12px; + font-weight: 700; +} + +.field-stack { + display: grid; + gap: 10px; +} + +.field-hint { + min-height: 42px; + padding: 11px 12px; + border-radius: 12px; + margin: 0; + color: var(--oms-slate-800); + background: var(--oms-slate-100); + font-size: 14px; + overflow-wrap: anywhere; +} + +.compact-hint { + min-height: auto; +} + +input, +select { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--oms-slate-300); + border-radius: 12px; + color: var(--oms-ink); + background: var(--oms-surface); + font-weight: 400; + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--oms-slate-400); + box-shadow: var(--oms-input-focus); +} + +input:disabled, +select:disabled { + color: var(--oms-muted-ink); + background: var(--oms-slate-100); +} + +button { + min-height: 44px; + border: 1px solid transparent; + border-radius: 12px; + padding: 10px 14px; + color: var(--oms-surface); + background: var(--oms-slate-950); + font-weight: 700; + cursor: pointer; + transition: + background-color 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +button:hover:not(:disabled) { + background: var(--oms-slate-800); +} + +button:active:not(:disabled) { + background: var(--oms-purple-700); +} + +button:focus-visible { + outline: none; + box-shadow: var(--oms-focus-ring); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +button.secondary { + color: var(--oms-slate-950); + background: transparent; + border-color: var(--oms-slate-500); +} + +button.secondary:hover:not(:disabled) { + background: transparent; + border-color: var(--oms-slate-950); +} + +button.secondary:active:not(:disabled) { + background: var(--oms-purple-100); + border-color: var(--oms-purple-200); +} + +button.secondary:disabled { + color: var(--oms-slate-400); + background: var(--oms-slate-200); + border-color: transparent; +} + +button.subtle { + min-height: 38px; + padding: 8px 12px; + font-size: 13px; +} + +.actions { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; +} + +.divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 10px; + color: var(--oms-muted-ink); + font-size: 13px; + font-weight: 700; +} + +.divider::before, +.divider::after { + content: ""; + height: 1px; + background: var(--oms-slate-300); +} + +.tool { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid var(--oms-slate-200); + border-radius: 20px; + background: var(--oms-slate-50); +} + +.tool-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.metadata-pill, +.network-meta { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: var(--oms-radius-pill); + color: var(--oms-slate-800); + background: var(--oms-slate-100); + font-size: 12px; + font-weight: 700; +} + +code { + color: var(--oms-ink); + font-family: var(--oms-font-mono); + font-size: 12px; + overflow-wrap: anywhere; +} + +output { + min-height: 42px; + padding: 11px 12px; + border-radius: 12px; + color: var(--oms-slate-800); + background: var(--oms-slate-100); + font-size: 14px; +} + +.fee-modal-backdrop, +.modal-backdrop { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgb(9 6 36 / 55%); +} + +.fee-modal-backdrop { + z-index: 10000; +} + +.modal-backdrop { + z-index: 20; +} + +.fee-options { + width: min(100%, 420px); + max-height: min(640px, calc(100vh - 40px)); + display: grid; + gap: 10px; + padding: 24px; + border: 1px solid var(--oms-slate-200); + border-radius: 24px; + background: var(--oms-surface); + overflow: auto; + box-shadow: 0 30px 80px rgb(9 6 36 / 28%); +} + +.fee-option-list { + display: grid; + gap: 8px; +} + +.fee-option, +.wallet-option { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 54px; + padding: 10px 12px; + border-radius: 12px; + color: var(--oms-ink); + background: var(--oms-slate-100); + text-align: left; +} + +.wallet-option { + row-gap: 6px; +} + +.fee-option:hover:not(:disabled), +.wallet-option:hover:not(:disabled) { + background: var(--oms-slate-200); +} + +.fee-option:active:not(:disabled), +.wallet-option:active:not(:disabled) { + background: var(--oms-purple-100); +} + +.fee-option span, +.wallet-option span { + min-width: 0; +} + +.fee-option span:last-child { + color: var(--oms-slate-500); + font-size: 13px; + text-align: right; +} + +.fee-option strong, +.fee-option small, +.wallet-option strong, +.wallet-option small { + display: block; + overflow-wrap: anywhere; +} + +.fee-option small, +.wallet-option small { + margin-top: 3px; + color: var(--oms-muted-ink); + font-size: 12px; +} + +.wallet-option-list { + display: grid; + gap: 8px; +} + +.wallet-option code { + text-align: right; +} + +.wallet-option-action { + grid-column: 1 / -1; + color: var(--oms-brand); + font-size: 12px; + font-weight: 800; +} + +.modal { + width: min(100%, 420px); + display: grid; + gap: 14px; + padding: 24px; + border-radius: 24px; + background: var(--oms-surface); + box-shadow: 0 30px 80px rgb(9 6 36 / 28%); +} + +.modal h2 { + color: var(--oms-ink); + font-size: 21px; + font-weight: 700; + line-height: 1.25; +} + +.modal p { + color: var(--oms-muted-ink); + font-size: 14px; + line-height: 1.5; +} + +.modal-detail { + padding: 10px 12px; + border: 1px solid var(--oms-slate-200); + border-radius: 12px; + background: var(--oms-slate-50); + overflow-wrap: anywhere; +} + +.modal-detail strong { + color: var(--oms-ink); +} + +.modal-hint { + padding: 10px 12px; + border-radius: 12px; + background: var(--oms-slate-100); +} + +.modal-actions { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; +} + +@media (max-width: 520px) { + .shell { + padding: 16px; + } + + .panel { + padding: 20px; + } + + .actions, + .session-lifetime-option, + .modal-actions { + grid-template-columns: 1fr; + } +} diff --git a/examples/shared/use-session-preferences.ts b/examples/shared/use-session-preferences.ts new file mode 100644 index 0000000..a0924f6 --- /dev/null +++ b/examples/shared/use-session-preferences.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from 'react' +import type { WalletSelectionBehavior } from '@0xsequence/typescript-sdk' +import { readStoredBoolean, readStoredPositiveInteger } from './example-utils' + +export function useSessionPreferences({ + manualWalletSelectionKey, + sessionLifetimeSecondsKey, + defaultSessionLifetimeSeconds, +}: { + manualWalletSelectionKey: string + sessionLifetimeSecondsKey: string + defaultSessionLifetimeSeconds: number +}) { + const [useManualWalletSelection, setUseManualWalletSelection] = useState( + () => readStoredBoolean(manualWalletSelectionKey), + ) + const [sessionLifetimeSeconds, setSessionLifetimeSeconds] = useState( + () => readStoredPositiveInteger(sessionLifetimeSecondsKey, defaultSessionLifetimeSeconds), + ) + + useEffect(() => { + window.sessionStorage.setItem(manualWalletSelectionKey, useManualWalletSelection ? 'true' : 'false') + }, [manualWalletSelectionKey, useManualWalletSelection]) + + useEffect(() => { + window.sessionStorage.setItem(sessionLifetimeSecondsKey, sessionLifetimeSeconds.toString()) + }, [sessionLifetimeSecondsKey, sessionLifetimeSeconds]) + + const updateSessionLifetime = useCallback((value: string) => { + const next = Math.floor(Number(value)) + if (!Number.isFinite(next)) return + setSessionLifetimeSeconds(Math.max(1, next)) + }, []) + + const saveSessionPreferences = useCallback(() => { + window.sessionStorage.setItem(manualWalletSelectionKey, useManualWalletSelection ? 'true' : 'false') + window.sessionStorage.setItem(sessionLifetimeSecondsKey, sessionLifetimeSeconds.toString()) + }, [ + manualWalletSelectionKey, + sessionLifetimeSeconds, + sessionLifetimeSecondsKey, + useManualWalletSelection, + ]) + + const walletSelection: WalletSelectionBehavior = useManualWalletSelection ? 'manual' : 'automatic' + + return { + useManualWalletSelection, + setUseManualWalletSelection, + sessionLifetimeSeconds, + updateSessionLifetime, + saveSessionPreferences, + walletSelection, + } +} diff --git a/examples/shared/vite-react-aliases.ts b/examples/shared/vite-react-aliases.ts new file mode 100644 index 0000000..dc86b97 --- /dev/null +++ b/examples/shared/vite-react-aliases.ts @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from 'node:url' + +export function reactAliasesForExample(importMetaUrl: string) { + return [ + { + find: /^react$/, + replacement: fileURLToPath(new URL('./node_modules/react/index.js', importMetaUrl)), + }, + { + find: /^react\/jsx-runtime$/, + replacement: fileURLToPath(new URL('./node_modules/react/jsx-runtime.js', importMetaUrl)), + }, + { + find: /^react\/jsx-dev-runtime$/, + replacement: fileURLToPath(new URL('./node_modules/react/jsx-dev-runtime.js', importMetaUrl)), + }, + ] +} diff --git a/examples/trails-actions/README.md b/examples/trails-actions/README.md index 53b411e..7ba337e 100644 --- a/examples/trails-actions/README.md +++ b/examples/trails-actions/README.md @@ -21,6 +21,7 @@ The dev server runs at `http://localhost:5173`. The deployed example is available at `https://0xsequence.github.io/typescript-sdk/trails-actions-example`. The OMS project used by the environment values must support Polygon. +Google and Apple redirect login use the SDK's default provider helpers. Build it from the repository root: diff --git a/examples/trails-actions/src/App.tsx b/examples/trails-actions/src/App.tsx index 124c5fe..b9c5e91 100644 --- a/examples/trails-actions/src/App.tsx +++ b/examples/trails-actions/src/App.tsx @@ -1,14 +1,35 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react' -import type { - FeeOptionSelection, - FeeOptionWithBalance, - OMSClientSessionExpiredEvent, - OMSClientSessionState, - OmsWallet, - PendingWalletSelection, - SendTransactionResponse, - WalletActivationResult, +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + FeeOptionSelector, + type FeeOptionSelection, + type FeeOptionWithBalance, + type OMSClientSessionExpiredEvent, + type OMSClientSessionState, + type OmsWallet, + type PendingWalletSelection, + type SendTransactionResponse, + type WalletActivationResult, } from '@0xsequence/typescript-sdk' +import { + EmailCodeForm, + EmailLoginForm, + FeeOptionsPanel, + OidcButtons, + SessionExpiredDialog, + SessionOptions, + WalletSelectionPanel, +} from '../../shared/example-components' +import { + canAffordFeeOption, + formatLoginType, + formatOidcProvider, + formatSessionExpiry, + formatWalletType, + hasOidcCallbackParams, + isPendingWalletSelection, + type OidcRedirectProvider, +} from '../../shared/example-utils' +import { useSessionPreferences } from '../../shared/use-session-preferences' import { TEST_SESSION_LIFETIME_SECONDS, oms } from './omsClient' import { DEFAULT_DEPOSIT_USDC_AMOUNT, @@ -46,12 +67,18 @@ type FeeSelectionController = { resolve: (selection: FeeOptionSelection) => void reject: (error: Error) => void } +type AutoFeeOptionKey = 'swap' | 'deposit' | 'earn' const MANUAL_WALLET_SELECTION_KEY = 'oms-trails-actions-manual-wallet-selection' const SESSION_LIFETIME_SECONDS_KEY = 'oms-trails-actions-session-lifetime-seconds' const NO_EARN_POSITIONS_STATUS = 'No deposited earn positions.' const POST_SEND_REFRESH_ATTEMPTS = 24 const POST_SEND_REFRESH_DELAY_MS = 2500 +const DEFAULT_AUTO_FEE_OPTIONS: Record = { + swap: true, + deposit: true, + earn: true, +} type SignedInDataRefresh = { balances: BalanceState | null @@ -64,8 +91,6 @@ function App() { const [email, setEmail] = useState('') const [code, setCode] = useState('') const [pendingWalletSelection, setPendingWalletSelection] = useState(null) - const [useManualWalletSelection, setUseManualWalletSelection] = useState(readManualWalletSelectionPreference) - const [sessionLifetimeSeconds, setSessionLifetimeSeconds] = useState(readSessionLifetimePreference) const [authStatus, setAuthStatus] = useState('Enter an email to start.') const [redirectStatus, setRedirectStatus] = useState('') const [sessionExpiredPrompt, setSessionExpiredPrompt] = useState(null) @@ -87,6 +112,8 @@ function App() { const [withdrawStatuses, setWithdrawStatuses] = useState>({}) const [lastWithdrawTransactions, setLastWithdrawTransactions] = useState>({}) const [feeOptions, setFeeOptions] = useState([]) + const [autoFeeOptions, setAutoFeeOptions] = useState(DEFAULT_AUTO_FEE_OPTIONS) + const [withdrawAutoFeeOptions, setWithdrawAutoFeeOptions] = useState>({}) const [logLines, setLogLines] = useState(['Ready.']) const [loadingAction, setLoadingAction] = useState(null) const [walletCopyLabel, setWalletCopyLabel] = useState<'Copy' | 'Copied'>('Copy') @@ -98,6 +125,18 @@ function App() { const walletAddress = session.walletAddress const isSignedIn = walletAddress != null const isBusy = loadingAction != null + const { + useManualWalletSelection, + setUseManualWalletSelection, + sessionLifetimeSeconds, + updateSessionLifetime, + saveSessionPreferences, + walletSelection, + } = useSessionPreferences({ + manualWalletSelectionKey: MANUAL_WALLET_SELECTION_KEY, + sessionLifetimeSecondsKey: SESSION_LIFETIME_SECONDS_KEY, + defaultSessionLifetimeSeconds: TEST_SESSION_LIFETIME_SECONDS, + }) const hasVisibleWithdrawStatus = earnPositions.some((position) => withdrawStatuses[position.id]) const showEarnPositionsStatus = !hasVisibleWithdrawStatus && (earnPositions.length > 0 || earnPositionsStatus !== NO_EARN_POSITIONS_STATUS) @@ -187,14 +226,6 @@ function App() { } }, [refreshBalances, refreshEarnPositions, walletAddress]) - useEffect(() => { - window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') - }, [useManualWalletSelection]) - - useEffect(() => { - window.sessionStorage.setItem(SESSION_LIFETIME_SECONDS_KEY, sessionLifetimeSeconds.toString()) - }, [sessionLifetimeSeconds]) - useEffect(() => { return oms.wallet.onSessionExpired(showSessionExpired) }, []) @@ -215,8 +246,7 @@ function App() { return } - const params = new URLSearchParams(window.location.search) - if (params.has('code') || params.has('state') || params.has('error')) { + if (hasOidcCallbackParams()) { if (oidcCallbackStarted.current) return oidcCallbackStarted.current = true void completeOidcRedirect() @@ -246,8 +276,7 @@ function App() { [session.expiresAt, session.loginType, session.sessionEmail], ) - function startEmailAuth(event: FormEvent) { - event.preventDefault() + function startEmailAuth() { void runAction( 'Start email sign-in', async () => { @@ -267,8 +296,7 @@ function App() { ) } - function completeEmailAuth(event: FormEvent) { - event.preventDefault() + function completeEmailAuth() { void runAction( 'Complete email sign-in', async () => { @@ -277,7 +305,7 @@ function App() { setAuthStatus('Verifying code...') const result = await oms.wallet.completeEmailAuth({ code: normalizedCode, - walletSelection: useManualWalletSelection ? 'manual' : 'automatic', + walletSelection, sessionLifetimeSeconds, }) setCode('') @@ -290,51 +318,45 @@ function App() { ) } - function startOidcRedirect() { + function startOidcRedirect(provider: OidcRedirectProvider) { + const providerLabel = formatOidcProvider(provider) void runAction( - 'Start Google sign-in', + `Start ${providerLabel} sign-in`, async () => { - window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') - window.sessionStorage.setItem(SESSION_LIFETIME_SECONDS_KEY, sessionLifetimeSeconds.toString()) + saveSessionPreferences() setSessionExpiredPrompt(null) setPendingWalletSelection(null) - setRedirectStatus('Redirecting to provider...') + setRedirectStatus(`Redirecting to ${providerLabel}...`) await oms.wallet.signInWithOidcRedirect({ - provider: 'google', - loginHint: email.trim() || oms.wallet.session.sessionEmail, + provider, + walletSelection, sessionLifetimeSeconds, }) }, (error) => { - setRedirectStatus(`Google sign-in error: ${describeError(error)}`) + setRedirectStatus(`${providerLabel} sign-in error: ${describeError(error)}`) }, ) } function completeOidcRedirect() { void runAction( - 'Complete Google sign-in', + 'Complete redirect sign-in', async () => { - const result = await oms.wallet.signInWithOidcRedirect({ - provider: 'google', - walletSelection: readManualWalletSelectionPreference() ? 'manual' : 'automatic', - sessionLifetimeSeconds: readSessionLifetimePreference(), - }) + const result = await oms.wallet.completeOidcRedirectAuth() if (result) { - handleAuthCompletion(result, 'Google login complete.') + handleAuthCompletion(result, 'Redirect login complete.') return } const restored = refreshSession() if (restored.walletAddress) { - setRedirectStatus('Google login complete.') + setRedirectStatus('Redirect login complete.') appendLog(`Wallet ready: ${restored.walletAddress}`) - } else { - setAuthStatus('Enter an email to start.') } }, (error) => { - setRedirectStatus(`Google redirect error: ${describeError(error)}`) + setRedirectStatus(`Redirect error: ${describeError(error)}`) }, ) } @@ -356,9 +378,7 @@ function App() { } function showSessionExpired(event: OMSClientSessionExpiredEvent) { - feeSelection.current?.reject(new Error('Session expired')) - feeSelection.current = null - selectedFeeOption.current = null + clearFeeSelection(new Error('Session expired')) setPendingWalletSelection(null) setSession(oms.wallet.session) setAuthStep('email') @@ -391,13 +411,12 @@ function App() { 'Reauthenticate with Google', async () => { setSessionExpiredPrompt(null) - window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') - window.sessionStorage.setItem(SESSION_LIFETIME_SECONDS_KEY, sessionLifetimeSeconds.toString()) + saveSessionPreferences() setPendingWalletSelection(null) setRedirectStatus('Redirecting to Google...') await oms.wallet.signInWithOidcRedirect({ provider: 'google', - loginHint: expiredSession.sessionEmail, + walletSelection, sessionLifetimeSeconds, }) }, @@ -408,6 +427,27 @@ function App() { return } + if (expiredSession.loginType === 'oidc') { + void runAction( + 'Reauthenticate with Apple', + async () => { + setSessionExpiredPrompt(null) + saveSessionPreferences() + setPendingWalletSelection(null) + setRedirectStatus('Redirecting to Apple...') + await oms.wallet.signInWithOidcRedirect({ + provider: 'apple', + walletSelection, + sessionLifetimeSeconds, + }) + }, + (error) => { + setRedirectStatus(`Apple reauth error: ${describeError(error)}`) + }, + ) + return + } + if (expiredSession.loginType === 'email' && expiredSession.sessionEmail) { void runAction( 'Send reauth code', @@ -437,12 +477,6 @@ function App() { setAuthStep('email') } - function updateSessionLifetime(value: string) { - const next = Math.floor(Number(value)) - if (!Number.isFinite(next)) return - setSessionLifetimeSeconds(Math.max(1, next)) - } - function selectPendingWallet(wallet: OmsWallet) { if (!pendingWalletSelection) return void runAction( @@ -524,9 +558,7 @@ function App() { } function updateSwapPolAmount(value: string) { - feeSelection.current?.reject(new Error('Amount changed')) - feeSelection.current = null - setFeeOptions([]) + clearFeeSelection(new Error('Amount changed')) setSwapPolAmount(normalizeAmountInput(value)) setPreparedSwap(null) setLastSwapTransaction(null) @@ -534,9 +566,7 @@ function App() { } function updateDepositUsdcAmount(value: string) { - feeSelection.current?.reject(new Error('Amount changed')) - feeSelection.current = null - setFeeOptions([]) + clearFeeSelection(new Error('Amount changed')) setDepositUsdcAmount(normalizeAmountInput(value)) setPreparedDeposit(null) setLastDepositTransaction(null) @@ -544,9 +574,7 @@ function App() { } function updateEarnPolAmount(value: string) { - feeSelection.current?.reject(new Error('Amount changed')) - feeSelection.current = null - setFeeOptions([]) + clearFeeSelection(new Error('Amount changed')) setEarnPolAmount(normalizeAmountInput(value)) setPreparedEarn(null) setLastEarnTransaction(null) @@ -612,19 +640,14 @@ function App() { async () => { const prepared = requirePreparedTransaction(preparedSwap) const initialBalances = balances - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() try { - setSwapStatus('Swap status: sending...') - const tx = await oms.wallet.sendTransaction({ - network: POLYGON_NETWORK, - to: prepared.to, - value: BigInt(prepared.value), - data: prepared.data, - selectFeeOption: waitForFeeOptionSelection, - }) - const result = transactionResult(tx) + const result = await sendPreparedTransaction( + prepared, + setSwapStatus, + 'Swap status: sending...', + autoFeeOptions.swap, + ) setLastSwapTransaction(result) setSwapStatus(`Swap status: sent ${shortHash(result.value)}. Refreshing balances...`) await waitForPostSendRefresh({ @@ -638,9 +661,7 @@ function App() { staleStatus: `Swap status: sent ${shortHash(result.value)}. USDC balance has not reached the expected swap output yet.`, }) } finally { - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() } }, (error) => { @@ -654,30 +675,19 @@ function App() { 'Send deposit', async () => { const prepared = requirePreparedYieldTransactions(preparedDeposit) - let lastResult: TransactionResult | null = null const initialBalances = balances const initialEarnPositions = earnPositions - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() try { - for (const [index, transaction] of prepared.transactions.entries()) { - const label = prepared.transactions.length === 1 ? 'transaction' : `transaction ${index + 1}/${prepared.transactions.length}` - setDepositStatus(`Deposit status: sending ${label}...`) - const tx = await oms.wallet.sendTransaction({ - network: POLYGON_NETWORK, - to: transaction.to, - value: transaction.value, - data: transaction.data, - selectFeeOption: waitForFeeOptionSelection, - }) - lastResult = transactionResult(tx) - setLastDepositTransaction(lastResult) - setDepositStatus(`Deposit status: sent ${label} ${shortHash(lastResult.value)}.`) - } - - if (!lastResult) throw new Error('Deposit did not send a transaction.') + const lastResult = await sendYieldTransactionBatch({ + autoPickFeeOption: autoFeeOptions.deposit, + emptyError: 'Deposit did not send a transaction.', + onResult: setLastDepositTransaction, + setStatus: setDepositStatus, + statusPrefix: 'Deposit status', + transactions: prepared.transactions, + }) setDepositStatus(`Deposit status: sent ${shortHash(lastResult.value)}. Refreshing balances and earn positions...`) await waitForPostSendRefresh({ initialBalances, @@ -689,9 +699,7 @@ function App() { staleStatus: `Deposit status: sent ${shortHash(lastResult.value)}. Earn position has not updated yet.`, }) } finally { - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() } }, (error) => { @@ -707,19 +715,14 @@ function App() { const prepared = requirePreparedTransaction(preparedEarn) const initialBalances = balances const initialEarnPositions = earnPositions - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() try { - setEarnStatus('Swap and Deposit status: sending...') - const tx = await oms.wallet.sendTransaction({ - network: POLYGON_NETWORK, - to: prepared.to, - value: BigInt(prepared.value), - data: prepared.data, - selectFeeOption: waitForFeeOptionSelection, - }) - const result = transactionResult(tx) + const result = await sendPreparedTransaction( + prepared, + setEarnStatus, + 'Swap and Deposit status: sending...', + autoFeeOptions.earn, + ) setLastEarnTransaction(result) setEarnStatus(`Swap and Deposit status: sent ${shortHash(result.value)}. Refreshing balances and earn positions...`) await waitForPostSendRefresh({ @@ -732,9 +735,7 @@ function App() { staleStatus: `Swap and Deposit status: sent ${shortHash(result.value)}. Earn position has not updated yet.`, }) } finally { - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() } }, (error) => { @@ -750,10 +751,7 @@ function App() { const address = requireWalletAddress(walletAddress) const initialBalances = balances const initialEarnPositions = earnPositions - let lastResult: TransactionResult | null = null - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() setWithdrawStatuses((current) => ({ ...current, [position.id]: `Withdraw status: preparing ${position.marketName}...`, @@ -771,37 +769,22 @@ function App() { position, }) - for (const [index, transaction] of prepared.transactions.entries()) { - const label = prepared.transactions.length === 1 - ? 'transaction' - : `transaction ${index + 1}/${prepared.transactions.length}` - setWithdrawStatuses((current) => ({ - ...current, - [position.id]: `Withdraw status: sending ${label}...`, - })) - setEarnPositionsStatus(`Withdraw status: sending ${label}...`) - const tx = await oms.wallet.sendTransaction({ - network: POLYGON_NETWORK, - to: transaction.to, - value: transaction.value, - data: transaction.data, - selectFeeOption: waitForFeeOptionSelection, - }) - const result = transactionResult(tx) - lastResult = result - setLastWithdrawTransactions((current) => ({ - ...current, - [position.id]: result, - })) - setWithdrawStatuses((current) => ({ - ...current, - [position.id]: `Withdraw status: sent ${label} ${shortHash(result.value)}.`, - })) - setEarnPositionsStatus(`Withdraw status: sent ${label} ${shortHash(result.value)}.`) - } - - if (!lastResult) throw new Error('Withdraw did not send a transaction.') - const sentResult = lastResult + const sentResult = await sendYieldTransactionBatch({ + autoPickFeeOption: withdrawAutoFeeOptions[position.id] ?? true, + emptyError: 'Withdraw did not send a transaction.', + onResult: (result) => { + setLastWithdrawTransactions((current) => ({ + ...current, + [position.id]: result, + })) + }, + setStatus: (status) => { + setWithdrawStatuses((current) => ({ ...current, [position.id]: status })) + setEarnPositionsStatus(status) + }, + statusPrefix: 'Withdraw status', + transactions: prepared.transactions, + }) await waitForPostSendRefresh({ initialBalances, initialEarnPositions, @@ -815,9 +798,7 @@ function App() { staleStatus: `Withdraw status: sent ${shortHash(sentResult.value)}. Earn position has not updated yet.`, }) } finally { - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection() } }, (error) => { @@ -838,6 +819,21 @@ function App() { }) } + async function selectFirstAvailableFeeOption(options: FeeOptionWithBalance[]): Promise { + const selection = await FeeOptionSelector.firstAvailable(options) + if (!selection) { + throw new Error('No fee option has enough balance.') + } + + selectedFeeOption.current = options.find((option) => option.selection.token === selection.token) ?? null + appendLog(`Selected ${selectedFeeOption.current?.feeOption.token.symbol ?? selection.token} fee automatically.`) + return selection + } + + function selectFeeOption(autoPickFeeOption: boolean) { + return autoPickFeeOption ? selectFirstAvailableFeeOption : waitForFeeOptionSelection + } + function chooseFeeOption(option: FeeOptionWithBalance) { if (!canAffordFeeOption(option)) { appendLog(`! Insufficient ${option.feeOption.token.symbol} balance for fee.`) @@ -852,17 +848,11 @@ function App() { } function cancelFeeSelection() { - feeSelection.current?.reject(new Error('Fee option selection cancelled')) - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection(new Error('Fee option selection cancelled')) } function clearPreparedState() { - feeSelection.current?.reject(new Error('Transaction state cleared')) - feeSelection.current = null - selectedFeeOption.current = null - setFeeOptions([]) + clearFeeSelection(new Error('Transaction state cleared')) setPreparedSwap(null) setPreparedDeposit(null) setPreparedEarn(null) @@ -876,6 +866,82 @@ function App() { setEarnStatus('Swap and Deposit status: waiting to prepare.') } + function clearFeeSelection(error?: Error) { + if (error) { + feeSelection.current?.reject(error) + } + feeSelection.current = null + selectedFeeOption.current = null + setFeeOptions([]) + } + + async function sendPreparedTransaction( + prepared: PreparedTrailsTransaction, + setStatus: (status: string) => void, + sendingStatus: string, + autoPickFeeOption: boolean, + ): Promise { + setStatus(sendingStatus) + const tx = await oms.wallet.sendTransaction({ + network: POLYGON_NETWORK, + to: prepared.to, + value: BigInt(prepared.value), + data: prepared.data, + selectFeeOption: selectFeeOption(autoPickFeeOption), + }) + return transactionResult(tx) + } + + async function sendYieldTransactionBatch({ + autoPickFeeOption, + emptyError, + onResult, + setStatus, + statusPrefix, + transactions, + }: { + autoPickFeeOption: boolean + emptyError: string + onResult: (result: TransactionResult) => void + setStatus: (status: string) => void + statusPrefix: string + transactions: PreparedYieldTransactions['transactions'] + }): Promise { + let lastResult: TransactionResult | null = null + + for (const [index, transaction] of transactions.entries()) { + const label = transactions.length === 1 ? 'transaction' : `transaction ${index + 1}/${transactions.length}` + setStatus(`${statusPrefix}: sending ${label}...`) + const tx = await oms.wallet.sendTransaction({ + network: POLYGON_NETWORK, + to: transaction.to, + value: transaction.value, + data: transaction.data, + selectFeeOption: selectFeeOption(autoPickFeeOption), + }) + lastResult = transactionResult(tx) + onResult(lastResult) + setStatus(`${statusPrefix}: sent ${label} ${shortHash(lastResult.value)}.`) + } + + if (!lastResult) throw new Error(emptyError) + return lastResult + } + + function updateAutoFeeOption(key: AutoFeeOptionKey, value: boolean) { + setAutoFeeOptions((current) => ({ + ...current, + [key]: value, + })) + } + + function updateWithdrawAutoFeeOption(positionId: string, value: boolean) { + setWithdrawAutoFeeOptions((current) => ({ + ...current, + [positionId]: value, + })) + } + async function waitForPostSendRefresh({ initialBalances, initialEarnPositions, @@ -926,143 +992,56 @@ function App() {

OMS Client TypeScript SDK

Trails Actions

{!isSignedIn && !pendingWalletSelection && authStep === 'email' && ( -
- - -
+ )} {!isSignedIn && !pendingWalletSelection && authStep === 'email' && ( -
+

Login Options

-
- - {redirectStatus &&

{redirectStatus}

} -
+
or
-
- -

{authStatus}

-
- - + +
)} {!isSignedIn && !pendingWalletSelection && authStep === 'code' && ( -
-
- -

{authStatus}

-
-
- - -
-
+ setAuthStep('email')} + /> )} {!isSignedIn && pendingWalletSelection && ( -
-
-
-

Choose wallet

- {formatWalletType(pendingWalletSelection.walletType)} -
-

Existing wallets

- {pendingWalletSelection.wallets.length > 0 ? ( -
- {pendingWalletSelection.wallets.map((wallet) => ( - - ))} -
- ) : ( -

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

- )} - -

Create new wallet

- - - -
- {authStatus && {authStatus}} -
+ )} {isSignedIn && ( @@ -1115,6 +1094,8 @@ function App() { onAmountChange={updateSwapPolAmount} onPrepare={prepareSwap} onSend={sendSwap} + autoPickFeeOption={autoFeeOptions.swap} + onAutoPickFeeOptionChange={(value) => updateAutoFeeOption('swap', value)} prepared={preparedSwap} result={lastSwapTransaction} disabled={isBusy} @@ -1129,6 +1110,8 @@ function App() { onAmountChange={updateDepositUsdcAmount} onPrepare={prepareDeposit} onSend={sendDeposit} + autoPickFeeOption={autoFeeOptions.deposit} + onAutoPickFeeOptionChange={(value) => updateAutoFeeOption('deposit', value)} preparedYield={preparedDeposit} result={lastDepositTransaction} disabled={isBusy} @@ -1143,6 +1126,8 @@ function App() { onAmountChange={updateEarnPolAmount} onPrepare={prepareEarn} onSend={sendEarn} + autoPickFeeOption={autoFeeOptions.earn} + onAutoPickFeeOptionChange={(value) => updateAutoFeeOption('earn', value)} prepared={preparedEarn} result={lastEarnTransaction} disabled={isBusy} @@ -1193,6 +1178,11 @@ function App() { Withdraw {position.canWithdraw ? 'All' : 'Unavailable'} + updateWithdrawAutoFeeOption(position.id, value)} + /> {withdrawStatuses[position.id] ? (

{withdrawStatuses[position.id]}

@@ -1223,39 +1213,12 @@ function App() { {sessionExpiredPrompt && ( -
-
-

Session expired

-

- Your wallet session has expired. Reauthenticate to continue using this wallet. -

- {sessionExpiredPrompt.session.sessionEmail && ( -

- Account {sessionExpiredPrompt.session.sessionEmail} -

- )} -

- {sessionExpiredPrompt.session.loginType === 'google-auth' - ? 'You will be redirected to Google with the same account selected.' - : sessionExpiredPrompt.session.loginType === 'email' && sessionExpiredPrompt.session.sessionEmail - ? 'A new sign-in code will be sent to the same email address.' - : 'Sign in again to continue.'} -

-
- - -
-
-
+ )} ) @@ -1267,6 +1230,8 @@ function TrailsActionCard({ onAmountChange, onPrepare, onSend, + autoPickFeeOption, + onAutoPickFeeOptionChange, prepared, preparedYield, result, @@ -1280,6 +1245,8 @@ function TrailsActionCard({ onAmountChange: (value: string) => void onPrepare: () => void onSend: () => void + autoPickFeeOption: boolean + onAutoPickFeeOptionChange: (value: boolean) => void prepared?: PreparedTrailsTransaction | null preparedYield?: PreparedYieldTransactions | null result: TransactionResult | null @@ -1308,6 +1275,11 @@ function TrailsActionCard({ Send +

{status}

{prepared ? : null} {preparedYield ? : null} @@ -1316,45 +1288,25 @@ function TrailsActionCard({ ) } -function FeeOptionsPanel({ - feeOptions, - onCancel, - onChoose, +function AutoFeeOptionCheckbox({ + checked, + disabled, + onChange, }: { - feeOptions: FeeOptionWithBalance[] - onCancel: () => void - onChoose: (option: FeeOptionWithBalance) => void + checked: boolean + disabled: boolean + onChange: (value: boolean) => void }) { return ( -
-
-

Fee option

-
- {feeOptions.map((option) => { - const canAfford = canAffordFeeOption(option) - - return ( - - ) - })} -
- -
-
+ ) } @@ -1439,39 +1391,6 @@ function transactionResult(tx: SendTransactionResponse): TransactionResult { } } -function formatLoginType(loginType: OMSClientSessionState['loginType']): string { - switch (loginType) { - case 'email': - return 'Email' - case 'google-auth': - return 'Google' - case 'oidc': - return 'OIDC' - default: - return 'Unknown' - } -} - -function formatSessionExpiry(expiresAt: string | undefined): string { - if (!expiresAt) return 'Unknown' - - 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 hasPostSendDataUpdate({ initialBalances, initialEarnPositions, @@ -1532,16 +1451,6 @@ function hasUsdcIncrease({ } } -function canAffordFeeOption(option: FeeOptionWithBalance): boolean { - if (option.availableRaw === undefined) return false - - try { - return BigInt(option.availableRaw) >= BigInt(option.feeOption.value) - } catch { - return false - } -} - function getSelectedUsdcFeeRaw(option?: FeeOptionWithBalance | null): bigint { if (option?.feeOption.token.symbol.toUpperCase() !== 'USDC') return 0n @@ -1607,18 +1516,4 @@ function sleep(milliseconds: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)) } -function readManualWalletSelectionPreference(): boolean { - return window.sessionStorage.getItem(MANUAL_WALLET_SELECTION_KEY) === 'true' -} - -function readSessionLifetimePreference(): number { - const stored = window.sessionStorage.getItem(SESSION_LIFETIME_SECONDS_KEY) - if (!stored) return TEST_SESSION_LIFETIME_SECONDS - - const parsed = Number(stored) - return Number.isFinite(parsed) && parsed > 0 - ? Math.floor(parsed) - : TEST_SESSION_LIFETIME_SECONDS -} - export default App diff --git a/examples/trails-actions/src/styles.css b/examples/trails-actions/src/styles.css index 4d7b784..2e42e34 100644 --- a/examples/trails-actions/src/styles.css +++ b/examples/trails-actions/src/styles.css @@ -1,325 +1,5 @@ -/* Design tokens live in one place — see examples/shared/oms-tokens.css. */ -@import url("../../shared/oms-tokens.css"); - -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -button, -input, -select { - font: inherit; -} - -.shell { - min-height: 100vh; - display: grid; - place-items: center; - padding: 32px; -} - -.panel { - width: min(100%, 560px); - display: grid; - gap: 18px; - padding: 28px; - border: 1px solid var(--oms-slate-200); - border-radius: 24px; - background: var(--oms-surface); - box-shadow: 0 24px 60px rgb(20 16 53 / 10%); -} - -.eyebrow { - margin: 0 0 6px; - color: var(--oms-brand); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -h1 { - margin: 0; - color: var(--oms-ink); - font-size: 28px; - font-weight: 700; - line-height: 1.15; -} - -.section-title { - margin: 0; - color: var(--oms-ink); - font-size: 17px; - font-weight: 700; - line-height: 1.25; -} - -.stack { - display: grid; - gap: 16px; -} - -label { - display: grid; - gap: 8px; - color: var(--oms-slate-900); - font-size: 14px; - font-weight: 600; -} - -.checkbox-row { - display: flex; - align-items: center; - gap: 10px; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-200); - border-radius: 12px; - background: var(--oms-slate-50); -} - -.checkbox-row input { - width: 18px; - min-height: 18px; - margin: 0; - accent-color: var(--oms-brand); -} - -.checkbox-row span { - min-width: 0; -} - -.header-option { - margin-top: 14px; -} - -.header-options { - display: grid; - gap: 10px; - margin-top: 14px; -} - -.session-lifetime-option { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(104px, 150px); - align-items: center; - gap: 12px; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-200); - border-radius: 12px; - background: var(--oms-slate-50); -} - -.session-lifetime-option > span { - display: grid; -} - -.session-lifetime-copy { - gap: 3px; -} - -.session-lifetime-copy strong { - color: var(--oms-slate-900); - font-size: 14px; - line-height: 1.25; -} - -.session-lifetime-copy small { - color: var(--oms-muted-ink); - font-size: 12px; - font-weight: 600; - line-height: 1.35; -} - -.session-lifetime-option > span:last-child { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; -} - -.session-lifetime-option input { - min-height: 36px; - padding: 8px 10px; -} - -.session-lifetime-option input[type='number'] { - appearance: textfield; - -moz-appearance: textfield; -} - -.session-lifetime-option input[type='number']::-webkit-inner-spin-button, -.session-lifetime-option input[type='number']::-webkit-outer-spin-button { - margin: 0; - appearance: none; - -webkit-appearance: none; -} - -.session-lifetime-option small { - color: var(--oms-muted-ink); - font-size: 12px; - font-weight: 700; -} - -.field-stack { - display: grid; - gap: 10px; -} - -.field-hint { - min-height: 42px; - padding: 11px 12px; - border-radius: 12px; - margin: 0; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 14px; -} - -.compact-hint { - min-height: auto; -} - -input, -select { - width: 100%; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-300); - border-radius: 12px; - color: var(--oms-ink); - background: var(--oms-surface); - transition: - border-color 160ms ease, - box-shadow 160ms ease; -} - -input:focus, -select:focus { - outline: none; - border-color: var(--oms-slate-400); - box-shadow: var(--oms-input-focus); -} - -input:disabled { - color: var(--oms-muted-ink); - background: var(--oms-slate-100); -} - -button { - min-height: 44px; - border: 1px solid transparent; - border-radius: 12px; - padding: 10px 14px; - color: var(--oms-surface); - background: var(--oms-slate-950); - font-weight: 700; - cursor: pointer; - transition: - background-color 160ms ease, - border-color 160ms ease, - box-shadow 160ms ease; -} - -button:hover:not(:disabled) { - background: var(--oms-slate-800); -} - -button:active:not(:disabled) { - background: var(--oms-purple-700); -} - -button:focus-visible { - outline: none; - box-shadow: var(--oms-focus-ring); -} - -button.secondary { - color: var(--oms-slate-950); - background: transparent; - border-color: var(--oms-slate-500); -} - -button.secondary:hover:not(:disabled) { - background: transparent; - border-color: var(--oms-slate-950); -} - -button.secondary:active:not(:disabled) { - background: var(--oms-purple-100); - border-color: var(--oms-purple-200); -} - -button.subtle { - color: var(--oms-muted-ink); - background: transparent; - border: 1px solid var(--oms-slate-300); -} - -button.subtle:hover:not(:disabled) { - background: transparent; - border-color: var(--oms-slate-400); -} - -button:disabled { - cursor: not-allowed; - color: var(--oms-slate-400); - background: var(--oms-slate-200); - border-color: transparent; -} - -.actions { - display: grid; - grid-template-columns: 1fr auto; - gap: 10px; -} - -.divider { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 10px; - color: var(--oms-muted-ink); - font-size: 13px; - font-weight: 700; -} - -.divider::before, -.divider::after { - content: ""; - height: 1px; - background: var(--oms-slate-300); -} - -.tool { - display: grid; - gap: 12px; - padding: 16px; - border: 1px solid var(--oms-slate-200); - border-radius: 16px; - background: var(--oms-slate-50); -} - -.tool h2 { - margin: 0; - color: var(--oms-ink); - font-size: 17px; - font-weight: 700; - line-height: 1.25; -} - -.tool h3 { - margin: 0; - color: var(--oms-ink); - font-size: 15px; - font-weight: 700; - line-height: 1.25; -} +/* Shared example structure and tokens live in examples/shared. */ +@import url("../../shared/oms-example-base.css"); .collapsible-tool { gap: 0; @@ -365,6 +45,29 @@ button:disabled { grid-column: 1 / -1; } +.auto-fee-option { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 4px 2px; + color: var(--oms-muted-ink); + font-size: 12px; + font-weight: 700; + line-height: 1.3; +} + +.auto-fee-option input { + width: 16px; + min-height: 16px; + margin: 0; + accent-color: var(--oms-brand); +} + +.auto-fee-option span { + min-width: 0; +} + .balance-panel { display: grid; gap: 4px; @@ -391,208 +94,15 @@ button:disabled { gap: 8px; } -.tool-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - .network-meta { + justify-content: center; min-width: 48px; - padding: 4px 8px; - border-radius: 16px; color: var(--oms-surface); background: var(--oms-brand); - font-size: 12px; - font-weight: 800; - line-height: 1.2; - text-align: center; -} - -.metadata-pill { - min-width: 48px; - padding: 4px 8px; - border: 1px solid var(--oms-slate-300); - border-radius: 16px; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 12px; font-weight: 800; - line-height: 1.2; text-align: center; } -.fee-modal-backdrop { - position: fixed; - inset: 0; - z-index: 10; - display: grid; - place-items: center; - padding: 20px; - background: rgb(9 6 36 / 55%); -} - -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: 20px; - background: rgb(9 6 36 / 55%); -} - -.modal { - width: min(100%, 420px); - display: grid; - gap: 12px; - padding: 24px; - border-radius: 24px; - background: var(--oms-surface); - box-shadow: 0 30px 80px rgb(9 6 36 / 28%); -} - -.modal h2, -.modal p { - margin: 0; -} - -.modal h2 { - color: var(--oms-ink); - font-size: 20px; - font-weight: 700; - line-height: 1.25; -} - -.modal p { - color: var(--oms-muted-ink); - font-size: 14px; - line-height: 1.5; -} - -.modal-detail { - padding: 10px 12px; - border-radius: 12px; - background: var(--oms-slate-100); -} - -.modal-detail strong { - color: var(--oms-ink); - overflow-wrap: anywhere; -} - -.modal-hint { - font-weight: 650; -} - -.modal-actions { - display: grid; - grid-template-columns: 1fr auto; - gap: 10px; - margin-top: 4px; -} - -.fee-options { - width: min(100%, 420px); - max-height: min(640px, calc(100vh - 40px)); - display: grid; - gap: 10px; - overflow: auto; - box-shadow: 0 30px 80px rgb(9 6 36 / 28%); -} - -.fee-option-list { - display: grid; - gap: 8px; -} - -.fee-option, -.wallet-option { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-height: 54px; - padding: 10px 12px; - border-radius: 12px; - color: var(--oms-ink); - background: var(--oms-slate-100); - text-align: left; -} - -.wallet-option { - grid-template-columns: minmax(0, 1fr) auto; - row-gap: 6px; -} - -/* Keep light-background option buttons readable; override the generic button hover/active. */ -.fee-option:hover:not(:disabled), -.wallet-option:hover:not(:disabled) { - background: var(--oms-slate-200); -} - -.fee-option:active:not(:disabled), -.wallet-option:active:not(:disabled) { - background: var(--oms-purple-100); -} - -.fee-option span, -.wallet-option span { - min-width: 0; -} - -.fee-option span:last-child { - color: var(--oms-slate-500); - font-size: 13px; - text-align: right; -} - -.fee-option strong, -.fee-option small, -.wallet-option strong, -.wallet-option small { - display: block; - overflow-wrap: anywhere; -} - -.fee-option small, -.wallet-option small { - margin-top: 3px; - color: var(--oms-muted-ink); - font-size: 12px; -} - -.wallet-option code { - grid-column: 1 / -1; - min-width: 0; - overflow-wrap: anywhere; - color: var(--oms-slate-800); - font-family: var(--oms-font-mono); - font-size: 12px; -} - -.wallet-option-action { - justify-self: end; - color: var(--oms-brand); - font-size: 13px; - font-weight: 800; -} - -.wallet-option-list { - display: grid; - gap: 8px; -} - -output { - min-height: 42px; - padding: 11px 12px; - border-radius: 12px; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 14px; -} - .wallet { display: grid; gap: 8px; @@ -834,23 +344,6 @@ output { } @media (max-width: 520px) { - .shell { - padding: 16px; - } - - .panel { - padding: 20px; - } - - .actions { - grid-template-columns: 1fr; - } - - .session-lifetime-option, - .modal-actions { - grid-template-columns: 1fr; - } - .prepared-summary div { gap: 8px; } diff --git a/examples/trails-actions/tsconfig.json b/examples/trails-actions/tsconfig.json index e8c909a..6cc1f89 100644 --- a/examples/trails-actions/tsconfig.json +++ b/examples/trails-actions/tsconfig.json @@ -9,12 +9,17 @@ "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, + "baseUrl": ".", "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "paths": { + "react": ["./node_modules/@types/react"], + "react/*": ["./node_modules/@types/react/*"] + } }, "include": ["src"], "references": [] diff --git a/examples/trails-actions/vite.config.ts b/examples/trails-actions/vite.config.ts index 990c145..0b851c9 100644 --- a/examples/trails-actions/vite.config.ts +++ b/examples/trails-actions/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { reactAliasesForExample } from '../shared/vite-react-aliases' export default defineConfig({ base: process.env.GITHUB_PAGES === 'true' ? '/typescript-sdk/trails-actions-example/' : '/', plugins: [react()], + resolve: { + alias: reactAliasesForExample(import.meta.url), + }, server: { port: 5173, strictPort: true, diff --git a/examples/wagmi/README.md b/examples/wagmi/README.md index fce64ec..80b09d6 100644 --- a/examples/wagmi/README.md +++ b/examples/wagmi/README.md @@ -19,6 +19,7 @@ The deployed example is available at `https://0xsequence.github.io/typescript-sd The example authenticates OMS Wallet with the SDK, then connects through wagmi. Account state, balance reads, chain switching, message signing, typed-data signing, transaction sending, fee-option selection, and transaction receipt polling are all performed with wagmi hooks. +Google and Apple redirect login use the SDK's default provider helpers. The Trails widget is configured with the same wagmi runtime through `@0xtrails/adapter-wagmi`. diff --git a/examples/wagmi/src/App.tsx b/examples/wagmi/src/App.tsx index a9292b0..921b244 100644 --- a/examples/wagmi/src/App.tsx +++ b/examples/wagmi/src/App.tsx @@ -14,8 +14,23 @@ import { } from 'wagmi' import { TrailsWidget } from '0xtrails' import { formatEther, isAddress, parseEther, type Address, type Hash } from 'viem' -import type { FeeOptionWithBalance, OMSClientSessionLoginType } from '@0xsequence/typescript-sdk' -import { TEST_SESSION_LIFETIME_SECONDS, oms } from './omsClient' +import type { FeeOptionWithBalance } from '@0xsequence/typescript-sdk' +import { + EmailCodeForm, + EmailLoginForm, + FeeOptionsPanel, + OidcButtons, +} from '../../shared/example-components' +import { + canAffordFeeOption, + formatLoginType, + formatOidcProvider, + hasOidcCallbackParams, + shortAddress, + shortHash, + type OidcRedirectProvider, +} from '../../shared/example-utils' +import { oms } from './omsClient' import { useFeeOptionSelection } from './useFeeOptionSelection' import { TRAILS_API_KEY } from './config' import { defaultChain, omsWalletChains, omsWalletNetworks, trailsAdapters } from './wagmiConfig' @@ -77,8 +92,16 @@ export function App() { const omsSession = oms.wallet.session const activeOmsSessionAddress = omsSession.walletAddress const showGoogleAuth = !activeOmsSessionAddress || omsSession.loginType !== 'google-auth' + const showAppleAuth = !activeOmsSessionAddress || omsSession.loginType !== 'oidc' + const showOidcAuth = showGoogleAuth || showAppleAuth const showEmailAuth = !activeOmsSessionAddress || omsSession.loginType !== 'email' const showEmailCodeInput = authStep === 'code' && !activeOmsSessionAddress + const oidcProviders = useMemo(() => { + const providers: OidcRedirectProvider[] = [] + if (showGoogleAuth) providers.push('google') + if (showAppleAuth) providers.push('apple') + return providers + }, [showAppleAuth, showGoogleAuth]) const omsConnector = useMemo( () => connectors.find((connector) => connector.type === OMS_WALLET_CONNECTOR_TYPE), @@ -160,11 +183,10 @@ export function App() { }, []) useEffect(() => { - const params = new URLSearchParams(window.location.search) - if (!params.has('code') && !params.has('state') && !params.has('error')) return + if (!hasOidcCallbackParams()) return if (!omsConnector || oidcCallbackStarted.current) return oidcCallbackStarted.current = true - void completeGoogleRedirect() + void completeOidcRedirect() }, [omsConnector]) async function startEmailAuth() { @@ -181,7 +203,6 @@ export function App() { await runAuth('Completing email sign-in...', async () => { await oms.wallet.completeEmailAuth({ code: code.trim(), - sessionLifetimeSeconds: TEST_SESSION_LIFETIME_SECONDS, }) setAuthStep('email') await connectOmsWallet('Email connected.') @@ -194,24 +215,19 @@ export function App() { }) } - async function startGoogleAuth() { - await runAuth('Redirecting to Google...', async () => { + async function startOidcRedirect(provider: OidcRedirectProvider) { + const providerLabel = formatOidcProvider(provider) + await runAuth(`Redirecting to ${providerLabel}...`, async () => { await oms.wallet.signInWithOidcRedirect({ - provider: 'google', - loginHint: email.trim() || oms.wallet.session.sessionEmail, - sessionLifetimeSeconds: TEST_SESSION_LIFETIME_SECONDS, + provider, }) }) } - async function completeGoogleRedirect() { - await runAuth('Completing Google sign-in...', async () => { - await oms.wallet.signInWithOidcRedirect({ - provider: 'google', - sessionLifetimeSeconds: TEST_SESSION_LIFETIME_SECONDS, - }) - await connectOmsWallet('Google connected.') - window.history.replaceState({}, document.title, window.location.pathname) + async function completeOidcRedirect() { + await runAuth('Completing redirect sign-in...', async () => { + await oms.wallet.completeOidcRedirectAuth() + await connectOmsWallet('OMS Wallet connected.') }) } @@ -409,81 +425,43 @@ export function App() {
{activeOmsSessionAddress && ( )} - {activeOmsSessionAddress && (showGoogleAuth || showEmailAuth) && ( + {activeOmsSessionAddress && (showOidcAuth || showEmailAuth) && (
or
)} - {showGoogleAuth && ( - + {showOidcAuth && ( + void startOidcRedirect(provider)} + /> )} - {showGoogleAuth && showEmailAuth && ( + {showOidcAuth && showEmailAuth && (
or
)} {showEmailAuth && (showEmailCodeInput ? ( -
{ - event.preventDefault() - void completeEmailAuth() - }}> -
- -
-
- - -
-
+ void completeEmailAuth()} + onBack={() => setAuthStep('email')} + /> ) : ( -
{ - event.preventDefault() - void startEmailAuth() - }}> -
- -
- -
+ void startEmailAuth()} + /> ))} {externalWalletConnectors.map((connector) => (
@@ -663,78 +641,15 @@ export function App() { ) } -function FeeOptionsPanel({ - feeOptions, - onCancel, - onChoose, -}: { - feeOptions: FeeOptionWithBalance[] - onCancel: () => void - onChoose: (option: FeeOptionWithBalance) => void -}) { - return ( -
-
-

Fee option

-
- {feeOptions.map((option) => { - const canAfford = canAffordFeeOption(option) - - return ( - - ) - })} -
- -
-
- ) -} - function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function shortAddress(address: string): string { - return `${address.slice(0, 6)}...${address.slice(-4)}` -} - -function shortHash(hash: string): string { - return `${hash.slice(0, 10)}...${hash.slice(-8)}` -} - function formatSessionContinuation(address: string, email: string | undefined): string { const label = `Continue as ${shortAddress(address)}` return email ? `${label} - ${email}` : label } -function formatSessionLoginType(loginType: OMSClientSessionLoginType | undefined): string { - switch (loginType) { - case 'email': - return 'Email' - case 'google-auth': - return 'Google' - case 'oidc': - return 'OIDC' - default: - return 'OMS Wallet' - } -} - function networkForChainId(chainId: number) { const network = omsWalletNetworks.find((candidate) => candidate.id === chainId) if (network) return network @@ -745,13 +660,3 @@ function networkForChainId(chainId: number) { } return defaultNetwork } - -function canAffordFeeOption(option: FeeOptionWithBalance): boolean { - if (option.availableRaw === undefined) return false - - try { - return BigInt(option.availableRaw) >= BigInt(option.feeOption.value) - } catch { - return false - } -} diff --git a/examples/wagmi/src/omsClient.ts b/examples/wagmi/src/omsClient.ts index 36a1e3c..083dfb1 100644 --- a/examples/wagmi/src/omsClient.ts +++ b/examples/wagmi/src/omsClient.ts @@ -1,8 +1,6 @@ import { OMSClient } from '@0xsequence/typescript-sdk' import { PUBLISHABLE_KEY } from './config' -export const TEST_SESSION_LIFETIME_SECONDS = 604_800 - export const oms = new OMSClient({ publishableKey: PUBLISHABLE_KEY, }) diff --git a/examples/wagmi/src/styles.css b/examples/wagmi/src/styles.css index 281fa2c..cfe7e9f 100644 --- a/examples/wagmi/src/styles.css +++ b/examples/wagmi/src/styles.css @@ -1,74 +1,5 @@ -/* Design tokens live in one place — see examples/shared/oms-tokens.css. */ -@import url("../../shared/oms-tokens.css"); - -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -button, -input, -select { - font: inherit; -} - -.shell { - min-height: 100vh; - display: grid; - place-items: center; - padding: 32px; -} - -.panel { - width: min(100%, 560px); - display: grid; - gap: 18px; - padding: 28px; - border: 1px solid var(--oms-slate-200); - border-radius: 24px; - background: var(--oms-surface); - box-shadow: 0 24px 60px rgb(20 16 53 / 10%); -} - -h1, -h2, -h3, -p { - margin: 0; -} - -.eyebrow { - margin: 0 0 6px; - color: var(--oms-brand); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -h1 { - color: var(--oms-ink); - font-size: 28px; - font-weight: 700; - line-height: 1.15; -} - -h2 { - color: var(--oms-ink); - font-size: 17px; - font-weight: 700; - line-height: 1.25; -} - -h3 { - color: var(--oms-ink); - font-size: 15px; - font-weight: 700; - line-height: 1.25; -} +/* Shared example structure and tokens live in examples/shared. */ +@import url("../../shared/oms-example-base.css"); .section { display: grid; @@ -77,30 +8,7 @@ h3 { border-top: 1px solid var(--oms-slate-200); } -.tool-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.field-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(132px, auto); - align-items: end; - gap: 10px; -} - -.stack { - display: grid; - gap: 16px; -} - -.auth-stack { - display: grid; - gap: 12px; -} - +.auth-stack, .auth-connector { display: grid; gap: 12px; @@ -108,22 +16,9 @@ h3 { .auth-method-button { width: 100%; - color: var(--oms-slate-950); - background: transparent; - border: 1px solid var(--oms-slate-500); font-weight: 800; } -.auth-method-button:hover:not(:disabled) { - background: transparent; - border-color: var(--oms-slate-950); -} - -.auth-method-button:active:not(:disabled) { - background: var(--oms-purple-100); - border-color: var(--oms-purple-200); -} - .session-auth-button { display: grid; justify-items: center; @@ -137,40 +32,6 @@ h3 { font-weight: 700; } -.auth-method-button:disabled { - color: var(--oms-slate-400); - background: var(--oms-slate-200); - border-color: transparent; -} - -.divider { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 10px; - color: var(--oms-muted-ink); - font-size: 13px; - font-weight: 700; -} - -.divider::before, -.divider::after { - content: ""; - height: 1px; - background: var(--oms-slate-300); -} - -.actions { - display: grid; - grid-template-columns: 1fr auto; - gap: 10px; -} - -.field-stack { - display: grid; - gap: 10px; -} - .operation-example + .operation-example { padding-top: 16px; border-top: 1px solid var(--oms-slate-200); @@ -187,136 +48,6 @@ h3 { width: 100%; } -.field-hint { - min-height: 42px; - padding: 11px 12px; - border-radius: 12px; - margin: 0; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 14px; - overflow-wrap: anywhere; -} - -label { - display: grid; - gap: 8px; - color: var(--oms-slate-900); - font-size: 14px; - font-weight: 600; -} - -input, -select { - width: 100%; - min-height: 44px; - padding: 10px 12px; - border: 1px solid var(--oms-slate-300); - border-radius: 12px; - color: var(--oms-ink); - background: var(--oms-surface); - font-weight: 400; - transition: - border-color 160ms ease, - box-shadow 160ms ease; -} - -input:focus, -select:focus { - outline: none; - border-color: var(--oms-slate-400); - box-shadow: var(--oms-input-focus); -} - -button { - min-height: 44px; - border: 1px solid transparent; - border-radius: 12px; - padding: 10px 14px; - color: var(--oms-surface); - background: var(--oms-slate-950); - font-weight: 700; - cursor: pointer; - transition: - background-color 160ms ease, - border-color 160ms ease, - box-shadow 160ms ease; -} - -button:hover:not(:disabled) { - background: var(--oms-slate-800); -} - -button:active:not(:disabled) { - background: var(--oms-purple-700); -} - -button:focus-visible { - outline: none; - box-shadow: var(--oms-focus-ring); -} - -button.secondary { - color: var(--oms-slate-950); - background: transparent; - border-color: var(--oms-slate-500); -} - -button.secondary:hover:not(:disabled) { - background: transparent; - border-color: var(--oms-slate-950); -} - -button.secondary:active:not(:disabled) { - background: var(--oms-purple-100); - border-color: var(--oms-purple-200); -} - -button:disabled { - cursor: not-allowed; - color: var(--oms-slate-400); - background: var(--oms-slate-200); - border-color: transparent; -} - -.connector-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.connector-button { - display: grid; - justify-items: start; - gap: 3px; - min-height: 64px; - color: var(--oms-slate-950); - border: 1px solid var(--oms-slate-300); - background: var(--oms-slate-50); -} - -.connector-button:hover:not(:disabled) { - background: var(--oms-slate-50); - border-color: var(--oms-slate-400); -} - -.connector-button.active { - color: var(--oms-surface); - border-color: transparent; - background: var(--oms-slate-950); -} - -.connector-button.active:hover:not(:disabled) { - background: var(--oms-slate-800); -} - -.connector-button small { - color: inherit; - opacity: 0.76; - font-size: 12px; - font-weight: 700; -} - .summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -361,88 +92,14 @@ button:disabled { } .network-meta { + justify-content: center; min-width: 48px; - padding: 4px 8px; - border-radius: 16px; color: var(--oms-surface); background: var(--oms-brand); - font-size: 12px; font-weight: 800; - line-height: 1.2; text-align: center; } -.fee-modal-backdrop { - position: fixed; - inset: 0; - z-index: 10000; - display: grid; - place-items: center; - padding: 20px; - background: rgb(9 6 36 / 55%); -} - -.fee-options { - width: min(100%, 420px); - max-height: min(640px, calc(100vh - 40px)); - display: grid; - gap: 10px; - padding: 24px; - border: 1px solid var(--oms-slate-200); - border-radius: 24px; - background: var(--oms-surface); - overflow: auto; - box-shadow: 0 30px 80px rgb(9 6 36 / 28%); -} - -.fee-option-list { - display: grid; - gap: 8px; -} - -.fee-option { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-height: 54px; - padding: 10px 12px; - border-radius: 12px; - color: var(--oms-ink); - background: var(--oms-slate-100); - text-align: left; -} - -/* Keep light-background option buttons readable; override the generic button hover/active. */ -.fee-option:hover:not(:disabled) { - background: var(--oms-slate-200); -} - -.fee-option:active:not(:disabled) { - background: var(--oms-purple-100); -} - -.fee-option span { - min-width: 0; -} - -.fee-option span:last-child { - color: var(--oms-slate-500); - font-size: 13px; - text-align: right; -} - -.fee-option strong, -.fee-option small { - display: block; - overflow-wrap: anywhere; -} - -.fee-option small { - color: var(--oms-muted-ink); - font-size: 12px; -} - .result { display: grid; gap: 6px; @@ -489,35 +146,8 @@ button:disabled { overflow-wrap: anywhere; } -output { - display: block; - min-height: 42px; - padding: 11px 12px; - border-radius: 12px; - color: var(--oms-slate-800); - background: var(--oms-slate-100); - font-size: 14px; - overflow-wrap: anywhere; -} - @media (max-width: 720px) { - .field-grid, - .connector-grid, .summary-grid { grid-template-columns: 1fr; } } - -@media (max-width: 520px) { - .shell { - padding: 16px; - } - - .panel { - padding: 20px; - } - - .actions { - grid-template-columns: 1fr; - } -} diff --git a/examples/wagmi/tsconfig.json b/examples/wagmi/tsconfig.json index e8c909a..6cc1f89 100644 --- a/examples/wagmi/tsconfig.json +++ b/examples/wagmi/tsconfig.json @@ -9,12 +9,17 @@ "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, + "baseUrl": ".", "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "paths": { + "react": ["./node_modules/@types/react"], + "react/*": ["./node_modules/@types/react/*"] + } }, "include": ["src"], "references": [] diff --git a/examples/wagmi/vite.config.ts b/examples/wagmi/vite.config.ts index ea44408..85248fb 100644 --- a/examples/wagmi/vite.config.ts +++ b/examples/wagmi/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { reactAliasesForExample } from '../shared/vite-react-aliases' export default defineConfig({ base: process.env.GITHUB_PAGES === 'true' ? '/typescript-sdk/wagmi-example/' : '/', plugins: [react()], + resolve: { + alias: reactAliasesForExample(import.meta.url), + }, server: { port: 5173, strictPort: true, From b3c764fad1ab015a26f6cdd3f6439c5de42c7338 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 01:32:09 +0300 Subject: [PATCH 3/9] Clean up Trails action preparation --- examples/trails-actions/src/trailsActions.ts | 116 +++++++++++-------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/examples/trails-actions/src/trailsActions.ts b/examples/trails-actions/src/trailsActions.ts index eb3578b..2eac9de 100644 --- a/examples/trails-actions/src/trailsActions.ts +++ b/examples/trails-actions/src/trailsActions.ts @@ -270,27 +270,10 @@ export async function prepareSwapPolToUsdc({ }): Promise { const amountRaw = parsePositivePolAmount(polAmount) const trailsClient = createTrailsClient() - const minAmountOutRaw = await getPolToUsdcMinAmountOutRaw(amountRaw) - const calls = await resolveActionsToCalls({ - actions: [ - custom({ - to: POLYGON_WPOL, - data: WRAPPED_NATIVE_DEPOSIT_CALLDATA, - value: amountRaw, - }), - swap({ - tokenIn: POLYGON_WPOL, - tokenOut: POLYGON_USDC, - fee: POL_TO_USDC_SWAP_FEE, - amountInRaw: amountRaw, - minAmountOutRaw, - provider: 'UNISWAP_V3', - }), - ], - destinationChain: POLYGON_CHAIN_ID_NUMBER, - userWalletAddress: walletAddress, + const { calls, minAmountOutRaw } = await createPolToUsdcSwapCalls({ + amountRaw, trailsClient, - publicClient: null, + walletAddress, }) return encodePreparedTransaction({ @@ -326,11 +309,7 @@ export async function prepareDepositUsdc({ receiverAddress: walletAddress, }, }) - const transactions = response.action.transactions - .filter((transaction) => !transaction.isMessage) - .map((transaction) => parseUnsignedYieldTransaction(transaction.unsignedTransaction)) - - assertPolygonTransactions(transactions, 'Deposit') + const transactions = parseYieldActionTransactions(response.action.transactions, 'Deposit') return { title: 'Deposit USDC using Earn', @@ -365,11 +344,7 @@ export async function prepareWithdrawEarnPosition({ outputTokenNetwork: position.outputTokenNetwork, }, }) - const transactions = response.action.transactions - .filter((transaction) => !transaction.isMessage) - .map((transaction) => parseUnsignedYieldTransaction(transaction.unsignedTransaction)) - - assertPolygonTransactions(transactions, 'Withdraw') + const transactions = parseYieldActionTransactions(response.action.transactions, 'Withdraw') return { title: `Withdraw ${position.marketName}`, @@ -393,28 +368,11 @@ export async function prepareSwapAndEarnUsdc({ const amountRaw = parsePositivePolAmount(polAmount) const trailsClient = createTrailsClient() const market = await findPolygonUsdcEarnMarket(trailsClient) - const minAmountOutRaw = await getPolToUsdcMinAmountOutRaw(amountRaw) - const calls = await resolveActionsToCalls({ - actions: [ - custom({ - to: POLYGON_WPOL, - data: WRAPPED_NATIVE_DEPOSIT_CALLDATA, - value: amountRaw, - }), - swap({ - tokenIn: POLYGON_WPOL, - tokenOut: POLYGON_USDC, - fee: POL_TO_USDC_SWAP_FEE, - amountInRaw: amountRaw, - minAmountOutRaw, - provider: 'UNISWAP_V3', - }), - buildEarnAction(market, walletAddress), - ], - destinationChain: POLYGON_CHAIN_ID_NUMBER, - userWalletAddress: walletAddress, + const { calls } = await createPolToUsdcSwapCalls({ + additionalActions: [buildEarnAction(market, walletAddress)], + amountRaw, trailsClient, - publicClient: null, + walletAddress, }) return encodePreparedTransaction({ @@ -463,6 +421,50 @@ async function getPolToUsdcMinAmountOutRaw(amountRaw: bigint): Promise { return minAmountOutRaw > 0n ? minAmountOutRaw : quote.amountOut } +async function createPolToUsdcSwapCalls({ + additionalActions = [], + amountRaw, + trailsClient, + walletAddress, +}: { + additionalActions?: ActionItem[] + amountRaw: bigint + trailsClient: TrailsApi + walletAddress: Address +}): Promise<{ + calls: Awaited> + minAmountOutRaw: bigint +}> { + const minAmountOutRaw = await getPolToUsdcMinAmountOutRaw(amountRaw) + const calls = await resolveActionsToCalls({ + actions: [ + custom({ + to: POLYGON_WPOL, + data: WRAPPED_NATIVE_DEPOSIT_CALLDATA, + value: amountRaw, + }), + swap({ + tokenIn: POLYGON_WPOL, + tokenOut: POLYGON_USDC, + fee: POL_TO_USDC_SWAP_FEE, + amountInRaw: amountRaw, + minAmountOutRaw, + provider: 'UNISWAP_V3', + }), + ...additionalActions, + ], + destinationChain: POLYGON_CHAIN_ID_NUMBER, + userWalletAddress: walletAddress, + trailsClient, + publicClient: null, + }) + + return { + calls, + minAmountOutRaw, + } +} + function getPrimaryEarnBalance(balances: EarnBalances): EarnBalance | undefined { if (balances.outputTokenBalance && hasPositiveEarnBalance(balances.outputTokenBalance)) { return balances.outputTokenBalance @@ -642,6 +644,18 @@ function parseUnsignedYieldTransaction(tx: unknown): ParsedYieldTransaction { } } +function parseYieldActionTransactions( + transactions: ReadonlyArray<{ isMessage?: boolean; unsignedTransaction?: unknown }>, + label: string, +): ParsedYieldTransaction[] { + const parsedTransactions = transactions + .filter((transaction) => !transaction.isMessage) + .map((transaction) => parseUnsignedYieldTransaction(transaction.unsignedTransaction)) + + assertPolygonTransactions(parsedTransactions, label) + return parsedTransactions +} + function assertPolygonTransactions(transactions: ParsedYieldTransaction[], label: string): void { if (transactions.length === 0) { throw new Error(`${label} action did not return a transaction.`) From 7c1bde60e9a97964f8cc29b7425e505f747ee8bc Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 02:30:31 +0300 Subject: [PATCH 4/9] Clean up example defaults and fee handling --- .../node-contract-deploy-example/deployErc20.ts | 1 - examples/react/src/WalletKitDollarExample.tsx | 2 -- examples/react/src/main.tsx | 6 ------ examples/trails-actions/src/trailsActions.ts | 15 +++++++-------- examples/wagmi/src/App.tsx | 6 ------ examples/wagmi/src/wagmiConfig.ts | 1 - 6 files changed, 7 insertions(+), 24 deletions(-) diff --git a/examples/node-contract-deploy-example/deployErc20.ts b/examples/node-contract-deploy-example/deployErc20.ts index a372055..54b9baa 100644 --- a/examples/node-contract-deploy-example/deployErc20.ts +++ b/examples/node-contract-deploy-example/deployErc20.ts @@ -93,7 +93,6 @@ async function main() { args: [initCode, salt], statusPolling: { timeoutMs: 120_000, - intervalMs: 2_000, }, }); diff --git a/examples/react/src/WalletKitDollarExample.tsx b/examples/react/src/WalletKitDollarExample.tsx index fe53a01..c70037e 100644 --- a/examples/react/src/WalletKitDollarExample.tsx +++ b/examples/react/src/WalletKitDollarExample.tsx @@ -69,7 +69,6 @@ export function WalletKitDollarExample() { args: [activeWallet, MINT_AMOUNT], statusPolling: { timeoutMs: 120_000, - intervalMs: 2_000, }, }) @@ -103,7 +102,6 @@ export function WalletKitDollarExample() { args: [recipient as Address, transferAmount], statusPolling: { timeoutMs: 120_000, - intervalMs: 2_000, }, }) diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 8273ff2..96cccb4 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -23,7 +23,6 @@ import { WalletSelectionPanel, } from '../../shared/example-components' import { - canAffordFeeOption, formatCount, formatLoginType, formatOidcProvider, @@ -428,11 +427,6 @@ function App() { } function chooseFeeOption(option: FeeOptionWithBalance) { - if (!canAffordFeeOption(option)) { - setWalletStatus(`Insufficient ${option.feeOption.token.symbol} balance for fee.`) - return - } - feeSelection.current?.resolve(option.selection) feeSelection.current = null setFeeOptions([]) diff --git a/examples/trails-actions/src/trailsActions.ts b/examples/trails-actions/src/trailsActions.ts index 2eac9de..f516198 100644 --- a/examples/trails-actions/src/trailsActions.ts +++ b/examples/trails-actions/src/trailsActions.ts @@ -33,7 +33,7 @@ export type PreparedTrailsTransaction = { title: string to: Address data: Hex - value: string + value: bigint callCount: number postSendExpectation: PostSendExpectation marketName?: string @@ -94,7 +94,6 @@ export type BalanceState = { } const TRAILS_API_URL = 'https://trails-api.sequence.app' -const POLYGON_CHAIN_ID_NUMBER = 137 const POLYGON_USDC = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' const POLYGON_WPOL = '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270' const POL_TO_USDC_SWAP_FEE = '0.05' @@ -223,7 +222,7 @@ export async function getPolygonEarnPositions( ), getEarnMarkets( { - chain: POLYGON_CHAIN_ID_NUMBER, + chain: POLYGON_NETWORK.id, limit: 100, }, trailsClient, @@ -405,7 +404,7 @@ function parsePositiveUsdcAmount(amount: string): string { } async function getPolToUsdcMinAmountOutRaw(amountRaw: bigint): Promise { - const quote = await uniswapV3.onChain(POLYGON_CHAIN_ID_NUMBER).quoteSwap({ + const quote = await uniswapV3.onChain(POLYGON_NETWORK.id).quoteSwap({ type: 'exactInputSingle', tokenIn: POLYGON_WPOL, tokenOut: POLYGON_USDC, @@ -453,7 +452,7 @@ async function createPolToUsdcSwapCalls({ }), ...additionalActions, ], - destinationChain: POLYGON_CHAIN_ID_NUMBER, + destinationChain: POLYGON_NETWORK.id, userWalletAddress: walletAddress, trailsClient, publicClient: null, @@ -554,7 +553,7 @@ function isUsdcMarket(market: EarnMarket): boolean { async function findPolygonUsdcEarnMarket(trailsClient: TrailsApi): Promise { const markets = await getEarnMarkets( { - chain: POLYGON_CHAIN_ID_NUMBER, + chain: POLYGON_NETWORK.id, search: 'USDC', limit: 50, }, @@ -616,7 +615,7 @@ function encodePreparedTransaction({ title, to: encoded.recipient, data: encoded.destinationCalldata, - value: value.toString(), + value, callCount: calls.length, postSendExpectation, marketName: market ? getMarketName(market) : undefined, @@ -661,7 +660,7 @@ function assertPolygonTransactions(transactions: ParsedYieldTransaction[], label throw new Error(`${label} action did not return a transaction.`) } - const unsupportedTransaction = transactions.find((transaction) => transaction.chainId !== POLYGON_CHAIN_ID_NUMBER) + const unsupportedTransaction = transactions.find((transaction) => transaction.chainId !== POLYGON_NETWORK.id) if (unsupportedTransaction) { throw new Error( `${label} returned chain ${unsupportedTransaction.chainId}, but this demo only sends Polygon transactions.`, diff --git a/examples/wagmi/src/App.tsx b/examples/wagmi/src/App.tsx index 921b244..bf08770 100644 --- a/examples/wagmi/src/App.tsx +++ b/examples/wagmi/src/App.tsx @@ -22,7 +22,6 @@ import { OidcButtons, } from '../../shared/example-components' import { - canAffordFeeOption, formatLoginType, formatOidcProvider, hasOidcCallbackParams, @@ -395,11 +394,6 @@ export function App() { } function chooseFeeOption(option: FeeOptionWithBalance) { - if (!canAffordFeeOption(option)) { - setWalletStatus(`Insufficient ${option.feeOption.token.symbol} balance for fee.`) - return - } - feeOptionSelection.resolveFeeOption(option.selection) setWalletStatus(`Selected ${option.feeOption.token.symbol}. Sending transaction...`) } diff --git a/examples/wagmi/src/wagmiConfig.ts b/examples/wagmi/src/wagmiConfig.ts index 9e1f557..7686dd6 100644 --- a/examples/wagmi/src/wagmiConfig.ts +++ b/examples/wagmi/src/wagmiConfig.ts @@ -51,7 +51,6 @@ export const wagmiConfig = createConfig({ connectors: [ omsWalletConnector({ client: oms, - networks: omsWalletNetworks, initialChainId: defaultChain.id, transactionOptions: { selectFeeOption: selectFeeOptionWithAppUi, From 2d6612adbaa2a8e1511151b6f1cc509bcfb8aeab Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 02:30:39 +0300 Subject: [PATCH 5/9] Improve Trails earn positions layout --- examples/trails-actions/src/App.tsx | 54 ++++++++------- examples/trails-actions/src/styles.css | 91 +++++++++++++++++++++----- 2 files changed, 100 insertions(+), 45 deletions(-) diff --git a/examples/trails-actions/src/App.tsx b/examples/trails-actions/src/App.tsx index b9c5e91..3b4a6b7 100644 --- a/examples/trails-actions/src/App.tsx +++ b/examples/trails-actions/src/App.tsx @@ -20,7 +20,6 @@ import { WalletSelectionPanel, } from '../../shared/example-components' import { - canAffordFeeOption, formatLoginType, formatOidcProvider, formatSessionExpiry, @@ -835,11 +834,6 @@ function App() { } function chooseFeeOption(option: FeeOptionWithBalance) { - if (!canAffordFeeOption(option)) { - appendLog(`! Insufficient ${option.feeOption.token.symbol} balance for fee.`) - return - } - selectedFeeOption.current = option feeSelection.current?.resolve(option.selection) feeSelection.current = null @@ -885,7 +879,7 @@ function App() { const tx = await oms.wallet.sendTransaction({ network: POLYGON_NETWORK, to: prepared.to, - value: BigInt(prepared.value), + value: prepared.value, data: prepared.data, selectFeeOption: selectFeeOption(autoPickFeeOption), }) @@ -1154,35 +1148,39 @@ function App() {
{earnPositions.map((position) => (
-
+
{position.marketName} - {position.provider} + {position.provider}
-
- - {position.amountDisplay} {position.tokenSymbol} - - {position.amountUsd ?? 'USD unavailable'} +
+
+ Balance + + {position.amountDisplay} {position.tokenSymbol} + + {position.amountUsd ?? 'USD unavailable'} +
+
+ APY + {position.apy} +
-
- {position.apy} - APY -
-
- - {position.canWithdraw ? 'All' : 'Unavailable'} +
updateWithdrawAutoFeeOption(position.id, value)} /> +
+ +
{withdrawStatuses[position.id] ? (

{withdrawStatuses[position.id]}

diff --git a/examples/trails-actions/src/styles.css b/examples/trails-actions/src/styles.css index 2e42e34..43df836 100644 --- a/examples/trails-actions/src/styles.css +++ b/examples/trails-actions/src/styles.css @@ -273,34 +273,86 @@ .position-row { display: grid; - grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(64px, 0.4fr) auto; - align-items: center; - gap: 10px; - padding: 10px 12px; + gap: 12px; + padding: 12px; border: 1px solid var(--oms-slate-200); - border-radius: 12px; + border-radius: var(--oms-radius-input); background: var(--oms-surface); } -.position-row div { - display: grid; - gap: 3px; +.position-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; min-width: 0; } -.position-row strong, -.position-row small { +.position-header strong { min-width: 0; - overflow-wrap: anywhere; + line-height: 1.25; + overflow-wrap: break-word; +} + +.position-provider { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 22px; + padding: 2px 8px; + border-radius: var(--oms-radius-pill); + color: var(--oms-muted-ink); + background: var(--oms-slate-100); + font-size: 12px; + font-weight: 700; + line-height: 1.2; +} + +.position-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; } -.position-row small { +.position-metric { + display: grid; + gap: 3px; + min-width: 0; + padding: 10px; + border: 1px solid var(--oms-slate-200); + border-radius: var(--oms-radius-button); + background: var(--oms-slate-50); +} + +.position-metric small, +.position-metric span { color: var(--oms-muted-ink); font-size: 12px; } +.position-metric strong { + min-width: 0; + overflow-wrap: break-word; +} + +.position-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 12px; +} + +.position-footer .auto-fee-option { + flex: 1 1 220px; + min-height: 0; + padding: 0; +} + .position-action { - justify-items: end; + display: flex; + flex: 0 0 auto; + justify-content: flex-end; } .position-action button { @@ -329,13 +381,18 @@ @media (max-width: 720px) { .trails-action-grid, .balance-grid, - .session-info, - .position-row { + .session-info { grid-template-columns: 1fr; } - .position-action { - justify-items: stretch; + .position-footer { + align-items: stretch; + flex-direction: column; + } + + .position-action, + .position-action button { + width: 100%; } .trails-action-grid .trails-action-card:last-child { From 00d211b862d47e282f7e4d532dceca8572498c7a Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 02:30:49 +0300 Subject: [PATCH 6/9] Align docs with OIDC and connector defaults --- API.md | 4 ++-- packages/oms-wallet-wagmi-connector/README.md | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index c9086c5..f675c9b 100644 --- a/API.md +++ b/API.md @@ -173,7 +173,7 @@ wallet.session: OMSClientSessionState Completed wallet sessions persist `walletAddress`, credential expiry, login type, and returned email in the configured `storage`. Pending email OTP and OIDC redirect state are not exposed through `session`; use the auth method results to drive pending UI. -Expired sessions are made inactive before protected wallet operations and throw `OmsSessionError` with code `OMS_SESSION_EXPIRED`. The SDK clears the active signer/session state, but keeps the expired session metadata in storage until the app explicitly starts a new auth flow or calls `signOut()`. Use `wallet.onSessionExpired` to update app state or route back to sign-in; the event includes the expired session snapshot so apps can reuse `sessionEmail` for email OTP reauth or as a Google `loginHint`, including after a page refresh. +Expired sessions are made inactive before protected wallet operations and throw `OmsSessionError` with code `OMS_SESSION_EXPIRED`. The SDK clears the active signer/session state, but keeps the expired session metadata in storage until the app explicitly starts a new auth flow or calls `signOut()`. Use `wallet.onSessionExpired` to update app state or route back to sign-in; the event includes the expired session snapshot so apps can reuse `sessionEmail` for email OTP reauth or provider-specific account hints, including Google `loginHint`, after a page refresh. ### onSessionExpired @@ -1006,7 +1006,7 @@ googleOidcProvider({ }) ``` -Apple can be configured with the `appleOidcProvider` helper: +Apple can be configured with the `appleOidcProvider` helper. The default Apple provider uses `response_mode=query`, no scopes, and PKCE auth-code mode: ```typescript // Uses the SDK default Apple Services ID (`service.oms.polygon.technology`) and relay redirect URI. diff --git a/packages/oms-wallet-wagmi-connector/README.md b/packages/oms-wallet-wagmi-connector/README.md index 7b17ad9..ff243db 100644 --- a/packages/oms-wallet-wagmi-connector/README.md +++ b/packages/oms-wallet-wagmi-connector/README.md @@ -22,7 +22,6 @@ export const wagmiConfig = createConfig({ connectors: [ omsWalletConnector({ client: oms, - networks: oms.supportedNetworks, initialChainId: polygon.id, transactionOptions: { selectFeeOption: FeeOptionSelector.firstAvailable, @@ -52,7 +51,7 @@ await connect({ }) ``` -For redirect-based OIDC flows such as Google, complete the SDK redirect callback first, then connect through wagmi once the wallet session is active. +For redirect-based OIDC flows such as Google or Apple, complete the SDK redirect callback first, then connect through wagmi once the wallet session is active. ## Disconnect @@ -67,7 +66,9 @@ await oms.wallet.signOut() ## Networks -OMS `Network` values are not wagmi chain definitions. Wagmi still needs viem `Chain` objects with RPC transport configuration. Use `wagmi/chains`, `viem/chains`, or custom viem `Chain` objects, then pass the OMS networks to the connector for OMS support validation. +OMS `Network` values are not wagmi chain definitions. Wagmi still needs viem `Chain` objects with RPC transport configuration. Use `wagmi/chains`, `viem/chains`, or custom viem `Chain` objects. + +By default, the connector validates OMS support with `client.supportedNetworks`. Pass `networks` only when you intentionally want a narrower or custom OMS network set for this connector instance. The connector validates `initialChainId`, `switchChain`, and provider chain switches against both the wagmi chain list and the OMS network list. A transaction `chainId` is used for that transaction without switching the connector's current chain, and must be supported by OMS. From df1e8a1271677d54aaeb0cfbe0ff366775c9c406 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 12:10:09 +0300 Subject: [PATCH 7/9] Avoid exposing OAuth default identifiers in docs --- API.md | 4 ++-- README.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index f675c9b..c8ee5f4 100644 --- a/API.md +++ b/API.md @@ -993,7 +993,7 @@ type OidcProviderConfig = { Provider configs are the source of truth for authorization scopes. If `scopes` is omitted or empty, the SDK does not send a `scope` authorization parameter. `authMode` defaults to `AuthMode.AuthCodePKCE`. -Google can be configured with the `googleOidcProvider` helper: +Google can be configured with the `googleOidcProvider` helper. The default Google provider uses the SDK default client ID, the SDK relay redirect URI, `openid email profile` scopes, and PKCE auth-code mode: ```typescript // Uses the SDK default Google client id and relay redirect URI. @@ -1009,7 +1009,7 @@ googleOidcProvider({ Apple can be configured with the `appleOidcProvider` helper. The default Apple provider uses `response_mode=query`, no scopes, and PKCE auth-code mode: ```typescript -// Uses the SDK default Apple Services ID (`service.oms.polygon.technology`) and relay redirect URI. +// Uses the SDK default Apple Services ID and relay redirect URI. appleOidcProvider() // Override defaults when needed. diff --git a/README.md b/README.md index 794f175..270af20 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,9 @@ Pass `loginHint` only when you want to prefill or select a specific Google accou Pending redirect state is stored in `sessionStorage` by default. Final wallet session metadata continues to use the configured SDK storage. -`appleOidcProvider()` uses the SDK default Apple Services ID (`service.oms.polygon.technology`), the SDK relay redirect URI, `response_mode=query`, no scopes, and PKCE auth-code mode by default. Requesting Apple `email` or `name` scopes requires Apple `response_mode=form_post`, which is not handled by the SDK's URL-query callback parser without a relay that converts the callback. +`googleOidcProvider()` uses the SDK default Google client ID, the SDK relay redirect URI, `openid email profile` scopes, and PKCE auth-code mode by default. + +`appleOidcProvider()` uses the SDK default Apple Services ID, the SDK relay redirect URI, `response_mode=query`, no scopes, and PKCE auth-code mode by default. Requesting Apple `email` or `name` scopes requires Apple `response_mode=form_post`, which is not handled by the SDK's URL-query callback parser without a relay that converts the callback. ### Session State From 4c6978af701be645bbf5519948bab99fa41e4391 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 12:27:32 +0300 Subject: [PATCH 8/9] Review and align documentation --- .github/ISSUE_TEMPLATE/bug_report.md | 3 +- .github/ISSUE_TEMPLATE/feature_request.md | 2 + .github/PULL_REQUEST_TEMPLATE.md | 9 +- AGENTS.md | 16 ++- API.md | 125 +++++++++++------- PUBLISHING.md | 2 + README.md | 27 ++-- TESTING.md | 6 +- docs/error-contracts.md | 5 +- docs/session-expiry-flow.md | 10 +- .../node-contract-deploy-example/.env.example | 1 + .../node-contract-deploy-example/README.md | 4 +- examples/node/README.md | 2 +- examples/react/README.md | 7 - examples/trails-actions/README.md | 1 + examples/wagmi/README.md | 1 + packages/oms-wallet-wagmi-connector/README.md | 20 +-- 17 files changed, 149 insertions(+), 92 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 58817ed..dcde60e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -14,7 +14,8 @@ labels: bug // Minimal code to reproduce ``` -**SDK version:** +**Affected package/example:** +**Package/example version or commit:** **Node version:** **Package manager:** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 93e3b74..f961b2e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -12,6 +12,8 @@ labels: enhancement +**Target surface:** + ## Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 70f231d..7d7da36 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,7 +2,7 @@ -> PR title must follow Conventional Commits, e.g. `feat(wallet): add session refresh`. +> Use a clear, descriptive PR title. Conventional Commits are welcome but not required unless a maintainer asks for that style. ## Changes @@ -12,10 +12,13 @@ -- [ ] `pnpm exec vitest run` passes +- [ ] `pnpm test` passes - [ ] `pnpm exec tsc --noEmit` passes - [ ] `pnpm test:types` passes (if public types changed) -- [ ] `pnpm build` succeeds (if touching exports, dist, or example builds) +- [ ] `pnpm --filter @0xsequence/oms-wallet-wagmi-connector test` passes (if connector behavior changed) +- [ ] `pnpm --filter @0xsequence/oms-wallet-wagmi-connector build` passes (if connector types/build changed) +- [ ] Relevant example builds pass: `pnpm build:example`, `pnpm build:trails-actions-example`, `pnpm build:wagmi-example`, `pnpm build:node-example`, or `pnpm build:node-contract-deploy-example` +- [ ] `pnpm build` succeeds (if touching exports, package output, or release behavior) ## Related diff --git a/AGENTS.md b/AGENTS.md index 2631326..97a1ebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,8 +81,10 @@ This repository is a pnpm workspace for the OMS TypeScript SDK. The root package - `type-tests/`: Compile-time API tests. - `examples/react/`: Vite React demo that consumes the SDK through the workspace. - `examples/wagmi/`: Vite React wagmi demo using the OMS Wallet connector and MetaMask connector. +- `examples/trails-actions/`: Vite React demo for Trails swap, Earn deposit, and Earn withdrawal flows. - `examples/node/`: Interactive Node OTP/signing example. -- `examples/shared/oms-tokens.css`: Shared OMS design tokens (`--oms-*`) imported by each browser example's `styles.css`. See the Example App Styling rules. +- `examples/node-contract-deploy-example/`: Interactive Node ERC-20 deployment example. +- `examples/shared/`: Shared browser-example design tokens, base styles, components, utilities, and Vite aliases. - `scripts/write-esm-package.cjs`: Writes `dist/esm/package.json` during the root build. ## Commands @@ -96,11 +98,15 @@ This repository is a pnpm workspace for the OMS TypeScript SDK. The root package - `pnpm --filter @0xsequence/oms-wallet-wagmi-connector build`: Build the wagmi connector package. - `pnpm --filter @0xsequence/oms-wallet-wagmi-connector test`: Run the wagmi connector package tests. - `pnpm build:example`: Build the React example for Vite/GitHub Pages output after `pnpm build` has produced SDK output. +- `pnpm build:trails-actions-example`: Build the Trails Actions React example. - `pnpm build:wagmi-example`: Build the wagmi React example. - `pnpm build:node-example`: Typecheck the Node example. +- `pnpm build:node-contract-deploy-example`: Typecheck the Node contract deploy example. - `pnpm dev:example`: Start the React demo dev server. +- `pnpm dev:trails-actions-example`: Start the Trails Actions React demo dev server. - `pnpm dev:wagmi-example`: Start the wagmi React demo dev server. - `pnpm dev:node-example`: Run the interactive Node OTP example. +- `pnpm dev:node-contract-deploy-example`: Run the interactive Node contract deploy example. - `pnpm test:watch`: Run Vitest in watch mode during local development. ## Verification Workflow @@ -113,7 +119,9 @@ This repository is a pnpm workspace for the OMS TypeScript SDK. The root package 6. Run `pnpm build:node-example` when SDK exports, module resolution, or Node example usage changes. 7. Run `pnpm build` before release/build-output work, package entrypoint changes, or React example builds from a clean tree. 8. Run `pnpm build:example` after `pnpm build` when changing the React example, Vite config, public browser API shape, or Pages deployment assumptions. -9. Run `pnpm build:wagmi-example` after `pnpm build` when changing the wagmi example, connector browser usage, or Pages deployment assumptions. +9. Run `pnpm build:trails-actions-example` after `pnpm build` when changing the Trails Actions example, shared browser example utilities, or Pages deployment assumptions. +10. Run `pnpm build:wagmi-example` after `pnpm build` when changing the wagmi example, connector browser usage, or Pages deployment assumptions. +11. Run `pnpm build:node-contract-deploy-example` when SDK exports, transaction APIs, module resolution, or the Node contract deploy example changes. ## Coding and Architecture Rules @@ -155,7 +163,7 @@ execution commands. ## Generated Files and External Artifacts - `src/generated/waas.gen.ts` is generated by Webrpc and marked `DO NOT EDIT`. Update the generated-client source of truth rather than hand-editing this file as normal source. -- The generated WaaS header references `schema/waas.ridl`; if regenerating the client, document the schema source and command used. +- The generated WaaS header records the upstream schema path and generation command. This repo does not currently include that schema; if regenerating the client, document the schema source and exact command used. - The wagmi connector's SDK peer dependency is intentionally `workspace:*` in source. Release with pnpm so the published package gets the exact SDK version; do not hand-edit that peer to a literal version. - `pnpm-lock.yaml` is the dependency lockfile. Update it through pnpm, not by hand. - `dist/`, `examples/react/dist/`, `examples/wagmi/dist/`, and `*.tsbuildinfo` files are build outputs and should not be edited as source. @@ -165,6 +173,8 @@ execution commands. - Do not commit real secrets. `.env.local` and `.env.*.local` files are ignored for local overrides. - The React example uses `examples/react/.env.example` for `VITE_OMS_PUBLISHABLE_KEY`; keep local overrides in `examples/react/.env.local`. - The wagmi React example uses `examples/wagmi/.env.example` for `VITE_OMS_PUBLISHABLE_KEY`; keep local overrides in `examples/wagmi/.env.local`. +- The Trails Actions example uses `examples/trails-actions/.env.example` for `VITE_OMS_PUBLISHABLE_KEY`; keep local overrides in `examples/trails-actions/.env.local`. +- The Node contract deploy example uses `examples/node-contract-deploy-example/.env.example` for `OMS_PUBLISHABLE_KEY`; keep local overrides in `examples/node-contract-deploy-example/.env.local`. - Treat credential signing, nonce handling, OIDC redirect state cleanup, session persistence, transaction execution/status polling, and access revocation as high-risk paths. Prefer focused regression tests for changes in these areas. - GitHub Pages reads `OMS_PUBLISHABLE_KEY` for deployed examples. The wagmi example also reads `VITE_TRAILS_API_KEY`. Do not require those secrets for ordinary local unit tests unless the test explicitly needs an external boundary. diff --git a/API.md b/API.md index c8ee5f4..df5cbab 100644 --- a/API.md +++ b/API.md @@ -56,9 +56,9 @@ - [ListAccessParams](#listaccessparams) - [AccessGrantPage](#accessgrantpage) - [WalletActivationResult](#walletactivationresult) - - [SendNativeTransactionParams](#sendnativetransactionparams) - - [SendDataTransactionParams](#senddatatransactionparams) - - [SendContractTransactionParams](#sendcontracttransactionparams) + - [Native Transaction Parameters](#native-transaction-parameters) + - [Raw Data Transaction Parameters](#raw-data-transaction-parameters) + - [ABI-Encoded Transaction Parameters](#abi-encoded-transaction-parameters) - [SendTransactionResponse](#sendtransactionresponse) - [TransactionStatusResponse](#transactionstatusresponse) - [TransactionStatusPollingOptions](#transactionstatuspollingoptions) @@ -83,7 +83,7 @@ - [TokenContractInfo](#tokencontractinfo) - [TokenMetadata](#tokenmetadata) - [TokenMetadataAsset](#tokenmetadataasset) - - [AbiArg](#abiarg) + - [Contract Call Arguments](#contract-call-arguments) - [AuthMode](#authmode) - [WalletType](#wallettype) @@ -288,7 +288,7 @@ startOidcRedirectAuth(params: { }): Promise<{ url: string; state: string; challenge: string }> ``` -Starts an OIDC authorization-code redirect flow and returns the provider authorization URL. If a wallet session is already active, it is cleared before the new auth attempt starts. The SDK stores transient redirect auth state so the callback can complete after a full-page redirect. +Starts an OIDC authorization-code redirect flow and returns the provider authorization URL. If a wallet session is already active, it is cleared before the new auth attempt starts. The SDK stores transient redirect auth state so the callback can complete after a full-page redirect with the same credential signer, credential id, and signing algorithm. If `provider` is a string, it must match a configured `auth.oidcProviders` key. Passing an `OidcProviderConfig` object directly is also supported. @@ -327,7 +327,7 @@ completeOidcRedirectAuth(params: { > ``` -Completes an OIDC redirect flow by validating the callback, completing auth with a one-week session lifetime by default, and activating an existing wallet or creating one. In browser environments, `callbackUrl` defaults to `window.location.href`; if the current URL has no OIDC callback params, the method returns `undefined` without requiring pending redirect storage. +Completes an OIDC redirect flow by validating the callback, completing auth with a one-week session lifetime by default, and activating an existing wallet or creating one. Completion must run with the same credential signer, credential id, and signing algorithm that started the redirect. In browser environments, `callbackUrl` defaults to `window.location.href`; if the current URL has no OIDC callback params, the method returns `undefined` without requiring pending redirect storage. Pass `sessionLifetimeSeconds` to request a shorter or longer session lifetime. Pass `walletSelection: 'manual'` to return a [`PendingWalletSelection`](#pendingwalletselection) for app-driven wallet selection. If omitted, completion uses values stored by `startOidcRedirectAuth` or `signInWithOidcRedirect`, then falls back to automatic wallet selection and the default one-week lifetime. @@ -381,7 +381,7 @@ if (result) { signOut(): Promise ``` -Clears the wallet session metadata from storage and clears the active credential signer where supported. After calling this, `walletAddress` and `session` metadata are no longer available and the user must authenticate again via [`startEmailAuth`](#startemailauth). +Clears the wallet session metadata from storage and clears the active credential signer where supported. After calling this, `walletAddress` and `session` metadata are no longer available and the user must authenticate again through email auth or OIDC redirect auth. **Returns** `Promise` @@ -538,7 +538,15 @@ Fetches the latest status for a prepared/executed transaction. This is useful af #### Native Token Transfer ```typescript -sendTransaction(params: SendNativeTransactionParams): Promise +sendTransaction(params: { + network: Network + to: Address + value: bigint + mode?: TransactionMode + selectFeeOption?: FeeOptionSelector + waitForStatus?: boolean + statusPolling?: TransactionStatusPollingOptions +}): Promise ``` Sends native tokens (ETH, POL, etc.) to an address. @@ -548,7 +556,7 @@ import { parseUnits } from 'viem' const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xRecipient', + to: '0x1111111111111111111111111111111111111111', value: parseUnits('1', 18), // 1 POL }) ``` @@ -556,7 +564,16 @@ const tx = await oms.wallet.sendTransaction({ #### Raw Data Transaction ```typescript -sendTransaction(params: SendDataTransactionParams): Promise +sendTransaction(params: { + network: Network + to: Address + data: Hex + value?: bigint + mode?: TransactionMode + selectFeeOption?: FeeOptionSelector + waitForStatus?: boolean + statusPolling?: TransactionStatusPollingOptions +}): Promise ``` Sends a transaction with arbitrary calldata as a hex string. Use this when you have pre-encoded calldata. @@ -564,18 +581,29 @@ Sends a transaction with arbitrary calldata as a hex string. Use this when you h ```typescript const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xContract', - data: '0xa9059cbb000000000000000000000000...', + to: '0x2222222222222222222222222222222222222222', + data: '0x12345678', }) ``` #### ABI-Encoded Contract Call ```typescript -sendTransaction(params: SendContractTransactionParams): Promise +sendTransaction(params: { + network: Network + to: Address + abi: Abi | readonly unknown[] + functionName: string + args?: unknown[] + value?: bigint + mode?: TransactionMode + selectFeeOption?: FeeOptionSelector + waitForStatus?: boolean + statusPolling?: TransactionStatusPollingOptions +}): Promise ``` -Sends a contract interaction with fully-typed ABI encoding via viem. The calldata is encoded automatically from `abi`, `functionName`, and `args`. +Sends a contract interaction with ABI encoding via viem. The calldata is encoded automatically from `abi`, `functionName`, and `args`. When `abi` is passed as a const ABI, TypeScript narrows valid `functionName` values and infers `args`. ```typescript import { parseUnits } from 'viem' @@ -593,14 +621,14 @@ const erc20Abi = [ const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xTokenContract', + to: '0x3333333333333333333333333333333333333333', abi: erc20Abi, functionName: 'transfer', - args: ['0xRecipient', parseUnits('1', 18)], + args: ['0x1111111111111111111111111111111111111111', parseUnits('1', 18)], }) ``` -All three variants share the following optional base fields: +The transaction variants share these common fields. `value` is required for native token transfers and optional for raw-data and ABI-encoded contract calls. | Name | Type | Description | |---|---|---| @@ -612,7 +640,7 @@ All three variants share the following optional base fields: **Returns** `Promise` — the prepared transaction ID, latest status, and transaction hash when available. -**Throws** if no session is active, the transaction reverts, or the request fails. +**Throws** if no session is active, local validation or fee selection fails, or a prepare/execute/status request fails. On-chain failed transaction statuses are returned as transaction status responses. When fee options are returned, `selectFeeOption` receives `FeeOptionWithBalance[]`. Each entry includes the `FeeOption` plus the selected wallet's balance @@ -629,7 +657,7 @@ callContract(params: { network: Network contractAddress: Address method: string - args?: AbiArg[] + args?: Array<{ type: string; value: unknown }> mode?: TransactionMode selectFeeOption?: FeeOptionSelector waitForStatus?: boolean @@ -646,7 +674,7 @@ Calls a state-changing smart contract function using a method signature string a | `network` | `Network` | Yes | Network identifier. See [Network](#network). | | `contractAddress` | `Address` | Yes | Address of the target contract. | | `method` | `string` | Yes | ABI function signature, e.g. `"transfer(address,uint256)"`. | -| `args` | `AbiArg[]` | No | Ordered list of typed arguments. See [AbiArg](#abiarg). | +| `args` | `Array<{ type: string; value: unknown }>` | No | Ordered list of typed arguments. See [Contract Call Arguments](#contract-call-arguments). | | `mode` | `TransactionMode` | No | Transaction execution mode. Defaults to `TransactionMode.Relayer`. | | `selectFeeOption` | `FeeOptionSelector` | No | Optional callback for choosing a fee option. | | `waitForStatus` | `boolean` | No | Set to `false` to return immediately after execute without polling transaction status. | @@ -661,10 +689,10 @@ import { parseUnits } from 'viem' const tx = await oms.wallet.callContract({ network: Networks.polygon, - contractAddress: '0xTokenContract', + contractAddress: '0x3333333333333333333333333333333333333333', method: 'transfer(address,uint256)', args: [ - { type: 'address', value: '0xRecipient' }, + { type: 'address', value: '0x1111111111111111111111111111111111111111' }, { type: 'uint256', value: parseUnits('1', 18).toString() }, ], }) @@ -993,7 +1021,7 @@ type OidcProviderConfig = { Provider configs are the source of truth for authorization scopes. If `scopes` is omitted or empty, the SDK does not send a `scope` authorization parameter. `authMode` defaults to `AuthMode.AuthCodePKCE`. -Google can be configured with the `googleOidcProvider` helper. The default Google provider uses the SDK default client ID, the SDK relay redirect URI, `openid email profile` scopes, and PKCE auth-code mode: +Google can be configured with the `googleOidcProvider` helper. The default Google provider uses the SDK default client ID, the SDK relay redirect URI, `openid email profile` scopes, PKCE auth-code mode, and Google authorization parameters `access_type=offline` and `prompt=consent`: ```typescript // Uses the SDK default Google client id and relay redirect URI. @@ -1024,7 +1052,8 @@ appleOidcProvider({ ### OIDC Provider Helpers ```typescript -type OidcProviderName = string +type OidcProviderName = + keyof NonNullable['oidcProviders']> & string type OidcProviderInput = OidcProviderName | OidcProviderConfig interface GoogleOidcProviderParams { @@ -1167,8 +1196,13 @@ Interface for request credential signing. The default implementation is `WebCryp ### Credential Signing Helpers ```typescript -class WebCryptoP256CredentialSigner implements CredentialSigner -class EthereumPrivateKeyCredentialSigner implements CredentialSigner +class WebCryptoP256CredentialSigner implements CredentialSigner { + constructor(id?: string) +} + +class EthereumPrivateKeyCredentialSigner implements CredentialSigner { + constructor(privateKey: Uint8Array) +} ``` `WebCryptoP256CredentialSigner` is the browser default. `EthereumPrivateKeyCredentialSigner` signs credential requests with an EVM private key and is useful for Node.js or server-side usage where the caller provides key material directly. @@ -1351,13 +1385,13 @@ Returned when an existing wallet is selected or a new wallet is created and acti --- -### SendNativeTransactionParams +### Native Transaction Parameters ```typescript -type SendNativeTransactionParams = { +{ network: Network to: Address - value: bigint // required — amount in wei + value: bigint mode?: TransactionMode selectFeeOption?: FeeOptionSelector waitForStatus?: boolean @@ -1369,13 +1403,13 @@ Used when sending a native token transfer. `value` is required and `data`/`abi` --- -### SendDataTransactionParams +### Raw Data Transaction Parameters ```typescript -type SendDataTransactionParams = { +{ network: Network to: Address - data: Hex // required — pre-encoded calldata + data: Hex value?: bigint mode?: TransactionMode selectFeeOption?: FeeOptionSelector @@ -1388,18 +1422,15 @@ Used when sending a transaction with raw calldata. `abi` must not be set. --- -### SendContractTransactionParams +### ABI-Encoded Transaction Parameters ```typescript -type SendContractTransactionParams< - abi extends Abi | readonly unknown[], - functionName extends ContractFunctionName | undefined -> = { +{ network: Network to: Address - abi: abi - functionName: functionName - args?: ... // inferred from abi + functionName + abi: Abi | readonly unknown[] + functionName: string + args?: unknown[] value?: bigint mode?: TransactionMode selectFeeOption?: FeeOptionSelector @@ -1408,7 +1439,7 @@ type SendContractTransactionParams< } ``` -Used for fully-typed ABI-encoded contract calls. `abi` and `functionName` are required; `args` types are inferred from the ABI. `data` must not be set. Calldata is encoded automatically using viem's `encodeFunctionData`. +Used for ABI-encoded contract calls. `abi` and `functionName` are required; `args` types are inferred from const ABIs. `data` must not be set. Calldata is encoded automatically using viem's `encodeFunctionData`. --- @@ -1881,21 +1912,21 @@ Media asset metadata associated with token metadata when returned. --- -### AbiArg +### Contract Call Arguments ```typescript -interface AbiArg { +{ type: string - value: any + value: unknown } ``` -A loosely-typed ABI argument used by [`callContract`](#callcontract). For fully-typed encoding, use the ABI overload of [`sendTransaction`](#abi-encoded-contract-call) instead. +A loosely-typed ABI argument object used by [`callContract`](#callcontract). For fully-typed encoding, use the ABI overload of [`sendTransaction`](#abi-encoded-contract-call) instead. | Field | Type | Description | |---|---|---| | `type` | `string` | Solidity type string, e.g. `"address"`, `"uint256"`, `"bytes32"`, `"bool"`. | -| `value` | `any` | The argument value. Use a string for large integers to avoid precision loss. | +| `value` | `unknown` | The argument value. Use a string for large integers to avoid precision loss. | --- @@ -1922,4 +1953,4 @@ enum WalletType { } ``` -Identifies the wallet type to load or create. Passed as the optional `walletType` parameter to [`completeEmailAuth`](#completeemailauth). Defaults to `WalletType.Ethereum`. +Identifies the wallet type to load or create. Accepted by wallet creation and auth completion flows, including [`completeEmailAuth`](#completeemailauth), [`startOidcRedirectAuth`](#startoidcredirectauth), [`signInWithOidcRedirect`](#signinwithoidcredirect), and [`createWallet`](#createwallet). Defaults to `WalletType.Ethereum`. diff --git a/PUBLISHING.md b/PUBLISHING.md index 4993c30..51d5735 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -43,8 +43,10 @@ pnpm check:package-versions ```bash pnpm test pnpm --filter @0xsequence/oms-wallet-wagmi-connector test +pnpm --filter @0xsequence/oms-wallet-wagmi-connector build pnpm build pnpm build:node-example +pnpm build:node-contract-deploy-example pnpm build:example pnpm build:trails-actions-example pnpm build:wagmi-example diff --git a/README.md b/README.md index 270af20..1789294 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,13 @@ To run it locally from the repository root: ```bash cp examples/wagmi/.env.example examples/wagmi/.env.local # Fill VITE_OMS_PUBLISHABLE_KEY in examples/wagmi/.env.local +# Replace VITE_TRAILS_API_KEY only if you need a different Trails project pnpm dev:wagmi-example ``` ## Trails Actions React Example -The Trails Actions example prepares and sends Polygon swap, deposit, and swap plus deposit flows with `0xtrails/actions`. +The Trails Actions example prepares and sends Polygon swap, Earn deposit, swap plus Earn deposit, and Earn withdrawal flows with `0xtrails/actions`. The deployed Trails Actions example is available at [https://0xsequence.github.io/typescript-sdk/trails-actions-example/](https://0xsequence.github.io/typescript-sdk/trails-actions-example/). @@ -156,7 +157,7 @@ console.log('Credential:', credential.credentialId) // 4. Send a transaction const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xRecipient', + to: '0x1111111111111111111111111111111111111111', value: parseUnits('1', 18), // 1 POL // If this Polygon mainnet transaction is not sponsored, choose the first fee token the wallet can pay. selectFeeOption: FeeOptionSelector.firstAvailable, @@ -348,7 +349,7 @@ import { parseUnits } from 'viem' const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xRecipient', + to: '0x1111111111111111111111111111111111111111', value: parseUnits('1', 18), // 1 POL }) ``` @@ -358,8 +359,8 @@ const tx = await oms.wallet.sendTransaction({ ```typescript const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xContract', - data: '0xa9059cbb000000000000000000000000...', + to: '0x2222222222222222222222222222222222222222', + data: '0x12345678', }) ``` @@ -383,10 +384,10 @@ const erc20Abi = [ const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xTokenContract', + to: '0x3333333333333333333333333333333333333333', abi: erc20Abi, functionName: 'transfer', - args: ['0xRecipient', parseUnits('1', 18)], + args: ['0x1111111111111111111111111111111111111111', parseUnits('1', 18)], }) ``` @@ -403,7 +404,7 @@ import { parseUnits } from 'viem' const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xRecipient', + to: '0x1111111111111111111111111111111111111111', value: parseUnits('0.001', 18), waitForStatus: false, }) @@ -418,7 +419,7 @@ import { parseUnits } from 'viem' await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xRecipient', + to: '0x1111111111111111111111111111111111111111', value: parseUnits('0.001', 18), statusPolling: { timeoutMs: 30_000, @@ -435,8 +436,8 @@ wallet can pay, or return `option.selection` from a custom selector. ```typescript const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, - to: '0xTokenContract', - data: '0xa9059cbb000000000000000000000000...', + to: '0x3333333333333333333333333333333333333333', + data: '0x12345678', selectFeeOption: async (feeOptions) => { const selected = feeOptions.find(option => option.feeOption.token.symbol === 'USDC') return selected?.selection @@ -535,10 +536,10 @@ import { parseUnits } from 'viem' const tx = await oms.wallet.callContract({ network: Networks.polygon, - contractAddress: '0xTokenContract', + contractAddress: '0x3333333333333333333333333333333333333333', method: 'transfer(address,uint256)', args: [ - { type: 'address', value: '0xRecipient' }, + { type: 'address', value: '0x1111111111111111111111111111111111111111' }, { type: 'uint256', value: parseUnits('1', 18).toString() }, ], }) diff --git a/TESTING.md b/TESTING.md index 9665a0c..38f41fa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -36,7 +36,11 @@ How testing works in this repo. `AGENTS.md` points here so agents know how to ve | Changed SDK behavior | `pnpm exec vitest run` | | Changed wagmi connector behavior | `pnpm --filter @0xsequence/oms-wallet-wagmi-connector test` | | Changed wagmi connector types/build | `pnpm --filter @0xsequence/oms-wallet-wagmi-connector build` | +| Changed React example | `pnpm build:example` | +| Changed Trails Actions example | `pnpm build:trails-actions-example` | | Changed wagmi React example | `pnpm build:wagmi-example` | +| Changed Node example | `pnpm build:node-example` | +| Changed Node contract deploy example | `pnpm build:node-contract-deploy-example` | | Changed public types / `src/index.ts` | `pnpm test:types` | | Full pre-handoff check | `pnpm exec tsc --noEmit && pnpm test` | | Watch mode during development | `pnpm test:watch` | @@ -44,7 +48,7 @@ How testing works in this repo. `AGENTS.md` points here so agents know how to ve ## Conventions -- Test filenames match the module they cover: `walletClient.ts` → `walletClient.test.ts` +- Test filenames should make their public behavior area clear. Broad clients may be split across focused files such as `walletSession.test.ts`, `walletTransactions.test.ts`, `walletSigning.test.ts`, and `walletAccess.test.ts`. - Bug fixes should include a regression test that would have caught the bug - Do not add tests that assert private implementation — test the externally visible promise - Network boundaries are stubbed; don't require live secrets for `pnpm test` diff --git a/docs/error-contracts.md b/docs/error-contracts.md index 67e4579..3b87471 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -26,8 +26,8 @@ supports, whether `upstreamError` should be present, and which test owns the con | `oms.wallet.startEmailAuth`, representative WaaS methods | WaaS HTTP error | `OmsRequestError`, `OMS_HTTP_ERROR`, status, retryable for 5xx | Use SDK code/status for branching; log upstream detail | Present | `snapshots WaaS HTTP responses with upstream details` | | `oms.wallet.completeEmailAuth` and pending wallet selection actions | Local auth/session/selection state | `OmsSessionError` or `OmsWalletSelectionError` | Fix local flow state or restart auth; do not look for backend diagnostics | Absent | `snapshots email auth completion local state errors`; `snapshots pending wallet selection local state errors` | | `oms.wallet.startOidcRedirectAuth`, `completeOidcRedirectAuth`, `signInWithOidcRedirect` | Local OIDC config, callback, storage, or state mismatch | `OmsSessionError` or wrapped SDK-local error | Fix redirect config/state or restart OIDC flow | Absent | `snapshots OIDC local error contracts without upstream details`; `snapshots OIDC redirect real-flow local mismatch errors`; `snapshots signInWithOidcRedirect missing assignUrl after real redirect start` | -| Protected wallet methods: `getIdToken`, `signMessage`, `signTypedData`, `sendTransaction`, `callContract`, `getTransactionStatus`, `listAccess`, `listAccessPages`, `revokeAccess` | Missing, expired, or stale local session | `OmsSessionError` | Authenticate again or recover local session; no remote request was made | Absent | `snapshots missing-session contracts for protected wallet methods` | -| `oms.wallet.signMessage`, `signTypedData`, `getIdToken`, `sendTransaction`, `callContract` | SDK-local validation or fee-selection failure | `OmsValidationError` | Correct parameters or local fee selection; do not retry as an upstream outage | Absent | `snapshots SDK-local errors without upstream details`; `snapshots transaction local validation errors without upstream details` | +| Protected wallet methods: `listWallets`, `useWallet`, `createWallet`, `getIdToken`, `signMessage`, `signTypedData`, `sendTransaction`, `callContract`, `listAccess`, `listAccessPages`, `revokeAccess` | Missing, expired, or stale local session | `OmsSessionError` | Authenticate again or recover local session; no remote request was made | Absent | `snapshots missing-session contracts for protected wallet methods` | +| `oms.wallet.sendTransaction`, `callContract` | SDK-local transaction validation or fee-selection failure | `OmsValidationError` | Correct parameters or local fee selection; do not retry as an upstream outage | Absent | `snapshots transaction local validation errors without upstream details` | | `oms.wallet.isValidMessageSignature`, `isValidTypedDataSignature` | WaaS validation backend failure | `OmsRequestError` or `OmsResponseError` with validation operation | Retry based on SDK code/status; log upstream detail | Present | `snapshots signature validation backend failures with upstream details` | | `oms.wallet.sendTransaction`, `callContract` | Execute request fails after prepare | `OmsTransactionError`, `OMS_TRANSACTION_EXECUTION_UNCONFIRMED`, `retryable: false`, `txnId` when available | Do not blindly resend the write; preserve `txnId` and upstream detail for diagnostics | Present when execute crossed transport/upstream boundary | `snapshots transaction execute failures as unconfirmed writes` | | `oms.wallet.sendTransaction`, `callContract` | Submitted transaction status polling fails | `OmsTransactionError`, `OMS_TRANSACTION_STATUS_LOOKUP_FAILED`, `retryable: true`, `txnId` | Retry status lookup, not the original write | Present when polling crossed transport/upstream boundary | `snapshots transaction status polling failures with txn and upstream details`; `snapshots transaction status polling backend errors as retryable` | @@ -54,4 +54,5 @@ supports, whether `upstreamError` should be present, and which test owns the con | `eth_sendTransaction`, `wallet_sendTransaction` | SDK transaction failure | `OmsWalletProviderRpcError`, `-32603`, SDK error preserved in `data` | Preserve SDK recovery fields through provider and wagmi wrapping | SDK error may carry `upstreamError` in `data` | `wraps SDK transaction failures as provider RPC errors`; `preserves SDK transaction error details through wagmi sendTransaction wrapping` | | `eth_sendTransaction`, `wallet_sendTransaction` | OMS response lacks EVM transaction hash | `OmsWalletProviderRpcError`, `-32603`, response preserved | Surface the OMS transaction id when available; wagmi requires an EVM hash | Not applicable unless response data carries it | `rejects with the OMS response when a sent transaction has no EVM hash` | | `wallet_switchEthereumChain`, `switchChain` | Chain not configured in wagmi or unsupported by OMS | `OmsWalletProviderRpcError` or wagmi switch error, `4901` | Configure the chain and ensure OMS supports it | Not applicable | `rejects provider chain switches to OMS-supported chains that are not configured in wagmi`; `rejects provider chain switches to wagmi-configured chains that OMS does not support`; `rejects wagmi switchChain calls to wagmi-configured chains that OMS does not support`; `uses and validates initialChainId`; `rejects initialChainId when OMS does not support it` | +| Exported `OmsWalletProviderRpcError` | Provider error class field contract | Stable public fields: `name`, `code`, `message`, `data` | Branch on EIP-1193 `code`; use `data` for preserved SDK diagnostics | Not applicable | `preserves the exported provider RPC error field contract` | | `eth_chainId`, `net_version`, `eth_accounts`, `wallet_getCapabilities`, `stringToPersonalSignHex` | Non-throwing public utility/state calls | Return current state or converted value | No error contract expected unless implementation changes | Not applicable | Covered indirectly by behavior tests or intentionally skipped | diff --git a/docs/session-expiry-flow.md b/docs/session-expiry-flow.md index 6ecd762..b08d49c 100644 --- a/docs/session-expiry-flow.md +++ b/docs/session-expiry-flow.md @@ -7,6 +7,7 @@ This note documents the wallet session expiry flow for maintainers. Public API b - A valid stored session is restored into memory and gets an expiry timer. - An expired stored session is not restored as active, but its metadata stays in storage so `onSessionExpired` can replay after a page refresh. - Active sessions can expire from the timer or from a protected wallet operation checking the session before use. +- Pending manual wallet selection uses the same auth metadata and expiry timer before a wallet is activated. - `signOut()` or a new auth flow clears or replaces stored session metadata, which cancels stale expired-session replay. ## Flow @@ -22,6 +23,10 @@ flowchart TD E -- "No" --> F["Restore active in-memory session"] F --> G["Schedule active session expiry timer"] + X["Manual auth completion"] --> Y["Create pending wallet selection snapshot"] + Y --> Z["Schedule pending selection expiry timer"] + Z --> P + E -- "Yes" --> H["Keep expired metadata in storage"] H --> I["Do not restore active in-memory session"] I --> J["Schedule deferred expiry replay"] @@ -35,15 +40,14 @@ flowchart TD N -- "Yes" --> O["Notify onSessionExpired"] G --> P["Timer fires"] - P --> Q{"In-memory session snapshot still current?"} + P --> Q{"Session or pending-selection snapshot still current?"} Q -- "No" --> R["Ignore stale timer"] Q -- "Yes" --> S{"Session expired now?"} S -- "No" --> G - S -- "Yes" --> T["Clear in-memory session and signer"] + S -- "Yes" --> T["Clear in-memory session or pending selection and signer"] T --> U["Keep expired metadata in storage"] U --> O V["signOut or new auth flow"] --> W["Clear or replace stored session"] W --> L ``` - diff --git a/examples/node-contract-deploy-example/.env.example b/examples/node-contract-deploy-example/.env.example index f2e1dbd..15ab43b 100644 --- a/examples/node-contract-deploy-example/.env.example +++ b/examples/node-contract-deploy-example/.env.example @@ -1,2 +1,3 @@ OMS_PUBLISHABLE_KEY=your-publishable-key # DEPLOY_SALT=0x0000000000000000000000000000000000000000000000000000000000000001 +# DEPLOYER_ADDRESS=0xce0042B868300000d44A59004Da54A005ffdcf9f diff --git a/examples/node-contract-deploy-example/README.md b/examples/node-contract-deploy-example/README.md index f8908cb..7c666a5 100644 --- a/examples/node-contract-deploy-example/README.md +++ b/examples/node-contract-deploy-example/README.md @@ -42,9 +42,11 @@ answers are `WalletKit Dollar`, `WKUSD`, and `6`. Optionally set a deterministic CREATE2 salt: ```bash -DEPLOY_SALT=0x0000000000000000000000000000000000000000000000000000000000000001 +DEPLOY_SALT=0x0000000000000000000000000000000000000000000000000000000000000001 pnpm dev:node-contract-deploy-example ``` +You can also set `DEPLOY_SALT` or `DEPLOYER_ADDRESS` in `.env.local`. + Each deploy writes a timestamped text record under `artifacts/` with the token metadata, computed contract address, transaction id, transaction hash, and explorer links. Generated artifact files are ignored by git. diff --git a/examples/node/README.md b/examples/node/README.md index 3946e97..68b666c 100644 --- a/examples/node/README.md +++ b/examples/node/README.md @@ -14,7 +14,7 @@ pnpm build OMS_PUBLISHABLE_KEY=your-publishable-key pnpm dev:node-example ``` -The example prompts for an email address, sends an OTP code, then prompts for the code. +The example prompts for an email address, sends an OTP code, prompts for the code, then signs a `test` message on Polygon Amoy. You can typecheck the example directly: diff --git a/examples/react/README.md b/examples/react/README.md index 04ac97c..fcca4a7 100644 --- a/examples/react/README.md +++ b/examples/react/README.md @@ -20,13 +20,6 @@ The dev server runs at `http://localhost:5173`. The deployed example is available at `https://0xsequence.github.io/typescript-sdk/react-example`. -The example requires a publishable key. Configure it locally before running the dev server: - -```bash -cp examples/react/.env.example examples/react/.env.local -# Fill VITE_OMS_PUBLISHABLE_KEY -``` - The Amoy-only "ERC20 example" panel includes a WalletKit Dollar example using the demo WKUSD contract deployed on Polygon Amoy. diff --git a/examples/trails-actions/README.md b/examples/trails-actions/README.md index 7ba337e..3d9b57c 100644 --- a/examples/trails-actions/README.md +++ b/examples/trails-actions/README.md @@ -5,6 +5,7 @@ This Vite React app uses the TypeScript SDK wallet client with Trails actions on - Swap POL to USDC - Deposit USDC using Earn - Swap POL to USDC and deposit USDC in one prepared Trails transaction +- View Earn positions and withdraw from withdrawable Earn positions Run it from the repository root: diff --git a/examples/wagmi/README.md b/examples/wagmi/README.md index 80b09d6..765c195 100644 --- a/examples/wagmi/README.md +++ b/examples/wagmi/README.md @@ -9,6 +9,7 @@ pnpm install pnpm build cp examples/wagmi/.env.example examples/wagmi/.env.local # Fill VITE_OMS_PUBLISHABLE_KEY +# Replace VITE_TRAILS_API_KEY only if you need a different Trails project pnpm dev:wagmi-example ``` diff --git a/packages/oms-wallet-wagmi-connector/README.md b/packages/oms-wallet-wagmi-connector/README.md index ff243db..f4b9ea0 100644 --- a/packages/oms-wallet-wagmi-connector/README.md +++ b/packages/oms-wallet-wagmi-connector/README.md @@ -14,20 +14,20 @@ const oms = new OMSClient({ publishableKey: import.meta.env.VITE_OMS_PUBLISHABLE_KEY, }) +const omsConnector = omsWalletConnector({ + client: oms, + initialChainId: polygon.id, + transactionOptions: { + selectFeeOption: FeeOptionSelector.firstAvailable, + }, +}) + export const wagmiConfig = createConfig({ chains: [polygon], transports: { [polygon.id]: http(), }, - connectors: [ - omsWalletConnector({ - client: oms, - initialChainId: polygon.id, - transactionOptions: { - selectFeeOption: FeeOptionSelector.firstAvailable, - }, - }), - ], + connectors: [omsConnector], }) ``` @@ -113,7 +113,7 @@ The connector ignores wallet-managed execution fields that OMS Wallet does not a Use this package through wagmi connector APIs. Do not treat `getProvider()` as a general RPC provider. -Supported provider methods are limited to the wallet operations that map to the SDK today: account discovery, local chain switching across configured OMS networks, `personal_sign`, `eth_signTypedData_v4`, `eth_sendTransaction`, and `wallet_sendTransaction`. +Supported provider methods are limited to the wallet operations and state queries that map to the SDK today: `eth_chainId`, `net_version`, `eth_accounts`, `eth_requestAccounts`, `wallet_switchEthereumChain`, `personal_sign`, `eth_signTypedData_v4`, `eth_sendTransaction`, `wallet_sendTransaction`, and `wallet_getCapabilities`. Unsupported methods include direct read/RPC methods such as `eth_call`, `eth_estimateGas`, `eth_getBalance`, `eth_getCode`, `eth_getTransactionCount`, `eth_getTransactionReceipt`, and `eth_blockNumber`. Use wagmi public transports for reads, or use `oms.wallet` directly for OMS SDK operations. From aa49a86973b1d5d3e73a81063e3e72581e7f1e09 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Thu, 2 Jul 2026 13:45:30 +0300 Subject: [PATCH 9/9] Update Apple OIDC defaults --- API.md | 2 +- README.md | 2 +- src/oidc.ts | 4 ++-- tests/oidcRedirectAuth.test.ts | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/API.md b/API.md index df5cbab..ee1a5aa 100644 --- a/API.md +++ b/API.md @@ -1034,7 +1034,7 @@ googleOidcProvider({ }) ``` -Apple can be configured with the `appleOidcProvider` helper. The default Apple provider uses `response_mode=query`, no scopes, and PKCE auth-code mode: +Apple can be configured with the `appleOidcProvider` helper. The default Apple provider uses `openid email` scopes, `response_mode=form_post`, and PKCE auth-code mode: ```typescript // Uses the SDK default Apple Services ID and relay redirect URI. diff --git a/README.md b/README.md index 1789294..87b4fb4 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Pending redirect state is stored in `sessionStorage` by default. Final wallet se `googleOidcProvider()` uses the SDK default Google client ID, the SDK relay redirect URI, `openid email profile` scopes, and PKCE auth-code mode by default. -`appleOidcProvider()` uses the SDK default Apple Services ID, the SDK relay redirect URI, `response_mode=query`, no scopes, and PKCE auth-code mode by default. Requesting Apple `email` or `name` scopes requires Apple `response_mode=form_post`, which is not handled by the SDK's URL-query callback parser without a relay that converts the callback. +`appleOidcProvider()` uses the SDK default Apple Services ID, the SDK relay redirect URI, `openid email` scopes, `response_mode=form_post`, and PKCE auth-code mode by default. ### Session State diff --git a/src/oidc.ts b/src/oidc.ts index 0fc48b3..646a8d0 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -42,11 +42,11 @@ export function appleOidcProvider(params: AppleOidcProviderParams = {}): OidcPro clientId: params.clientId || defaultAppleClientId, issuer: 'https://appleid.apple.com', authorizationUrl: 'https://appleid.apple.com/auth/authorize', - scopes: params.scopes ?? [], + scopes: params.scopes ?? ['openid', 'email'], relayRedirectUri: params.relayRedirectUri || defaultRelayRedirectUri, authMode: params.authMode ?? AuthMode.AuthCodePKCE, authorizeParams: { - response_mode: 'query', + response_mode: 'form_post', ...params.authorizeParams, }, }; diff --git a/tests/oidcRedirectAuth.test.ts b/tests/oidcRedirectAuth.test.ts index de1b89c..03353f0 100644 --- a/tests/oidcRedirectAuth.test.ts +++ b/tests/oidcRedirectAuth.test.ts @@ -376,15 +376,15 @@ describe("WalletClient OIDC redirect auth", () => { relayRedirectUri: expectedDefaultRelayRedirectUri, issuer: "https://appleid.apple.com", authorizationUrl: "https://appleid.apple.com/auth/authorize", - scopes: [], + scopes: ["openid", "email"], authMode: AuthMode.AuthCodePKCE, authorizeParams: { - response_mode: "query", + response_mode: "form_post", }, }); }); - it("starts an Apple query redirect flow from the default auth config", async () => { + it("starts an Apple form_post redirect flow from the default auth config", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const body = JSON.parse(init?.body as string); expect(body).toMatchObject({ @@ -422,8 +422,8 @@ describe("WalletClient OIDC redirect auth", () => { expect(authorizeUrl.searchParams.get("client_id")).toBe(expectedDefaultAppleClientId); expect(authorizeUrl.searchParams.get("redirect_uri")).toBe(expectedDefaultRelayRedirectUri); expect(authorizeUrl.searchParams.get("response_type")).toBe("code"); - expect(authorizeUrl.searchParams.get("response_mode")).toBe("query"); - expect(authorizeUrl.searchParams.get("scope")).toBeNull(); + expect(authorizeUrl.searchParams.get("response_mode")).toBe("form_post"); + expect(authorizeUrl.searchParams.get("scope")).toBe("openid email"); expect(authorizeUrl.searchParams.get("code_challenge")).toBe("challenge-1"); expect(authorizeUrl.searchParams.get("code_challenge_method")).toBe("S256"); }); @@ -1107,8 +1107,8 @@ describe("WalletClient OIDC redirect auth", () => { expect(assignedUrl.origin + assignedUrl.pathname).toBe("https://appleid.apple.com/auth/authorize"); expect(assignedUrl.searchParams.get("client_id")).toBe(expectedDefaultAppleClientId); expect(assignedUrl.searchParams.get("redirect_uri")).toBe(expectedDefaultRelayRedirectUri); - expect(assignedUrl.searchParams.get("response_mode")).toBe("query"); - expect(assignedUrl.searchParams.get("scope")).toBeNull(); + expect(assignedUrl.searchParams.get("response_mode")).toBe("form_post"); + expect(assignedUrl.searchParams.get("scope")).toBe("openid email"); }); it("requires provider to start the one-call browser convenience method", async () => {