accounts
Version:
Tempo Accounts SDK
768 lines (705 loc) • 28.6 kB
text/typescript
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
}
}