accounts
Version:
Tempo Accounts SDK
520 lines (471 loc) • 18.7 kB
text/typescript
import {
Address as core_Address,
Base64,
Hex,
P256,
Provider as core_Provider,
PublicKey,
RpcResponse,
} from 'ox'
import { KeyAuthorization } from 'ox/tempo'
import { prepareTransactionRequest } from 'viem/actions'
import { Actions, Account as TempoAccount, Secp256k1 } from 'viem/tempo'
import * as AccessKey from '../core/AccessKey.js'
import * as Adapter from '../core/Adapter.js'
import type * as Storage from '../core/Storage.js'
/**
* Creates a React Native adapter that authorizes access keys via the system browser.
*
* Authentication opens a browser session and completes via a redirect callback
* that returns the signed key authorization.
*/
export function reactNative(options: reactNative.Options): Adapter.Adapter {
const { name = 'Tempo Mobile', rdns = 'xyz.tempo.mobile' } = options
return Adapter.define({ name, rdns }, ({ getAccount, getClient, store }) => {
async function loadManagedKey(
address: Adapter.authorizeAccessKey.ReturnType['rootAddress'],
parameters: loadManagedKey.Options = {},
): Promise<loadManagedKey.ReturnType | undefined> {
const { keyType } = parameters
const { secureStorage } = options
if (!secureStorage) return undefined
const { chainId } = store.getState()
const storageKeys = keyType
? [managedKeyStorageKey(address, chainId, keyType)]
: [
managedKeyStorageKey(address, chainId, 'secp256k1'),
managedKeyStorageKey(address, chainId, 'p256'),
managedKeyStorageKey(address, chainId),
]
let entry: ManagedKeyEntry | null = null
for (const storageKey of storageKeys) {
entry = await secureStorage.getItem<ManagedKeyEntry>(storageKey)
if (entry) break
}
if (!entry) return undefined
const account =
entry.keyType === 'p256'
? TempoAccount.fromP256(entry.key, { access: address })
: TempoAccount.fromSecp256k1(entry.key, { access: address })
const keyAddress = core_Address.fromPublicKey(PublicKey.from(account.publicKey))
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
if (keyAuthorization.address.toLowerCase() === keyAddress.toLowerCase())
AccessKey.save({
address,
keyAuthorization,
privateKey: entry.key,
store,
})
else
store.setState((state) => ({
accessKeys: state.accessKeys.filter(
(accessKey) =>
accessKey.address.toLowerCase() !== keyAuthorization.address.toLowerCase(),
),
}))
return {
account,
expiry: entry.expiry,
key: entry.key,
keyAddress,
keyType: entry.keyType,
publicKey: account.publicKey,
storedAuthorization: keyAuthorization,
}
}
async function resolveManagedKey(
resolveOptions: {
address?: Adapter.authorizeAccessKey.ReturnType['rootAddress'] | undefined
keyType?: Adapter.authorizeAccessKey.Parameters['keyType'] | undefined
} = {},
): Promise<resolveManagedKey.ReturnType> {
const { address, keyType } = resolveOptions
const requestedKeyType = keyType === 'p256' || keyType === 'secp256k1' ? keyType : undefined
const entry = address
? await loadManagedKey(address, requestedKeyType ? { keyType: requestedKeyType } : {})
: undefined
if (entry)
return {
account: entry.account,
key: entry.key,
keyAddress: entry.keyAddress,
keyType: entry.keyType,
publicKey: entry.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: core_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: KeyAuthorization.Signed,
) {
if (!managedKey) return
AccessKey.save({
address,
keyAuthorization,
privateKey: managedKey.key,
store,
})
const { secureStorage } = options
if (!secureStorage) return
const { chainId } = store.getState()
const storageKey = managedKeyStorageKey(address, chainId, managedKey.keyType)
const entry: ManagedKeyEntry = {
chainId,
expiry: keyAuthorization.expiry ?? 0,
key: managedKey.key,
keyAddress: managedKey.keyAddress,
keyAuthorization: KeyAuthorization.serialize(keyAuthorization),
keyType: managedKey.keyType,
walletAddress: address,
}
await secureStorage.setItem(storageKey, entry)
}
async function isManagedKeyAuthorized(
address: Adapter.authorizeAccessKey.ReturnType['rootAddress'],
managedKey: loadManagedKey.ReturnType,
) {
try {
const metadata = await Actions.accessKey.getMetadata(getClient(), {
account: address,
accessKey: managedKey.keyAddress,
})
return (
metadata.address.toLowerCase() === managedKey.keyAddress.toLowerCase() &&
!metadata.isRevoked
)
} catch {
return false
}
}
async function reauthorizeManagedKey(
address: Adapter.authorizeAccessKey.ReturnType['rootAddress'],
managedKey: loadManagedKey.ReturnType,
) {
const result = await authorize({
account: address,
authorizeAccessKey: {
expiry: managedKey.expiry,
keyType: managedKey.keyType,
...(managedKey.storedAuthorization.limits
? { limits: managedKey.storedAuthorization.limits.map((limit) => ({ ...limit })) }
: {}),
publicKey: managedKey.publicKey,
},
method: 'wallet_authorizeAccessKey',
})
await saveManagedKey(address, managedKey, result.keyAuthorization)
return result.keyAuthorization
}
async function withManagedAccessKey<result>(
fn: (
account: TempoAccount.Account,
keyAuthorization?: KeyAuthorization.Signed | undefined,
) => Promise<result>,
) {
const rootAddress = store.getState().accounts[store.getState().activeAccount]?.address
const managedKey = rootAddress ? await loadManagedKey(rootAddress) : undefined
const account = managedKey?.account ?? getAccount({ signable: true })
let keyAuthorization = AccessKey.getPending(account, { store })
if (rootAddress && managedKey && !keyAuthorization)
if (!(await isManagedKeyAuthorized(rootAddress, managedKey)))
keyAuthorization = await reauthorizeManagedKey(rootAddress, managedKey)
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, redirectUri, open = defaultOpen } = 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 React Native adapter requires `capabilities.authorizeAccessKey`.'
: '`wallet_authorizeAccessKey` on the React Native adapter requires key parameters.',
})
const state = Base64.fromBytes(Hex.toBytes(Hex.random(16)), { pad: false, url: true })
const authUrl = buildAuthUrl(host, {
callback: redirectUri,
chainId: store.getState().chainId,
...(typeof authorizeAccessKey?.expiry !== 'undefined'
? { expiry: authorizeAccessKey.expiry }
: {}),
...(keyType ? { keyType } : {}),
...(authorizeAccessKey?.limits
? { limits: authorizeAccessKey.limits.map((l) => ({ ...l, limit: String(l.limit) })) }
: {}),
pubKey: publicKey,
state,
})
const callbackUrl = await open(authUrl, redirectUri)
if (!callbackUrl) throw new AuthCancelledError()
const params = new URL(callbackUrl).searchParams
const returnedState = params.get('state')
if (returnedState !== state) throw new StateMismatchError()
const accountAddress = params.get('accountAddress')
if (!accountAddress) throw new Error('Missing accountAddress in callback.')
const keyAuthorizationHex = params.get('keyAuthorization')
if (!keyAuthorizationHex) throw new Error('Missing keyAuthorization in callback.')
const keyAuthorization = KeyAuthorization.deserialize(keyAuthorizationHex as Hex.Hex)
if (!keyAuthorization.signature)
throw new Error('Key authorization in callback is missing a signature.')
const signed = keyAuthorization as KeyAuthorization.Signed
if (managedKey)
await saveManagedKey(accountAddress as core_Address.Address, managedKey, signed)
return {
accountAddress: accountAddress as core_Address.Address,
keyAuthorization: signed,
}
}
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: KeyAuthorization.toRpc(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 React Native adapter.',
)
const result = await authorize({
authorizeAccessKey: parameters?.authorizeAccessKey,
method: 'wallet_connect',
})
return {
accounts: [
{
address: result.accountAddress,
capabilities: {},
},
],
keyAuthorization: KeyAuthorization.toRpc(result.keyAuthorization),
}
},
async revokeAccessKey() {
throw unsupported('`wallet_revokeAccessKey` not supported by React Native 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 reactNative {
export type Options = {
/** Host URL for the mobile auth page. @default "https://wallet-next.tempo.xyz" */
host: string
/** Provider display name. @default "Tempo Mobile" */
name?: string | undefined
/**
* Browser opener override. Opens the auth URL and returns the callback URL.
* @default Uses `expo-web-browser`'s `openAuthSessionAsync`.
*/
open?: ((url: string, redirectUri: string) => Promise<string | null>) | undefined
/** Redirect URI for the auth callback (e.g. your app's deep link scheme). */
redirectUri: string
/** Reverse-DNS provider identifier. @default "xyz.tempo.mobile" */
rdns?: string | undefined
/** Secure storage adapter for persisting managed access keys. */
secureStorage?: Storage.Storage | undefined
}
}
declare namespace resolveManagedKey {
type ReturnType = {
account: TempoAccount.Account
key: Hex.Hex
keyAddress: core_Address.Address
keyType: 'secp256k1' | 'p256'
publicKey: Hex.Hex
}
}
declare namespace loadManagedKey {
type Options = {
keyType?: 'secp256k1' | 'p256' | undefined
}
type ReturnType = resolveManagedKey.ReturnType & {
expiry: number
storedAuthorization: KeyAuthorization.Signed
}
}
/** Entry shape persisted to secure storage for managed access keys. */
type ManagedKeyEntry = {
chainId: number
expiry: number
key: Hex.Hex
keyAddress: core_Address.Address
keyAuthorization: Hex.Hex
keyType: 'secp256k1' | 'p256'
walletAddress: core_Address.Address
}
class AuthCancelledError extends Error {
constructor() {
super('Authentication was cancelled by the user.')
this.name = 'AuthCancelledError'
}
}
class StateMismatchError extends Error {
constructor() {
super('State parameter mismatch — possible CSRF attack.')
this.name = 'StateMismatchError'
}
}
async function defaultOpen(url: string, redirectUri: string): Promise<string | null> {
const { openAuthSessionAsync } = await import('expo-web-browser')
const result = await openAuthSessionAsync(url, redirectUri)
if (result.type !== 'success') return null
return result.url
}
function buildAuthUrl(
host: string,
params: {
callback: string
chainId: number
expiry?: number | undefined
keyType?: string | undefined
limits?: readonly { token: string; limit: string }[] | undefined
pubKey: Hex.Hex
state: string
},
): string {
// TODO: use the new host
// const url = new URL('/remote/auth/mobile', host)
const url = new URL('/mobile-auth', host)
url.searchParams.set('pubKey', params.pubKey)
if (params.keyType) url.searchParams.set('keyType', params.keyType)
url.searchParams.set('chainId', String(params.chainId))
if (typeof params.expiry !== 'undefined') url.searchParams.set('expiry', String(params.expiry))
if (params.limits) url.searchParams.set('limits', JSON.stringify(params.limits))
url.searchParams.set('callback', params.callback)
url.searchParams.set('state', params.state)
return url.toString()
}
function managedKeyStorageKey(
address: core_Address.Address,
chainId: number,
keyType?: string | undefined,
): string {
return `managedKey.${address.toLowerCase()}.${chainId}${keyType ? `.${keyType}` : ''}`
}
function unsupported(message: string) {
return new core_Provider.UnsupportedMethodError({ message })
}