UNPKG

accounts

Version:

Tempo Accounts SDK

494 lines (444 loc) 16.7 kB
import { spawn } from 'node:child_process' import { setTimeout as sleep } from 'node:timers/promises' import { Address, Base64, Hash, Hex, P256, Provider as core_Provider, PublicKey, RpcResponse, } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { prepareTransactionRequest } from 'viem/actions' import { Account as TempoAccount, Secp256k1 } from 'viem/tempo' import * as z from 'zod/mini' import * as AccessKey from '../core/AccessKey.js' import * as Adapter from '../core/Adapter.js' import * as CliAuth from '../server/CliAuth.js' import * as Keyring from './keyring.js' /** * Creates a CLI bootstrap adapter backed by the device-code protocol. */ export function cli(options: cli.Options): Adapter.Adapter { const { name = 'Tempo CLI', rdns = 'xyz.tempo.cli' } = options return Adapter.define({ name, rdns }, ({ getAccount, getClient, store }) => { async function loadManagedKey( address: Adapter.authorizeAccessKey.ReturnType['rootAddress'], parameters: loadManagedKey.Options = {}, ): Promise<Keyring.Entry | undefined> { const { keyType } = parameters const { chainId } = store.getState() const entry = await Keyring.find({ chainId, ...(keyType ? { keyType } : {}), ...(options.keysPath ? { path: options.keysPath } : {}), walletAddress: address, }) if (!entry) return const deserialized = KeyAuthorization.deserialize(entry.keyAuthorization) if (!deserialized.signature) throw new Error('Managed access key is missing a signature.') const keyAuthorization = deserialized as KeyAuthorization.Signed AccessKey.save({ address, keyAuthorization, privateKey: entry.key, store, }) return entry } async function resolveManagedKey( options: { address?: Adapter.authorizeAccessKey.ReturnType['rootAddress'] | undefined keyType?: Adapter.authorizeAccessKey.Parameters['keyType'] | undefined } = {}, ): Promise<resolveManagedKey.ReturnType> { const { address, keyType } = options const requestedKeyType = keyType === 'p256' || keyType === 'secp256k1' ? keyType : undefined const entry = address ? await loadManagedKey(address, requestedKeyType ? { keyType: requestedKeyType } : {}) : undefined if (entry) { const account = entry.keyType === 'p256' ? TempoAccount.fromP256(entry.key, { access: address }) : TempoAccount.fromSecp256k1(entry.key, { access: address }) return { account, key: entry.key, keyAddress: entry.keyAddress, keyType: entry.keyType, publicKey: account.publicKey, } } const nextKeyType = requestedKeyType === 'p256' ? 'p256' : 'secp256k1' const key = nextKeyType === 'p256' ? P256.randomPrivateKey() : Secp256k1.randomPrivateKey() const account = nextKeyType === 'p256' ? TempoAccount.fromP256(key, address ? { access: address } : undefined) : TempoAccount.fromSecp256k1(key, address ? { access: address } : undefined) return { account, key, keyAddress: Address.fromPublicKey(PublicKey.from(account.publicKey)), keyType: nextKeyType, publicKey: account.publicKey, } } async function saveManagedKey( address: Adapter.authorizeAccessKey.ReturnType['rootAddress'], managedKey: Awaited<ReturnType<typeof resolveManagedKey>>, keyAuthorization: z.output<typeof CliAuth.keyAuthorization>, ) { if (!managedKey) return const signed = KeyAuthorization.fromRpc(z.encode(CliAuth.keyAuthorization, keyAuthorization)) AccessKey.save({ address, keyAuthorization: signed, privateKey: managedKey.key, store, }) await Keyring.upsert( { chainId: Number(keyAuthorization.chainId), expiry: keyAuthorization.expiry ?? 0, key: managedKey.key, keyAddress: managedKey.keyAddress, keyAuthorization: KeyAuthorization.serialize(signed), keyType: managedKey.keyType, ...(keyAuthorization.limits ? { limits: keyAuthorization.limits.map((limit) => ({ ...limit })) } : {}), walletAddress: address, walletType: 'passkey', }, options.keysPath ? { path: options.keysPath } : {}, ) } async function withManagedAccessKey<result>( fn: ( account: TempoAccount.Account, keyAuthorization?: KeyAuthorization.Signed | undefined, ) => Promise<result>, ) { const rootAddress = store.getState().accounts[store.getState().activeAccount]?.address if (rootAddress) await loadManagedKey(rootAddress) const account = getAccount({ signable: true }) const keyAuthorization = AccessKey.getPending(account, { store }) try { return await fn(account, keyAuthorization ?? undefined) } catch (error) { AccessKey.remove(account, { store }) throw error } } async function authorize(request: { account?: Adapter.authorizeAccessKey.ReturnType['rootAddress'] | undefined authorizeAccessKey: Adapter.authorizeAccessKey.Parameters | undefined method: 'wallet_authorizeAccessKey' | 'wallet_connect' }) { const { host, open = defaultOpen, pollIntervalMs = 2_000, timeoutMs = 5 * 60 * 1_000, } = options const { account, authorizeAccessKey, method } = request const managedKey = authorizeAccessKey && !authorizeAccessKey.publicKey && !authorizeAccessKey.address ? await resolveManagedKey({ ...(account ? { address: account } : {}), ...(authorizeAccessKey.keyType ? { keyType: authorizeAccessKey.keyType } : {}), }) : undefined const publicKey = authorizeAccessKey?.publicKey ?? managedKey?.publicKey const keyType = authorizeAccessKey?.keyType ?? managedKey?.keyType if (!publicKey) throw new RpcResponse.InvalidParamsError({ message: method === 'wallet_connect' ? '`wallet_connect` on the CLI adapter requires `capabilities.authorizeAccessKey`.' : '`wallet_authorizeAccessKey` on the CLI adapter requires key parameters.', }) const codeVerifier = createCodeVerifier() const codeChallenge = createCodeChallenge(codeVerifier) const body: z.output<typeof CliAuth.createRequest> = { ...(account ? { account } : {}), chainId: BigInt(store.getState().chainId), codeChallenge, ...(typeof authorizeAccessKey?.expiry !== 'undefined' ? { expiry: authorizeAccessKey.expiry } : {}), ...(keyType ? { keyType } : {}), ...(authorizeAccessKey?.limits ? { limits: authorizeAccessKey.limits } : {}), pubKey: publicKey, } const created = await post({ body, request: CliAuth.createRequest, response: CliAuth.createResponse, url: getApiUrl(host, 'code'), }) const url = getBrowserUrl(host, created.code) try { await open(url) } catch (error) { throw new OpenError(url, created.code, error) } const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const result = await post({ body: { codeVerifier, } satisfies z.output<typeof CliAuth.pollRequest>, request: CliAuth.pollRequest, response: CliAuth.pollResponse, url: getApiUrl(host, `poll/${created.code}`), }) if (result.status === 'pending') { await sleep(pollIntervalMs) continue } if (result.status === 'expired') throw new Error('Device code expired before authorization completed.') if (managedKey) await saveManagedKey(result.accountAddress, managedKey, result.keyAuthorization) return result } throw new TimeoutError(url, created.code) } return { actions: { async authorizeAccessKey(parameters) { const { accounts, activeAccount } = store.getState() const account = accounts[activeAccount]?.address const result = await authorize({ ...(account ? { account } : {}), authorizeAccessKey: parameters, method: 'wallet_authorizeAccessKey', }) if (!account) store.setState({ accounts: [{ address: result.accountAddress }], activeAccount: 0, }) return { keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization), rootAddress: result.accountAddress, } }, async createAccount(params, request) { return this.loadAccounts(params, request) }, async loadAccounts(parameters) { if (parameters?.digest) throw unsupported('`wallet_connect` digest signing not supported by CLI adapter.') const result = await authorize({ authorizeAccessKey: parameters?.authorizeAccessKey, method: 'wallet_connect', }) return { accounts: [ { address: result.accountAddress, capabilities: {}, }, ], keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization), } }, async revokeAccessKey() { throw unsupported('`wallet_revokeAccessKey` not supported by CLI adapter.') }, async sendTransaction(parameters) { const { feePayer, ...rest } = parameters const client = getClient(typeof feePayer === 'string' ? { feePayer } : {}) const { account, prepared } = await withManagedAccessKey( async (account, keyAuthorization) => ({ account, prepared: await prepareTransactionRequest(client, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), ...(keyAuthorization ? { keyAuthorization } : {}), type: 'tempo', } as never), }), ) const signed = await account.signTransaction(prepared as never) const result = await client.request({ method: 'eth_sendRawTransaction' as never, params: [signed], }) AccessKey.removePending(account, { store }) return result }, async sendTransactionSync(parameters) { const { feePayer, ...rest } = parameters const client = getClient(typeof feePayer === 'string' ? { feePayer } : {}) const { account, prepared } = await withManagedAccessKey( async (account, keyAuthorization) => ({ account, prepared: await prepareTransactionRequest(client, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), ...(keyAuthorization ? { keyAuthorization } : {}), type: 'tempo', } as never), }), ) const signed = await account.signTransaction(prepared as never) const result = await client.request({ method: 'eth_sendRawTransactionSync' as never, params: [signed], }) AccessKey.removePending(account, { store }) return result }, async signPersonalMessage({ address, data }) { await loadManagedKey(address) const account = getAccount({ address, signable: true }) return await account.signMessage({ message: { raw: data } }) }, async signTransaction(parameters) { const { feePayer, ...rest } = parameters const client = getClient(typeof feePayer === 'string' ? { feePayer } : {}) const { account, prepared } = await withManagedAccessKey( async (account, keyAuthorization) => ({ account, prepared: await prepareTransactionRequest(client, { account, ...rest, ...(feePayer ? { feePayer: true } : {}), ...(keyAuthorization ? { keyAuthorization } : {}), type: 'tempo', } as never), }), ) return await account.signTransaction(prepared as never) }, async signTypedData({ address, data }) { await loadManagedKey(address) const account = getAccount({ address, signable: true }) return await account.signTypedData(JSON.parse(data) as never) }, }, } }) } export declare namespace cli { export type Options = { /** Host URL for the device-code flow. API calls are made under the same base path. */ host: string /** Provider display name. @default "Tempo CLI" */ name?: string | undefined /** Path for managed CLI access keys. @default "~/.tempo/wallet/keys.toml" */ keysPath?: string | undefined /** Browser opener override. */ open?: ((url: string) => Promise<void> | void) | undefined /** Poll interval in milliseconds. @default 2000 */ pollIntervalMs?: number | undefined /** Reverse-DNS provider identifier. @default "xyz.tempo.cli" */ rdns?: string | undefined /** Poll timeout in milliseconds. @default 300000 */ timeoutMs?: number | undefined } } declare namespace resolveManagedKey { type ReturnType = { account: TempoAccount.Account key: Hex.Hex keyAddress: Keyring.Entry['keyAddress'] keyType: Keyring.Entry['keyType'] publicKey: Hex.Hex } } declare namespace loadManagedKey { type Options = { keyType?: Keyring.Entry['keyType'] | undefined } } class OpenError extends Error { code: string override cause?: unknown | undefined url: string constructor(url: string, code: string, cause?: unknown) { super(`Failed to open browser for device code ${formatCode(code)}. Open ${url} manually.`) this.name = 'OpenError' this.code = code this.cause = cause this.url = url } } class TimeoutError extends Error { code: string url: string constructor(url: string, code: string) { super(`Timed out waiting for device code ${formatCode(code)}. Continue at ${url}.`) this.name = 'TimeoutError' this.code = code this.url = url } } function createCodeChallenge(codeVerifier: string) { return Base64.fromBytes(Hash.sha256(Hex.fromString(codeVerifier), { as: 'Bytes' }), { pad: false, url: true, }) } function createCodeVerifier() { return Base64.fromBytes(Hex.toBytes(Hex.random(32)), { pad: false, url: true }) } function formatCode(code: string) { return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code } function defaultOpen(url: string) { const command = process.platform === 'darwin' ? { command: 'open', args: [url] } : process.platform === 'win32' ? { command: 'cmd', args: ['/c', 'start', '', url] } : { command: 'xdg-open', args: [url] } const child = spawn(command.command, command.args, { detached: true, stdio: 'ignore', }) child.unref() } function getApiUrl(serviceUrl: string, path: string) { const url = new URL(serviceUrl) url.pathname = `${url.pathname.replace(/\/$/, '')}/${path.replace(/^\//, '')}` url.search = '' return url.toString() } function getBrowserUrl(serviceUrl: string, code: string) { const url = new URL(serviceUrl) url.searchParams.set('code', code) return url.toString() } async function post< const request extends z.ZodMiniType, const response extends z.ZodMiniType, >(options: { body: z.output<request> request: request response: response url: string }): Promise<z.output<response>> { const result = await fetch(options.url, { body: JSON.stringify(z.encode(options.request, options.body)), headers: { 'content-type': 'application/json' }, method: 'POST', }) const json = (await result.json().catch(() => ({}))) as z.input<response> if (!result.ok) { const error = (json as { error?: unknown }).error throw new Error(typeof error === 'string' ? error : `Request failed: ${result.status}`) } return z.decode(options.response, json) } function unsupported(message: string) { return new core_Provider.UnsupportedMethodError({ message }) }