accounts
Version:
Tempo Accounts SDK
582 lines (523 loc) • 18.4 kB
text/typescript
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),
}
}