UNPKG

accounts

Version:

Tempo Accounts SDK

690 lines (625 loc) 25 kB
import { Address as core_Address, Bytes, Hex, Provider as ox_Provider, PublicKey, RpcResponse, Secp256k1, } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { hashMessage, hashTypedData, isAddressEqual } from 'viem' import type { Address } from 'viem/accounts' import { prepareTransactionRequest } from 'viem/actions' import { Account as TempoAccount } from 'viem/tempo' import * as AccessKey from '../AccessKey.js' import * as Adapter from '../Adapter.js' import * as Store from '../Store.js' const turnkeySessionErrorCodes = new Set([ 'API_KEY_EXPIRED', 'NO_SESSION_FOUND', 'REQUEST_NOT_AUTHORIZED', 'SESSION_EXPIRED', 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'UNAUTHENTICATED', 'UNAUTHORIZED', ]) /** * Creates a Turnkey adapter backed by `@turnkey/core` client sessions and Ethereum wallet accounts. * * The adapter owns silent reconnect, session-expiry cleanup, and provider signing actions. * Apps provide the UI-bearing login or sign-up flow through `loadAccounts`. The adapter * fetches Ethereum wallet accounts from Turnkey after the flow completes. Provide * `createAccount` only when registration needs a distinct Turnkey flow. * * @example * ```ts * import { TurnkeyClient, generateWalletAccountsFromAddressFormat } from '@turnkey/core' * import { Provider, turnkey } from 'accounts' * * const provider = Provider.create({ * adapter: turnkey({ * client: new TurnkeyClient({ organizationId, authProxyConfigId }), * createAccount: async ({ client, parameters }) => { * await client.signUpWithPasskey({ * passkeyDisplayName: parameters.name, * createSubOrgParams: { * userName: parameters.name, * customWallet: { * walletName: 'FooBar', * walletAccounts: generateWalletAccountsFromAddressFormat({ * addresses: ['ADDRESS_FORMAT_ETHEREUM'], * }), * }, * }, * }) * }, * loadAccounts: async ({ client }) => { * await client.loginWithPasskey() * }, * }), * }) * ``` */ export function turnkey<const client extends turnkey.Client>( options: turnkey.Options<client>, ): Adapter.Adapter { const { icon, name = 'Turnkey', rdns = 'com.turnkey', sessionSkewMs = 10_000 } = options return Adapter.define({ icon, name, rdns }, ({ getAccount, getClient, store }) => { let turnkeyClient_promise: Promise<client> | undefined let expiry_timeout: ReturnType<typeof setTimeout> | undefined let restore_promise: Promise<void> | undefined let walletAccounts: readonly turnkey.WalletAccount[] = [] async function getTurnkeyClient(): Promise<client> { turnkeyClient_promise ??= (async () => { const { client } = options await client.init?.() return client })() return await turnkeyClient_promise } function toStoreAccount(account: turnkey.WalletAccount, label?: string | undefined) { return { address: core_Address.from(account.address), ...(label ? { label } : {}), } } function toTempoAccount(account: turnkey.WalletAccount): TempoAccount.Account { const publicKey = toPublicKey(account) assertAddress(account, publicKey) const sign = async (parameters: { hash: Hex.Hex }) => await signPayload({ payload: parameters.hash, turnkeyClient: await getTurnkeyClient(), walletAccount: account, }) return TempoAccount.from({ keyType: 'secp256k1', publicKey, sign, }) } function toPublicKey(account: turnkey.WalletAccount) { const publicKey = account.publicKey.startsWith('0x') ? account.publicKey : `0x${account.publicKey}` Hex.assert(publicKey, { strict: true }) return PublicKey.from(Secp256k1.noble.ProjectivePoint.fromHex(Bytes.fromHex(publicKey))) } function assertAddress(account: turnkey.WalletAccount, publicKey: PublicKey.PublicKey) { const address = core_Address.from(account.address) const address_publicKey = core_Address.fromPublicKey(publicKey) if (isAddressEqual(address, address_publicKey)) return throw new RpcResponse.InternalError({ message: `Turnkey account publicKey does not match address "${address}".`, }) } async function fetchWalletAccounts(): Promise<readonly turnkey.WalletAccount[]> { const turnkeyClient = await getTurnkeyClient() return (await turnkeyClient.fetchWallets()).flatMap((wallet) => wallet.accounts.filter((account) => account.addressFormat === 'ADDRESS_FORMAT_ETHEREUM'), ) } function selectWalletAccounts( accounts: readonly turnkey.WalletAccount[], addresses: turnkey.AccountSelection, ) { 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 RpcResponse.InternalError({ message: `Turnkey callback returned address "${address_}" that was not found in fetched wallet accounts.`, }) }) } function clear() { if (expiry_timeout) clearTimeout(expiry_timeout) expiry_timeout = undefined restore_promise = undefined walletAccounts = [] store.setState({ accessKeys: [], accounts: [], activeAccount: 0 }) } function scheduleExpiry(session: turnkey.Session) { if (expiry_timeout) clearTimeout(expiry_timeout) expiry_timeout = undefined const delay = Math.max(session.expiry * 1000 - Date.now() - sessionSkewMs, 0) expiry_timeout = setTimeout(() => clear(), delay) } async function getValidSession() { const turnkeyClient = await getTurnkeyClient() const session = await turnkeyClient.getSession() if (!session || session.expiry * 1000 - sessionSkewMs <= Date.now()) { clear() return undefined } scheduleExpiry(session) return session } async function restore() { await Store.waitForHydration(store) if (walletAccounts.length > 0) return if (restore_promise) return await restore_promise restore_promise = (async () => { const state = store.getState() const persisted = state.accounts if (persisted.length === 0) return const session = await getValidSession() if (!session) return const restored = await fetchWalletAccounts() walletAccounts = persisted .map((account) => restored.find((walletAccount) => isAddressEqual(core_Address.from(walletAccount.address), account.address), ), ) .filter((account): account is turnkey.WalletAccount => !!account) if (walletAccounts.length === 0) return store.setState({ accounts: walletAccounts.map((account) => toStoreAccount(account)), activeAccount: Math.min(state.activeAccount, walletAccounts.length - 1), }) })() try { await restore_promise } finally { restore_promise = undefined } } async function requireSession() { const session = await getValidSession() if (!session) throw new ox_Provider.DisconnectedError({ message: 'Turnkey session expired.' }) } async function accountForSigning(address: Address | undefined) { await restore() await requireSession() const address_ = address ?? store.getState().accounts[store.getState().activeAccount]?.address if (!address_) throw new ox_Provider.DisconnectedError({ message: 'No accounts connected.' }) const account = walletAccounts.find((account) => isAddressEqual(core_Address.from(account.address), address_), ) if (account) return account if (walletAccounts.length === 0) throw new ox_Provider.DisconnectedError({ message: 'No Turnkey account connected.', }) throw new ox_Provider.UnauthorizedError({ message: `Account "${address_}" not found.` }) } function signatureToHex(value: turnkey.SignatureResponse): Hex.Hex { const v = value.v.startsWith('0x') ? (value.v as Hex.Hex) : Hex.fromNumber(Number(value.v)) return Hex.concat(value.r as Hex.Hex, value.s as Hex.Hex, Hex.padLeft(v, 1)) } async function signPayload(parameters: { payload: Hex.Hex turnkeyClient: turnkey.Client walletAccount: turnkey.WalletAccount }) { const { payload, turnkeyClient, walletAccount } = parameters const result = await turnkeyClient.httpClient .signRawPayload({ encoding: 'PAYLOAD_ENCODING_HEXADECIMAL', hashFunction: 'HASH_FUNCTION_NO_OP', payload, signWith: walletAccount.address, }) .catch((error) => { if (!isSessionError(error)) throw error clear() throw new ox_Provider.DisconnectedError({ message: 'Turnkey session expired.' }) }) return signatureToHex(result) } async function withAccessKey<result>( options: { address?: Address | undefined calls?: Adapter.signTransaction.Parameters['calls'] chainId?: number | undefined }, fn: ( account: TempoAccount.Account, keyAuthorization?: KeyAuthorization.Signed, ) => Promise<result>, ): Promise<{ account: TempoAccount.Account; result: result } | undefined> { const account = (() => { try { return getAccount({ ...options, signable: true }) } catch { return undefined } })() if (!account || account.source !== 'accessKey') return undefined const keyAuthorization = AccessKey.getPending(account, { store }) try { const result = await fn(account, keyAuthorization ?? undefined) return { account, result } } catch (error) { AccessKey.invalidate(account, error, { store }) return undefined } } async function signTransaction(parameters: Adapter.signTransaction.Parameters) { const account = toTempoAccount(await accountForSigning(parameters.from)) const { feePayer, ...rest } = parameters const viemClient = getClient({ chainId: parameters.chainId, feePayer: feePayer === true ? undefined : feePayer, }) const prepared = await prepareTransactionRequest(viemClient, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), type: 'tempo', } as never) return await account.signTransaction(prepared as never) } function isSessionError(error: unknown) { const code = getTurnkeyErrorCode(error) return !!code && turnkeySessionErrorCodes.has(code) } function getTurnkeyErrorCode(error: unknown): string | undefined { if (!isObject(error)) return undefined if (typeof error.code === 'string') return error.code if (Array.isArray(error.details)) { for (const detail of error.details) { if (!isObject(detail)) continue if (typeof detail.turnkeyErrorCode === 'string') return detail.turnkeyErrorCode } } return getTurnkeyErrorCode(error.cause) } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null } void restore() return { cleanup() { if (expiry_timeout) clearTimeout(expiry_timeout) }, 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 turnkeyClient = await getTurnkeyClient() const addresses = options.createAccount ? await options.createAccount({ client: turnkeyClient, parameters }) : await options.loadAccounts({ client: turnkeyClient, parameters: { authorizeAccessKey, digest: parameters.digest, ...(personalSign ? { personalSign } : {}), }, }) await requireSession() walletAccounts = selectWalletAccounts(await fetchWalletAccounts(), addresses) restore_promise = undefined const digest = personalSign ? hashMessage(personalSign.message) : parameters.digest const account = walletAccounts[0] const keyAuthorization = authorizeAccessKey ? account ? await AccessKey.authorize({ account: toTempoAccount(account), chainId: getClient().chain.id, parameters: authorizeAccessKey, store, }) : undefined : undefined return { accounts: walletAccounts.map((account, index) => toStoreAccount(account, index === 0 ? parameters.name : undefined), ), ...(personalSign ? { personalSign: { message: personalSign.message } } : {}), ...(keyAuthorization ? { keyAuthorization } : {}), signature: digest && account ? await signPayload({ payload: digest, turnkeyClient, walletAccount: account, }) : undefined, } }, 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 turnkeyClient = await getTurnkeyClient() const addresses = await options.loadAccounts({ client: turnkeyClient, parameters }) await requireSession() walletAccounts = selectWalletAccounts(await fetchWalletAccounts(), addresses) restore_promise = undefined const digest = personalSign ? hashMessage(personalSign.message) : parameters?.digest const account = walletAccounts[0] const keyAuthorization = authorizeAccessKey && account ? await AccessKey.authorize({ account: toTempoAccount(account), chainId: getClient().chain.id, parameters: authorizeAccessKey, store, }) : undefined return { accounts: walletAccounts.map((account) => toStoreAccount(account)), ...(personalSign ? { personalSign: { message: personalSign.message } } : {}), ...(keyAuthorization ? { keyAuthorization } : {}), signature: digest && account ? await signPayload({ payload: digest, turnkeyClient, walletAccount: account, }) : undefined, } }, async authorizeAccessKey(parameters) { const account = await accountForSigning(undefined) const keyAuthorization = await AccessKey.authorize({ account: toTempoAccount(account), chainId: getClient().chain.id, parameters, store, }) return { keyAuthorization, rootAddress: core_Address.from(account.address) } }, async signPersonalMessage(parameters) { const turnkeyClient = await getTurnkeyClient() const account = await accountForSigning(parameters.address) return await signPayload({ payload: hashMessage({ raw: parameters.data }), turnkeyClient, walletAccount: account, }) }, async signTransaction(parameters) { const result = await withAccessKey( { address: parameters.from, calls: parameters.calls, chainId: parameters.chainId }, async (account, keyAuthorization) => { const { feePayer, ...rest } = parameters const viemClient = getClient({ chainId: parameters.chainId, feePayer: feePayer === true ? undefined : feePayer, }) const prepared = await prepareTransactionRequest(viemClient, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), keyAuthorization, type: 'tempo', } as never) return await account.signTransaction(prepared as never) }, ) if (result !== undefined) return result.result return await signTransaction(parameters) }, async signTypedData(parameters) { const turnkeyClient = await getTurnkeyClient() const account = await accountForSigning(parameters.address) const typedData = JSON.parse(parameters.data) as { domain: Record<string, unknown> message: Record<string, unknown> primaryType: string types: Record<string, unknown> } return await signPayload({ payload: hashTypedData(typedData as never), turnkeyClient, walletAccount: account, }) }, async sendTransaction(parameters) { const result = await withAccessKey( { address: parameters.from, calls: parameters.calls, chainId: parameters.chainId }, async (account, keyAuthorization) => { const { feePayer, ...rest } = parameters const viemClient = getClient({ chainId: parameters.chainId, feePayer: feePayer === true ? undefined : feePayer, }) const prepared = await prepareTransactionRequest(viemClient, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), keyAuthorization, type: 'tempo', } as never) const signed = await account.signTransaction(prepared as never) return await viemClient.request({ method: 'eth_sendRawTransaction' as never, params: [signed], }) }, ) if (result !== undefined) { AccessKey.removePending(result.account, { store }) return result.result } const signed = await signTransaction(parameters) const viemClient = getClient({ chainId: parameters.chainId, feePayer: parameters.feePayer === true ? undefined : parameters.feePayer, }) return await viemClient.request({ method: 'eth_sendRawTransaction' as never, params: [signed], }) }, async sendTransactionSync(parameters) { const result = await withAccessKey( { address: parameters.from, calls: parameters.calls, chainId: parameters.chainId }, async (account, keyAuthorization) => { const { feePayer, ...rest } = parameters const viemClient = getClient({ chainId: parameters.chainId, feePayer: feePayer === true ? undefined : feePayer, }) const prepared = await prepareTransactionRequest(viemClient, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), keyAuthorization, type: 'tempo', } as never) const signed = await account.signTransaction(prepared as never) return await viemClient.request({ method: 'eth_sendRawTransactionSync' as never, params: [signed], }) }, ) if (result !== undefined) { AccessKey.removePending(result.account, { store }) return result.result } const signed = await signTransaction(parameters) const viemClient = getClient({ chainId: parameters.chainId, feePayer: parameters.feePayer === true ? undefined : parameters.feePayer, }) return await viemClient.request({ method: 'eth_sendRawTransactionSync' as never, params: [signed], }) }, async disconnect() { await (await getTurnkeyClient()).logout() clear() }, }, } }) } export declare namespace turnkey { /** Options for {@link turnkey}. */ type Options<client extends Client = Client> = { /** Existing Turnkey client, such as `TurnkeyClient` from `@turnkey/core`. */ client: client /** * Creates/registers a Turnkey wallet account. UI is allowed. Defaults to `loadAccounts`. * May return selected addresses; the first address is treated as active by default. */ createAccount?: | ((parameters: { /** Initialized Turnkey 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 /** * Loads/logs into existing Turnkey wallet accounts. UI is allowed. May return selected * addresses; the first address is treated as active by default. */ loadAccounts: (parameters: { /** Initialized Turnkey client. */ client: client /** Provider load-accounts parameters. */ parameters?: Adapter.loadAccounts.Parameters | undefined }) => Promise<AccountSelection> /** Display name of the provider. @default "Turnkey" */ name?: string | undefined /** Reverse DNS identifier. @default "com.turnkey" */ rdns?: string | undefined /** Milliseconds before Turnkey session expiry to proactively disconnect. @default 10000 */ sessionSkewMs?: number | undefined } /** * Optional selected addresses returned from a Turnkey login/sign-up callback. * When omitted, all fetched Turnkey Ethereum accounts are used. When provided, * fetched accounts are ordered to match this list, and the first address is active by default. */ type AccountSelection = readonly Address[] | void /** Minimal structural Turnkey client surface used by the adapter. */ type Client = { /** Fetches wallets visible to the current Turnkey session. */ fetchWallets: () => Promise<readonly Wallet[]> /** Returns the current Turnkey session, if any. */ getSession: () => Promise<Session | null | undefined> /** Low-level Turnkey HTTP client. */ httpClient: { /** Signs a raw payload with Turnkey. */ signRawPayload: (parameters: SignRawPayloadParameters) => Promise<SignatureResponse> } /** Initializes the client. Called once by the adapter. */ init?: (() => Promise<void> | void) | undefined /** Clears the current Turnkey session. */ logout: () => Promise<void> | void } /** Minimal Turnkey session shape used by the adapter. */ type Session = { /** Session expiry in Unix seconds. */ expiry: number } /** Minimal structural Turnkey wallet shape used by the adapter. */ type Wallet = { /** Wallet accounts. */ accounts: readonly WalletAccount[] } /** Minimal structural Turnkey wallet account fetched by the adapter. */ type WalletAccount = { /** EVM address for the Turnkey wallet account. */ address: string /** Turnkey Ethereum address format. */ addressFormat?: 'ADDRESS_FORMAT_ETHEREUM' | undefined /** Raw compressed secp256k1 public key for the Turnkey wallet account. */ publicKey: string } /** Signature parts returned by Turnkey raw-payload signing. */ type SignatureResponse = { /** Signature r value. */ r: string /** Signature s value. */ s: string /** Signature recovery id/value. */ v: string } /** Parameters for low-level Turnkey raw payload signing. */ type SignRawPayloadParameters = { /** Payload encoding. */ encoding: 'PAYLOAD_ENCODING_HEXADECIMAL' /** Hash function Turnkey should apply. */ hashFunction: 'HASH_FUNCTION_NO_OP' /** Payload digest. */ payload: Hex.Hex /** Turnkey signer identifier. */ signWith: string } }