accounts
Version:
Tempo Accounts SDK
481 lines (427 loc) • 14.3 kB
text/typescript
import { Provider as ox_Provider } from 'ox'
import { tempoLocalnet } from 'viem/tempo/chains'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vp/test'
import { accounts, privateKeys } from '../../../test/config.js'
import * as Dialog from '../Dialog.js'
import * as RemoteRequests from '../internal/RemoteRequests.js'
import * as Provider from '../Provider.js'
import type * as Remote from '../Remote.js'
import * as Storage from '../Storage.js'
import * as Store from '../Store.js'
import { dialog } from './dialog.js'
const address = '0x0000000000000000000000000000000000000001'
const accessKey = '0x0000000000000000000000000000000000000002'
const host = 'https://wallet.test/embed'
const receipt = {
blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
blockNumber: '0x1',
contractAddress: null,
cumulativeGasUsed: '0x1',
effectiveGasPrice: '0x1',
from: address,
gasUsed: '0x1',
logs: [],
logsBloom: `0x${'00'.repeat(256)}`,
status: '0x1',
to: address,
transactionHash: '0x1111111111111111111111111111111111111111111111111111111111111111',
transactionIndex: '0x0',
type: '0x76',
}
function setup() {
const storage = Storage.memory()
const store = Store.create({ chainId: tempoLocalnet.id, storage })
const dialog_ = createDialog()
const adapter = dialog({ dialog: dialog_.dialog, host })({
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, dialog: dialog_, store }
}
function createDialog() {
let parameters: Dialog.SetupFn.Parameters | undefined
const syncs: Remote.Sync[] = []
const synced: (readonly Remote.Request[])[] = []
return {
dialog: Dialog.define({ name: 'test' }, (options) => ({
close() {},
destroy() {},
open() {},
async syncRequests(sync) {
parameters = options
syncs.push(sync)
synced.push(sync.requests)
},
syncTheme() {},
})),
async takeRequest() {
await vi.waitFor(() => {
if (!syncs[0]?.requests[0]) throw new Error('request not synced')
})
return syncs[0]!.requests[0]!
},
failure(request: Remote.Request, error: { code: number; message: string }) {
parameters!.onResponse({ error, id: request.request.id })
},
success(request: Remote.Request, result: unknown) {
parameters!.onResponse({ id: request.request.id, result })
},
syncs,
synced,
}
}
async function getDialogClient(adapter: ReturnType<typeof setup>['adapter']) {
const result = await adapter.getAccount!()
if (result.account.type !== 'json-rpc') throw new Error('Expected JSON-RPC account.')
if (!result.transport) throw new Error('Expected dialog transport.')
return result.transport({}) as never as {
request: (request: unknown) => Promise<unknown>
}
}
describe('dialog', () => {
beforeEach(() => {
RemoteRequests.reset(host)
})
afterEach(() => {
vi.restoreAllMocks()
RemoteRequests.reset(host)
})
test('behavior: loadAccounts forwards auth capabilities returned by the dialog', async () => {
const { adapter, dialog } = setup()
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)
const queued = await dialog.takeRequest()
dialog.success(queued, {
accounts: [
{
address,
capabilities: {
auth: { token: 'test-token' },
},
},
],
})
await expect(promise).resolves.toMatchInlineSnapshot(`
{
"accounts": [
{
"address": "0x0000000000000000000000000000000000000001",
},
],
"auth": {
"token": "test-token",
},
}
`)
})
test('behavior: host session syncs pending requests through the attached dialog', async () => {
const storage = Storage.memory()
const store_a = Store.create({ chainId: 123, storage })
store_a.setState({ accounts: [{ address }], activeAccount: 0 })
const store_b = Store.create({ chainId: 456, storage })
const dialog_a = createDialog()
const dialog_b = createDialog()
const adapter_a = dialog({ dialog: dialog_a.dialog, host })({
getAccount: () => ({ address, type: 'json-rpc' }) as never,
getClient: () => ({}) as never,
storage,
store: store_a,
})
const adapter_b = dialog({ dialog: dialog_b.dialog, host })({
getAccount: () => ({ address, type: 'json-rpc' }) as never,
getClient: () => ({}) as never,
storage,
store: store_b,
})
const client = await getDialogClient(adapter_a)
const promise = client.request({
method: 'eth_sendTransaction',
params: [{ from: address }],
})
const queued = await dialog_b.takeRequest()
expect(dialog_a.synced).toMatchInlineSnapshot(`[]`)
expect(dialog_b.synced.map((requests) => requests.map((x) => x.request.method)))
.toMatchInlineSnapshot(`
[
[
"eth_sendTransaction",
],
]
`)
expect(dialog_b.syncs.map((sync) => ({ account: sync.account, chainId: sync.chainId })))
.toMatchInlineSnapshot(`
[
{
"account": {
"address": "0x0000000000000000000000000000000000000001",
},
"chainId": 123,
},
]
`)
dialog_b.success(queued, '0x1234')
await expect(promise).resolves.toMatchInlineSnapshot(`"0x1234"`)
adapter_a.cleanup?.()
adapter_b.cleanup?.()
})
test('behavior: remounted provider resumes pending host requests', async () => {
const storage = Storage.memory()
const store_a = Store.create({ chainId: 123, storage })
store_a.setState({ accounts: [{ address }], activeAccount: 0 })
const dialog_a = createDialog()
const adapter_a = dialog({ dialog: dialog_a.dialog, host })({
getAccount: () => ({ address, type: 'json-rpc' }) as never,
getClient: () => ({}) as never,
storage,
store: store_a,
})
const client = await getDialogClient(adapter_a)
const promise = client.request({
method: 'eth_sendTransaction',
params: [{ from: address }],
})
const queued = await dialog_a.takeRequest()
adapter_a.cleanup?.()
const store_b = Store.create({ chainId: 456, storage })
const dialog_b = createDialog()
const adapter_b = dialog({ dialog: dialog_b.dialog, host })({
getAccount: () => ({ address, type: 'json-rpc' }) as never,
getClient: () => ({}) as never,
storage,
store: store_b,
})
expect(dialog_b.synced.map((requests) => requests.map((x) => x.request.method)))
.toMatchInlineSnapshot(`
[
[
"eth_sendTransaction",
],
]
`)
expect(dialog_b.syncs.map((sync) => ({ account: sync.account, chainId: sync.chainId })))
.toMatchInlineSnapshot(`
[
{
"account": {
"address": "0x0000000000000000000000000000000000000001",
},
"chainId": 123,
},
]
`)
dialog_b.success(queued, '0x1234')
await expect(promise).resolves.toMatchInlineSnapshot(`"0x1234"`)
adapter_b.cleanup?.()
})
test('behavior: resolving one pending request advances the remote queue', async () => {
const { adapter, dialog } = setup()
const client = await getDialogClient(adapter)
const first = client.request({
method: 'eth_sendTransaction',
params: [{ from: address }],
})
const second = client.request({
method: 'eth_signTransaction',
params: [{ from: address }],
})
const request_1 = await dialog.takeRequest()
dialog.success(request_1, '0x1')
await expect(first).resolves.toMatchInlineSnapshot(`"0x1"`)
expect(dialog.synced.map((requests) => requests.map((x) => x.request.id)))
.toMatchInlineSnapshot(`
[
[
0,
],
[
1,
],
]
`)
const request_2 = dialog.synced.at(-1)![0]!
dialog.success(request_2, '0x2')
await expect(second).resolves.toMatchInlineSnapshot(`"0x2"`)
})
test('behavior: dialog transport resolves chain ID locally', async () => {
const { adapter, dialog } = setup()
const client = await getDialogClient(adapter)
await expect(client.request({ method: 'eth_chainId' })).resolves.toMatchInlineSnapshot(
`"0x539"`,
)
expect(dialog.synced).toMatchInlineSnapshot(`[]`)
})
test('behavior: provider forwards eth_sendTransaction without chain preflight', async () => {
const dialog_ = createDialog()
const provider = Provider.create({
adapter: dialog({ dialog: dialog_.dialog, host }),
chains: [tempoLocalnet],
storage: Storage.memory(),
})
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
const promise = provider.request({
method: 'eth_sendTransaction',
params: [{ calls: [{ to: address, value: '0x1' }] }],
})
const request = await dialog_.takeRequest()
expect(request.request).toMatchInlineSnapshot(`
{
"id": 0,
"jsonrpc": "2.0",
"method": "eth_sendTransaction",
"params": [
{
"calls": [
{
"data": "0x",
"to": "0x0000000000000000000000000000000000000001",
"value": "0x1",
},
],
"chainId": "0x539",
"data": undefined,
"from": "0x0000000000000000000000000000000000000001",
"to": undefined,
"type": "0x76",
"value": undefined,
},
],
}
`)
dialog_.success(request, '0x1234')
await expect(promise).resolves.toMatchInlineSnapshot(`"0x1234"`)
})
test('behavior: provider forwards eth_sendTransactionSync with encoded params', async () => {
const dialog_ = createDialog()
const provider = Provider.create({
adapter: dialog({ dialog: dialog_.dialog, host }),
chains: [tempoLocalnet],
storage: Storage.memory(),
})
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
const promise = provider.request({
method: 'eth_sendTransactionSync',
params: [{ calls: [{ to: address, value: '0x1' }] }],
})
const request = await dialog_.takeRequest()
expect(request.request).toMatchInlineSnapshot(`
{
"id": 0,
"jsonrpc": "2.0",
"method": "eth_sendTransactionSync",
"params": [
{
"calls": [
{
"to": "0x0000000000000000000000000000000000000001",
"value": "0x1",
},
],
"chainId": "0x539",
"from": "0x0000000000000000000000000000000000000001",
},
],
}
`)
dialog_.success(request, receipt)
await expect(promise.then((x) => x.status)).resolves.toMatchInlineSnapshot(`"0x1"`)
})
test('behavior: provider forwards JSON-RPC access-key authorization with encoded params', async () => {
const dialog_ = createDialog()
const provider = Provider.create({
adapter: dialog({ dialog: dialog_.dialog, host }),
chains: [tempoLocalnet],
storage: Storage.memory(),
})
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
const promise = provider.request({
method: 'wallet_authorizeAccessKey',
params: [{ address: accessKey, expiry: 123 }],
})
const request = await dialog_.takeRequest()
expect(request.request).toMatchInlineSnapshot(`
{
"id": 0,
"jsonrpc": "2.0",
"method": "wallet_authorizeAccessKey",
"params": [
{
"address": "0x0000000000000000000000000000000000000002",
"chainId": "0x539",
"expiry": 123,
"keyType": "secp256k1",
},
],
}
`)
dialog_.failure(request, { code: 4001, message: 'Rejected' })
await promise.catch(() => undefined)
})
test('behavior: provider keeps SDK access-key private material out of forwarded JSON-RPC', async () => {
const dialog_ = createDialog()
const provider = Provider.create({
adapter: dialog({ dialog: dialog_.dialog, host }),
authorizeAccessKey: () => ({ expiry: 123, keyType: 'secp256k1', privateKey: privateKeys[1] }),
chains: [tempoLocalnet],
storage: Storage.memory(),
})
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
const promise = provider.request({
method: 'wallet_connect',
})
const request = await dialog_.takeRequest()
const { _decoded, ...request_rpc } = request.request as typeof request.request & {
_decoded?: unknown
}
void _decoded
expect(request_rpc).toMatchInlineSnapshot(`
{
"id": 0,
"jsonrpc": "2.0",
"method": "wallet_connect",
"params": [
{
"capabilities": {
"authorizeAccessKey": {
"address": "${accounts[1]!.address}",
"chainId": "0x539",
"expiry": 123,
"keyType": "secp256k1",
},
},
},
],
}
`)
dialog_.failure(request, { code: 4001, message: 'Rejected' })
await promise.catch(() => undefined)
})
test('behavior: getAccount materializes a json-rpc account with dialog transport', async () => {
const { adapter } = setup()
const result = await adapter.getAccount!()
expect(result.account).toMatchInlineSnapshot(`
{
"address": "0x0000000000000000000000000000000000000001",
"type": "json-rpc",
}
`)
expect(typeof result.transport).toMatchInlineSnapshot(`"function"`)
})
})