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