UNPKG

accounts

Version:

Tempo Accounts SDK

618 lines (552 loc) 18.7 kB
import { Address, Base64, Bytes, Hex, Json, PublicKey } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { parseUnits, type Address as viem_Address } from 'viem' import { Actions, Addresses } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import { Identity, type MobileWebAuth } from 'wata' import { Wata as HostWata, mobileWebAuth as hostMobileWebAuth } from 'wata/host' import * as z from 'zod/mini' import { accounts, chain, getClient } from '../../../../test/config.js' import * as Provider from '../../Provider.js' import * as Storage from '../../Storage.js' import * as Store from '../../Store.js' import * as Rpc from '../../zod/rpc.js' import { mobileWebAuth } from './mobileWebAuth.js' import { tempoWallet } from './tempoWallet.js' const callback = 'xyz.tempo.accounts.playground:/auth' const consumerOrigin = 'https://accounts-playground.example' const hostIdentity = Base64.fromBytes(Bytes.random(32), { pad: false, url: true }) const hostOrigin = 'https://wallet.tempo.xyz' const root = accounts[0]! const transferCall = Actions.token.transfer.call({ to: '0x0000000000000000000000000000000000000001', token: Addresses.pathUsd, amount: parseUnits('1', 6), }) async function fund(address: viem_Address) { await Actions.token.transferSync(getClient(), { account: root, feeToken: Addresses.pathUsd, to: address, token: Addresses.pathUsd, amount: parseUnits('10', 6), }) } function asyncJsonStorage(options: Storage.from.Options = {}) { const store = new Map<string, string>() return Storage.from( { async getItem(name) { const raw = store.get(name) if (!raw) return null return Json.parse(raw) }, async setItem(name, value) { store.set(name, Json.stringify(value)) }, async removeItem(name) { store.delete(name) }, }, options, ) } function createWallet() { let calls = 0 const fetches: string[] = [] const requests: { method: string; params: unknown }[] = [] return { calls: () => calls, fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { const request = input instanceof Request ? input : new Request(input, init) fetches.push(request.url) if (request.url === `${hostOrigin}/.well-known/urpc/host.json`) return Response.json({ id: 'wallet.tempo.xyz', identity_pubkey: hostIdentity, name: 'Tempo Wallet', origin: hostOrigin, transports: { 'mobile-web-auth': { auth_url: `${hostOrigin}/auth/mobile` }, }, version: '1.0', }) throw new Error(`unexpected fetch to ${request.url}`) }, fetches: () => fetches, requests: () => requests, openAuthSession: (async ({ authorizationUrl }) => { calls += 1 const host = await createHost(requests) const response = await host.fetch(new Request(authorizationUrl)) return response.headers.get('location') ?? undefined }) satisfies NonNullable<MobileWebAuth.Options['openAuthSession']>, } } async function createHost(requests: { method: string; params: unknown }[]) { const host = HostWata.create({ baseUrl: hostOrigin, identity: Identity.fromPrivateKey( '0x2222222222222222222222222222222222222222222222222222222222222222', ), meta: { name: 'Tempo Wallet' }, transports: [ hostMobileWebAuth({ fetch: async (input): Promise<Response> => { const url = input instanceof Request ? input.url : String(input) if (url === `${consumerOrigin}/.well-known/urpc/consumer.json`) return Response.json({ callback_urls: [callback], id: 'accounts-playground.example', name: 'Accounts RN Test', origin: consumerOrigin, version: '1.0', }) throw new Error(`unexpected fetch to ${url}`) }, html: { authenticate: ({ actions }) => actions.approve(), }, path: '/auth/mobile', }), ], }) const session = await host.start() session.onRequest(async (event) => { requests.push({ method: event.method, params: event.params }) if (event.method === 'wallet_connect') { const [parameters] = z.decode(Rpc.wallet_connect.schema.params!, event.params as never) ?? [] const capabilities = parameters?.capabilities const authorization = capabilities?.authorizeAccessKey await event.respond({ accounts: [ { address: root.address, capabilities: { ...(authorization ? { keyAuthorization: KeyAuthorization.toRpc( await signKeyAuthorization(authorization), ), } : {}), ...(capabilities?.personalSign ? { personalSign: capabilities.personalSign } : {}), ...(await signature(capabilities)), }, }, ], }) return } if (event.method === 'wallet_authorizeAccessKey') { const [parameters] = z.decode( Rpc.wallet_authorizeAccessKey.schema.params!, event.params as never, ) await event.respond({ keyAuthorization: KeyAuthorization.toRpc(await signKeyAuthorization(parameters)), rootAddress: root.address, }) return } if (event.method === 'personal_sign') { const [data] = z.decode(Rpc.personal_sign.schema.params!, event.params as never) await event.respond(await root.signMessage({ message: { raw: data } })) return } if (event.method === 'wallet_deposit') { await event.respond({ receipts: [] }) return } if (event.method === 'wallet_revokeAccessKey') { await event.respond(null) } }) return host } async function signKeyAuthorization(parameters: AdapterAuthorizeParameters) { return await root.signKeyAuthorization( { accessKeyAddress: accessKeyAddress(parameters), keyType: parameters.keyType ?? 'p256', }, { chainId: parameters.chainId ?? BigInt(chain.id), expiry: parameters.expiry, ...(parameters.limits ? { limits: parameters.limits } : {}), }, ) } function accessKeyAddress(parameters: AdapterAuthorizeParameters) { if (parameters.address) return parameters.address if (!parameters.publicKey) throw new Error('Expected access key address or public key in wallet request.') return Address.fromPublicKey(PublicKey.fromHex(parameters.publicKey)) } async function signature( capabilities: | NonNullable<NonNullable<Rpc.wallet_connect.Decoded['params']>[number]['capabilities']> | undefined, ) { if (capabilities?.digest) return { signature: await root.sign({ hash: capabilities.digest }) } if (capabilities?.personalSign) return { signature: await root.signMessage({ message: capabilities.personalSign.message }) } return {} } function connectParameters(wallet: ReturnType<typeof createWallet>, index: number) { const [parameters] = z.decode(Rpc.wallet_connect.schema.params!, wallet.requests()[index]!.params as never) ?? [] return parameters } function authorizeParameters(wallet: ReturnType<typeof createWallet>, index: number) { const [parameters] = z.decode( Rpc.wallet_authorizeAccessKey.schema.params!, wallet.requests()[index]!.params as never, ) return parameters } function personalSignParameters(wallet: ReturnType<typeof createWallet>, index: number) { const [data, address] = z.decode( Rpc.personal_sign.schema.params!, wallet.requests()[index]!.params as never, ) return { address, data } } type AdapterAuthorizeParameters = NonNullable< Rpc.wallet_authorizeAccessKey.Decoded['params'] >[number] function createProvider( wallet: ReturnType<typeof createWallet>, options: Omit<Provider.create.Options, 'adapter' | 'chains'> & Partial< Pick< mobileWebAuth.Options, 'baseUrl' | 'host' | 'name' | 'openAuthSession' | 'rdns' | 'redirectUri' > > = {}, ) { const { baseUrl = consumerOrigin, host = hostOrigin, name = 'Accounts RN Test', openAuthSession = wallet.openAuthSession, rdns = 'xyz.tempo.accounts.playground', redirectUri = callback, ...rest } = options return Provider.create({ chains: [chain], ...rest, adapter: mobileWebAuth({ baseUrl, fetch: wallet.fetch, host, name, openAuthSession, rdns, redirectUri, }), }) } describe('create', () => { test('behavior: tempoWallet defaults host to the Tempo Wallet origin', async () => { const wallet = createWallet() const provider = Provider.create({ adapter: tempoWallet({ baseUrl: consumerOrigin, fetch: wallet.fetch, openAuthSession: wallet.openAuthSession, redirectUri: callback, }), chains: [chain], storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(wallet.fetches()).toMatchInlineSnapshot(` [ "https://wallet.tempo.xyz/.well-known/urpc/host.json", ] `) expect(wallet.requests()[0]?.method).toBe('wallet_connect') }) test('behavior: accepts explicit mobile web auth adapter', async () => { const wallet = createWallet() const provider = Provider.create({ adapter: mobileWebAuth({ baseUrl: consumerOrigin, fetch: wallet.fetch, host: hostOrigin, name: 'Custom Mobile App', openAuthSession: wallet.openAuthSession, rdns: 'xyz.tempo.custom', redirectUri: callback, }), chains: [chain], storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(wallet.requests()[0]?.method).toBe('wallet_connect') }) test('behavior: persists managed access keys through provider storage', async () => { const storage = asyncJsonStorage({ key: 'react-native-managed-key' }) const wallet = createWallet() const provider1 = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage, }) const result = await provider1.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', name: 'Accounts RN Test' } }], }) expect(result.accounts[0]!.address).toBe(root.address) expect( connectParameters(wallet, 0)?.capabilities?.authorizeAccessKey?.keyType, ).toMatchInlineSnapshot(`"p256"`) await fund(root.address) const provider2 = createProvider(wallet, { storage, }) await Store.waitForHydration(provider2.store) const receipt = await provider2.request({ method: 'eth_sendTransactionSync', params: [{ calls: [transferCall], feeToken: Addresses.pathUsd }], }) expect(receipt.status).toBe('0x1') expect(wallet.calls()).toBe(1) }) test('behavior: forwards showDeposit boolean through wallet_connect for registration', async () => { const wallet = createWallet() const provider = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', showDeposit: true } }], }) expect(connectParameters(wallet, 0)?.capabilities?.showDeposit).toBe(true) }) test('behavior: forwards showDeposit boolean through wallet_connect for login', async () => { const wallet = createWallet() const provider = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login', showDeposit: true } }], }) expect(connectParameters(wallet, 0)?.capabilities?.showDeposit).toBe(true) }) test('behavior: forwards showDeposit params through wallet_connect for registration', async () => { const wallet = createWallet() const provider = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', showDeposit: { amount: '50', displayName: 'DoorDash', on: 'register', token: 'USDC', }, }, }, ], }) expect(connectParameters(wallet, 0)?.capabilities?.showDeposit).toMatchInlineSnapshot(` { "amount": "50", "displayName": "DoorDash", "on": "register", "token": "USDC", } `) }) test('behavior: forwards showDeposit params through wallet_authorizeAccessKey', async () => { const wallet = createWallet() const provider = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) await provider.request({ method: 'wallet_authorizeAccessKey', params: [ { expiry: Math.floor(Date.now() / 1000) + 3600, showDeposit: { amount: '25', token: 'USDC', }, }, ], }) expect(authorizeParameters(wallet, 1).showDeposit).toMatchInlineSnapshot(` { "amount": "25", "token": "USDC", } `) }) test('behavior: forwards personalSign through wallet_connect and returns signature', async () => { const wallet = createWallet() const provider = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage: Storage.memory(), }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login', personalSign: { message: 'hello' } } }], }) expect(connectParameters(wallet, 0)?.capabilities?.personalSign).toMatchInlineSnapshot(` { "message": "hello", } `) expect(result.accounts[0]!.capabilities.personalSign).toMatchInlineSnapshot(` { "message": "hello", } `) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: forwards digest through wallet_connect and returns signature', async () => { const wallet = createWallet() const provider = createProvider(wallet, { authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage: Storage.memory(), }) const digest = Hex.random(32) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { digest, method: 'login' } }], }) expect(connectParameters(wallet, 0)?.capabilities?.digest).toBe(digest) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: forwards personal_sign through mobile web auth', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) const data = Hex.fromString('hello') const signature = await provider.request({ method: 'personal_sign', params: [data, root.address], }) const forwarded = personalSignParameters(wallet, 1) expect(forwarded.address).toBe(root.address) expect(forwarded.data).toBe(data) expect(signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: forwards wallet_deposit through mobile web auth', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) const result = await provider.request({ method: 'wallet_deposit', params: [{ amount: '25', token: 'USDC' }], }) expect(wallet.requests()[0]).toMatchInlineSnapshot(` { "method": "wallet_deposit", "params": [ { "amount": "25", "token": "USDC", }, ], } `) expect(result).toMatchInlineSnapshot(` { "receipts": [], } `) }) test('behavior: disconnects locally without opening an auth session', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(provider.store.getState().accounts).toHaveLength(1) await provider.request({ method: 'wallet_disconnect' }) expect(provider.store.getState().accounts).toHaveLength(0) expect(wallet.calls()).toBe(1) }) test('behavior: switches chains locally without opening an auth session', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: Hex.fromNumber(chain.id) }], }) expect(await provider.request({ method: 'eth_chainId' })).toBe(Hex.fromNumber(chain.id)) expect(wallet.calls()).toBe(0) }) test('behavior: forwards wallet_revokeAccessKey through mobile web auth', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) await provider.request({ method: 'wallet_revokeAccessKey', params: [{ accessKeyAddress: root.address, address: root.address }], }) const request = wallet.requests()[1]! expect(request.method).toBe('wallet_revokeAccessKey') expect(request.params).toMatchInlineSnapshot(` [ { "accessKeyAddress": "${root.address}", "address": "${root.address}", }, ] `) }) })