UNPKG

accounts

Version:

Tempo Accounts SDK

375 lines (344 loc) 13.9 kB
import { Provider as ox_Provider, type WebCryptoP256 } from 'ox' import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo' import { hashMessage } from 'viem' import { prepareTransactionRequest } from 'viem/actions' import { Account as TempoAccount, Actions } from 'viem/tempo' import * as z from 'zod/mini' import * as AccessKey from '../AccessKey.js' import * as Account from '../Account.js' import * as Adapter from '../Adapter.js' import * as AccessKeyTransaction from '../internal/AccessKeyTransaction.js' import * as u from '../zod/utils.js' const secp256k1Schema = z.object({ address: u.address(), keyType: z.literal('secp256k1'), label: z.optional(z.string()), privateKey: u.hex(), }) const p256Schema = z.object({ address: u.address(), keyType: z.literal('p256'), label: z.optional(z.string()), privateKey: u.hex(), }) const webAuthnSchema = z.object({ address: u.address(), credential: z.object({ id: z.string(), publicKey: u.hex(), rpId: z.string(), }), keyType: z.literal('webAuthn'), label: z.optional(z.string()), }) const webAuthnHeadlessSchema = z.object({ address: u.address(), keyType: z.literal('webAuthn_headless'), label: z.optional(z.string()), origin: z.string(), privateKey: u.hex(), rpId: z.string(), }) const webCryptoSchema = z.object({ address: u.address(), keyPair: z.custom<Awaited<ReturnType<typeof WebCryptoP256.createKeyPair>>>(), keyType: z.literal('webCrypto'), label: z.optional(z.string()), }) const functionSignerSchema = z.object({ address: u.address(), keyType: z.union([z.literal('secp256k1'), z.literal('p256'), z.literal('webAuthn')]), label: z.optional(z.string()), sign: z.custom<TempoAccount.Account['sign']>(), }) const signableSchema = z.union([ secp256k1Schema, p256Schema, webAuthnSchema, webAuthnHeadlessSchema, webCryptoSchema, functionSignerSchema, ]) /** * Creates a local adapter where the app manages keys and signing in-process. * * @example * ```ts * import { local, Provider } from 'accounts' * * const Provider = Provider.create({ * adapter: local({ * loadAccounts: async () => ({ * accounts: [{ address: '0x...' }], * }), * }), * }) * ``` */ export function local(options: local.Options): Adapter.Adapter { const { createAccount, icon, loadAccounts, name, rdns } = options return Adapter.define( { icon, name, rdns, schema: signableSchema }, ({ getAccount, getClient, store }) => { async function prepareTransaction(parameters: Adapter.signTransaction.Parameters) { const { feePayer, ...rest } = parameters const client = getClient({ chainId: parameters.chainId, feePayer: (() => { if (feePayer === false) return false if (typeof feePayer === 'string') return feePayer return undefined })(), }) const request = { ...rest, ...(feePayer ? { feePayer: true as const } : {}), } 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, store, }) : undefined if (transaction) { try { return await transaction.prepare(request) } catch {} } const account = getAccount({ address: parameters.from, signable: true, }) const prepared = await prepareTransactionRequest(client, { account, ...request, type: 'tempo', }) async function sign() { return await account.signTransaction(prepared as never) } return { request: prepared, sign, async send() { const signed = await sign() return (await client.request({ method: 'eth_sendRawTransaction' as never, params: [signed], })) as Adapter.sendTransaction.ReturnType }, async sendSync() { const signed = await sign() return (await client.request({ method: 'eth_sendRawTransactionSync' as never, params: [signed], })) as Adapter.sendTransactionSync.ReturnType }, } } return { actions: { async createAccount(parameters) { if (!createAccount) throw new ox_Provider.UnsupportedMethodError({ message: '`createAccount` not configured on adapter.', }) const { authorizeAccessKey: grantOptions, personalSign, ...rest } = parameters // `personalSign` claims the ceremony's challenge slot. It conflicts // with a caller-supplied `digest` because both target the single // WebAuthn challenge in the create-account ceremony. if (personalSign && rest.digest) throw new ox_Provider.ProviderRpcError( -32602, '`digest` and `personalSign` cannot both be set on `wallet_connect`.', ) const peronsalSign_digest = personalSign ? hashMessage(personalSign.message) : undefined const digest = peronsalSign_digest ?? rest.digest const { accounts, email, signature, username } = await createAccount({ ...rest, digest, }) // Hydrate the first account for signing. Must be done here (not via // the store) because accounts aren't merged into the store until // Provider.ts processes the return value. const account = Account.hydrate(accounts[0]!, { signable: true }) // If the caller requested a digest signature but the adapter didn't // produce one (e.g. secp256k1 adapters), sign it ourselves. const signature_ = digest && !signature ? await account.sign({ hash: digest }) : signature const keyAuthorization = await (async () => { if (!grantOptions) return undefined return await AccessKey.authorize({ account, chainId: getClient().chain.id, parameters: grantOptions, store, }) })() return { accounts, email, keyAuthorization, signature: signature_, username, ...(personalSign ? { personalSign: { message: personalSign.message } } : {}), } }, async authorizeAccessKey(parameters) { const account = getAccount({ signable: true }) const keyAuthorization = await AccessKey.authorize({ account, chainId: getClient().chain.id, parameters, store, }) return { keyAuthorization, rootAddress: account.address } }, async loadAccounts(parameters) { const { authorizeAccessKey, personalSign, ...rest } = parameters ?? ({} as Adapter.loadAccounts.Parameters) // `personalSign` claims the ceremony's challenge slot. It conflicts // with a caller-supplied `digest` because both target the single // WebAuthn challenge in the load-accounts ceremony. if (personalSign && rest.digest) throw new ox_Provider.ProviderRpcError( -32602, '`digest` and `personalSign` cannot both be set on `wallet_connect`.', ) const peronsalSign_digest = personalSign ? hashMessage(personalSign.message) : undefined const keyAuthorization_unsigned = authorizeAccessKey ? await AccessKey.prepareAuthorization({ ...authorizeAccessKey, chainId: authorizeAccessKey.chainId ?? getClient().chain.id, }) : undefined const keyAuthorization_digest = keyAuthorization_unsigned ? KeyAuthorization.getSignPayload(keyAuthorization_unsigned.keyAuthorization) : undefined // Slot allocation: // 1. `personalSign` digest, if present. // 2. Else unsigned key-auth digest (existing 1-prompt fold for `authorizeAccessKey`). // 3. Else caller's `rest.digest`. // When BOTH `personalSign` and `authorizeAccessKey` are present, // `personalSign` wins the load-accounts ceremony and the key // authorization gets its own follow-up `account.sign` ceremony // (2 prompts total). const digest = peronsalSign_digest ?? keyAuthorization_digest ?? rest.digest // Pass the prepared digest (or the caller's) into loadAccounts so // the ceremony can sign it in a single biometric prompt. const { accounts, email, signature, username } = await loadAccounts({ ...rest, digest }) // Hydrate here (not from the store) — same reason as createAccount. // Guard against empty accounts (e.g. user cancelled the ceremony). const account = accounts[0] ? Account.hydrate(accounts[0], { signable: true }) : undefined // Fall back to local signing if the adapter didn't return a signature. let signature_ = signature if (digest && !signature_ && account) signature_ = await account.sign({ hash: digest }) // Key auth signing path: // - If `personalSign` took the ceremony slot AND `authorizeAccessKey` // is set, we need a SECOND ceremony to sign the key-auth digest. // - Else (key-auth digest took the slot), reuse `signature_`. const keyAuthorization = await (async () => { if (!keyAuthorization_unsigned || !account) return undefined const signature_keyAuthorization = peronsalSign_digest || !signature_ ? await account.sign({ hash: keyAuthorization_digest! }) : signature_ const keyAuthorization = KeyAuthorization.from( keyAuthorization_unsigned.keyAuthorization, { signature: SignatureEnvelope.from(signature_keyAuthorization), }, ) AccessKey.add({ account: account.address, authorization: keyAuthorization, ...(keyAuthorization_unsigned.keyPair ? { keyPair: keyAuthorization_unsigned.keyPair } : {}), store, }) return KeyAuthorization.toRpc(keyAuthorization) })() return { accounts, email, keyAuthorization, signature: signature_, username, ...(personalSign ? { personalSign: { message: personalSign.message } } : {}), } }, async revokeAccessKey(parameters) { const account = getAccount({ signable: true }) const client = getClient() try { await Actions.accessKey.revoke(client, { account, accessKey: parameters.accessKeyAddress, }) } catch (error) { if (!AccessKey.isUnavailableError(error)) throw error } AccessKey.remove({ accessKey: parameters.accessKeyAddress, account: account.address, chainId: client.chain.id, store, }) }, async signPersonalMessage({ data, address }) { const account = getAccount({ address, signable: true }) return await account.signMessage({ message: { raw: data } }) }, async signTransaction(parameters) { const prepared = await prepareTransaction(parameters) return await prepared.sign() }, async signTypedData({ data, address }) { const account = getAccount({ address, signable: true }) const parsed = JSON.parse(data) as { domain: Record<string, unknown> message: Record<string, unknown> primaryType: string types: Record<string, unknown> } return await account.signTypedData(parsed) }, async sendTransaction(parameters) { const prepared = await prepareTransaction(parameters) return await prepared.send() }, async sendTransactionSync(parameters) { const prepared = await prepareTransaction(parameters) return await prepared.sendSync() }, }, } }, ) } export declare namespace local { type Options = { /** Create a new account. Optional — omit for login-only flows. */ createAccount?: | ((params: Adapter.createAccount.Parameters) => Promise<Adapter.createAccount.ReturnType>) | undefined /** Discover existing accounts (e.g. WebAuthn assertion). */ loadAccounts: ( params?: Adapter.loadAccounts.Parameters | undefined, ) => Promise<Adapter.loadAccounts.ReturnType> /** Data URI of the provider icon. @default Black 1×1 SVG. */ icon?: `data:image/${string}` | undefined /** Display name of the provider (e.g. `"My Wallet"`). @default "Injected Wallet" */ name?: string | undefined /** Reverse DNS identifier. @default `com.{lowercase name}` */ rdns?: string | undefined } }