UNPKG

accounts

Version:

Tempo Accounts SDK

582 lines (523 loc) 18.4 kB
import { Hex, PublicKey } from 'ox' import type { Address } from 'viem/accounts' import { describe, expect, test } from 'vp/test' import { accounts } from '../../../test/config.js' import * as Storage from '../Storage.js' import * as Store from '../Store.js' import { turnkey } from './turnkey.js' const account = accounts[0] const account_2 = accounts[1] const address = account.address const other = account_2.address describe('turnkey', () => { test('default: createAccount delegates registration and signs the requested digest', async () => { const { adapter, client } = setup() const result = await adapter.actions.createAccount( { digest: '0x1234', name: 'Ada' }, { method: 'wallet_connect', params: undefined }, ) expect(client.initCalls).toMatchInlineSnapshot(`1`) expect(client.signPayloads).toMatchInlineSnapshot(` [ "0x1234", ] `) expect(result).toMatchInlineSnapshot(` { "accounts": [ { "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "label": "Ada", }, ], "signature": "0x000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b", } `) }) test('default: createAccount falls back to loadAccounts', async () => { const { adapter, client } = setup({ createAccount: false }) const result = await adapter.actions.createAccount( { digest: '0x1234', name: 'Ada' }, { method: 'wallet_connect', params: undefined }, ) expect(client.createCalls).toMatchInlineSnapshot(`0`) expect(client.loadCalls).toMatchInlineSnapshot(`1`) expect(result).toMatchInlineSnapshot(` { "accounts": [ { "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "label": "Ada", }, ], "signature": "0x000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b", } `) }) test('default: createAccount can select the active fetched wallet account', async () => { const { adapter, client } = setup({ createAddresses: [other] }) client.wallets = [ { accounts: [toWalletAccount(account), toWalletAccount(account_2)], }, ] const result = await adapter.actions.createAccount( { digest: '0x1234', name: 'Ada' }, { method: 'wallet_connect', params: undefined }, ) expect(client.signWith).toMatchInlineSnapshot(` [ "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", ] `) expect(result).toMatchInlineSnapshot(` { "accounts": [ { "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", "label": "Ada", }, ], "signature": "0x000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b", } `) }) test('default: loadAccounts delegates login and caches wallet accounts for signing', async () => { const { adapter, client } = setup() await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }) const result = await adapter.actions.signPersonalMessage( { address, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', address] }, ) expect(client.loadCalls).toMatchInlineSnapshot(`1`) expect(client.signWith).toMatchInlineSnapshot(` [ "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", ] `) expect(result).toMatchInlineSnapshot( `"0x000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b"`, ) }) test('default: loadAccounts can select and order fetched wallet accounts', async () => { const { adapter, client } = setup({ loadAddresses: [other, address] }) client.wallets = [ { accounts: [toWalletAccount(account), toWalletAccount(account_2)], }, ] const result = await adapter.actions.loadAccounts( { digest: '0x1234' }, { method: 'wallet_connect', params: undefined }, ) expect(client.signWith).toMatchInlineSnapshot(` [ "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", ] `) expect(result).toMatchInlineSnapshot(` { "accounts": [ { "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", }, { "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", }, ], "signature": "0x000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b", } `) }) test('default: signs transactions with a hydrated Tempo account', async () => { const { adapter, client } = setup() await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }) const result = await adapter.actions.signTransaction( { chainId: 1, from: address, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n, nonce: 0, to: other, value: 1n, }, { method: 'eth_signTransaction', params: [{ from: address }] }, ) expect(client.signWith).toMatchInlineSnapshot(` [ "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", ] `) expect(client.signPayloads).toMatchInlineSnapshot(` [ "0x1d573a406538a466857ad6ac07f34eac6ede297aba6e85116a1e9a7cda46d9f2", ] `) expect(result).toMatchInlineSnapshot( `"0x76f86a010101825208d8d7948c8d35429f74ec245f8ef2f4fd1e551cff97d6500180c0808080808080c0b841000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b"`, ) }) test('default: accepts prefixed Turnkey public keys', async () => { const { adapter, client } = setup() const walletAccount = toWalletAccount(account) client.wallets = [ { accounts: [ { ...walletAccount, publicKey: `0x${walletAccount.publicKey}`, }, ], }, ] await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }) const result = await adapter.actions.signTransaction( { chainId: 1, from: address, gas: 21_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n, nonce: 0, to: other, value: 1n, }, { method: 'eth_signTransaction', params: [{ from: address }] }, ) expect(result).toMatchInlineSnapshot( `"0x76f86a010101825208d8d7948c8d35429f74ec245f8ef2f4fd1e551cff97d6500180c0808080808080c0b841000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000221b"`, ) }) test('default: loadAccounts can provision an external access key', async () => { const { adapter, client } = setup() const result = await adapter.actions.loadAccounts( { authorizeAccessKey: { address: other, expiry: 123, keyType: 'secp256k1', }, }, { method: 'wallet_connect', params: undefined }, ) expect(client.signPayloads).toMatchInlineSnapshot(` [ "0xea47721547363fc82a5dca62b4544e4718d861b3df10bfac65d30102594b5c26", ] `) expect(result).toMatchInlineSnapshot(` { "accounts": [ { "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", }, ], "keyAuthorization": { "chainId": "0x1", "expiry": "0x7b", "keyId": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", "keyType": "secp256k1", "limits": undefined, "signature": { "r": "0x0000000000000000000000000000000000000000000000000000000000000011", "s": "0x0000000000000000000000000000000000000000000000000000000000000022", "type": "secp256k1", "yParity": "0x0", }, }, "signature": undefined, } `) }) test('default: authorizeAccessKey signs with the connected Turnkey account', async () => { const { adapter, client, store } = setup() store.setState({ accounts: [{ address }], activeAccount: 0 }) const result = await adapter.actions.authorizeAccessKey!( { address: other, expiry: 123, keyType: 'secp256k1', }, { method: 'wallet_authorizeAccessKey', params: [{ expiry: 123 }] }, ) expect(client.fetchCalls).toMatchInlineSnapshot(`1`) expect(result).toMatchInlineSnapshot(` { "keyAuthorization": { "chainId": "0x1", "expiry": "0x7b", "keyId": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", "keyType": "secp256k1", "limits": undefined, "signature": { "r": "0x0000000000000000000000000000000000000000000000000000000000000011", "s": "0x0000000000000000000000000000000000000000000000000000000000000022", "type": "secp256k1", "yParity": "0x0", }, }, "rootAddress": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", } `) }) test('behavior: signing silently restores wallet accounts from an existing session', async () => { const { adapter, client, store } = setup() store.setState({ accounts: [{ address }], activeAccount: 0 }) await adapter.actions.signPersonalMessage( { address, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', address] }, ) expect(client.fetchCalls).toMatchInlineSnapshot(`1`) expect(client.loadCalls).toMatchInlineSnapshot(`0`) }) test('behavior: silent restore does not connect accounts when the provider store is empty', async () => { const { adapter, client } = setup() await expect( adapter.actions.signPersonalMessage( { address, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', address] }, ), ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: No Turnkey account connected.]') expect(client.fetchCalls).toMatchInlineSnapshot(`0`) expect(client.signPayloads).toMatchInlineSnapshot(`[]`) }) test('behavior: silent restore only reconnects persisted provider accounts', async () => { const { adapter, client, store } = setup() client.wallets = [ { accounts: [toWalletAccount(account), toWalletAccount(account_2)], }, ] store.setState({ accounts: [{ address: other }], activeAccount: 0 }) await adapter.actions.signPersonalMessage( { address: other, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', other] }, ) expect(client.signWith).toMatchInlineSnapshot(` [ "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", ] `) expect(store.getState().accounts).toMatchInlineSnapshot(` [ { "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", }, ] `) }) test('behavior: silent restore ignores non-Ethereum wallet accounts', async () => { const { adapter, client, store } = setup() client.wallets = [ { accounts: [ { address, addressFormat: 'ADDRESS_FORMAT_SUI', publicKey: toWalletAccount(account).publicKey, }, ], }, ] store.setState({ accounts: [{ address }], activeAccount: 0 }) await expect( adapter.actions.signPersonalMessage( { address, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', address] }, ), ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: No Turnkey account connected.]') expect(client.signPayloads).toMatchInlineSnapshot(`[]`) }) test('behavior: expired sessions clear provider accounts', async () => { const { adapter, client, store } = setup({ session: { expiry: 1 } }) store.setState({ accounts: [{ address }], activeAccount: 0 }) await expect( adapter.actions.signPersonalMessage( { address, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', address] }, ), ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Turnkey session expired.]') expect(client.signPayloads).toMatchInlineSnapshot(`[]`) expect(store.getState().accounts).toMatchInlineSnapshot(`[]`) }) test('behavior: server session errors clear provider accounts', async () => { const { adapter, store } = setup({ signError: { details: [{ turnkeyErrorCode: 'API_KEY_EXPIRED' }] }, }) store.setState({ accounts: [{ address }], activeAccount: 0 }) await expect( adapter.actions.signPersonalMessage( { address, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', address] }, ), ).rejects.toMatchInlineSnapshot('[Provider.DisconnectedError: Turnkey session expired.]') expect(store.getState().accounts).toMatchInlineSnapshot(`[]`) }) test('error: signing an unconnected account fails', async () => { const { adapter } = setup() await adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }) await expect( adapter.actions.signPersonalMessage( { address: other, data: '0x68656c6c6f' }, { method: 'personal_sign', params: ['0x68656c6c6f', other] }, ), ).rejects.toMatchInlineSnapshot( `[Provider.UnauthorizedError: Account "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650" not found.]`, ) }) test('error: rejects a Turnkey wallet account with mismatched address and public key', async () => { const { adapter, client, store } = setup() client.wallets = [ { accounts: [ { ...toWalletAccount(account_2), address, }, ], }, ] store.setState({ accounts: [{ address }], activeAccount: 0 }) await expect( adapter.actions.signTransaction( { from: address }, { method: 'eth_signTransaction', params: [{ from: address }] }, ), ).rejects.toMatchInlineSnapshot( `[RpcResponse.InternalError: Turnkey account publicKey does not match address "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".]`, ) }) test('error: rejects a selected address missing from fetched wallet accounts', async () => { const { adapter } = setup({ loadAddresses: [other] }) await expect( adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: undefined }), ).rejects.toMatchInlineSnapshot( `[RpcResponse.InternalError: Turnkey callback returned address "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650" that was not found in fetched wallet accounts.]`, ) }) }) function setup(options: setup.Options = {}) { const storage = Storage.memory() const store = Store.create({ chainId: 1, storage }) const client = createClient(options) const adapter = turnkey({ client, ...(options.createAccount === false ? {} : { createAccount: async () => { client.createCalls++ return options.createAddresses }, }), loadAccounts: async () => { client.loadCalls++ return options.loadAddresses }, })({ getAccount: (() => { throw new Error('not implemented') }) as never, getClient: (() => ({ chain: { id: 1 } })) as never, storage, store, }) return { adapter, client, store } } declare namespace setup { type Options = { createAccount?: boolean | undefined createAddresses?: readonly Address[] | undefined loadAddresses?: readonly Address[] | undefined session?: turnkey.Session | null | undefined signError?: unknown } } function createClient(options: setup.Options = {}) { type WalletShape = { accounts: { address: string; addressFormat?: string | undefined; publicKey: string }[] } const state = { createCalls: 0, fetchCalls: 0, initCalls: 0, loadCalls: 0, signPayloads: [] as Hex.Hex[], signWith: [] as string[], wallets: [{ accounts: [toWalletAccount(account)] }] as WalletShape[], } const client = { get fetchCalls() { return state.fetchCalls }, get createCalls() { return state.createCalls }, set createCalls(value: number) { state.createCalls = value }, get initCalls() { return state.initCalls }, get loadCalls() { return state.loadCalls }, set loadCalls(value: number) { state.loadCalls = value }, get signPayloads() { return state.signPayloads }, get signWith() { return state.signWith }, get wallets() { return state.wallets }, set wallets(value: WalletShape[]) { state.wallets = value }, fetchWallets: async () => { state.fetchCalls++ return state.wallets as readonly turnkey.Wallet[] }, getSession: async () => options.session === undefined ? { expiry: Math.floor(Date.now() / 1000) + 60 } : options.session, httpClient: { signRawPayload: async (parameters: turnkey.SignRawPayloadParameters) => { if (options.signError) throw options.signError state.signPayloads.push(parameters.payload) state.signWith.push(parameters.signWith) return { r: Hex.padLeft('0x11', 32), s: Hex.padLeft('0x22', 32), v: '27', } }, }, init: () => { state.initCalls++ }, logout: () => {}, } satisfies turnkey.Client & { createCalls: number fetchCalls: number initCalls: number loadCalls: number signPayloads: Hex.Hex[] signWith: string[] wallets: WalletShape[] } return client } function toWalletAccount(account: (typeof accounts)[number]): turnkey.WalletAccount { return { address: account.address, addressFormat: 'ADDRESS_FORMAT_ETHEREUM', publicKey: PublicKey.toHex(PublicKey.compress(PublicKey.from(account.publicKey))).slice(2), } }