accounts
Version:
Tempo Accounts SDK
618 lines (552 loc) • 18.7 kB
text/typescript
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}",
},
]
`)
})
})