UNPKG

accounts

Version:

Tempo Accounts SDK

481 lines (427 loc) 14.3 kB
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"`) }) })