UNPKG

accounts

Version:

Tempo Accounts SDK

535 lines 25.2 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 { 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(options) { const { icon, name = 'Privy', rdns = 'io.privy' } = options; return Adapter.define({ icon, name, rdns }, ({ getClient, store }) => { let privyClient_promise; let restore_promise; let walletAccounts; async function getPrivyClient() { privyClient_promise ??= (async () => { await options.client.initialize?.(); return options.client; })(); return await privyClient_promise; } function toStoreAccount(account, label) { return { address: core_Address.from(account.address), ...(label ? { label } : {}), }; } function toTempoAccount(account) { const address = core_Address.from(account.address); async function sign(parameters) { return await signPayload({ payload: parameters.hash, walletAccount: account, }); } return { address, sign, signTransaction: async (transaction) => await privySignTransaction({ sign, transaction }), 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) { 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; return await Promise.all(wallets.map(async (wallet) => ({ address: core_Address.from(wallet.address), provider: await privyClient.embeddedWallet.getEthereumProvider({ wallet, entropyId, entropyIdVerifier: 'ethereum-address-verifier', }), }))); } function selectWalletAccounts(accounts, addresses) { 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); // 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) { 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) { 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 = 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) { 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', }); return await account.signTransaction(prepared); } async function privySignTransaction(parameters) { 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); const signature = await sign({ hash: keccak256(unsignedTransaction) }); return await TempoTransaction.serialize(transaction, SignatureEnvelope.from(Signature.fromHex(signature))); } async function connectAccounts(parameters) { 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) { 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, sign, async send() { const signed = await sign(); return await viemClient.request({ method: 'eth_sendRawTransaction', params: [signed], }); }, async sendSync() { const signed = await sign(); return await viemClient.request({ method: 'eth_sendRawTransactionSync', params: [signed], }); }, }; } function isSessionError(error) { 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) { 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) { 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) { 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 ?? {}; 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, 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); return await (await getTempoAccount(parameters.address)).sign({ hash: hashTypedData(typedData), }); }, 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(); } }, }, }; }); } //# sourceMappingURL=privy.js.map