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