UNPKG

accounts

Version:

Tempo Accounts SDK

768 lines (705 loc) 28.6 kB
import { Address as core_Address, Hex, Provider as ox_Provider, Secp256k1, Signature } from 'ox' import { SignatureEnvelope } from 'ox/tempo' import { hashMessage, hashTypedData, isAddressEqual, keccak256 } from 'viem' import type { Address, LocalAccount } from 'viem/accounts' import { prepareTransactionRequest } from 'viem/actions' import { Actions, Transaction as TempoTransaction } from 'viem/tempo' import * as AccessKey from '../AccessKey.js' import * as Adapter from '../Adapter.js' import * as AccessKeyTransaction from '../internal/AccessKeyTransaction.js' import * as Store from '../Store.js' const privySessionErrorCodes = new Set([ 'attempted_rpc_call_before_logged_in', 'attempted_to_read_storage_before_client_initialized', 'embedded_wallet_before_logged_in', 'embedded_wallet_does_not_exist', 'embedded_wallet_request_error', 'missing_auth_token', 'missing_privy_token', 'oauth_session_failed', 'oauth_session_timeout', 'session_expired', 'unauthenticated', 'unauthorized', ]) /** * Creates a Privy adapter backed by `@privy-io/js-sdk-core` Privy sessions and embedded * Ethereum wallets. * * The adapter owns silent reconnect, session-expiry cleanup, and signing. Apps supply * the UI-bearing login flow via `loadAccounts` (and optionally a distinct `createAccount` * for registration). Callbacks fire only on user-initiated `wallet_connect`/registration — * never during silent restore on page reload. * * Silent restore on page reload pulls wallets directly from the Privy SDK * (`client.user.get` + `client.embeddedWallet.getEthereumProvider`), so apps don't * need to re-run the login UI when the user returns with a still-valid Privy session. * * Callbacks only run the Privy auth UI. They may optionally return a subset of * embedded wallet addresses to expose; if omitted, the adapter exposes every * embedded wallet on the resulting Privy user. * * @example * ```ts * import Privy from '@privy-io/js-sdk-core' * * const client = new Privy({ appId: import.meta.env.VITE_PRIVY_APP_ID }) * * const provider = Provider.create({ * adapter: privy({ * client, * // Optional: omit to route registration through `loadAccounts`. * createAccount: async ({ client }) => { * await myPrivyRegisterUI(client) * }, * loadAccounts: async ({ client }) => { * await myPrivyLoginUI(client) * }, * }), * }) * ``` */ export function privy<const client extends privy.Client>( options: privy.Options<client>, ): Adapter.Adapter { const { icon, name = 'Privy', rdns = 'io.privy' } = options return Adapter.define({ icon, name, rdns }, ({ getClient, store }) => { let privyClient_promise: Promise<client> | undefined let restore_promise: Promise<void> | undefined let walletAccounts: readonly privy.EmbeddedWallet[] | undefined async function getPrivyClient(): Promise<client> { privyClient_promise ??= (async () => { await options.client.initialize?.() return options.client })() return await privyClient_promise } function toStoreAccount(account: privy.EmbeddedWallet, label?: string | undefined) { return { address: core_Address.from(account.address), ...(label ? { label } : {}), } } function toTempoAccount(account: privy.EmbeddedWallet) { const address = core_Address.from(account.address) async function sign(parameters: { hash: Hex.Hex }) { return await signPayload({ payload: parameters.hash, walletAccount: account, }) } return { address, sign, signTransaction: async (transaction: unknown) => await privySignTransaction({ sign, transaction }), source: 'privy', type: 'local', } satisfies { address: Address sign: (parameters: { hash: Hex.Hex }) => Promise<Hex.Hex> signTransaction: (transaction: unknown) => Promise<Hex.Hex> source: 'privy' type: 'local' } } function clear() { restore_promise = undefined walletAccounts = undefined store.setState({ accessKeys: [], accounts: [], activeAccount: 0 }) } async function hasValidSession() { const token = await (await getPrivyClient()).getAccessToken().catch((error) => { if (isSessionError(error)) return null throw error }) return !!token } /** * Loads the user's Privy embedded Ethereum wallets and constructs their * EIP-1193 providers. Mirrors `getAllUserEmbeddedEthereumWallets` + * `getEntropyDetailsFromUser` from `@privy-io/js-sdk-core`: per the SDK, * `entropyId` is the **primary** embedded wallet's address (wallet_index === 0) * shared across all wallets of the same user, and `entropyIdVerifier` is * hardcoded to `'ethereum-address-verifier'` for Ethereum wallets. */ async function loadEthereumWallets( privyClient: privy.Client, ): Promise<readonly privy.EmbeddedWallet[]> { const { user } = await privyClient.user.get() const wallets = (user?.linked_accounts ?? []) .filter( (account) => account.type === 'wallet' && account.wallet_client_type === 'privy' && account.connector_type === 'embedded' && account.chain_type === 'ethereum' && typeof account.address === 'string', ) .slice() .sort((a, b) => { // Wallets without a `wallet_index` are sorted to the end so they // never accidentally become primary when a sibling has an index. const a_index = a.wallet_index ?? Number.POSITIVE_INFINITY const b_index = b.wallet_index ?? Number.POSITIVE_INFINITY return a_index - b_index }) // Primary is the wallet with `wallet_index === 0`. Fall back to the // lowest-indexed wallet only when no wallet declares index 0. const primary = wallets.find((wallet) => wallet.wallet_index === 0) ?? wallets[0] if (!primary) return [] const entropyId = primary.address as string return await Promise.all( wallets.map(async (wallet) => ({ address: core_Address.from(wallet.address as string), provider: await privyClient.embeddedWallet.getEthereumProvider({ wallet, entropyId, entropyIdVerifier: 'ethereum-address-verifier', }), })), ) } function selectWalletAccounts( accounts: readonly privy.EmbeddedWallet[], addresses: privy.AccountSelection, ): readonly privy.EmbeddedWallet[] { if (!addresses) return accounts return addresses.map((address) => { const address_ = core_Address.from(address) const account = accounts.find((account) => isAddressEqual(core_Address.from(account.address), address_), ) if (account) return account throw new ox_Provider.UnauthorizedError({ message: `Privy callback returned address "${address_}" that was not found in the user's embedded wallets.`, }) }) } async function restore() { await Store.waitForHydration(store) if (walletAccounts) return if (restore_promise) return await restore_promise restore_promise = (async () => { const state = store.getState() const persisted = state.accounts if (persisted.length === 0) return if (!(await hasValidSession())) { clear() throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' }) } const restored = await loadEthereumWallets(await getPrivyClient()).catch((error) => { if (!isSessionError(error)) throw error clear() throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' }) }) walletAccounts = restored const accounts = persisted .map((account) => restored.find((walletAccount) => isAddressEqual(core_Address.from(walletAccount.address), account.address), ), ) .filter((account): account is privy.EmbeddedWallet => !!account) // If the persisted accounts no longer exist in Privy (different user // signed in, wallets removed), wipe the stale state so callers see a // clean disconnected state instead of ghost accounts without providers. if (accounts.length === 0) { clear() throw new ox_Provider.DisconnectedError({ message: 'Privy session no longer matches persisted accounts.', }) } store.setState({ accounts: accounts.map((account) => toStoreAccount(account)), activeAccount: Math.min(state.activeAccount, accounts.length - 1), }) })() try { await restore_promise } finally { restore_promise = undefined } } async function requireSession() { if (await hasValidSession()) return clear() throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' }) } async function getTempoAccount(address: Address | undefined) { await restore() await requireSession() const state = store.getState() const address_ = address ?? state.accounts[state.activeAccount]?.address if (!address_) throw new ox_Provider.DisconnectedError({ message: 'No accounts connected.' }) if (state.accounts.length === 0) throw new ox_Provider.DisconnectedError({ message: 'No Privy account connected.', }) const connected = state.accounts.some((account) => isAddressEqual(account.address, address_)) if (!connected) throw new ox_Provider.UnauthorizedError({ message: `Account "${address_}" not found.` }) const account = (walletAccounts ?? []).find((account) => isAddressEqual(core_Address.from(account.address), address_), ) if (account) return toTempoAccount(account) throw new ox_Provider.DisconnectedError({ message: 'Privy session no longer matches persisted accounts.', }) } async function signPayload(parameters: { payload: Hex.Hex walletAccount: privy.EmbeddedWallet }) { const { payload, walletAccount } = parameters const result = await walletAccount.provider .request({ method: 'secp256k1_sign', params: [payload] }) .catch((error) => { const code = getPrivyErrorCode(error) const message = getPrivyErrorMessage(error).toLowerCase() const unsupported = (typeof code === 'number' && (code === 4200 || code === -32601)) || (typeof code === 'string' && code.toLowerCase().includes('unsupported')) || message.includes('unsupported') || message.includes('method not found') if (unsupported) throw new ox_Provider.UnsupportedMethodError({ message: 'Privy adapter requires raw secp256k1 hash signing via `secp256k1_sign` for Tempo transactions and access keys.', }) if (isSessionError(error)) { clear() throw new ox_Provider.DisconnectedError({ message: 'Privy session expired.' }) } throw error }) if (typeof result !== 'string' || !Hex.validate(result)) throw new ox_Provider.ProviderRpcError( -32603, 'Privy provider returned a non-hex secp256k1_sign result.', ) const signature: Hex.Hex = result // Verify Privy returned a signature for the wallet we asked. const expected = core_Address.from(walletAccount.address) const recovered = (() => { try { return Secp256k1.recoverAddress({ payload, signature: Signature.fromHex(signature) }) } catch { return undefined } })() if (!recovered || !isAddressEqual(recovered, expected)) throw new ox_Provider.UnauthorizedError({ message: `Privy provider returned a signature for "${recovered ?? 'unknown'}" that does not match the requested wallet "${expected}".`, }) return signature } async function signTransaction(parameters: Adapter.signTransaction.Parameters) { const account = await getTempoAccount(parameters.from) const { feePayer, ...rest } = parameters const viemClient = getClient({ chainId: parameters.chainId, feePayer: feePayer === true ? undefined : feePayer, }) const prepared = await prepareTransactionRequest(viemClient, { account: account.address, ...rest, ...(feePayer ? { feePayer: true } : {}), type: 'tempo', } as never) return await account.signTransaction(prepared) } async function privySignTransaction(parameters: { sign: (parameters: { hash: Hex.Hex }) => Promise<Hex.Hex> transaction: unknown }) { const { sign, transaction } = parameters const presign = (() => { if ( transaction && typeof transaction === 'object' && 'feePayerSignature' in transaction && transaction.feePayerSignature ) return { ...transaction, feePayerSignature: null } return transaction })() const unsignedTransaction = await TempoTransaction.serialize(presign as never) const signature = await sign({ hash: keccak256(unsignedTransaction) }) return await TempoTransaction.serialize( transaction as never, SignatureEnvelope.from(Signature.fromHex(signature)) as never, ) } async function connectAccounts(parameters: { addresses: privy.AccountSelection authorizeAccessKey?: Adapter.authorizeAccessKey.Parameters | undefined digest?: Hex.Hex | undefined label?: string | undefined noAccountsMessage?: string | undefined personalSign?: { message: string } | undefined privyClient: privy.Client }) { const { addresses, authorizeAccessKey, label, personalSign, privyClient } = parameters await requireSession() const wallets = await loadEthereumWallets(privyClient) const selected = selectWalletAccounts(wallets, addresses) const account = selected[0] ? toTempoAccount(selected[0]) : undefined if (!account && parameters.noAccountsMessage) throw new ox_Provider.DisconnectedError({ message: parameters.noAccountsMessage, }) const digest = personalSign ? hashMessage(personalSign.message) : parameters.digest const keyAuthorization = authorizeAccessKey && account ? await AccessKey.authorize({ account, chainId: getClient().chain.id, parameters: authorizeAccessKey, store, }) : undefined const signature = digest && account ? await account.sign({ hash: digest }) : undefined walletAccounts = wallets restore_promise = undefined return { accounts: selected.map((account, index) => toStoreAccount(account, index === 0 ? label : undefined), ), ...(personalSign ? { personalSign: { message: personalSign.message } } : {}), ...(keyAuthorization ? { keyAuthorization } : {}), signature, } } async function prepareTransaction(parameters: Adapter.signTransaction.Parameters) { const viemClient = getClient({ chainId: parameters.chainId, feePayer: parameters.feePayer === true ? undefined : parameters.feePayer, }) const state = store.getState() const address = parameters.from ?? state.accounts[state.activeAccount]?.address const transaction = address ? await AccessKeyTransaction.create({ address, calls: parameters.calls, chainId: parameters.chainId ?? state.chainId, client: viemClient, store, }) : undefined if (transaction) { const { feePayer, ...rest } = parameters try { return await transaction.prepare({ ...rest, ...(feePayer ? { feePayer: true } : {}), }) } catch {} } async function sign() { return await signTransaction(parameters) } return { request: undefined as never, sign, async send() { const signed = await sign() return await viemClient.request({ method: 'eth_sendRawTransaction' as never, params: [signed], }) }, async sendSync() { const signed = await sign() return await viemClient.request({ method: 'eth_sendRawTransactionSync' as never, params: [signed], }) }, } } function isSessionError(error: unknown) { const code = getPrivyErrorCode(error) if (typeof code === 'string') { const normalized = code.toLowerCase() if (privySessionErrorCodes.has(normalized)) return true if (normalized.includes('session')) return true if (normalized.includes('before_logged_in')) return true } const message = getPrivyErrorMessage(error).toLowerCase() return ( message.includes('missing privy token') || message.includes('must be logged in') || message.includes('not authenticated') || message.includes('not logged in') || message.includes('session expired') ) } function getPrivyErrorCode(error: unknown): string | number | undefined { if (!isObject(error)) return undefined if (typeof error.code === 'string' || typeof error.code === 'number') return error.code if (typeof error.error_code === 'string' || typeof error.error_code === 'number') return error.error_code if (typeof error.errorCode === 'string' || typeof error.errorCode === 'number') return error.errorCode return getPrivyErrorCode(error.cause) } function getPrivyErrorMessage(error: unknown): string { if (error instanceof Error) { const caused = getPrivyErrorMessage(error.cause) return caused ? `${error.message} ${caused}` : error.message } if (!isObject(error)) return '' const own = (typeof error.message === 'string' && error.message) || (typeof error.error === 'string' && error.error) || '' const caused = getPrivyErrorMessage(error.cause) if (own && caused) return `${own} ${caused}` return own || caused } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null } return { cleanup() {}, actions: { async createAccount(parameters) { const { authorizeAccessKey, personalSign } = parameters if (personalSign && parameters.digest) throw new ox_Provider.ProviderRpcError( -32602, '`digest` and `personalSign` cannot both be set on `wallet_connect`.', ) const privyClient = await getPrivyClient() const addresses = options.createAccount ? await options.createAccount({ client: privyClient, parameters }) : await options.loadAccounts({ client: privyClient, parameters: { ...(authorizeAccessKey ? { authorizeAccessKey } : {}), ...(parameters.digest ? { digest: parameters.digest } : {}), ...(personalSign ? { personalSign } : {}), }, }) return await connectAccounts({ addresses, ...(authorizeAccessKey ? { authorizeAccessKey } : {}), ...(parameters.digest ? { digest: parameters.digest } : {}), label: parameters.name, ...(personalSign ? { personalSign } : {}), privyClient, noAccountsMessage: 'Privy returned no wallet.', }) }, async loadAccounts(parameters) { const { authorizeAccessKey, personalSign } = parameters ?? ({} as Adapter.loadAccounts.Parameters) if (personalSign && parameters?.digest) throw new ox_Provider.ProviderRpcError( -32602, '`digest` and `personalSign` cannot both be set on `wallet_connect`.', ) const privyClient = await getPrivyClient() const addresses = await options.loadAccounts({ client: privyClient, parameters }) return await connectAccounts({ addresses, ...(authorizeAccessKey ? { authorizeAccessKey } : {}), ...(parameters?.digest ? { digest: parameters.digest } : {}), ...(personalSign ? { personalSign } : {}), privyClient, }) }, async authorizeAccessKey(parameters) { const account = await getTempoAccount(undefined) const keyAuthorization = await AccessKey.authorize({ account, chainId: getClient().chain.id, parameters, store, }) return { keyAuthorization, rootAddress: account.address } }, async revokeAccessKey(parameters) { const account = await getTempoAccount(parameters.address) try { await Actions.accessKey.revoke(getClient(), { account: account as LocalAccount<'privy'>, accessKey: parameters.accessKeyAddress, }) } catch (error) { if (!AccessKey.isUnavailableError(error)) throw error } AccessKey.remove({ accessKey: parameters.accessKeyAddress, account: account.address, chainId: store.getState().chainId, store, }) }, async signPersonalMessage(parameters) { return await ( await getTempoAccount(parameters.address) ).sign({ hash: hashMessage({ raw: parameters.data }), }) }, async signTransaction(parameters) { return await (await prepareTransaction(parameters)).sign() }, async signTypedData(parameters) { const typedData = JSON.parse(parameters.data) as { domain: Record<string, unknown> message: Record<string, unknown> primaryType: string types: Record<string, unknown> } return await ( await getTempoAccount(parameters.address) ).sign({ hash: hashTypedData(typedData as never), }) }, async sendTransaction(parameters) { return await (await prepareTransaction(parameters)).send() }, async sendTransactionSync(parameters) { return await (await prepareTransaction(parameters)).sendSync() }, async disconnect() { try { const privyClient = await getPrivyClient() const userId = await privyClient.user .get() .then(({ user }) => user.id) .catch(() => undefined) await privyClient.auth.logout(userId ? { userId } : undefined) } finally { clear() } }, }, } }) } export declare namespace privy { /** Options for {@link privy}. */ type Options<client extends Client = Client> = { /** Existing Privy client, such as `Privy` from `@privy-io/js-sdk-core`. */ client: client /** * Runs the Privy registration UI. May optionally return a subset of the user's * embedded wallet addresses to expose to the provider; if omitted, the adapter * exposes every embedded wallet on the resulting Privy user. * * The adapter materializes EIP-1193 providers internally via * `client.embeddedWallet.getEthereumProvider` — callbacks should not. * * Defaults to `loadAccounts` — apps that don't distinguish register vs login * can omit this. */ createAccount?: | ((parameters: { /** Initialized Privy client. */ client: client /** Provider create-account parameters. */ parameters: Adapter.createAccount.Parameters }) => Promise<AccountSelection>) | undefined /** Data URI of the provider icon. @default Black 1×1 SVG. */ icon?: `data:image/${string}` | undefined /** * Runs the Privy login UI in response to a user-initiated `wallet_connect`. * May optionally return a subset of the user's embedded wallet addresses to * expose to the provider; if omitted, the adapter exposes every embedded * wallet on the Privy user. * * Silent restore on page reload pulls wallets directly from the Privy SDK * (`client.user.get` + `client.embeddedWallet.getEthereumProvider`) and does * NOT call this function. */ loadAccounts: (parameters: { /** Initialized Privy client. */ client: client /** Provider load-accounts parameters. */ parameters?: Adapter.loadAccounts.Parameters | undefined }) => Promise<AccountSelection> /** Display name of the provider. @default "Privy" */ name?: string | undefined /** Reverse DNS identifier. @default "io.privy" */ rdns?: string | undefined } /** * Optional subset of embedded wallet addresses returned from `createAccount` / * `loadAccounts`. `void`/`undefined` means "expose every embedded wallet". */ type AccountSelection = readonly Address[] | void /** * Minimal structural Privy client surface used by the adapter for session checks, * silent restore, and disconnect. User-initiated `wallet_connect`/registration * is delegated to the app's `loadAccounts` / `createAccount` callbacks. * * Satisfied by `Privy` from `@privy-io/js-sdk-core` — apps pass the SDK instance * directly. The adapter never imports `@privy-io/js-sdk-core` itself; the structural * shape keeps the dependency one-way. */ type Client = { /** Auth API; the adapter only needs `logout`. */ auth: { /** * Clears the current Privy session. The adapter passes the current user id * (when available) so multi-tab/multi-user setups scope the logout correctly. */ logout: (parameters?: { userId: string } | undefined) => Promise<void> | void } /** Embedded wallet API used by the adapter to materialize EIP-1193 providers. */ embeddedWallet: { /** Returns an EIP-1193 provider for a Privy embedded Ethereum wallet. */ getEthereumProvider(parameters: { wallet: LinkedAccount entropyId: string entropyIdVerifier: string }): Promise<EthereumProvider> | EthereumProvider } /** Returns the current Privy access token, or `null` if no session. */ getAccessToken: () => Promise<string | null> /** Initializes the client. Called once by the adapter, before any other method. */ initialize?: (() => Promise<void> | void) | undefined /** User API used by the adapter to scope `auth.logout` and to silently restore wallets. */ user: { /** Returns the currently authenticated Privy user. */ get: () => Promise<{ user: User }> } } /** Minimal Privy user shape used by the adapter for silent restore. */ type User = { id: string linked_accounts?: readonly LinkedAccount[] | undefined } /** Minimal Privy linked account shape used by the adapter for silent restore. */ type LinkedAccount = { address?: string | undefined chain_type?: string | undefined connector_type?: string | undefined type?: string | undefined wallet_client_type?: string | undefined wallet_index?: number | undefined } /** Minimal EIP-1193 provider surface used by the adapter for `secp256k1_sign`. */ type EthereumProvider = { request(parameters: { method: string params?: readonly unknown[] | undefined }): Promise<unknown> } /** * Materialized Privy embedded wallet — the `{ address, provider }` shape the * adapter caches internally after calling * `client.embeddedWallet.getEthereumProvider`. The adapter calls * `provider.request({ method: 'secp256k1_sign', params: [hash] })` for signing. */ type EmbeddedWallet = { address: string provider: EthereumProvider } }