UNPKG

accounts

Version:

Tempo Accounts SDK

511 lines (443 loc) 13.6 kB
import { WebCryptoP256 } from 'ox' import { describe, expect, test } from 'vp/test' import * as z from 'zod/mini' import { testKeystore } from '../../test/keystore.js' import { createJsonStorage } from '../../test/utils.js' import * as Storage from './Storage.js' import * as Store from './Store.js' import * as u from './zod/utils.js' const secp256k1Account = z.object({ address: u.address(), keyType: z.literal('secp256k1'), privateKey: u.hex(), }) const account = '0x0000000000000000000000000000000000000001' const account2 = '0x0000000000000000000000000000000000000002' const accessKey = '0x0000000000000000000000000000000000000099' const privateKey = '0x1234' async function setup(options: Omit<Store.Options, 'chainId'> = {}) { const storage = options.storage ?? Storage.memory() const store = Store.create({ chainId: 123, ...options, storage }) await Store.waitForHydration(store) return { storage, store } } async function getPersistedState(storage: Storage.Storage) { const persisted = await storage.getItem<{ state: Record<string, unknown> }>('store') return persisted?.state } describe('create', () => { test('default', () => { const store = Store.create({ chainId: 123 }) expect(store.getState()).toMatchInlineSnapshot(` { "accessKeys": [], "accounts": [], "activeAccount": 0, "chainId": 123, } `) }) }) describe('persistence', () => { test('default: persists accounts, activeAccount, and chainId to storage', async () => { const { storage, store } = await setup() store.setState({ accounts: [{ address: account }], activeAccount: 1, chainId: 456, }) expect(await getPersistedState(storage)).toMatchInlineSnapshot(` { "accessKeys": [], "accounts": [ { "address": "0x0000000000000000000000000000000000000001", }, ], "activeAccount": 1, "chainId": 456, } `) }) test('behavior: hydrates from storage', async () => { const storage = Storage.memory() const { store: store1 } = await setup({ storage }) store1.setState({ accounts: [{ address: account }], activeAccount: 0, chainId: 456, }) const { store: store2 } = await setup({ storage }) expect(store2.getState()).toMatchInlineSnapshot(` { "accessKeys": [], "accounts": [ { "address": "0x0000000000000000000000000000000000000001", }, ], "activeAccount": 0, "chainId": 456, } `) }) test('behavior: filters stored accounts with the adapter restore guard', async () => { const storage = Storage.memory() storage.setItem('store', { state: { accounts: [ { address: account }, { address: account2, keyType: 'secp256k1', privateKey, }, ], activeAccount: 1, chainId: 456, }, version: 0, }) const { store } = await setup({ schema: secp256k1Account, storage, }) expect(store.getState()).toMatchInlineSnapshot(` { "accessKeys": [], "accounts": [ { "address": "0x0000000000000000000000000000000000000002", "keyType": "secp256k1", "privateKey": "0x1234", }, ], "activeAccount": 0, "chainId": 456, } `) }) test('behavior: preserves in-memory account credentials during hydration', async () => { let resolve!: () => void const hydration = new Promise<void>((r) => (resolve = r)) const storage = { async getItem<value>(name: string) { await hydration if (name !== 'store') return null return { state: { accounts: [{ address: account }], activeAccount: 0, chainId: 456, }, version: 0, } as value }, removeItem() {}, setItem() {}, } satisfies Storage.Storage const store = Store.create({ chainId: 123, storage }) store.setState({ accounts: [ { address: account, keyType: 'secp256k1', privateKey, }, ], }) resolve() await Store.waitForHydration(store) expect(store.getState().accounts).toMatchInlineSnapshot(` [ { "address": "0x0000000000000000000000000000000000000001", "keyType": "secp256k1", "privateKey": "0x1234", }, ] `) }) test('behavior: hydrates accessKeys from storage', async () => { const storage = Storage.memory() const { store: store1 } = await setup({ storage }) store1.setState({ accounts: [{ address: account }], accessKeys: [ { address: accessKey, access: account, chainId: 123, expiry: 9999999999, limits: [{ token: '0x0000000000000000000000000000000000000abc', limit: 500n }], keyType: 'secp256k1', privateKey, }, ], }) const { store: store2 } = await setup({ storage }) expect(store2.getState().accessKeys).toMatchInlineSnapshot(` [ { "access": "0x0000000000000000000000000000000000000001", "address": "0x0000000000000000000000000000000000000099", "chainId": 123, "expiry": 9999999999, "keyType": "secp256k1", "limits": [ { "limit": 500n, "token": "0x0000000000000000000000000000000000000abc", }, ], "privateKey": "0x1234", }, ] `) }) test('behavior: limits persisted accounts', async () => { const { storage, store } = await setup({ maxAccounts: 1 }) store.setState({ accounts: [{ address: account }, { address: account2 }], }) expect((await getPersistedState(storage))?.accounts).toMatchInlineSnapshot(` [ { "address": "0x0000000000000000000000000000000000000001", }, ] `) }) test('behavior: skips access keys when credential persistence is disabled', async () => { const { storage, store } = await setup({ persistCredentials: false }) store.setState({ accounts: [{ address: account }], accessKeys: [ { access: account, address: accessKey, chainId: 123, keyType: 'secp256k1', privateKey, }, ], }) expect(await getPersistedState(storage)).toMatchInlineSnapshot(` { "accounts": [ { "address": "0x0000000000000000000000000000000000000001", }, ], "activeAccount": 0, "chainId": 123, } `) }) test('behavior: persists private key material inline', async () => { const { storage, store } = await setup() store.setState({ accessKeys: [ { access: account, address: accessKey, chainId: 123, keyType: 'secp256k1', privateKey, }, ], }) expect((await getPersistedState(storage))?.accessKeys).toMatchInlineSnapshot(` [ { "access": "0x0000000000000000000000000000000000000001", "address": "0x0000000000000000000000000000000000000099", "chainId": 123, "keyType": "secp256k1", "privateKey": "0x1234", }, ] `) }) test('behavior: strips WebCrypto key pairs from JSON-backed storage', async () => { const keyPair = await WebCryptoP256.createKeyPair() const storage = createJsonStorage() const { store } = await setup({ storage }) store.setState({ accessKeys: [ { access: account, address: accessKey, chainId: 123, keyPair, keyType: 'webCrypto', }, ], }) expect((await getPersistedState(storage))?.accessKeys).toMatchInlineSnapshot(` [ { "access": "0x0000000000000000000000000000000000000001", "address": "0x0000000000000000000000000000000000000099", "chainId": 123, "keyType": "webCrypto", }, ] `) }) test('behavior: keeps WebCrypto key pairs in structured-clone storage', async () => { const keyPair = await WebCryptoP256.createKeyPair() const { storage, store } = await setup() store.setState({ accessKeys: [ { access: account, address: accessKey, chainId: 123, keyPair, keyType: 'webCrypto', }, ], }) const accessKeys = (await getPersistedState(storage))?.accessKeys as Record<string, unknown>[] expect('keyPair' in accessKeys[0]!).toMatchInlineSnapshot(`true`) }) test('behavior: strips structured-clone keystore handles from JSON-backed storage', async () => { const storage = createJsonStorage() const { store } = await setup({ storage }) store.setState({ accessKeys: [ { access: account, address: accessKey, chainId: 123, handle: { kind: 'webcrypto-p256' }, keyType: 'p256', publicKey: '0x1234', }, ], }) const accessKeys = (await getPersistedState(storage))?.accessKeys as Record<string, unknown>[] expect('handle' in accessKeys[0]!).toMatchInlineSnapshot(`false`) }) test('behavior: keeps structured-clone keystore handles in structured-clone storage', async () => { const { storage, store } = await setup() store.setState({ accessKeys: [ { access: account, address: accessKey, chainId: 123, handle: { kind: 'webcrypto-p256' }, keyType: 'p256', publicKey: '0x1234', }, ], }) const accessKeys = (await getPersistedState(storage))?.accessKeys as Record<string, unknown>[] expect('handle' in accessKeys[0]!).toMatchInlineSnapshot(`true`) }) test('behavior: persists json keystore handles inline and rehydrates them', async () => { const keystore = testKeystore() const keystores = { p256: keystore } const key = await keystore.createKey() const storage = createJsonStorage() const { store } = await setup({ keystores, storage }) store.setState({ accessKeys: [ { access: account, address: accessKey, chainId: 123, handle: key.handle, keyType: 'p256', publicKey: key.publicKey, }, ], }) const persisted = (await getPersistedState(storage))?.accessKeys as Record<string, unknown>[] expect(persisted[0]!.handle).toMatchObject({ kind: 'test' }) const store2 = Store.create({ chainId: 123, keystores, storage }) await Store.waitForHydration(store2) const hydrated = await store2.accessKeys.get({ accessKey, account, chainId: 123 }) expect(hydrated?.accessKeyAddress).toBeDefined() expect(keystore.stats.toAccountCalls).toMatchInlineSnapshot(`1`) }) test('behavior: drops legacy access key without chain context', async () => { const storage = Storage.memory() storage.setItem('store', { state: { accessKeys: [ { access: account, address: accessKey, keyPair: {}, keyType: 'webCrypto', }, ], }, version: 0, }) const { store } = await setup({ storage }) expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`) }) test('behavior: disconnect clears accounts, auth, and access keys', async () => { const { store } = await setup({ storage: Storage.memory() }) store.setState({ accounts: [{ address: account }], accessKeys: [ { address: accessKey, access: account, chainId: 123, keyType: 'secp256k1', privateKey, }, ], auth: { logout: 'https://example.com/logout' }, }) store.disconnect() expect(store.getState().accessKeys).toMatchInlineSnapshot(`[]`) expect(store.getState().accounts).toMatchInlineSnapshot(`[]`) expect(store.getState().auth).toMatchInlineSnapshot(`undefined`) }) test('behavior: custom storage key', async () => { const storage = Storage.memory({ key: 'custom' }) const { store } = await setup({ storage }) store.setState({ chainId: 789 }) expect((await getPersistedState(storage))?.chainId).toMatchInlineSnapshot(`789`) }) }) describe('waitForHydration', () => { test('default: resolves after hydration', async () => { const storage = Storage.memory() storage.setItem('store', { state: { accounts: [{ address: '0x0000000000000000000000000000000000000001' }], activeAccount: 0, chainId: 789, }, version: 0, }) const store = Store.create({ chainId: 123, storage }) await Store.waitForHydration(store) expect(store.getState()).toMatchInlineSnapshot(` { "accessKeys": [], "accounts": [ { "address": "0x0000000000000000000000000000000000000001", }, ], "activeAccount": 0, "chainId": 789, } `) }) test('behavior: resolves multiple times', async () => { const store = Store.create({ chainId: 123 }) await Store.waitForHydration(store) await Store.waitForHydration(store) expect(store.getState().chainId).toMatchInlineSnapshot(`123`) }) })