accounts
Version:
Tempo Accounts SDK
564 lines (519 loc) • 16.5 kB
text/typescript
import { Address, Hex, Provider as ox_Provider, PublicKey } from 'ox'
import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'
import { tempoLocalnet } from 'viem/tempo/chains'
import { afterEach, describe, expect, test, vi } from 'vp/test'
import * as Dialog from '../Dialog.js'
import * as AccessKeyTransaction from '../internal/AccessKeyTransaction.js'
import * as Storage from '../Storage.js'
import * as Store from '../Store.js'
import { dialog } from './dialog.js'
const address = '0x0000000000000000000000000000000000000001'
const recipient = '0x0000000000000000000000000000000000000002'
function createKeyAuthorization(options: {
expiry: number
keyType: 'secp256k1' | 'p256'
publicKey: Hex.Hex
}) {
const { expiry, keyType, publicKey } = options
return KeyAuthorization.toRpc(
KeyAuthorization.from(
{
address: Address.fromPublicKey(PublicKey.from(publicKey)),
chainId: BigInt(tempoLocalnet.id),
expiry,
type: keyType,
},
{ signature: SignatureEnvelope.from(`0x${'00'.repeat(65)}`) },
),
)
}
function setup() {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
const adapter = dialog({ dialog: Dialog.noop() })({
getAccount: (options) => {
if (options?.signable) throw new ox_Provider.UnauthorizedError({ message: 'No signer.' })
return { address, type: 'json-rpc' } as never
},
getClient: () => ({}) as never,
storage,
store,
})
return { adapter, store }
}
async function takeRequest(store: Store.Store) {
await vi.waitFor(() => {
if (!store.getState().requestQueue[0]) throw new Error('request not queued')
})
return store.getState().requestQueue[0]!
}
describe('dialog', () => {
afterEach(() => {
vi.restoreAllMocks()
})
test('behavior: sendTransaction signs locally when an access key is selected', async () => {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
const clientRequests: unknown[] = []
const signRequests: unknown[] = []
vi.spyOn(AccessKeyTransaction, 'create').mockResolvedValue({
fill: async () => ({ capabilities: { sponsored: false }, tx: {} }),
prepare: async (request) => ({
request: request as never,
send: async () => {
signRequests.push(request)
clientRequests.push({ method: 'eth_sendRawTransaction', params: ['0xsigned'] })
return '0xtransaction'
},
sendSync: async () => {
throw new Error('unexpected sendSync')
},
sign: async () => {
signRequests.push(request)
return '0xsigned'
},
}),
})
const adapter = dialog({ dialog: Dialog.noop() })({
getAccount: () => {
throw new ox_Provider.UnauthorizedError({ message: 'No local signer.' })
},
getClient: () =>
({
chain: { id: tempoLocalnet.id },
request: async (request: unknown) => {
clientRequests.push(request)
return '0xtransaction'
},
}) as never,
storage,
store,
})
const result = await adapter.actions.sendTransaction(
{
calls: [{ data: '0x12345678', to: recipient }],
chainId: 1,
from: address,
gas: 1n,
maxFeePerGas: 1n,
maxPriorityFeePerGas: 1n,
nonce: 0,
},
{
method: 'eth_sendTransaction',
params: [
{
calls: [{ data: '0x12345678' as const, to: recipient }],
chainId: '0x1' as const,
from: address,
},
] as const,
},
)
expect(result).toMatchInlineSnapshot(`"0xtransaction"`)
expect(signRequests.length).toMatchInlineSnapshot(`1`)
expect(clientRequests).toMatchInlineSnapshot(`
[
{
"method": "eth_sendRawTransaction",
"params": [
"0xsigned",
],
},
]
`)
expect(store.getState().requestQueue).toMatchInlineSnapshot(`[]`)
})
test('behavior: loadAccounts forwards auth capabilities returned by the dialog', async () => {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
const adapter = dialog({ dialog: Dialog.noop() })({
getAccount: () => {
throw new ox_Provider.UnauthorizedError({ message: 'No local signer.' })
},
getClient: () => ({}) as never,
storage,
store,
})
const request = {
method: 'wallet_connect' as const,
params: [
{
capabilities: {
auth: {
url: 'https://app.example/auth',
returnToken: true,
},
},
chainId: '0x1079' as const,
},
] as const,
}
const promise = adapter.actions.loadAccounts(undefined, request)
await vi.waitFor(() => {
if (!store.getState().requestQueue[0]) throw new Error('request not queued')
})
const queued = store.getState().requestQueue[0]!
store.setState({
requestQueue: [
{
request: queued.request,
result: {
accounts: [
{
address,
capabilities: {
auth: { token: 'test-token' },
},
},
],
},
status: 'success',
},
],
})
await expect(promise).resolves.toMatchInlineSnapshot(`
{
"accounts": [
{
"address": "0x0000000000000000000000000000000000000001",
},
],
"auth": {
"token": "test-token",
},
}
`)
})
test('behavior: sendTransaction falls through when no access key is selected', async () => {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
const lookups: unknown[] = []
const adapter = dialog({ dialog: Dialog.noop() })({
getAccount: (options) => {
lookups.push(options)
throw new ox_Provider.UnauthorizedError({ message: 'No local signer.' })
},
getClient: () => ({}) as never,
storage,
store,
})
const request = {
method: 'eth_sendTransaction' as const,
params: [
{
calls: [{ data: '0x12345678' as const, to: recipient }],
chainId: '0x1' as const,
from: address,
},
] as const,
}
const promise = adapter.actions.sendTransaction(
{
calls: [{ data: '0x12345678', to: recipient }],
chainId: 1,
from: address,
},
request,
)
await vi.waitFor(() => {
if (!store.getState().requestQueue[0]) throw new Error('request not queued')
})
const queued = store.getState().requestQueue[0]!
store.setState({
requestQueue: [
{
request: queued.request,
result: '0x1234',
status: 'success',
},
],
})
await expect(promise).resolves.toMatchInlineSnapshot(`"0x1234"`)
expect(lookups).toMatchInlineSnapshot(`[]`)
})
test('behavior: revokeAccessKey clears the forwarded key from local state', async () => {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
store.setState({
accessKeys: [
{
access: address,
address: recipient,
chainId: tempoLocalnet.id,
keyType: 'p256',
} as never,
],
})
const adapter = dialog({ dialog: Dialog.noop() })({
getAccount: () => {
throw new ox_Provider.UnauthorizedError({ message: 'No local signer.' })
},
getClient: () => ({}) as never,
storage,
store,
})
const request = {
method: 'wallet_revokeAccessKey' as const,
params: [{ accessKeyAddress: recipient, address }] as const,
}
const promise = adapter.actions.revokeAccessKey!(
{ accessKeyAddress: recipient, address },
request,
)
await vi.waitFor(() => {
if (!store.getState().requestQueue[0]) throw new Error('request not queued')
})
const queued = store.getState().requestQueue[0]!
store.setState({
requestQueue: [
{
request: queued.request,
result: undefined,
status: 'success',
},
],
})
await expect(promise).resolves.toMatchInlineSnapshot(`undefined`)
expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
})
test('behavior: authorizeAccessKey forwards an external secp256k1 key', async () => {
const { adapter, store } = setup()
const expiry = 123
const promise = adapter.actions.authorizeAccessKey!(
{ address: recipient, expiry, keyType: 'secp256k1' },
{
method: 'wallet_authorizeAccessKey',
params: [{ address: recipient, expiry, keyType: 'secp256k1' }],
},
)
const queued = await takeRequest(store)
const request = queued.request as {
params: [{ address: typeof recipient; expiry: number; keyType: 'secp256k1' }]
}
const params = request.params[0]
const keyAuthorization = KeyAuthorization.toRpc(
KeyAuthorization.from(
{
address: recipient,
chainId: BigInt(tempoLocalnet.id),
expiry,
type: 'secp256k1',
},
{ signature: SignatureEnvelope.from(`0x${'00'.repeat(65)}`) },
),
)
store.setState({
requestQueue: [
{
request: queued.request,
result: {
keyAuthorization,
rootAddress: address,
},
status: 'success',
},
],
})
await expect(promise).resolves.toMatchObject({ rootAddress: address })
expect(params.keyType).toMatchInlineSnapshot(`"secp256k1"`)
expect(params.address).toMatchInlineSnapshot(`"0x0000000000000000000000000000000000000002"`)
expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
})
test('behavior: authorizeAccessKey generates a p256 key by default', async () => {
const { adapter, store } = setup()
const expiry = 123
const promise = adapter.actions.authorizeAccessKey!(
{ expiry },
{ method: 'wallet_authorizeAccessKey', params: [{ expiry }] },
)
const queued = await takeRequest(store)
const request = queued.request as {
params: [{ expiry: number; keyType: 'p256'; publicKey: Hex.Hex }]
}
const params = request.params[0]
store.setState({
requestQueue: [
{
request: queued.request,
result: {
keyAuthorization: createKeyAuthorization(params),
rootAddress: address,
},
status: 'success',
},
],
})
await expect(promise).resolves.toMatchObject({ rootAddress: address })
expect(params.keyType).toMatchInlineSnapshot(`"p256"`)
expect(store.getState().accessKeys).toMatchObject([
{
access: address,
keyType: 'p256',
},
])
expect('keyPair' in store.getState().accessKeys[0]!).toMatchInlineSnapshot(`true`)
})
test('behavior: authorizeAccessKey forwards showDeposit', async () => {
const { adapter, store } = setup()
const expiry = 123
const showDeposit = { amount: '50', token: 'USDC' }
const promise = adapter.actions.authorizeAccessKey!(
{ expiry, showDeposit },
{ method: 'wallet_authorizeAccessKey', params: [{ expiry, showDeposit }] },
)
const queued = await takeRequest(store)
const request = queued.request as {
params: [
{
expiry: number
keyType: 'p256'
publicKey: Hex.Hex
showDeposit: typeof showDeposit
},
]
}
const params = request.params[0]
store.setState({
requestQueue: [
{
request: queued.request,
result: {
keyAuthorization: createKeyAuthorization(params),
rootAddress: address,
},
status: 'success',
},
],
})
await expect(promise).resolves.toMatchObject({ rootAddress: address })
expect(params.showDeposit).toMatchInlineSnapshot(`
{
"amount": "50",
"token": "USDC",
}
`)
})
test('behavior: authorizeAccessKey generates a p256 key when requested', async () => {
const { adapter, store } = setup()
const expiry = 123
const promise = adapter.actions.authorizeAccessKey!(
{ expiry, keyType: 'p256' },
{ method: 'wallet_authorizeAccessKey', params: [{ expiry, keyType: 'p256' }] },
)
const queued = await takeRequest(store)
const request = queued.request as {
params: [{ expiry: number; keyType: 'p256'; publicKey: Hex.Hex }]
}
const params = request.params[0]
store.setState({
requestQueue: [
{
request: queued.request,
result: {
keyAuthorization: createKeyAuthorization(params),
rootAddress: address,
},
status: 'success',
},
],
})
await expect(promise).resolves.toMatchObject({ rootAddress: address })
expect(params.keyType).toMatchInlineSnapshot(`"p256"`)
expect(store.getState().accessKeys).toMatchObject([
{
access: address,
keyType: 'p256',
},
])
expect('keyPair' in store.getState().accessKeys[0]!).toMatchInlineSnapshot(`true`)
})
test('error: secp256k1 access key requires external key material', async () => {
const { adapter, store } = setup()
await expect(
adapter.actions.authorizeAccessKey!(
{ expiry: 123, keyType: 'secp256k1' },
{
method: 'wallet_authorizeAccessKey',
params: [{ expiry: 123, keyType: 'secp256k1' }],
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[RpcResponse.InvalidParamsError: \`keyType: "secp256k1"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`,
)
expect(store.getState().requestQueue).toMatchInlineSnapshot(`[]`)
})
test('error: webAuthn access key requires external key material', async () => {
const { adapter, store } = setup()
await expect(
adapter.actions.authorizeAccessKey!(
{ expiry: 123, keyType: 'webAuthn' },
{
method: 'wallet_authorizeAccessKey',
params: [{ expiry: 123, keyType: 'webAuthn' }],
},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[RpcResponse.InvalidParamsError: \`keyType: "webAuthn"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`,
)
expect(store.getState().requestQueue).toMatchInlineSnapshot(`[]`)
})
test('error: wallet validation errors keep their RPC code', async () => {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
const adapter = dialog({ dialog: Dialog.noop() })({
getAccount: () => {
throw new ox_Provider.UnauthorizedError({ message: 'No local signer.' })
},
getClient: () => ({}) as never,
storage,
store,
})
const promise = adapter.actions.sendTransaction(
{
calls: [{ data: '0x12345678', to: recipient }],
chainId: 1,
from: address,
},
{
method: 'eth_sendTransaction',
params: [
{
calls: [{ data: '0x12345678' as const, to: recipient }],
chainId: '0x1' as const,
from: address,
},
] as const,
},
)
await vi.waitFor(() => {
if (!store.getState().requestQueue[0]) throw new Error('request not queued')
})
const queued = store.getState().requestQueue[0]!
store.setState({
requestQueue: [
{
request: queued.request,
error: {
code: -32602,
message: '`authorizeAccessKey` must include at least one `limits` entry.',
},
status: 'error',
},
],
})
await expect(
promise.catch((error) => ({
code: error.code,
message: error.message,
name: error.name,
})),
).resolves.toMatchInlineSnapshot(`
{
"code": -32602,
"message": "\`authorizeAccessKey\` must include at least one \`limits\` entry.",
"name": "RpcResponse.InvalidParamsError",
}
`)
})
})