diff --git a/API.md b/API.md index 582ac8d..7a1578e 100644 --- a/API.md +++ b/API.md @@ -17,6 +17,7 @@ - [listWallets](#listwallets) - [useWallet](#usewallet) - [createWallet](#createwallet) + - [getIdToken](#getidtoken) - [signMessage](#signmessage) - [signTypedData](#signtypeddata) - [isValidMessageSignature](#isvalidmessagesignature) @@ -41,6 +42,8 @@ - [StorageManager](#storagemanager) - [CredentialSigner](#credentialsigner) - [OmsWallet](#omswallet) + - [PendingWalletSelection](#pendingwalletselection) + - [WalletSelectionBehavior](#walletselectionbehavior) - [WalletCredential](#walletcredential) - [AccessGrant](#accessgrant) - [ListAccessParams](#listaccessparams) @@ -113,7 +116,7 @@ new OMSClient(params: { oms.supportedNetworks: readonly Network[] ``` -Returns the supported network registry. Each entry has `id`, `name`, `nativeTokenSymbol`, and `explorerUrl`. +Returns the supported network registry. Each entry has `id`, `name`, `nativeTokenSymbol`, `explorerUrl`, and `displayName`. ## WalletClient @@ -180,16 +183,16 @@ await oms.wallet.startEmailAuth({ email: 'user@example.com' }) completeEmailAuth(params: { code: string walletType?: WalletType - autoActivate?: boolean + walletSelection?: 'automatic' | 'manual' }): Promise< | { walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } - | { wallets: OmsWallet[]; credential: WalletCredential } + | PendingWalletSelection > ``` Verifies the OTP code and activates a wallet. Must be called after [`startEmailAuth`](#startemailauth). -This method verifies the code with a one-week WaaS session lifetime, loads all wallet pages, then automatically selects an existing wallet matching `walletType`, or creates a new one if none exists. Wallet metadata is persisted to storage. Pass `autoActivate: false` to return `{ wallets, credential }` without selecting or creating a wallet; then call [`useWallet`](#usewallet) or [`createWallet`](#createwallet). +This method verifies the code with a one-week WaaS session lifetime, loads all wallet pages, then automatically selects an existing wallet matching `walletType`, or creates a new one if none exists. Wallet metadata is persisted to storage. Pass `walletSelection: 'manual'` to return a [`PendingWalletSelection`](#pendingwalletselection) bound to the verified auth flow; complete selection through that object. **Parameters** @@ -197,9 +200,9 @@ This method verifies the code with a one-week WaaS session lifetime, loads all w |---|---|---|---| | `code` | `string` | Yes | The one-time passcode entered by the user. | | `walletType` | `WalletType` | No | The wallet type to load or create. Defaults to `WalletType.Ethereum`. | -| `autoActivate` | `boolean` | No | Defaults to `true`. Set to `false` to let the app choose an existing wallet or create a new one. | +| `walletSelection` | `'automatic' \| 'manual'` | No | Defaults to `'automatic'`. Set to `'manual'` to let the app choose an existing wallet or create one through the returned pending selection. | -**Returns** `Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential }>` by default, or `Promise<{ wallets: OmsWallet[]; credential: WalletCredential }>` when `autoActivate` is `false`. +**Returns** `Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential }>` by default, or `Promise` when `walletSelection` is `'manual'`. **Throws** if the code is incorrect, expired, or the network request fails. @@ -214,6 +217,20 @@ try { } ``` +Manual selection: + +```typescript +const selection = await oms.wallet.completeEmailAuth({ + code: '123456', + walletType: WalletType.Ethereum, + walletSelection: 'manual', +}) + +await selection.selectWallet({ walletId: selection.wallets[0].id }) +// or: +await selection.createAndSelectWallet({ reference: 'main' }) +``` + --- ### startOidcRedirectAuth @@ -252,14 +269,14 @@ completeOidcRedirectAuth(params: { callbackUrl: string cleanUrl?: boolean replaceUrl?: (url: string) => void - autoActivate?: boolean + walletSelection?: 'automatic' | 'manual' }): Promise< | { walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } - | { wallets: OmsWallet[]; credential: WalletCredential } + | PendingWalletSelection > ``` -Completes an OIDC redirect flow by validating the persisted state nonce, exchanging the authorization code with WaaS using a one-week session lifetime, and activating an existing wallet or creating one. Pass `autoActivate: false` to return `{ wallets, credential }` for app-driven wallet selection. `cleanUrl` removes OAuth query parameters after successful completion; outside a browser, pass `replaceUrl`. +Completes an OIDC redirect flow by validating the persisted state nonce, exchanging the authorization code with WaaS using a one-week session lifetime, and activating an existing wallet or creating one. Pass `walletSelection: 'manual'` to return a [`PendingWalletSelection`](#pendingwalletselection) for app-driven wallet selection. `cleanUrl` removes OAuth query parameters after successful completion; outside a browser, pass `replaceUrl`. ```typescript const { walletAddress, credential } = await oms.wallet.completeOidcRedirectAuth({ @@ -277,14 +294,14 @@ signInWithOidcRedirect(params: { provider: string | OidcProviderConfig redirectUri?: string walletType?: WalletType - autoActivate?: boolean + walletSelection?: 'automatic' | 'manual' relayRedirectUri?: string authorizeParams?: Record cleanUrl?: boolean currentUrl?: string assignUrl?: (url: string) => void replaceUrl?: (url: string) => void -}): Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } | { wallets: OmsWallet[]; credential: WalletCredential } | void> +}): Promise<{ walletAddress: Address; wallet: OmsWallet; wallets: OmsWallet[]; credential: WalletCredential } | PendingWalletSelection | void> ``` Browser convenience method for regular web apps. If the current URL has OIDC callback params, it completes auth and returns the same result as [`completeOidcRedirectAuth`](#completeoidcredirectauth). Otherwise it starts auth, redirects with `window.location.assign`, and returns `void`. For router-driven apps, prefer [`startOidcRedirectAuth`](#startoidcredirectauth) and [`completeOidcRedirectAuth`](#completeoidcredirectauth). @@ -319,7 +336,7 @@ await oms.wallet.signOut() listWallets(): Promise ``` -Returns all wallets available to the authenticated credential. This can be used after completing auth with `autoActivate: false` to show a wallet picker. +Returns all wallets available to an authenticated active or pending wallet-selection session. --- @@ -329,7 +346,7 @@ Returns all wallets available to the authenticated credential. This can be used useWallet(params: { walletId: string }): Promise<{ walletAddress: Address; wallet: OmsWallet }> ``` -Activates an existing wallet by server-side wallet id and persists it as the current wallet session. +Activates an existing wallet by server-side wallet id and persists it as the current wallet session. Requires an active wallet session; pending manual auth flows must use [`PendingWalletSelection.selectWallet`](#pendingwalletselection). --- @@ -339,7 +356,29 @@ Activates an existing wallet by server-side wallet id and persists it as the cur createWallet(params?: { type?: WalletType; reference?: string }): Promise<{ walletAddress: Address; wallet: OmsWallet }> ``` -Creates a new wallet, activates it, and persists it as the current wallet session. `type` defaults to `WalletType.Ethereum`. +Creates a new wallet, activates it, and persists it as the current wallet session. Requires an active wallet session. `type` defaults to `WalletType.Ethereum`. Pending manual auth flows must use [`PendingWalletSelection.createAndSelectWallet`](#pendingwalletselection), which uses the auth-requested wallet type automatically. + +--- + +### getIdToken + +```typescript +getIdToken(params?: { + ttlSeconds?: number + customClaims?: Record +}): Promise +``` + +Requests an ID token for the active wallet session. The SDK uses the active wallet id automatically. + +**Parameters** + +| Name | Type | Description | +|---|---|---| +| `ttlSeconds` | `number` | Optional token lifetime in seconds. | +| `customClaims` | `Record` | Optional custom claims to include in the token. | + +**Returns** `Promise` — the issued ID token. --- @@ -718,6 +757,9 @@ type OmsSdkErrorCode = | 'OMS_REQUEST_FAILED' | 'OMS_AUTH_COMMITMENT_CONSUMED' | 'OMS_SESSION_MISSING' + | 'OMS_WALLET_SELECTION_STALE' + | 'OMS_WALLET_SELECTION_UNAVAILABLE' + | 'OMS_WALLET_SELECTION_IN_FLIGHT' | 'OMS_TRANSACTION_STATUS_LOOKUP_FAILED' | 'OMS_VALIDATION_ERROR' ``` @@ -730,6 +772,7 @@ type OmsSdkErrorCode = | `OmsRequestError` | Network, fetch, or non-2xx HTTP failures. | | `OmsResponseError` | Invalid JSON or malformed API responses. | | `OmsTransactionError` | Transaction was submitted but status polling failed; includes `txnId`. | +| `OmsWalletSelectionError` | Manual wallet selection is stale, invalid, or already processing an action. | | `OmsValidationError` | SDK-side validation failures before a request is sent. | Use `isOmsSdkError(err)` or `err instanceof OmsSdkError` to branch on structured error fields. @@ -746,34 +789,36 @@ interface Network { readonly name: string readonly nativeTokenSymbol: string readonly explorerUrl: string + readonly displayName: string } ``` A supported OMS network entry. The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)`. +`name` is the registry/routing slug for indexer URLs, while `displayName` is the user-facing label. ```typescript findNetworkById(id: number): Network | undefined findNetworkByName(name: string): Network | undefined ``` -| Key | id | name | nativeTokenSymbol | explorerUrl | -|---|---:|---|---|---| -| `Networks.mainnet` | 1 | `mainnet` | `ETH` | `https://etherscan.io` | -| `Networks.sepolia` | 11155111 | `sepolia` | `ETH` | `https://sepolia.etherscan.io` | -| `Networks.polygon` | 137 | `polygon` | `POL` | `https://polygonscan.com` | -| `Networks.amoy` | 80002 | `amoy` | `POL` | `https://amoy.polygonscan.com` | -| `Networks.arbitrum` | 42161 | `arbitrum` | `ETH` | `https://arbiscan.io` | -| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | `ETH` | `https://sepolia.arbiscan.io` | -| `Networks.optimism` | 10 | `optimism` | `ETH` | `https://optimistic.etherscan.io` | -| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | `ETH` | `https://sepolia-optimism.etherscan.io` | -| `Networks.base` | 8453 | `base` | `ETH` | `https://basescan.org` | -| `Networks.baseSepolia` | 84532 | `base-sepolia` | `ETH` | `https://sepolia.basescan.org` | -| `Networks.bsc` | 56 | `bsc` | `BNB` | `https://bscscan.com` | -| `Networks.bscTestnet` | 97 | `bsc-testnet` | `BNB` | `https://testnet.bscscan.com` | -| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | `ETH` | `https://nova.arbiscan.io` | -| `Networks.avalanche` | 43114 | `avalanche` | `AVAX` | `https://subnets.avax.network/c-chain` | -| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | `AVAX` | `https://subnets-test.avax.network/c-chain` | -| `Networks.katana` | 747474 | `katana` | `ETH` | `https://katanascan.com` | +| Key | id | name | displayName | nativeTokenSymbol | explorerUrl | +|---|---:|---|---|---|---| +| `Networks.mainnet` | 1 | `mainnet` | `Ethereum` | `ETH` | `https://etherscan.io` | +| `Networks.sepolia` | 11155111 | `sepolia` | `Sepolia` | `ETH` | `https://sepolia.etherscan.io` | +| `Networks.polygon` | 137 | `polygon` | `Polygon` | `POL` | `https://polygonscan.com` | +| `Networks.amoy` | 80002 | `amoy` | `Polygon Amoy` | `POL` | `https://amoy.polygonscan.com` | +| `Networks.arbitrum` | 42161 | `arbitrum` | `Arbitrum` | `ETH` | `https://arbiscan.io` | +| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | `Arbitrum Sepolia` | `ETH` | `https://sepolia.arbiscan.io` | +| `Networks.optimism` | 10 | `optimism` | `Optimism` | `ETH` | `https://optimistic.etherscan.io` | +| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | `Optimism Sepolia` | `ETH` | `https://sepolia-optimism.etherscan.io` | +| `Networks.base` | 8453 | `base` | `Base` | `ETH` | `https://basescan.org` | +| `Networks.baseSepolia` | 84532 | `base-sepolia` | `Base Sepolia` | `ETH` | `https://sepolia.basescan.org` | +| `Networks.bsc` | 56 | `bsc` | `BSC` | `BNB` | `https://bscscan.com` | +| `Networks.bscTestnet` | 97 | `bsc-testnet` | `BSC Testnet` | `BNB` | `https://testnet.bscscan.com` | +| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | `Arbitrum Nova` | `ETH` | `https://nova.arbiscan.io` | +| `Networks.avalanche` | 43114 | `avalanche` | `Avalanche` | `AVAX` | `https://subnets.avax.network/c-chain` | +| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | `Avalanche Testnet` | `AVAX` | `https://subnets-test.avax.network/c-chain` | +| `Networks.katana` | 747474 | `katana` | `Katana` | `ETH` | `https://katanascan.com` | ### OmsEnvironment @@ -886,6 +931,33 @@ Wallet metadata returned by auth and wallet listing APIs. --- +### PendingWalletSelection + +```typescript +interface PendingWalletSelection { + walletType: WalletType + wallets: OmsWallet[] + credential: WalletCredential + + selectWallet(params: { walletId: string }): Promise + createAndSelectWallet(params?: { reference?: string }): Promise +} +``` + +Returned by manual email or OIDC auth completion. The selection is bound to the verified auth flow and signer that created it. It can be used once to select one of the returned `wallets` or to create and select a new wallet of `walletType`. + +--- + +### WalletSelectionBehavior + +```typescript +type WalletSelectionBehavior = 'automatic' | 'manual' +``` + +Controls whether auth completion immediately activates a wallet or returns a [`PendingWalletSelection`](#pendingwalletselection). + +--- + ### WalletCredential ```typescript diff --git a/README.md b/README.md index 2fc54dc..69eeb3e 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ pnpm dev:example ## Quick Start ```typescript -import { Networks, OMSClient } from '@0xsequence/typescript-sdk' +import { Networks, OMSClient, WalletType } from '@0xsequence/typescript-sdk' import { parseUnits } from 'viem' const oms = new OMSClient({ @@ -121,6 +121,22 @@ Email OTP is a two-step flow: 1. **`startEmailAuth({ email })`** — clears any active session and sends a one-time code to the user's inbox. 2. **`completeEmailAuth({ code })`** — verifies the code, then automatically loads an existing wallet or creates a new one if none exists. Returns `{ walletAddress, wallet, wallets, credential }`. +Use manual wallet selection when the app needs to present wallet choices: + +```typescript +const selection = await oms.wallet.completeEmailAuth({ + code: '123456', + walletType: WalletType.Ethereum, + walletSelection: 'manual', +}) + +await selection.selectWallet({ walletId: selection.wallets[0].id }) +// or: +await selection.createAndSelectWallet({ reference: 'main' }) +``` + +The returned pending selection is bound to the verified auth flow and signer. Hold that object and complete selection through it instead of saving `{ wallets }` and later calling global wallet activation methods. + ### OIDC Redirect Auth Google redirect auth is configured on the default environment. The redirect auth APIs are provider-neutral, so custom environments can add or replace providers. @@ -149,6 +165,8 @@ const { walletAddress, wallet, wallets, credential } = await oms.wallet.complete }) ``` +OIDC redirect auth also supports manual wallet selection by passing `walletSelection: 'manual'` to `completeOidcRedirectAuth`. + For simple browser apps, use the one-call convenience method from a sign-in action and from the callback page: ```typescript @@ -168,6 +186,8 @@ const walletAddress = oms.wallet.walletAddress const { expiresAt, loginType, sessionEmail } = oms.wallet.session ``` +Use `oms.wallet.getIdToken({ ttlSeconds, customClaims })` to request an ID token for the active wallet session. + Pending email OTP and OIDC redirect state are not exposed through `session`; use the auth method results to drive pending UI. To end the session, call: @@ -178,7 +198,7 @@ await oms.wallet.signOut() ## Networks -The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)` for the networks currently configured by OMS. Each network has `id`, `name`, `nativeTokenSymbol`, and `explorerUrl`. +The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)` for the networks currently configured by OMS. Each network has `id`, `name`, `nativeTokenSymbol`, `explorerUrl`, and `displayName`. `name` is the registry/routing slug, while `displayName` is the user-facing label. The `network` parameter on all transaction and signing methods accepts a `Network` from the SDK registry: @@ -191,24 +211,24 @@ console.log(supportedNetworks) console.log(findNetworkById(80002)) // Networks.amoy ``` -| Key | id | name | native token | explorerUrl | -|---|---:|---|---|---| -| `Networks.mainnet` | 1 | `mainnet` | ETH | `https://etherscan.io` | -| `Networks.sepolia` | 11155111 | `sepolia` | ETH | `https://sepolia.etherscan.io` | -| `Networks.polygon` | 137 | `polygon` | POL | `https://polygonscan.com` | -| `Networks.amoy` | 80002 | `amoy` | POL | `https://amoy.polygonscan.com` | -| `Networks.arbitrum` | 42161 | `arbitrum` | ETH | `https://arbiscan.io` | -| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | ETH | `https://sepolia.arbiscan.io` | -| `Networks.optimism` | 10 | `optimism` | ETH | `https://optimistic.etherscan.io` | -| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | ETH | `https://sepolia-optimism.etherscan.io` | -| `Networks.base` | 8453 | `base` | ETH | `https://basescan.org` | -| `Networks.baseSepolia` | 84532 | `base-sepolia` | ETH | `https://sepolia.basescan.org` | -| `Networks.bsc` | 56 | `bsc` | BNB | `https://bscscan.com` | -| `Networks.bscTestnet` | 97 | `bsc-testnet` | BNB | `https://testnet.bscscan.com` | -| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | ETH | `https://nova.arbiscan.io` | -| `Networks.avalanche` | 43114 | `avalanche` | AVAX | `https://subnets.avax.network/c-chain` | -| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | AVAX | `https://subnets-test.avax.network/c-chain` | -| `Networks.katana` | 747474 | `katana` | ETH | `https://katanascan.com` | +| Key | id | name | display name | native token | explorerUrl | +|---|---:|---|---|---|---| +| `Networks.mainnet` | 1 | `mainnet` | Ethereum | ETH | `https://etherscan.io` | +| `Networks.sepolia` | 11155111 | `sepolia` | Sepolia | ETH | `https://sepolia.etherscan.io` | +| `Networks.polygon` | 137 | `polygon` | Polygon | POL | `https://polygonscan.com` | +| `Networks.amoy` | 80002 | `amoy` | Polygon Amoy | POL | `https://amoy.polygonscan.com` | +| `Networks.arbitrum` | 42161 | `arbitrum` | Arbitrum | ETH | `https://arbiscan.io` | +| `Networks.arbitrumSepolia` | 421614 | `arbitrum-sepolia` | Arbitrum Sepolia | ETH | `https://sepolia.arbiscan.io` | +| `Networks.optimism` | 10 | `optimism` | Optimism | ETH | `https://optimistic.etherscan.io` | +| `Networks.optimismSepolia` | 11155420 | `optimism-sepolia` | Optimism Sepolia | ETH | `https://sepolia-optimism.etherscan.io` | +| `Networks.base` | 8453 | `base` | Base | ETH | `https://basescan.org` | +| `Networks.baseSepolia` | 84532 | `base-sepolia` | Base Sepolia | ETH | `https://sepolia.basescan.org` | +| `Networks.bsc` | 56 | `bsc` | BSC | BNB | `https://bscscan.com` | +| `Networks.bscTestnet` | 97 | `bsc-testnet` | BSC Testnet | BNB | `https://testnet.bscscan.com` | +| `Networks.arbitrumNova` | 42170 | `arbitrum-nova` | Arbitrum Nova | ETH | `https://nova.arbiscan.io` | +| `Networks.avalanche` | 43114 | `avalanche` | Avalanche | AVAX | `https://subnets.avax.network/c-chain` | +| `Networks.avalancheTestnet` | 43113 | `avalanche-testnet` | Avalanche Testnet | AVAX | `https://subnets-test.avax.network/c-chain` | +| `Networks.katana` | 747474 | `katana` | Katana | ETH | `https://katanascan.com` | ## Sending Transactions diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index b4f43cf..b2bcb21 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -8,10 +8,13 @@ import { type FeeOptionWithBalance, type Network, type OMSClientSessionLoginType, + type OmsWallet, + type PendingWalletSelection, + type WalletActivationResult, } from '@0xsequence/typescript-sdk' import './styles.css' -type Step = 'email' | 'code' | 'wallet' +type Step = 'email' | 'code' | 'wallet-selection' | 'wallet' type FeeSelectionController = { resolve: (selection: FeeOptionSelection) => void reject: (error: Error) => void @@ -21,6 +24,7 @@ const DEFAULT_MESSAGE = 'test' const DEFAULT_TX_TO = '0xE5E8B483FfC05967FcFed58cc98D053265af6D99' const PUBLIC_API_KEY = requiredEnv('VITE_OMS_PUBLIC_API_KEY', import.meta.env.VITE_OMS_PUBLIC_API_KEY) const PROJECT_ID = requiredEnv('VITE_OMS_PROJECT_ID', import.meta.env.VITE_OMS_PROJECT_ID) +const MANUAL_WALLET_SELECTION_KEY = 'oms-demo-manual-wallet-selection' function requiredEnv(name: string, value: string | undefined): string { if (!value) { @@ -39,9 +43,12 @@ function App() { const [transactionValue, setTransactionValue] = useState('0') const [walletAddress, setWalletAddress] = useState('') const [lastSignature, setLastSignature] = useState('') + const [lastIdToken, setLastIdToken] = useState('') const [lastTransactionHash, setLastTransactionHash] = useState('') const [lastTransactionExplorerUrl, setLastTransactionExplorerUrl] = useState('') const [feeOptions, setFeeOptions] = useState([]) + const [useManualWalletSelection, setUseManualWalletSelection] = useState(readManualWalletSelectionPreference) + const [pendingWalletSelection, setPendingWalletSelection] = useState(null) const [emailAuthStatus, setEmailAuthStatus] = useState('Enter an email to start.') const [redirectStatus, setRedirectStatus] = useState('') const [walletStatus, setWalletStatus] = useState('') @@ -79,6 +86,7 @@ function App() { feeSelection.current = null setFeeOptions([]) setLastSignature('') + setLastIdToken('') setLastTransactionHash('') setLastTransactionExplorerUrl('') if (step === 'wallet') { @@ -86,6 +94,10 @@ function App() { } }, [selectedNetworkId, step]) + useEffect(() => { + window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') + }, [useManualWalletSelection]) + async function run( label: string, setActiveStatus: (message: string) => void, @@ -105,6 +117,7 @@ function App() { async function startEmailAuth() { if (!email.trim()) return await run('Sending code...', setEmailAuthStatus, async () => { + setPendingWalletSelection(null) await oms.wallet.startEmailAuth({ email: email.trim() }) setStep('code') setEmailAuthStatus('Code sent. Check your email.') @@ -114,25 +127,83 @@ function App() { async function completeEmailAuth() { if (!code.trim()) return await run('Completing sign-in...', setEmailAuthStatus, async () => { - const result = await oms.wallet.completeEmailAuth({ code: code.trim() }) - setWalletAddress(result.walletAddress) - setStep('wallet') - setWalletStatus('Wallet ready.') + const result = await oms.wallet.completeEmailAuth({ + code: code.trim(), + walletSelection: useManualWalletSelection ? 'manual' : 'automatic', + }) + handleAuthCompletion(result, 'Email login complete.') }) } async function startOidcRedirect() { await run('Redirecting to provider...', setRedirectStatus, async () => { + window.sessionStorage.setItem(MANUAL_WALLET_SELECTION_KEY, useManualWalletSelection ? 'true' : 'false') + setPendingWalletSelection(null) await oms.wallet.signInWithOidcRedirect({ provider: 'google' }) }) } async function completeOidcRedirect() { await run('Completing redirect sign-in...', setRedirectStatus, async () => { - const result = await oms.wallet.signInWithOidcRedirect({ provider: 'google' }) - setWalletAddress(result?.walletAddress ?? oms.wallet.walletAddress ?? '') - setStep('wallet') - setWalletStatus('Wallet ready.') + const result = await oms.wallet.signInWithOidcRedirect({ + provider: 'google', + walletSelection: readManualWalletSelectionPreference() ? 'manual' : 'automatic', + }) + if (result) { + handleAuthCompletion(result, 'Google login complete.') + return + } + + const restoredAddress = oms.wallet.walletAddress ?? '' + setWalletAddress(restoredAddress) + setStep(restoredAddress ? 'wallet' : 'email') + setWalletStatus(restoredAddress ? 'Wallet ready.' : '') + }) + } + + function handleAuthCompletion( + result: PendingWalletSelection | WalletActivationResult, + status: string, + ) { + if (isPendingWalletSelection(result)) { + setPendingWalletSelection(result) + setStep('wallet-selection') + setEmailAuthStatus('') + setRedirectStatus('') + return + } + + setPendingWalletSelection(null) + setLastIdToken('') + setWalletAddress(result.walletAddress) + setStep('wallet') + setWalletStatus(status) + } + + async function selectPendingWallet(wallet: OmsWallet) { + if (!pendingWalletSelection) return + await run('Selecting wallet...', setEmailAuthStatus, async () => { + const result = await pendingWalletSelection.selectWallet({ walletId: wallet.id }) + handleAuthCompletion(result, 'Wallet selected.') + }) + } + + async function createPendingWallet() { + if (!pendingWalletSelection) return + await run('Creating wallet...', setEmailAuthStatus, async () => { + const result = await pendingWalletSelection.createAndSelectWallet({ reference: 'main' }) + handleAuthCompletion(result, 'Wallet created.') + }) + } + + async function cancelPendingWalletSelection() { + await run('Cancelling wallet selection...', setEmailAuthStatus, async () => { + await oms.wallet.signOut() + setPendingWalletSelection(null) + setWalletAddress('') + setCode('') + setStep('email') + setEmailAuthStatus('Enter an email to start.') }) } @@ -168,6 +239,14 @@ function App() { }) } + async function getIdToken() { + await run('Getting ID token...', setWalletStatus, async () => { + const idToken = await oms.wallet.getIdToken() + setLastIdToken(idToken) + setWalletStatus('ID token issued.') + }) + } + function waitForFeeOptionSelection(options: FeeOptionWithBalance[]): Promise { setFeeOptions(options) setWalletStatus('Choose a fee token to continue.') @@ -193,8 +272,10 @@ function App() { await run('Signing out...', setWalletStatus, async () => { await oms.wallet.signOut() setCode('') + setPendingWalletSelection(null) setWalletAddress('') setLastSignature('') + setLastIdToken('') setLastTransactionHash('') setLastTransactionExplorerUrl('') setFeeOptions([]) @@ -211,6 +292,17 @@ function App() {

OMS Client Typescript SDK

Wallet Demo

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

Login Options

+ ))} +
+ ) : ( +

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

+ )} + +

Create new wallet

+ + + + + {emailAuthStatus && {emailAuthStatus}} + + )} + {step === 'wallet' && (
@@ -315,7 +457,7 @@ function App() { > {supportedNetworks.map(network => ( ))} @@ -396,6 +538,16 @@ function App() { )} +
+ Other operations +
+ + {lastIdToken && {lastIdToken}} +
+
+ @@ -418,14 +570,6 @@ function transactionExplorerUrl(network: Network, txnHash: string): string { return `${network.explorerUrl.replace(/\/+$/, '')}/tx/${txnHash}` } -function networkLabel(network: Network): string { - const label = network.name - .split('-') - .map(part => part.toUpperCase() === 'BSC' ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1)) - .join(' ') - return `${label} (${network.id})` -} - function formatLoginType(loginType: OMSClientSessionLoginType | undefined): string { switch (loginType) { case 'email': @@ -445,3 +589,20 @@ function formatSessionExpiry(expiresAt: string | undefined): string { const date = new Date(expiresAt) return Number.isNaN(date.getTime()) ? expiresAt : date.toLocaleString() } + +function formatWalletType(walletType: string): string { + return walletType + .split(/[-_]/) + .map(part => part ? part[0].toUpperCase() + part.slice(1) : part) + .join(' ') +} + +function isPendingWalletSelection( + result: PendingWalletSelection | WalletActivationResult, +): result is PendingWalletSelection { + return 'selectWallet' in result +} + +function readManualWalletSelectionPreference(): boolean { + return window.sessionStorage.getItem(MANUAL_WALLET_SELECTION_KEY) === 'true' +} diff --git a/examples/react/src/styles.css b/examples/react/src/styles.css index 6fe051e..2e959a8 100644 --- a/examples/react/src/styles.css +++ b/examples/react/src/styles.css @@ -52,6 +52,13 @@ h1 { line-height: 1.15; } +.section-title { + margin: 0; + color: #1f2937; + font-size: 17px; + line-height: 1.25; +} + .stack { display: grid; gap: 16px; @@ -65,6 +72,32 @@ label { font-weight: 650; } +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #e1e6ee; + border-radius: 6px; + background: #fbfcfe; +} + +.checkbox-row input { + width: 18px; + min-height: 18px; + margin: 0; + accent-color: #1d4ed8; +} + +.checkbox-row span { + min-width: 0; +} + +.header-option { + margin-top: 14px; +} + .field-stack { display: grid; gap: 10px; @@ -114,6 +147,12 @@ button.secondary { background: #e6ebf2; } +button.subtle { + color: #475467; + background: transparent; + border: 1px solid #d8dee8; +} + button:disabled { cursor: not-allowed; background: #a7b1c2; @@ -165,6 +204,32 @@ button:disabled { line-height: 1.25; } +.collapsible-tool { + gap: 0; + padding: 0; + overflow: hidden; +} + +.collapsible-tool summary { + min-height: 52px; + padding: 16px; + color: #1f2937; + font-size: 17px; + font-weight: 800; + line-height: 1.25; + cursor: pointer; +} + +.collapsible-tool summary::marker { + color: #667085; +} + +.collapsible-content { + display: grid; + gap: 12px; + padding: 0 16px 16px; +} + .network-tool { gap: 8px; } @@ -188,6 +253,19 @@ button:disabled { text-align: center; } +.metadata-pill { + min-width: 48px; + padding: 4px 8px; + border: 1px solid #d8dee8; + border-radius: 6px; + color: #475467; + background: #eef2f7; + font-size: 12px; + font-weight: 800; + line-height: 1.2; + text-align: center; +} + select:disabled { color: #667085; background: #eef2f7; @@ -203,7 +281,8 @@ select:disabled { gap: 8px; } -.fee-option { +.fee-option, +.wallet-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; @@ -215,7 +294,13 @@ select:disabled { text-align: left; } -.fee-option span { +.wallet-option { + grid-template-columns: minmax(0, 1fr) auto; + row-gap: 6px; +} + +.fee-option span, +.wallet-option span { min-width: 0; } @@ -226,17 +311,40 @@ select:disabled { } .fee-option strong, -.fee-option small { +.fee-option small, +.wallet-option strong, +.wallet-option small { display: block; overflow-wrap: anywhere; } -.fee-option small { +.fee-option small, +.wallet-option small { margin-top: 3px; color: #667085; font-size: 12px; } +.wallet-option code { + grid-column: 1 / -1; + min-width: 0; + overflow-wrap: anywhere; + color: #344054; + font-size: 12px; +} + +.wallet-option-action { + justify-self: end; + color: #1d4ed8; + font-size: 13px; + font-weight: 800; +} + +.wallet-option-list { + display: grid; + gap: 8px; +} + output { min-height: 42px; padding: 11px 12px; diff --git a/src/clients/indexerClient.ts b/src/clients/indexerClient.ts index dd8d875..85cb0a9 100644 --- a/src/clients/indexerClient.ts +++ b/src/clients/indexerClient.ts @@ -3,6 +3,7 @@ import {HttpClient} from "../httpClient.js"; import {errorMessage, OmsRequestError, OmsResponseError} from "../errors.js"; import type {Network} from "../networks.js"; +import {IndexerOperation} from "../operations.js"; export interface TokenBalancesPage { page: number; @@ -193,7 +194,7 @@ export class IndexerClient { const bodyString = JSON.stringify(request); const baseUrl = this.indexerUrl(params.network); - const response = await this.postJson("indexer.getTokenBalances", { + const response = await this.postJson(IndexerOperation.getTokenBalances, { baseUrl, path: "/GetTokenBalances", body: bodyString, @@ -211,7 +212,7 @@ export class IndexerClient { network: Network walletAddress: string }): Promise { - const response = await this.postJson("indexer.getNativeTokenBalance", { + const response = await this.postJson(IndexerOperation.getNativeTokenBalance, { baseUrl: this.indexerUrl(params.network), path: "/GetNativeTokenBalance", body: JSON.stringify({ accountAddress: params.walletAddress }), @@ -235,7 +236,7 @@ export class IndexerClient { } private async postJson( - operation: string, + operation: IndexerOperation, args: Parameters[0], ): Promise<{statusCode: number, payload: T}> { let response; @@ -325,7 +326,7 @@ function mapTokenMetadata(raw: TokenMetadataRaw): TokenMetadata { }; } -function responseErrorMessage(payload: unknown, operation: string, status: number): string { +function responseErrorMessage(payload: unknown, operation: IndexerOperation, status: number): string { if (payload && typeof payload === "object" && "message" in payload) { const message = (payload as {message?: unknown}).message; if (typeof message === "string" && message) { diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index d59e613..d2b0f37 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -10,7 +10,11 @@ import {createDefaultStorage, SessionStorageManager, StorageManager} from "../st import {createSignedFetch} from "../signedFetch.js"; import {Constants} from "../utils/constants.js"; import {RequestUtils} from "../utils/requestUtils.js"; -import {CredentialSigner, WebCryptoP256CredentialSigner} from "../credentialSigner.js"; +import { + CredentialSigner, + type CredentialSigningAlgorithm, + WebCryptoP256CredentialSigner, +} from "../credentialSigner.js"; import { buildOidcAuthorizationUrl, cleanOidcCallbackUrl, @@ -20,7 +24,7 @@ import { parseOidcCallbackUrl, redirectUriFromCurrentUrl, } from "../utils/oidcRedirect.js"; -import {OmsSessionError, OmsTransactionError, toOmsSdkError} from "../errors.js"; +import {OmsSessionError, OmsTransactionError, OmsWalletSelectionError, toOmsSdkError} from "../errors.js"; import { Wallet as Walletclient, @@ -40,6 +44,7 @@ import { RevokeAccessRequest, SignMessageRequest, SignTypedDataRequest, + GetIDTokenRequest, IsValidMessageSignatureRequest, IsValidTypedDataSignatureRequest, PrepareEthereumTransactionRequest, @@ -71,6 +76,7 @@ import { WalletCredential, } from "../types/accessGrant.js"; import {IndexerClient, TokenBalance} from "./indexerClient.js"; +import {WalletOperation} from "../operations.js"; export type OidcProviderName = keyof NonNullable['oidcProviders']> & string; @@ -96,17 +102,21 @@ export interface CompleteOidcRedirectAuthParams { callbackUrl: string; cleanUrl?: boolean; replaceUrl?: (url: string) => void; - autoActivate?: boolean; + walletSelection?: WalletSelectionBehavior; } export interface CompleteEmailAuthParams { code: string; walletType?: WalletType; - autoActivate?: boolean; + walletSelection?: WalletSelectionBehavior; } -type AutoActivateParams = Omit & {autoActivate?: true} -type ManualActivateParams = Omit & {autoActivate: false} +export type WalletSelectionBehavior = "automatic" | "manual"; + +type AutomaticWalletSelectionParams = + Omit & {walletSelection?: "automatic"} +type ManualWalletSelectionParams = + Omit & {walletSelection: "manual"} export interface OmsWallet { id: string; @@ -134,9 +144,13 @@ export interface CompleteOidcRedirectAuthResult { credential: WalletCredential; } -export interface CompleteAuthWalletSelectionResult { +export interface PendingWalletSelection { + walletType: WalletType; wallets: Array; credential: WalletCredential; + + selectWallet(params: {walletId: string}): Promise; + createAndSelectWallet(params?: {reference?: string}): Promise; } export type OMSClientSessionLoginType = 'email' | 'google-auth' | 'oidc'; @@ -158,6 +172,11 @@ export interface SignTypedDataParams { typedData: any } +export interface GetIdTokenParams { + ttlSeconds?: number + customClaims?: Record +} + export interface IsValidMessageSignatureParams { network?: Network walletAddress?: Address @@ -178,7 +197,7 @@ export interface SignInWithOidcRedirectParams; redirectUri?: string; walletType?: WalletType; - autoActivate?: boolean; + walletSelection?: WalletSelectionBehavior; relayRedirectUri?: string; authorizeParams?: Record; cleanUrl?: boolean; @@ -192,6 +211,8 @@ interface PendingOidcRedirectAuth { nonce: string; provider: string | null; walletType: WalletType; + signerCredentialId: string; + signerKeyType: CredentialSigningAlgorithm; redirectUri: string; issuer: string; projectId: string; @@ -208,6 +229,87 @@ interface WalletSessionMetadata { sessionEmail?: string; } +interface ActivePendingWalletSelection { + id: string; + signerCredentialId: string; + signerKeyType: CredentialSigningAlgorithm; + walletType: WalletType; + metadata: WalletSessionMetadata; +} + +interface ActiveWalletActivationContext { + walletId: string; + metadata?: WalletSessionMetadata; +} + +interface EmailAuthCompletionParams { + code: string; + walletType: WalletType; + walletSelection: WalletSelectionBehavior; +} + +interface ActiveEmailAuthAttempt { + verifier: string; + challenge: string; + completion?: { + params: EmailAuthCompletionParams; + promise: Promise; + }; +} + +class PendingWalletSelectionImpl implements PendingWalletSelection { + private readonly availableWalletIds: Set; + private inFlight = false; + + constructor( + public readonly walletType: WalletType, + public readonly wallets: Array, + public readonly credential: WalletCredential, + private readonly selectWalletAction: (walletId: string) => Promise, + private readonly createAndSelectWalletAction: (reference?: string) => Promise, + ) { + this.availableWalletIds = new Set(wallets.map(wallet => wallet.id)); + } + + async selectWallet(params: {walletId: string}): Promise { + return this.runExclusive(WalletOperation.pendingWalletSelectionSelectWallet, async () => { + if (!this.availableWalletIds.has(params.walletId)) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_UNAVAILABLE", + operation: WalletOperation.pendingWalletSelectionSelectWallet, + message: "Selected wallet is not one of the available options", + }); + } + return this.selectWalletAction(params.walletId); + }); + } + + async createAndSelectWallet(params: {reference?: string} = {}): Promise { + return this.runExclusive(WalletOperation.pendingWalletSelectionCreateAndSelectWallet, () => + this.createAndSelectWalletAction(params.reference), + ); + } + + private async runExclusive(operation: WalletOperation, action: () => Promise): Promise { + if (this.inFlight) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_IN_FLIGHT", + operation, + message: "Pending wallet selection already has an action in flight", + }); + } + + this.inFlight = true; + try { + return await action(); + } catch (error) { + throw toOmsSdkError(error, operation); + } finally { + this.inFlight = false; + } + } +} + export class WalletClient { private readonly client: Walletclient private readonly publicClient: WalletPublicclient @@ -227,11 +329,11 @@ export class WalletClient { private sessionExpiresAt: string | undefined private sessionLoginType: OMSClientSessionLoginType | undefined private sessionEmail: string | undefined - private pendingSessionMetadata: WalletSessionMetadata | undefined + private activePendingWalletSelection: ActivePendingWalletSelection | undefined + private activeEmailAuthAttempt: ActiveEmailAuthAttempt | undefined + private nextPendingWalletSelectionId = 1 private walletId: string - private verifier = '' - private challenge = '' constructor(params: { publicApiKey: string, @@ -303,7 +405,7 @@ export class WalletClient { async startEmailAuth(params: { email: string }): Promise { - return this.runOperation("wallet.startEmailAuth", async () => { + return this.runOperation(WalletOperation.startEmailAuth, async () => { await this.clearSession() const request: CommitVerifierRequest = { identityType: IdentityType.Email, @@ -312,8 +414,10 @@ export class WalletClient { handle: params.email, } const response = await this.client.commitVerifier(request) - this.verifier = response.verifier - this.challenge = response.challenge + this.activeEmailAuthAttempt = { + verifier: response.verifier, + challenge: response.challenge, + } }) } @@ -324,23 +428,42 @@ export class WalletClient { * existing wallet or creates a new one and returns the wallet address plus * the credential added by WaaS. */ - async completeEmailAuth(params: ManualActivateParams): Promise - async completeEmailAuth(params: AutoActivateParams): Promise - async completeEmailAuth(params: CompleteEmailAuthParams): Promise - async completeEmailAuth(params: CompleteEmailAuthParams): Promise { - return this.runOperation("wallet.completeEmailAuth", async () => { - const walletType = params.walletType ?? WalletType.Ethereum; - const answer = await RequestUtils.hashEmailAuthAnswer(this.challenge, params.code); - - const request: CompleteAuthRequest = { - identityType: IdentityType.Email, - authMode: AuthMode.OTP, - verifier: this.verifier, - answer, - lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, + async completeEmailAuth(params: ManualWalletSelectionParams): Promise + async completeEmailAuth(params: AutomaticWalletSelectionParams): Promise + async completeEmailAuth(params: CompleteEmailAuthParams): Promise + async completeEmailAuth(params: CompleteEmailAuthParams): Promise { + return this.runOperation(WalletOperation.completeEmailAuth, async () => { + const completionParams: EmailAuthCompletionParams = { + code: params.code, + walletType: params.walletType ?? WalletType.Ethereum, + walletSelection: params.walletSelection ?? "automatic", } - const response = await this.client.completeAuth(request) - return this.completeWalletAuth(response, walletType, params.autoActivate ?? true) + const attempt = this.currentEmailAuthAttempt(WalletOperation.completeEmailAuth) + const completion = attempt.completion + if (completion) { + if (!sameEmailAuthCompletionParams(completion.params, completionParams)) { + throw new OmsSessionError({ + operation: WalletOperation.completeEmailAuth, + message: "Email auth completion is already in flight", + }) + } + return completion.promise + } + + let promise: Promise + promise = this.completeEmailAuthAttempt(attempt, completionParams).catch(error => { + const sdkError = toOmsSdkError(error, WalletOperation.completeEmailAuth) + if (this.activeEmailAuthAttempt === attempt && attempt.completion?.promise === promise) { + if (sdkError.code === "OMS_AUTH_COMMITMENT_CONSUMED") { + this.activeEmailAuthAttempt = undefined + } else { + attempt.completion = undefined + } + } + throw sdkError + }) + attempt.completion = {params: completionParams, promise} + return promise }) } @@ -353,7 +476,7 @@ export class WalletClient { async startOidcRedirectAuth( params: StartOidcRedirectAuthParams, ): Promise { - return this.runOperation("wallet.startOidcRedirectAuth", async () => { + return this.runOperation(WalletOperation.startOidcRedirectAuth, async () => { await this.clearSession() const redirectAuthStorage = this.requireRedirectAuthStorage() const provider = this.resolveOidcProvider(params.provider) @@ -369,6 +492,7 @@ export class WalletClient { }, } const response = await this.client.commitVerifier(request) + const signerCredentialId = await this.credentialSigner.credentialId() const nonce = generateOidcNonce() const state = encodeOidcState({ nonce, @@ -381,6 +505,8 @@ export class WalletClient { nonce, provider: provider.name, walletType: params.walletType ?? WalletType.Ethereum, + signerCredentialId, + signerKeyType: this.credentialSigner.signingAlgorithm, redirectUri: params.redirectUri, issuer: provider.config.issuer, projectId: this.projectId, @@ -416,18 +542,18 @@ export class WalletClient { * WaaS auth, and activates an existing wallet or creates a new one. */ async completeOidcRedirectAuth( - params: ManualActivateParams, - ): Promise + params: ManualWalletSelectionParams, + ): Promise async completeOidcRedirectAuth( - params: AutoActivateParams, + params: AutomaticWalletSelectionParams, ): Promise async completeOidcRedirectAuth( params: CompleteOidcRedirectAuthParams, - ): Promise + ): Promise async completeOidcRedirectAuth( params: CompleteOidcRedirectAuthParams, - ): Promise { - return this.runOperation("wallet.completeOidcRedirectAuth", async () => { + ): Promise { + return this.runOperation(WalletOperation.completeOidcRedirectAuth, async () => { const redirectAuthStorage = this.requireRedirectAuthStorage() try { @@ -445,6 +571,7 @@ export class WalletClient { const pending = this.loadPendingOidcRedirectAuth(redirectAuthStorage) this.validateOidcState(callback.state, pending) + await this.validatePendingOidcRedirectSigner(pending, WalletOperation.completeOidcRedirectAuth) const request: CompleteAuthRequest = { identityType: IdentityType.OIDC, @@ -454,9 +581,9 @@ export class WalletClient { lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, } const response = await this.client.completeAuth(request) - const result = await this.completeWalletAuth(response, pending.walletType, params.autoActivate ?? true) + const result = await this.completeWalletAuth(response, pending.walletType, params.walletSelection ?? "automatic") - if ((params.autoActivate ?? true) && !this.walletAddress) { + if ((params.walletSelection ?? "automatic") === "automatic" && !this.walletAddress) { throw new Error('OIDC auth completed without an active wallet') } @@ -473,11 +600,11 @@ export class WalletClient { * If the current URL contains an OIDC callback, it completes auth. Otherwise it * starts auth and redirects to the provider. */ - async signInWithOidcRedirect(params: ManualActivateParams>): Promise - async signInWithOidcRedirect(params: AutoActivateParams>): Promise - async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise - async signInWithOidcRedirect(params: SignInWithOidcRedirectParams): Promise { - return this.runOperation("wallet.signInWithOidcRedirect", async () => { + async signInWithOidcRedirect(params: ManualWalletSelectionParams>): Promise + async signInWithOidcRedirect(params: AutomaticWalletSelectionParams>): 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) { @@ -485,7 +612,7 @@ export class WalletClient { callbackUrl: currentUrl, cleanUrl: params.cleanUrl ?? true, replaceUrl: params.replaceUrl, - autoActivate: params.autoActivate, + walletSelection: params.walletSelection, }) } @@ -503,27 +630,45 @@ export class WalletClient { } async signOut(): Promise { - return this.runOperation("wallet.signOut", () => this.clearSession()) + return this.runOperation(WalletOperation.signOut, () => this.clearSession()) } async listWallets(): Promise> { - return this.runOperation("wallet.listWallets", () => this.listAllWallets()) + return this.runOperation(WalletOperation.listWallets, async () => { + await this.requireWalletSelectionOrActiveSession(WalletOperation.listWallets) + return this.listAllWallets() + }) } async useWallet(params: {walletId: string}): Promise { - return this.runOperation("wallet.useWallet", () => - this.useWalletUnchecked(params.walletId, this.sessionMetadataForActivation()), - ) + return this.runOperation(WalletOperation.useWallet, async () => { + const context = await this.activeWalletActivationContext(WalletOperation.useWallet) + const wallet = await this.requestUseWallet(params.walletId) + await this.requireActiveWalletActivationContextStillActive(context, WalletOperation.useWallet) + return this.activateWallet(wallet, context.metadata) + }) } async createWallet(params: {type?: WalletType; reference?: string} = {}): Promise { - return this.runOperation("wallet.createWallet", () => - this.createWalletUnchecked( - params.type ?? WalletType.Ethereum, - this.sessionMetadataForActivation(), - params.reference, - ), - ) + return this.runOperation(WalletOperation.createWallet, async () => { + const context = await this.activeWalletActivationContext(WalletOperation.createWallet) + const wallet = await this.requestCreateWallet(params.type ?? WalletType.Ethereum, params.reference) + await this.requireActiveWalletActivationContextStillActive(context, WalletOperation.createWallet) + return this.activateWallet(wallet, context.metadata) + }) + } + + async getIdToken(params: GetIdTokenParams = {}): Promise { + return this.runOperation(WalletOperation.getIdToken, async () => { + await this.requireActiveSession(WalletOperation.getIdToken) + const request: GetIDTokenRequest = { + walletId: this.walletId, + ttlSeconds: params.ttlSeconds, + customClaims: params.customClaims, + } + const response = await this.client.getIDToken(request) + return response.idToken + }) } private async clearSession(): Promise { @@ -537,16 +682,15 @@ export class WalletClient { this.sessionExpiresAt = undefined this.sessionLoginType = undefined this.sessionEmail = undefined - this.pendingSessionMetadata = undefined - this.verifier = '' - this.challenge = '' + this.activePendingWalletSelection = undefined + this.activeEmailAuthAttempt = undefined this.redirectAuthStorage?.delete(Constants.redirectAuthStorageKey) await this.credentialSigner.clear?.() } async signMessage(params: SignMessageParams): Promise { - return this.runOperation("wallet.signMessage", async () => { - await this.requireActiveSession("wallet.signMessage") + return this.runOperation(WalletOperation.signMessage, async () => { + await this.requireActiveSession(WalletOperation.signMessage) const request: SignMessageRequest = { network: params.network.id.toString(), walletId: this.walletId, @@ -558,8 +702,8 @@ export class WalletClient { } async signTypedData(params: SignTypedDataParams): Promise { - return this.runOperation("wallet.signTypedData", async () => { - await this.requireActiveSession("wallet.signTypedData") + return this.runOperation(WalletOperation.signTypedData, async () => { + await this.requireActiveSession(WalletOperation.signTypedData) const request: SignTypedDataRequest = { network: params.network.id.toString(), walletId: this.walletId, @@ -571,7 +715,7 @@ export class WalletClient { } async isValidMessageSignature(params: IsValidMessageSignatureParams): Promise { - return this.runOperation("wallet.isValidMessageSignature", async () => { + return this.runOperation(WalletOperation.isValidMessageSignature, async () => { const request: IsValidMessageSignatureRequest = { network: params.network?.id.toString(), walletAddress: params.walletAddress, @@ -585,7 +729,7 @@ export class WalletClient { } async isValidTypedDataSignature(params: IsValidTypedDataSignatureParams): Promise { - return this.runOperation("wallet.isValidTypedDataSignature", async () => { + return this.runOperation(WalletOperation.isValidTypedDataSignature, async () => { const request: IsValidTypedDataSignatureRequest = { network: params.network?.id.toString(), walletAddress: params.walletAddress, @@ -605,8 +749,8 @@ export class WalletClient { functionName extends ContractFunctionName | undefined = ContractFunctionName, >(params: SendContractTransactionParams): Promise async sendTransaction(params: SendTransactionParams): Promise { - return this.runOperation("wallet.sendTransaction", async () => { - await this.requireActiveSession("wallet.sendTransaction") + return this.runOperation(WalletOperation.sendTransaction, async () => { + await this.requireActiveSession(WalletOperation.sendTransaction) const data = 'abi' in params ? encodeFunctionData(params as EncodeFunctionDataParameters) @@ -642,8 +786,8 @@ export class WalletClient { waitForStatus?: boolean statusPolling?: TransactionStatusPollingOptions }): Promise { - return this.runOperation("wallet.callContract", async () => { - await this.requireActiveSession("wallet.callContract") + return this.runOperation(WalletOperation.callContract, async () => { + await this.requireActiveSession(WalletOperation.callContract) const request: PrepareEthereumContractCallRequest = { network: params.network.id.toString(), walletId: this.walletId, @@ -667,15 +811,15 @@ export class WalletClient { async getTransactionStatus(params: { txnId: string }): Promise { - return this.runOperation("wallet.getTransactionStatus", () => + return this.runOperation(WalletOperation.getTransactionStatus, () => this.client.transactionStatus({txnId: params.txnId} as TransactionStatusRequest), ) } async listAccess(params: ListAccessParams = {}): Promise { - return this.runOperation("wallet.listAccess", async () => { + return this.runOperation(WalletOperation.listAccess, async () => { const grants: AccessGrant[] = [] - for await (const page of this.listAccessPagesUnchecked(params, "wallet.listAccess")) { + for await (const page of this.listAccessPagesUnchecked(params, WalletOperation.listAccess)) { grants.push(...page.grants) } return grants @@ -684,17 +828,17 @@ export class WalletClient { async *listAccessPages(params: ListAccessParams = {}): AsyncIterable { try { - yield* this.listAccessPagesUnchecked(params, "wallet.listAccessPages") + yield* this.listAccessPagesUnchecked(params, WalletOperation.listAccessPages) } catch (error) { - throw toOmsSdkError(error, "wallet.listAccessPages") + throw toOmsSdkError(error, WalletOperation.listAccessPages) } } async revokeAccess(params: { targetCredentialId: string }): Promise { - return this.runOperation("wallet.revokeAccess", async () => { - await this.requireActiveSession("wallet.revokeAccess") + return this.runOperation(WalletOperation.revokeAccess, async () => { + await this.requireActiveSession(WalletOperation.revokeAccess) const request: RevokeAccessRequest = { targetCredentialId: params.targetCredentialId, walletId: this.walletId @@ -710,17 +854,13 @@ export class WalletClient { * The wallet ID and address are persisted to storage so the session can be * restored when the configured credential signer is also available. */ - private async createWalletUnchecked( + private async requestCreateWallet( type: WalletType, - metadata?: WalletSessionMetadata, reference?: string, - ): Promise { + ): Promise { const params: CreateWalletRequest = { type, reference } const response = await this.client.createWallet(params) - this.persistSession(response.wallet.id, response.wallet.address, metadata) - this.pendingSessionMetadata = undefined - const wallet = this.toOmsWallet(response.wallet) - return {walletAddress: wallet.address, wallet} + return this.toOmsWallet(response.wallet) } /** @@ -728,33 +868,57 @@ export class WalletClient { * * The wallet ID and address are persisted to storage. */ - private async useWalletUnchecked(walletId: string, metadata?: WalletSessionMetadata): Promise { + private async requestUseWallet(walletId: string): Promise { const params: UseWalletRequest = { walletId } const response = await this.client.useWallet(params) - this.persistSession(response.wallet.id, response.wallet.address, metadata) - this.pendingSessionMetadata = undefined - const wallet = this.toOmsWallet(response.wallet) + return this.toOmsWallet(response.wallet) + } + + private activateWallet(wallet: OmsWallet, metadata?: WalletSessionMetadata): WalletActivationResult { + this.persistSession(wallet.id, wallet.address, metadata) + this.activePendingWalletSelection = undefined return {walletAddress: wallet.address, wallet} } private async completeWalletAuth( response: CompleteAuthResponse, walletType: WalletType, - autoActivate: boolean, - ): Promise { + walletSelection: WalletSelectionBehavior, + emailAuthAttempt?: ActiveEmailAuthAttempt, + ): Promise { + this.activePendingWalletSelection = undefined + const metadata = this.sessionMetadataFromAuthResponse(response) const wallets = await this.listAllWalletsFromAuthResponse(response) const credential = this.toWalletCredential(response.credential) + const candidateWallets = wallets.filter(wallet => wallet.type === walletType) + const operation = WalletOperation.completeEmailAuth + const requireCurrentEmailAuth = () => { + if (emailAuthAttempt) { + this.requireActiveEmailAuthAttempt(emailAuthAttempt, operation) + } + } - if (!autoActivate) { - this.pendingSessionMetadata = metadata - return {wallets, credential} + requireCurrentEmailAuth() + + if (walletSelection === "manual") { + const selection = await this.createPendingWalletSelection({ + walletType, + wallets: candidateWallets, + credential, + metadata, + }, requireCurrentEmailAuth) + this.clearEmailAuthAttempt(emailAuthAttempt) + return selection } - const wallet = wallets.find(candidate => candidate.type === walletType) - const activated = wallet - ? await this.useWalletUnchecked(wallet.id, metadata) - : await this.createWalletUnchecked(walletType, metadata) + const wallet = candidateWallets[0] + const selectedWallet = wallet + ? await this.requestUseWallet(wallet.id) + : await this.requestCreateWallet(walletType) + requireCurrentEmailAuth() + const activated = this.activateWallet(selectedWallet, metadata) + this.clearEmailAuthAttempt(emailAuthAttempt) const resultWallets = wallet ? wallets : [...wallets, activated.wallet] return { @@ -765,9 +929,29 @@ export class WalletClient { } } + private async completeEmailAuthAttempt( + attempt: ActiveEmailAuthAttempt, + params: EmailAuthCompletionParams, + ): Promise { + this.requireActiveEmailAuthAttempt(attempt, WalletOperation.completeEmailAuth) + const answer = await RequestUtils.hashEmailAuthAnswer(attempt.challenge, params.code) + this.requireActiveEmailAuthAttempt(attempt, WalletOperation.completeEmailAuth) + + const request: CompleteAuthRequest = { + identityType: IdentityType.Email, + authMode: AuthMode.OTP, + verifier: attempt.verifier, + answer, + lifetime: DEFAULT_SESSION_LIFETIME_SECONDS, + } + const response = await this.client.completeAuth(request) + this.requireActiveEmailAuthAttempt(attempt, WalletOperation.completeEmailAuth) + return this.completeWalletAuth(response, params.walletType, params.walletSelection, attempt) + } + private async *listAccessPagesUnchecked( params: ListAccessParams, - operation: string, + operation: WalletOperation, ): AsyncIterable { await this.requireActiveSession(operation) @@ -854,13 +1038,8 @@ export class WalletClient { this.setOptionalStorageValue(Constants.sessionEmailStorageKey, this.sessionEmail) } - private sessionMetadataForActivation(): WalletSessionMetadata | undefined { - if (this.pendingSessionMetadata) { - return this.pendingSessionMetadata - } - if (!this.sessionExpiresAt) { - return undefined - } + private currentSessionMetadata(): WalletSessionMetadata | undefined { + if (!this.sessionExpiresAt) return undefined return { expiresAt: this.sessionExpiresAt, loginType: this.sessionLoginType, @@ -868,6 +1047,147 @@ export class WalletClient { } } + private async activeWalletActivationContext(operation: WalletOperation): Promise { + await this.requireActiveSession(operation) + return { + walletId: this.walletId, + metadata: this.currentSessionMetadata(), + } + } + + private async requireActiveWalletActivationContextStillActive( + context: ActiveWalletActivationContext, + operation: WalletOperation, + ): Promise { + await this.requireActiveSession(operation) + if (this.walletId !== context.walletId) { + throw new OmsSessionError({ + operation, + message: "Active wallet session changed", + }) + } + } + + private async requireWalletSelectionOrActiveSession(operation: WalletOperation): Promise { + if (this.activePendingWalletSelection) { + await this.requireActivePendingWalletSelection(this.activePendingWalletSelection, operation) + return + } + + if (this.walletId) { + await this.requireActiveSession(operation) + return + } + + throw new OmsSessionError({ + operation, + message: 'No authenticated wallet session', + }) + } + + private currentEmailAuthAttempt(operation: WalletOperation): ActiveEmailAuthAttempt { + if (!this.activeEmailAuthAttempt) { + throw new OmsSessionError({ + operation, + message: "No pending email auth attempt", + }) + } + + return this.activeEmailAuthAttempt + } + + private requireActiveEmailAuthAttempt( + attempt: ActiveEmailAuthAttempt, + operation: WalletOperation, + ): void { + if (this.activeEmailAuthAttempt !== attempt) { + throw new OmsSessionError({ + operation, + message: "Email auth attempt is no longer active", + }) + } + } + + private clearEmailAuthAttempt(attempt: ActiveEmailAuthAttempt | undefined): void { + if (attempt && this.activeEmailAuthAttempt === attempt) { + this.activeEmailAuthAttempt = undefined + } + } + + private async createPendingWalletSelection(params: { + walletType: WalletType; + wallets: Array; + credential: WalletCredential; + metadata: WalletSessionMetadata; + }, beforeCommit?: () => void): Promise { + const signerCredentialId = await this.credentialSigner.credentialId() + beforeCommit?.() + const selectionSession: ActivePendingWalletSelection = { + id: `pending-${this.nextPendingWalletSelectionId++}`, + signerCredentialId, + signerKeyType: this.credentialSigner.signingAlgorithm, + walletType: params.walletType, + metadata: params.metadata, + } + this.activePendingWalletSelection = selectionSession + + const wallets = params.wallets.map(wallet => ({...wallet})) + const credential = {...params.credential} + + return new PendingWalletSelectionImpl( + params.walletType, + wallets, + credential, + async walletId => { + const operation = WalletOperation.pendingWalletSelectionSelectWallet + await this.requireActivePendingWalletSelection(selectionSession, operation) + const wallet = await this.requestUseWallet(walletId) + await this.requireActivePendingWalletSelection(selectionSession, operation) + return this.activateWallet(wallet, selectionSession.metadata) + }, + async reference => { + const operation = WalletOperation.pendingWalletSelectionCreateAndSelectWallet + await this.requireActivePendingWalletSelection(selectionSession, operation) + const wallet = await this.requestCreateWallet(selectionSession.walletType, reference) + await this.requireActivePendingWalletSelection(selectionSession, operation) + return this.activateWallet(wallet, selectionSession.metadata) + }, + ) + } + + private async requireActivePendingWalletSelection( + selectionSession: ActivePendingWalletSelection, + operation: WalletOperation, + ): Promise { + if (this.activePendingWalletSelection?.id !== selectionSession.id) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_STALE", + operation, + message: "Pending wallet selection is no longer active", + }) + } + + if (this.credentialSigner.hasCredential && !(await this.credentialSigner.hasCredential())) { + this.activePendingWalletSelection = undefined + throw new OmsSessionError({ + operation, + message: 'No active credential', + }) + } + + const signerCredentialId = await this.credentialSigner.credentialId() + if ( + normalizeCredentialId(signerCredentialId) !== normalizeCredentialId(selectionSession.signerCredentialId) || + this.credentialSigner.signingAlgorithm !== selectionSession.signerKeyType + ) { + throw new OmsWalletSelectionError({ + code: "OMS_WALLET_SELECTION_STALE", + operation, + message: "Pending wallet selection is no longer active", + }) + } + } + private sessionMetadataFromAuthResponse(response: CompleteAuthResponse): WalletSessionMetadata { return { expiresAt: response.credential.expiresAt, @@ -937,6 +1257,8 @@ export class WalletClient { if ( typeof parsed.verifier !== 'string' || typeof parsed.nonce !== 'string' || + typeof parsed.signerCredentialId !== 'string' || + typeof parsed.signerKeyType !== 'string' || typeof parsed.redirectUri !== 'string' || typeof parsed.issuer !== 'string' || typeof parsed.projectId !== 'string' @@ -949,6 +1271,8 @@ export class WalletClient { nonce: parsed.nonce, provider: typeof parsed.provider === 'string' ? parsed.provider : null, walletType: isWalletType(parsed.walletType) ? parsed.walletType : WalletType.Ethereum, + signerCredentialId: parsed.signerCredentialId, + signerKeyType: parsed.signerKeyType as CredentialSigningAlgorithm, redirectUri: parsed.redirectUri, issuer: parsed.issuer, projectId: parsed.projectId, @@ -971,6 +1295,29 @@ export class WalletClient { } } + private async validatePendingOidcRedirectSigner( + pending: PendingOidcRedirectAuth, + operation: WalletOperation, + ): Promise { + if (this.credentialSigner.hasCredential && !(await this.credentialSigner.hasCredential())) { + throw new OmsSessionError({ + operation, + message: 'No active credential', + }) + } + + const signerCredentialId = await this.credentialSigner.credentialId() + if ( + normalizeCredentialId(signerCredentialId) !== normalizeCredentialId(pending.signerCredentialId) || + this.credentialSigner.signingAlgorithm !== pending.signerKeyType + ) { + throw new OmsSessionError({ + operation, + message: 'OIDC redirect auth signer mismatch', + }) + } + } + private replaceOidcCallbackUrl(callbackUrl: string, replaceUrl?: (url: string) => void): void { const cleanUrl = cleanOidcCallbackUrl(callbackUrl) if (replaceUrl) { @@ -1034,7 +1381,7 @@ export class WalletClient { ) } catch (error) { throw new OmsTransactionError({ - operation: "wallet.transactionStatus", + operation: WalletOperation.transactionStatus, txnId: params.prepared.txnId, retryable: true, cause: error, @@ -1188,7 +1535,7 @@ export class WalletClient { : options.intervalMs ?? this.transactionStatusPollIntervalMs } - private async requireActiveSession(operation: string): Promise { + private async requireActiveSession(operation: WalletOperation): Promise { if (!this.walletId) { throw new OmsSessionError({ operation, @@ -1242,7 +1589,7 @@ export class WalletClient { return new Promise(resolve => setTimeout(resolve, ms)) } - private async runOperation(operation: string, action: () => Promise): Promise { + private async runOperation(operation: WalletOperation, action: () => Promise): Promise { try { return await action() } catch (error) { @@ -1273,6 +1620,19 @@ function isWalletType(value: unknown): value is WalletType { return typeof value === 'string' && Object.values(WalletType).includes(value as WalletType) } +function sameEmailAuthCompletionParams( + left: EmailAuthCompletionParams, + right: EmailAuthCompletionParams, +): boolean { + return left.code === right.code && + left.walletType === right.walletType && + left.walletSelection === right.walletSelection +} + +function normalizeCredentialId(value: string): string { + return value.trim().toLowerCase() +} + function parseSessionLoginType(value: string | null): OMSClientSessionLoginType | undefined { return value === 'email' || value === 'google-auth' || value === 'oidc' ? value diff --git a/src/errors.ts b/src/errors.ts index 5d5276f..86b4acd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,9 +1,14 @@ +import type {OmsSdkOperation} from "./operations.js"; + export type OmsSdkErrorCode = | "OMS_HTTP_ERROR" | "OMS_INVALID_RESPONSE" | "OMS_REQUEST_FAILED" | "OMS_AUTH_COMMITMENT_CONSUMED" | "OMS_SESSION_MISSING" + | "OMS_WALLET_SELECTION_STALE" + | "OMS_WALLET_SELECTION_UNAVAILABLE" + | "OMS_WALLET_SELECTION_IN_FLIGHT" | "OMS_TRANSACTION_STATUS_LOOKUP_FAILED" | "OMS_VALIDATION_ERROR" @@ -67,6 +72,18 @@ export class OmsTransactionError extends OmsSdkError { } } +export class OmsWalletSelectionError extends OmsSdkError { + constructor(params: Omit & { + code: + | "OMS_WALLET_SELECTION_STALE" + | "OMS_WALLET_SELECTION_UNAVAILABLE" + | "OMS_WALLET_SELECTION_IN_FLIGHT" + }) { + super(params) + this.name = "OmsWalletSelectionError" + } +} + export class OmsValidationError extends OmsSdkError { constructor(params: Omit & { code?: OmsSdkErrorCode }) { super({code: params.code ?? "OMS_VALIDATION_ERROR", ...params}) @@ -78,7 +95,7 @@ export function isOmsSdkError(error: unknown): error is OmsSdkError { return error instanceof OmsSdkError } -export function toOmsSdkError(error: unknown, operation: string): OmsSdkError { +export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSdkError { if (isOmsSdkError(error)) { return error } diff --git a/src/index.ts b/src/index.ts index f8ed44b..0877c34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,6 @@ export { type OmsEnvironment, } from './omsEnvironment.js' export { - defaultGoogleClientId, - defaultRelayRedirectUri, googleOidcProvider, type GoogleOidcProviderParams, } from './oidc.js' @@ -35,7 +33,6 @@ export { export { TransactionMode, TransactionStatus, - SigningAlgorithm, WalletType, type TransactionStatusResponse, } from './generated/waas.gen.js' @@ -45,17 +42,17 @@ export { OmsSdkError, OmsSessionError, OmsTransactionError, + OmsWalletSelectionError, OmsValidationError, isOmsSdkError, type OmsSdkErrorCode, - type OmsSdkErrorParams, } from './errors.js' export type { - CompleteAuthWalletSelectionResult, CompleteEmailAuthParams, CompleteEmailAuthResult, CompleteOidcRedirectAuthParams, CompleteOidcRedirectAuthResult, + GetIdTokenParams, IsValidMessageSignatureParams, IsValidTypedDataSignatureParams, OMSClientSessionLoginType, @@ -63,12 +60,14 @@ export type { OmsWallet, OidcProviderInput, OidcProviderName, + PendingWalletSelection, SignMessageParams, SignInWithOidcRedirectParams, SignTypedDataParams, StartOidcRedirectAuthParams, StartOidcRedirectAuthResult, WalletActivationResult, + WalletSelectionBehavior, } from './clients/walletClient.js' export type { TokenContractInfo, diff --git a/src/networks.ts b/src/networks.ts index 2471957..297d138 100644 --- a/src/networks.ts +++ b/src/networks.ts @@ -3,6 +3,7 @@ export interface Network { readonly name: string readonly nativeTokenSymbol: string readonly explorerUrl: string + readonly displayName: string } export const Networks = { @@ -11,96 +12,112 @@ export const Networks = { name: 'mainnet', nativeTokenSymbol: 'ETH', explorerUrl: 'https://etherscan.io', + displayName: 'Ethereum', }, sepolia: { id: 11155111, name: 'sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia.etherscan.io', + displayName: 'Sepolia', }, polygon: { id: 137, name: 'polygon', nativeTokenSymbol: 'POL', explorerUrl: 'https://polygonscan.com', + displayName: 'Polygon', }, amoy: { id: 80002, name: 'amoy', nativeTokenSymbol: 'POL', explorerUrl: 'https://amoy.polygonscan.com', + displayName: 'Polygon Amoy', }, arbitrum: { id: 42161, name: 'arbitrum', nativeTokenSymbol: 'ETH', explorerUrl: 'https://arbiscan.io', + displayName: 'Arbitrum', }, arbitrumSepolia: { id: 421614, name: 'arbitrum-sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia.arbiscan.io', + displayName: 'Arbitrum Sepolia', }, optimism: { id: 10, name: 'optimism', nativeTokenSymbol: 'ETH', explorerUrl: 'https://optimistic.etherscan.io', + displayName: 'Optimism', }, optimismSepolia: { id: 11155420, name: 'optimism-sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia-optimism.etherscan.io', + displayName: 'Optimism Sepolia', }, base: { id: 8453, name: 'base', nativeTokenSymbol: 'ETH', explorerUrl: 'https://basescan.org', + displayName: 'Base', }, baseSepolia: { id: 84532, name: 'base-sepolia', nativeTokenSymbol: 'ETH', explorerUrl: 'https://sepolia.basescan.org', + displayName: 'Base Sepolia', }, bsc: { id: 56, name: 'bsc', nativeTokenSymbol: 'BNB', explorerUrl: 'https://bscscan.com', + displayName: 'BSC', }, bscTestnet: { id: 97, name: 'bsc-testnet', nativeTokenSymbol: 'BNB', explorerUrl: 'https://testnet.bscscan.com', + displayName: 'BSC Testnet', }, arbitrumNova: { id: 42170, name: 'arbitrum-nova', nativeTokenSymbol: 'ETH', explorerUrl: 'https://nova.arbiscan.io', + displayName: 'Arbitrum Nova', }, avalanche: { id: 43114, name: 'avalanche', nativeTokenSymbol: 'AVAX', explorerUrl: 'https://subnets.avax.network/c-chain', + displayName: 'Avalanche', }, avalancheTestnet: { id: 43113, name: 'avalanche-testnet', nativeTokenSymbol: 'AVAX', explorerUrl: 'https://subnets-test.avax.network/c-chain', + displayName: 'Avalanche Testnet', }, katana: { id: 747474, name: 'katana', nativeTokenSymbol: 'ETH', explorerUrl: 'https://katanascan.com', + displayName: 'Katana', }, } as const satisfies Record; diff --git a/src/operations.ts b/src/operations.ts new file mode 100644 index 0000000..905e146 --- /dev/null +++ b/src/operations.ts @@ -0,0 +1,36 @@ +export const WalletOperation = { + pendingWalletSelectionSelectWallet: "wallet.pendingWalletSelection.selectWallet", + pendingWalletSelectionCreateAndSelectWallet: "wallet.pendingWalletSelection.createAndSelectWallet", + startEmailAuth: "wallet.startEmailAuth", + completeEmailAuth: "wallet.completeEmailAuth", + startOidcRedirectAuth: "wallet.startOidcRedirectAuth", + completeOidcRedirectAuth: "wallet.completeOidcRedirectAuth", + signInWithOidcRedirect: "wallet.signInWithOidcRedirect", + signOut: "wallet.signOut", + listWallets: "wallet.listWallets", + useWallet: "wallet.useWallet", + createWallet: "wallet.createWallet", + getIdToken: "wallet.getIdToken", + signMessage: "wallet.signMessage", + signTypedData: "wallet.signTypedData", + isValidMessageSignature: "wallet.isValidMessageSignature", + isValidTypedDataSignature: "wallet.isValidTypedDataSignature", + sendTransaction: "wallet.sendTransaction", + callContract: "wallet.callContract", + getTransactionStatus: "wallet.getTransactionStatus", + listAccess: "wallet.listAccess", + listAccessPages: "wallet.listAccessPages", + revokeAccess: "wallet.revokeAccess", + transactionStatus: "wallet.transactionStatus", +} as const + +export type WalletOperation = typeof WalletOperation[keyof typeof WalletOperation] + +export const IndexerOperation = { + getTokenBalances: "indexer.getTokenBalances", + getNativeTokenBalance: "indexer.getNativeTokenBalance", +} as const + +export type IndexerOperation = typeof IndexerOperation[keyof typeof IndexerOperation] + +export type OmsSdkOperation = WalletOperation | IndexerOperation diff --git a/src/types/evmTypes.ts b/src/types/evmTypes.ts deleted file mode 100644 index 5ec00e1..0000000 --- a/src/types/evmTypes.ts +++ /dev/null @@ -1 +0,0 @@ -export type {Network} from "../networks.js"; diff --git a/src/utils/byteUtils.ts b/src/utils/byteUtils.ts index 92247ee..5f913b8 100644 --- a/src/utils/byteUtils.ts +++ b/src/utils/byteUtils.ts @@ -1,14 +1,5 @@ export class ByteUtils { - static hexToBytes(hex: string): Uint8Array { - const h = hex.startsWith('0x') ? hex.slice(2) : hex - const bytes = new Uint8Array(h.length / 2) - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16) - } - return bytes - } - static bytesToHex(bytes: Uint8Array): string { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') } -} \ No newline at end of file +} diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts deleted file mode 100644 index f22b6b9..0000000 --- a/src/utils/timeUtils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class TimeUtils { - static currentTimestampInSecondsString(): string { - return Math.floor(Date.now()).toString() - } -} \ No newline at end of file diff --git a/tests/networks.test.ts b/tests/networks.test.ts index 1fae63c..6b8f6a4 100644 --- a/tests/networks.test.ts +++ b/tests/networks.test.ts @@ -33,13 +33,33 @@ describe("Networks", () => { name: "katana", nativeTokenSymbol: "ETH", explorerUrl: "https://katanascan.com", + displayName: "Katana", }); + expect(supportedNetworks.map(network => network.displayName)).toEqual([ + "Ethereum", + "Sepolia", + "Polygon", + "Polygon Amoy", + "Arbitrum", + "Arbitrum Sepolia", + "Optimism", + "Optimism Sepolia", + "Base", + "Base Sepolia", + "BSC", + "BSC Testnet", + "Arbitrum Nova", + "Avalanche", + "Avalanche Testnet", + "Katana", + ]); }); it("looks up networks by id or name", () => { expect(findNetworkById(43113)).toBe(Networks.avalancheTestnet); expect(findNetworkById(421614)).toBe(Networks.arbitrumSepolia); expect(findNetworkByName("base-sepolia")).toBe(Networks.baseSepolia); + expect(findNetworkByName("Ethereum")).toBeUndefined(); }); it("is available from OMSClient", () => { diff --git a/tests/oidcRedirectAuth.test.ts b/tests/oidcRedirectAuth.test.ts index 3c0a31c..58203ab 100644 --- a/tests/oidcRedirectAuth.test.ts +++ b/tests/oidcRedirectAuth.test.ts @@ -17,8 +17,14 @@ class MockSigner implements CredentialSigner { readonly signingAlgorithm = "ecdsa-p256-sha256"; readonly preimages: string[] = []; + constructor(private credential = "0x04" + "11".repeat(64)) {} + async credentialId(): Promise { - return "0x04" + "11".repeat(64); + return this.credential; + } + + setCredential(credential: string): void { + this.credential = credential; } async nextNonce(): Promise { @@ -331,6 +337,118 @@ describe("WalletClient OIDC redirect auth", () => { expect(replaceUrl).toHaveBeenCalledWith("https://app.example/auth/callback"); }); + it("can complete an OIDC callback with a pending wallet selection", async () => { + const redirectAuthStorage = new MemoryStorageManager(); + const otherType = "future-wallet" as WalletType; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + } + + if (url.endsWith("/CompleteAuth")) { + expect(body).toEqual({ + identityType: "oidc", + authMode: "auth-code-pkce", + verifier: "verifier-1", + answer: "auth-code", + lifetime: 604_800, + }); + return jsonResponse({ + identity: {type: "oidc", iss: "https://accounts.google.com", sub: "user-1"}, + wallets: [ + { + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }, + { + id: "wallet-other", + type: otherType, + address: "0x2222222222222222222222222222222222222222", + }, + ], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet") || url.endsWith("/CreateWallet")) { + throw new Error("OIDC manual auth should not activate a wallet"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletClient({redirectAuthStorage}); + const started = await wallet.startOidcRedirectAuth({ + provider: "google", + redirectUri: "https://app.example/auth/callback", + }); + + const selection = await wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, + walletSelection: "manual", + }); + + expect(selection).toMatchObject({ + walletType: WalletType.Ethereum, + wallets: [{ + id: "wallet-id", + type: WalletType.Ethereum, + address: "0x1111111111111111111111111111111111111111", + }], + credential: testCredential(), + }); + expect(selection.selectWallet).toEqual(expect.any(Function)); + expect(selection.createAndSelectWallet).toEqual(expect.any(Function)); + expect(wallet.walletAddress).toBeUndefined(); + expect(redirectAuthStorage.get(Constants.redirectAuthStorageKey)).toBeNull(); + }); + + it("rejects OIDC callbacks when the signer changed after starting redirect auth", async () => { + const redirectAuthStorage = new MemoryStorageManager(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-1", + challenge: "challenge-1", + }); + } + + if (url.endsWith("/CompleteAuth")) { + throw new Error("CompleteAuth should not be called after signer mismatch"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const signer = new MockSigner(); + const wallet = createWalletClient({redirectAuthStorage, credentialSigner: signer}); + const started = await wallet.startOidcRedirectAuth({ + provider: "google", + redirectUri: "https://app.example/auth/callback", + }); + signer.setCredential("0x04" + "99".repeat(64)); + + await expect(wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, + })).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "OIDC redirect auth signer mismatch", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(redirectAuthStorage.get(Constants.redirectAuthStorageKey)).toBeNull(); + }); + it("cleans the callback URL when OIDC completion fails", async () => { const redirectAuthStorage = new MemoryStorageManager(); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { diff --git a/tests/walletErrors.test.ts b/tests/walletErrors.test.ts index 9a85076..0c51e3e 100644 --- a/tests/walletErrors.test.ts +++ b/tests/walletErrors.test.ts @@ -90,6 +90,7 @@ describe("WalletClient errors", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); + seedEmailAuthAttempt(wallet); await expect(wallet.completeEmailAuth({code: "123456"})).rejects.toMatchObject({ code: "OMS_AUTH_COMMITMENT_CONSUMED", @@ -97,9 +98,21 @@ describe("WalletClient errors", () => { status: 400, retryable: false, }); + expect(activeEmailAuthAttempt(wallet)).toBeUndefined(); }); }); +function seedEmailAuthAttempt(wallet: WalletClient): void { + (wallet as any).activeEmailAuthAttempt = { + verifier: "verifier-1", + challenge: "challenge-1", + }; +} + +function activeEmailAuthAttempt(wallet: WalletClient): unknown { + return (wallet as any).activeEmailAuthAttempt; +} + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, diff --git a/tests/walletSession.test.ts b/tests/walletSession.test.ts index aa92521..53a0f93 100644 --- a/tests/walletSession.test.ts +++ b/tests/walletSession.test.ts @@ -6,6 +6,7 @@ import {Networks} from "../src/networks"; import {OMSClient} from "../src/omsClient"; import {MemoryStorageManager} from "../src/storageManager"; import {Constants} from "../src/utils/constants"; +import {RequestUtils} from "../src/utils/requestUtils"; import {WalletType} from "../src/generated/waas.gen"; class MockSigner implements CredentialSigner { @@ -36,6 +37,18 @@ afterEach(() => { vi.unstubAllGlobals(); }); +function seedEmailAuthAttempt( + wallet: WalletClient, + verifier = "verifier-1", + challenge = "challenge-1", +): void { + (wallet as any).activeEmailAuthAttempt = {verifier, challenge}; +} + +function activeEmailAuthAttempt(wallet: WalletClient): unknown { + return (wallet as any).activeEmailAuthAttempt; +} + describe("WalletClient session storage", () => { it("falls back to memory storage when localStorage is unavailable", () => { vi.stubGlobal("localStorage", undefined); @@ -97,13 +110,11 @@ describe("WalletClient session storage", () => { loginType: "email", sessionEmail: "user@example.com", }); - (wallet as any).verifier = "old-verifier"; - (wallet as any).challenge = "old-challenge"; + seedEmailAuthAttempt(wallet, "old-verifier", "old-challenge"); await wallet.signOut(); expect(wallet.walletAddress).toBeUndefined(); - expect((wallet as any).verifier).toBe(""); - expect((wallet as any).challenge).toBe(""); + expect(activeEmailAuthAttempt(wallet)).toBeUndefined(); expect(wallet.session).toEqual({ walletAddress: undefined, expiresAt: undefined, @@ -233,8 +244,7 @@ describe("WalletClient session storage", () => { storage, credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const result = await wallet.completeEmailAuth({code: "123456"}); @@ -263,6 +273,157 @@ describe("WalletClient session storage", () => { expect(storage.get(Constants.sessionEmailStorageKey)).toBe("user@example.com"); }); + it("deduplicates concurrent email auth completion for the same auth attempt", async () => { + const completeAuth = deferred(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return completeAuth.promise; + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-id", WalletType.Ethereum, "11")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer").mockResolvedValue("answer"); + + const first = wallet.completeEmailAuth({code: "123456"}); + const second = wallet.completeEmailAuth({code: "123456"}); + await waitForRequest(fetchMock, "/CompleteAuth"); + + expect(requestCount(fetchMock, "/CompleteAuth")).toBe(1); + completeAuth.resolve(jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-id", WalletType.Ethereum, "11")], + credential: testCredential(), + })); + + await expect(first).resolves.toMatchObject({ + wallet: {id: "wallet-id"}, + }); + await expect(second).resolves.toMatchObject({ + wallet: {id: "wallet-id"}, + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(1); + }); + + it("does not persist stale automatic email auth after a newer email auth starts", async () => { + const completeAuth = deferred(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return completeAuth.promise; + } + + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({ + verifier: "verifier-2", + challenge: "challenge-2", + }); + } + + if (url.endsWith("/UseWallet")) { + throw new Error("UseWallet should not be called for stale auth"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer").mockResolvedValue("old-answer"); + + const staleCompletion = wallet.completeEmailAuth({code: "111111"}); + await waitForRequest(fetchMock, "/CompleteAuth"); + await wallet.startEmailAuth({email: "new@example.com"}); + completeAuth.resolve(jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "old@example.com", + wallets: [testWallet("wallet-old", WalletType.Ethereum, "44")], + credential: testCredential(), + })); + + await expect(staleCompletion).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.completeEmailAuth", + message: "Email auth attempt is no longer active", + }); + expect(wallet.walletAddress).toBeUndefined(); + expect(activeEmailAuthAttempt(wallet)).toMatchObject({ + verifier: "verifier-2", + challenge: "challenge-2", + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(0); + }); + + it("allows email auth completion retry after a failed completion request", async () => { + let completeAuthCalls = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + completeAuthCalls += 1; + if (completeAuthCalls === 1) { + throw new Error("temporary CompleteAuth failure"); + } + + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-id", WalletType.Ethereum, "11")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-id", WalletType.Ethereum, "11")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer").mockResolvedValue("answer"); + + await expect(wallet.completeEmailAuth({code: "123456"})).rejects.toMatchObject({ + operation: "wallet.completeEmailAuth", + }); + await expect(wallet.completeEmailAuth({code: "123456"})).resolves.toMatchObject({ + wallet: {id: "wallet-id"}, + }); + expect(requestCount(fetchMock, "/CompleteAuth")).toBe(2); + }); + it("loads remaining auth wallet pages before creating a wallet", async () => { const requestedType = "future-wallet" as WalletType; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -306,8 +467,7 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); const result = await wallet.completeEmailAuth({code: "123456", walletType: requestedType}); @@ -318,7 +478,8 @@ describe("WalletClient session storage", () => { ]); }); - it("returns all wallets without activating when autoActivate is false", async () => { + it("returns a pending wallet selection with filtered wallets when wallet selection is manual", async () => { + const otherType = "future-wallet" as WalletType; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const body = JSON.parse(init?.body as string); @@ -327,7 +488,10 @@ describe("WalletClient session storage", () => { return jsonResponse({ identity: {type: "email", sub: "user-1"}, email: "user@example.com", - wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], + wallets: [ + testWallet("wallet-1", WalletType.Ethereum, "11"), + testWallet("wallet-other", otherType, "33"), + ], page: {cursor: "cursor-2"}, credential: testCredential(), }); @@ -362,21 +526,29 @@ describe("WalletClient session storage", () => { storage, credentialSigner: new MockSigner(), }); - (wallet as any).verifier = "verifier-1"; - (wallet as any).challenge = "challenge-1"; + seedEmailAuthAttempt(wallet); - const result = await wallet.completeEmailAuth({code: "123456", autoActivate: false}); + const result = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); - expect(result).toEqual({ + expect(result).toMatchObject({ + walletType: WalletType.Ethereum, wallets: [ testWallet("wallet-1", WalletType.Ethereum, "11"), testWallet("wallet-2", WalletType.Ethereum, "22"), ], credential: testCredential(), }); + expect(result.selectWallet).toEqual(expect.any(Function)); + expect(result.createAndSelectWallet).toEqual(expect.any(Function)); expect(wallet.walletAddress).toBeUndefined(); - const activated = await wallet.useWallet({walletId: "wallet-2"}); + await expect(result.selectWallet({walletId: "wallet-other"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_UNAVAILABLE", + operation: "wallet.pendingWalletSelection.selectWallet", + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(0); + + const activated = await result.selectWallet({walletId: "wallet-2"}); expect(activated.walletAddress).toBe("0x2222222222222222222222222222222222222222"); expect(wallet.session).toEqual({ @@ -388,7 +560,533 @@ describe("WalletClient session storage", () => { expect(storage.get(Constants.sessionEmailStorageKey)).toBe("user@example.com"); }); - it("can explicitly create and activate a new wallet", async () => { + it("pending create uses the requested wallet type", async () => { + const requestedType = "future-wallet" as WalletType; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + expect(body).toEqual({ + type: requestedType, + reference: "fresh", + }); + return jsonResponse({wallet: testWallet("wallet-new", requestedType, "33", "fresh")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + + const selection = await wallet.completeEmailAuth({ + code: "123456", + walletType: requestedType, + walletSelection: "manual", + }); + + const result = await selection.createAndSelectWallet({reference: "fresh"}); + + expect(result).toEqual({ + walletAddress: "0x3333333333333333333333333333333333333333", + wallet: testWallet("wallet-new", requestedType, "33", "fresh"), + }); + expect(wallet.walletAddress).toBe("0x3333333333333333333333333333333333333333"); + }); + + it("stale pending wallet selections fail before network after newer manual auth", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: [testWallet(body.answer === "first" ? "wallet-first" : "wallet-second", WalletType.Ethereum, body.answer === "first" ? "11" : "22")], + credential: testCredential(), + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + + const staleSelection = await wallet.completeEmailAuth({ + code: "111111", + walletSelection: "manual", + }); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); + await wallet.completeEmailAuth({ + code: "222222", + walletSelection: "manual", + }); + const requestCountBeforeStaleSelection = fetchMock.mock.calls.length; + + await expect(staleSelection.selectWallet({walletId: "wallet-first"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + await expect(staleSelection.createAndSelectWallet({reference: "stale"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(fetchMock.mock.calls.length).toBe(requestCountBeforeStaleSelection); + expect(wallet.walletAddress).toBeUndefined(); + }); + + it("stale pending wallet selections fail before network after newer automatic auth", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: [testWallet(body.answer === "first" ? "wallet-first" : "wallet-second", WalletType.Ethereum, body.answer === "first" ? "11" : "22")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-second", WalletType.Ethereum, "22")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + + const staleSelection = await wallet.completeEmailAuth({ + code: "111111", + walletSelection: "manual", + }); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); + await wallet.completeEmailAuth({code: "222222"}); + const requestCountBeforeStaleSelection = fetchMock.mock.calls.length; + + await expect(staleSelection.createAndSelectWallet({reference: "stale"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(fetchMock.mock.calls.length).toBe(requestCountBeforeStaleSelection); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + }); + + it("reused pending wallet selections fail before network after success", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], + credential: testCredential(), + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-1", WalletType.Ethereum, "11")}); + } + + if (url.endsWith("/CreateWallet")) { + throw new Error("CreateWallet should not be called"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + + const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + await selection.selectWallet({walletId: "wallet-1"}); + const requestCountAfterSelection = fetchMock.mock.calls.length; + + await expect(selection.createAndSelectWallet({reference: "again"})).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(fetchMock.mock.calls.length).toBe(requestCountAfterSelection); + }); + + it("concurrent pending create calls send only one create wallet request", async () => { + let resolveCreate!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + expect(body).toEqual({type: WalletType.Ethereum, reference: "fresh"}); + return new Promise(resolve => { + resolveCreate = resolve; + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + + const firstCreate = selection.createAndSelectWallet({reference: "fresh"}); + const secondCreate = selection.createAndSelectWallet({reference: "fresh"}); + + await expect(secondCreate).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_IN_FLIGHT", + }); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(1); + + resolveCreate(jsonResponse({wallet: testWallet("wallet-new", WalletType.Ethereum, "33", "fresh")})); + await expect(firstCreate).resolves.toMatchObject({ + wallet: {id: "wallet-new"}, + }); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(1); + }); + + it("failed pending create can be retried when the selection was not consumed", async () => { + let createAttempts = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + createAttempts += 1; + if (createAttempts === 1) { + throw new Error("network failed"); + } + return jsonResponse({wallet: testWallet("wallet-new", WalletType.Ethereum, "33", "fresh")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + const selection = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + + await expect(selection.createAndSelectWallet({reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_REQUEST_FAILED", + }); + + await expect(selection.createAndSelectWallet({reference: "fresh"})).resolves.toMatchObject({ + wallet: {id: "wallet-new"}, + }); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(2); + }); + + it("pending create invalidated while in flight does not persist the stale result", async () => { + let resolveCreate!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: body.answer === "first" ? "first@example.com" : "second@example.com", + wallets: body.answer === "first" + ? [] + : [testWallet("wallet-second", WalletType.Ethereum, "22")], + credential: testCredential(), + }); + } + + if (url.endsWith("/CreateWallet")) { + return new Promise(resolve => { + resolveCreate = resolve; + }); + } + + if (url.endsWith("/UseWallet")) { + return jsonResponse({wallet: testWallet("wallet-second", WalletType.Ethereum, "22")}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + vi.spyOn(RequestUtils, "hashEmailAuthAnswer") + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + const selection = await wallet.completeEmailAuth({code: "111111", walletSelection: "manual"}); + + const staleCreate = selection.createAndSelectWallet({reference: "stale"}); + seedEmailAuthAttempt(wallet, "verifier-2", "challenge-2"); + await wallet.completeEmailAuth({code: "222222"}); + resolveCreate(jsonResponse({wallet: testWallet("wallet-stale", WalletType.Ethereum, "44", "stale")})); + + await expect(staleCreate).rejects.toMatchObject({ + code: "OMS_WALLET_SELECTION_STALE", + }); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + }); + + it("public wallet activation methods reject while manual selection is pending", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + identity: {type: "email", sub: "user-1"}, + email: "user@example.com", + wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], + credential: testCredential(), + }); + } + + if (url.endsWith("/ListWallets")) { + return jsonResponse({wallets: [testWallet("wallet-1", WalletType.Ethereum, "11")], page: {}}); + } + + if (url.endsWith("/UseWallet") || url.endsWith("/CreateWallet")) { + throw new Error( + "Public activation methods should not be used while manual wallet selection is pending; complete selection through PendingWalletSelection", + ); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + seedEmailAuthAttempt(wallet); + await wallet.completeEmailAuth({code: "111111", walletSelection: "manual"}); + + await expect(wallet.listWallets()).resolves.toEqual([testWallet("wallet-1", WalletType.Ethereum, "11")]); + await expect(wallet.useWallet({walletId: "wallet-1"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.useWallet", + message: "No active wallet session", + }); + await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.createWallet", + message: "No active wallet session", + }); + expect(requestCount(fetchMock, "/UseWallet")).toBe(0); + expect(requestCount(fetchMock, "/CreateWallet")).toBe(0); + }); + + it("public wallet activation methods require an active session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + throw new Error(`Unexpected request: ${input.toString()}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage: new MemoryStorageManager(), + credentialSigner: new MockSigner(), + }); + + await expect(wallet.listWallets()).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No authenticated wallet session", + }); + await expect(wallet.useWallet({walletId: "wallet-1"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No active wallet session", + }); + await expect(wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"})).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + message: "No active wallet session", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("active wallet switch invalidated while in flight by sign-out does not persist the stale result", async () => { + const storage = new MemoryStorageManager(); + let resolveUse!: (response: Response) => void; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/UseWallet")) { + expect(body).toEqual({walletId: "wallet-2"}); + return new Promise(resolve => { + resolveUse = resolve; + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage, + credentialSigner: new MockSigner(), + }); + (wallet as any).persistSession("wallet-1", "0x1111111111111111111111111111111111111111", { + expiresAt: "2026-01-01T00:00:00Z", + loginType: "email", + sessionEmail: "user@example.com", + }); + + const staleUse = wallet.useWallet({walletId: "wallet-2"}); + await waitForRequest(fetchMock, "/UseWallet"); + await wallet.signOut(); + resolveUse(jsonResponse({wallet: testWallet("wallet-2", WalletType.Ethereum, "22")})); + + await expect(staleUse).rejects.toMatchObject({ + code: "OMS_SESSION_MISSING", + operation: "wallet.useWallet", + message: "No active wallet session", + }); + expect(wallet.walletAddress).toBeUndefined(); + expect(storage.get(Constants.walletIdStorageKey)).toBeNull(); + expect(storage.get(Constants.walletAddressStorageKey)).toBeNull(); + }); + + it("can switch to an existing wallet from an active session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + + if (url.endsWith("/UseWallet")) { + expect(body).toEqual({walletId: "wallet-2"}); + return jsonResponse({wallet: testWallet("wallet-2", WalletType.Ethereum, "22")}); + } + + if (url.endsWith("/SignMessage")) { + expect(body).toEqual({ + network: Networks.polygon.id.toString(), + walletId: "wallet-2", + message: "hello", + }); + return jsonResponse({signature: "0xsigned"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const storage = new MemoryStorageManager(); + const wallet = new WalletClient({ + publicApiKey: "public-api-key", + projectId: "project-id", + environment: testEnvironment(), + storage, + credentialSigner: new MockSigner(), + }); + (wallet as any).persistSession("wallet-1", "0x1111111111111111111111111111111111111111", { + expiresAt: "2026-01-01T00:00:00Z", + loginType: "email", + sessionEmail: "user@example.com", + }); + + const result = await wallet.useWallet({walletId: "wallet-2"}); + + expect(result).toEqual({ + walletAddress: "0x2222222222222222222222222222222222222222", + wallet: testWallet("wallet-2", WalletType.Ethereum, "22"), + }); + expect(wallet.walletAddress).toBe("0x2222222222222222222222222222222222222222"); + expect(storage.get(Constants.walletIdStorageKey)).toBe("wallet-2"); + expect(storage.get(Constants.walletAddressStorageKey)).toBe("0x2222222222222222222222222222222222222222"); + expect(requestCount(fetchMock, "/UseWallet")).toBe(1); + + await expect(wallet.signMessage({network: Networks.polygon, message: "hello"})).resolves.toBe("0xsigned"); + expect(requestCount(fetchMock, "/SignMessage")).toBe(1); + }); + + it("can explicitly create and activate a new wallet from an active session", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const body = JSON.parse(init?.body as string); @@ -412,6 +1110,11 @@ describe("WalletClient session storage", () => { storage: new MemoryStorageManager(), credentialSigner: new MockSigner(), }); + (wallet as any).persistSession("wallet-id", "0x1111111111111111111111111111111111111111", { + expiresAt: "2026-01-01T00:00:00Z", + loginType: "email", + sessionEmail: "user@example.com", + }); const result = await wallet.createWallet({type: WalletType.Ethereum, reference: "fresh"}); @@ -453,3 +1156,27 @@ function testWallet(id: string, type: WalletType, seed: string, reference?: stri ...(reference ? {reference} : {}), }; } + +function requestCount(fetchMock: ReturnType, endpoint: string): number { + return fetchMock.mock.calls.filter(([input]) => input.toString().endsWith(endpoint)).length; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return {promise, resolve, reject}; +} + +async function waitForRequest(fetchMock: ReturnType, endpoint: string): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + if (requestCount(fetchMock, endpoint) > 0) { + return; + } + await Promise.resolve(); + } + throw new Error(`Expected ${endpoint} request`); +} diff --git a/tests/walletSigning.test.ts b/tests/walletSigning.test.ts index da75979..822da27 100644 --- a/tests/walletSigning.test.ts +++ b/tests/walletSigning.test.ts @@ -31,6 +31,37 @@ afterEach(() => { }); describe("WalletClient signing", () => { + it("gets an ID token for the active wallet session", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse(init?.body as string); + const headers = init?.headers as Record; + + expect(headers["X-Access-Key"]).toBe("public-api-key"); + expect(headers["OMS-Wallet-Signature"]).toContain('alg="ecdsa-p256-sha256"'); + expect(headers.Authorization).toBeUndefined(); + + if (url.endsWith("/GetIDToken")) { + expect(body).toEqual({ + walletId: "wallet-id", + ttlSeconds: 300, + customClaims: {role: "admin"}, + }); + return jsonResponse({idToken: "jwt-token"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession("0x1111111111111111111111111111111111111111"); + + await expect(wallet.getIdToken({ + ttlSeconds: 300, + customClaims: {role: "admin"}, + })).resolves.toBe("jwt-token"); + }); + it("signs typed data through the generated wallet client", async () => { const typedData = { domain: {name: "Test", chainId: 137n}, diff --git a/type-tests/oidcProviderTypes.ts b/type-tests/oidcProviderTypes.ts index f35e373..6951636 100644 --- a/type-tests/oidcProviderTypes.ts +++ b/type-tests/oidcProviderTypes.ts @@ -6,6 +6,7 @@ import { findNetworkByName, supportedNetworks, type Network, + type GetIdTokenParams, type OMSClientSessionLoginType, type OMSClientSessionState, type TokenBalance, @@ -50,8 +51,11 @@ if (false) { }); void (async () => { - const manualAuth = await wallet.completeEmailAuth({code: "123456", autoActivate: false}); + const manualAuth = await wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + void manualAuth.walletType; void manualAuth.wallets; + void manualAuth.selectWallet({walletId: manualAuth.wallets[0].id}); + void manualAuth.createAndSelectWallet({reference: "main"}); // @ts-expect-error manual auth does not activate a wallet. void manualAuth.walletAddress; @@ -74,8 +78,11 @@ new OMSClient({projectAccessKey: "public-api-key", projectId: "project-id"}); // @ts-expect-error old authorizationScope initializer name is not supported. new OMSClient({publicApiKey: "public-api-key", authorizationScope: "project-id"}); const session: OMSClientSessionState = defaultClient.wallet.session; +const idTokenParams: GetIdTokenParams = {ttlSeconds: 300, customClaims: {role: "admin"}}; +const idToken: Promise = defaultClient.wallet.getIdToken(idTokenParams); const loginType: OMSClientSessionLoginType | undefined = defaultClient.wallet.session.loginType; const polygonNetwork: Network = Networks.polygon; +const polygonDisplayName: string = Networks.polygon.displayName; const amoyNetwork: Network | undefined = findNetworkById(80002); const baseNetwork: Network | undefined = findNetworkByName("base"); const allNetworks: readonly Network[] = supportedNetworks;