accounts
Version:
Tempo Accounts SDK
622 lines (569 loc) • 19.1 kB
text/typescript
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Address, Hex, PublicKey } from 'ox'
import { KeyAuthorization } from 'ox/tempo'
import { type Address as ViemAddress, parseUnits } from 'viem'
import { Actions, Addresses } from 'viem/tempo'
import { describe, expect, test } from 'vp/test'
import * as z from 'zod/mini'
import { accounts, chain, getClient } from '../../test/config.js'
import { createServer } from '../../test/utils.js'
import * as CliAuth from '../server/CliAuth.js'
import * as Handler from '../server/Handler.js'
import * as Keyring from './keyring.js'
import * as Provider from './Provider.js'
const root = accounts[0]!
const accessKey = accounts[1]!
const accessKey_2 = accounts[2]!
const expiry = Math.floor(Date.now() / 1000) + 3_600
const expiry_2 = expiry + 60
async function authorize(
code: string,
options: {
accessKey?: typeof accessKey | undefined
expiry?: number | undefined
} = {},
) {
const { accessKey: key = accessKey, expiry: expiry_ = expiry } = options
const signed = await root.signKeyAuthorization(
{
accessKeyAddress: key.address,
keyType: key.keyType,
},
{
chainId: BigInt(chain.id),
expiry: expiry_,
},
)
const keyAuthorization = KeyAuthorization.toRpc(signed)
return z.encode(CliAuth.authorizeRequest, {
accountAddress: root.address,
code,
keyAuthorization: z.decode(CliAuth.keyAuthorization, {
...keyAuthorization,
address: keyAuthorization.keyId,
}),
})
}
function connectRequest(
options: {
accessKey?: typeof accessKey | undefined
expiry?: number | undefined
} = {},
) {
const { accessKey: key = accessKey, expiry: expiry_ = expiry } = options
return {
method: 'wallet_connect',
params: [
{
capabilities: {
authorizeAccessKey: {
expiry: expiry_,
keyType: key.keyType,
publicKey: key.publicKey,
},
},
},
],
} as const
}
function createHandler() {
let random = 0
return Handler.codeAuth({
path: '/cli-auth',
chains: [chain],
policy: {
validate({ expiry: requestedExpiry, limits }) {
return {
expiry: requestedExpiry ?? expiry,
...(limits ? { limits } : {}),
}
},
},
random: () => {
const out = new Uint8Array(Array.from({ length: 8 }, (_, i) => random + i))
random += 8
return out
},
})
}
async function authorizePending(serverUrl: string, code: string) {
const response = await fetch(`${serverUrl}/cli-auth/pending/${code}`)
const pending = z.decode(CliAuth.pendingResponse, (await response.json()) as never)
const signed = await root.signKeyAuthorization(
{
accessKeyAddress: Address.fromPublicKey(PublicKey.from(pending.pubKey)),
keyType: pending.keyType,
},
{
chainId: pending.chainId,
expiry: pending.expiry,
...(pending.limits ? { limits: pending.limits } : {}),
},
)
const keyAuthorization = KeyAuthorization.toRpc(signed)
return z.encode(CliAuth.authorizeRequest, {
accountAddress: root.address,
code,
keyAuthorization: z.decode(CliAuth.keyAuthorization, {
...keyAuthorization,
address: keyAuthorization.keyId,
}),
})
}
async function createKeysPath() {
return join(await mkdtemp(join(tmpdir(), 'accounts-cli-')), 'keys.toml')
}
async function fund(address: ViemAddress) {
await Actions.token.transferSync(getClient(), {
account: root,
feeToken: Addresses.pathUsd,
to: address,
token: Addresses.pathUsd,
amount: parseUnits('10', 6),
})
}
const transferCall = Actions.token.transfer.call({
to: '0x0000000000000000000000000000000000000001',
token: Addresses.pathUsd,
amount: parseUnits('1', 6),
})
describe('Provider.create', () => {
test('default: bootstraps wallet_connect through the device-code flow', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
const opened: string[] = []
try {
const provider = Provider.create({
chains: [chain],
open: async (url) => {
opened.push(url)
const code = new URL(url).searchParams.get('code')!
console.log('[DEBUG test open] authorizing code', code)
const res = await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(await authorize(code)),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
console.log('[DEBUG test open] authorize response', res.status, await res.clone().text())
},
host: `${server.url}/cli-auth`,
})
const result = await provider.request(connectRequest())
const account = result.accounts[0]!
const keyAuthorization = account.capabilities.keyAuthorization
? {
...account.capabilities.keyAuthorization,
signature: {
type: account.capabilities.keyAuthorization.signature.type,
},
}
: undefined
expect({
account: {
address: account.address,
capabilities: keyAuthorization ? { keyAuthorization } : {},
},
opened: opened.map((url) => url.replace(server.url, 'http://service')),
}).toMatchInlineSnapshot(`
{
"account": {
"address": "${root.address}",
"capabilities": {
"keyAuthorization": {
"address": "${accessKey.address}",
"chainId": "${Hex.fromNumber(chain.id)}",
"expiry": "${Hex.fromNumber(expiry)}",
"keyId": "${accessKey.address}",
"keyType": "secp256k1",
"signature": {
"type": "secp256k1",
},
},
},
},
"opened": [
"http://service/cli-auth?code=ABCDEFGH",
],
}
`)
} finally {
await server.closeAsync()
}
})
test('behavior: browser-open failures surface the URL and code', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
try {
const provider = Provider.create({
chains: [chain],
open() {
throw new Error('browser unavailable')
},
host: `${server.url}/cli-auth`,
})
await expect(
provider
.request(connectRequest())
.catch((error: { code: number; message: string; name: string }) => {
return {
code: error.code,
message: error.message.replace(server.url, 'http://service'),
name: error.name,
}
}),
).resolves.toMatchInlineSnapshot(`
{
"code": -32603,
"message": "Failed to open browser for device code ABCD-EFGH. Open http://service/cli-auth?code=ABCDEFGH manually.",
"name": "RpcResponse.InternalError",
}
`)
} finally {
await server.closeAsync()
}
})
test('behavior: times out while waiting for authorization', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
try {
const provider = Provider.create({
chains: [chain],
open() {},
pollIntervalMs: 1,
host: `${server.url}/cli-auth`,
timeoutMs: 10,
})
await expect(
provider
.request(connectRequest())
.catch((error: { code: number; message: string; name: string }) => {
return {
code: error.code,
message: error.message.replace(server.url, 'http://service'),
name: error.name,
}
}),
).resolves.toMatchInlineSnapshot(`
{
"code": -32603,
"message": "Timed out waiting for device code ABCD-EFGH. Continue at http://service/cli-auth?code=ABCDEFGH.",
"name": "RpcResponse.InternalError",
}
`)
} finally {
await server.closeAsync()
}
})
test('behavior: authorizes an access key while disconnected when publicKey is provided', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
const opened: string[] = []
try {
const provider = Provider.create({
chains: [chain],
open: async (url) => {
opened.push(url)
const code = new URL(url).searchParams.get('code')!
try {
await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(
await authorize(code, { accessKey: accessKey_2, expiry: expiry_2 }),
),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
} catch {}
},
host: `${server.url}/cli-auth`,
})
const result = await provider.request({
method: 'wallet_authorizeAccessKey',
params: [
{ expiry: expiry_2, keyType: accessKey_2.keyType, publicKey: accessKey_2.publicKey },
],
})
const keyAuthorization = {
...result.keyAuthorization,
signature: {
type: result.keyAuthorization.signature.type,
},
}
expect({
keyAuthorization,
rootAddress: result.rootAddress,
opened: opened.map((url) => url.replace(server.url, 'http://service')),
}).toMatchInlineSnapshot(`
{
"keyAuthorization": {
"address": "${accessKey_2.address}",
"chainId": "${Hex.fromNumber(chain.id)}",
"expiry": "${Hex.fromNumber(expiry_2)}",
"keyId": "${accessKey_2.address}",
"keyType": "secp256k1",
"signature": {
"type": "secp256k1",
},
},
"opened": [
"http://service/cli-auth?code=ABCDEFGH",
],
"rootAddress": "${root.address}",
}
`)
} finally {
await server.closeAsync()
}
})
test('behavior: authorizes an access key for the active account', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
const opened: string[] = []
try {
const approvals = [
(code: string) => authorize(code),
(code: string) => authorize(code, { accessKey: accessKey_2, expiry: expiry_2 }),
]
const provider = Provider.create({
chains: [chain],
open: async (url) => {
opened.push(url)
const approve = approvals.shift()
if (!approve) throw new Error('Unexpected device-code approval request.')
const code = new URL(url).searchParams.get('code')!
await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(await approve(code)),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
},
host: `${server.url}/cli-auth`,
})
await provider.request(connectRequest())
const result = await provider.request({
method: 'wallet_authorizeAccessKey',
params: [
{ expiry: expiry_2, keyType: accessKey_2.keyType, publicKey: accessKey_2.publicKey },
],
})
const keyAuthorization = {
...result.keyAuthorization,
signature: {
type: result.keyAuthorization.signature.type,
},
}
expect({
keyAuthorization,
rootAddress: result.rootAddress,
opened: opened.map((url) => url.replace(server.url, 'http://service')),
}).toMatchInlineSnapshot(`
{
"keyAuthorization": {
"address": "${accessKey_2.address}",
"chainId": "${Hex.fromNumber(chain.id)}",
"expiry": "${Hex.fromNumber(expiry_2)}",
"keyId": "${accessKey_2.address}",
"keyType": "secp256k1",
"signature": {
"type": "secp256k1",
},
},
"opened": [
"http://service/cli-auth?code=ABCDEFGH",
"http://service/cli-auth?code=JKLMNPQR",
],
"rootAddress": "${root.address}",
}
`)
} finally {
await server.closeAsync()
}
})
test('behavior: rejects unsupported revokeAccessKey after bootstrap', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
try {
const provider = Provider.create({
chains: [chain],
open: async (url) => {
const code = new URL(url).searchParams.get('code')!
await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(await authorize(code)),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
},
host: `${server.url}/cli-auth`,
})
await provider.request(connectRequest())
await expect(
provider.request({
method: 'wallet_revokeAccessKey',
params: [{ accessKeyAddress: accessKey.address, address: root.address }],
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Provider.UnsupportedMethodError: \`wallet_revokeAccessKey\` not supported by CLI adapter.]`,
)
} finally {
await server.closeAsync()
}
})
test('behavior: generates, persists, and uses a managed key during wallet_connect', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
const keysPath = await createKeysPath()
try {
const provider = Provider.create({
chains: [chain],
keysPath,
open: async (url) => {
const code = new URL(url).searchParams.get('code')!
await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(await authorizePending(server.url, code)),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
},
host: `${server.url}/cli-auth`,
})
const result = await provider.request({
method: 'wallet_connect',
params: [{ capabilities: { authorizeAccessKey: { expiry: expiry_2 } } }],
})
const account = result.accounts[0]!
await fund(account.address)
const receipt = await provider.request({
method: 'eth_sendTransactionSync',
params: [{ calls: [transferCall] }],
})
const [entry] = await Keyring.load({ path: keysPath })
const { key, keyAuthorization, ...persisted } = entry!
expect(receipt.status).toMatchInlineSnapshot(`"0x1"`)
expect({
...persisted,
keyAddress: persisted.keyAddress.toLowerCase(),
}).toMatchInlineSnapshot(`
{
"chainId": ${chain.id},
"expiry": ${expiry_2},
"keyAddress": "${account.capabilities.keyAuthorization!.keyId.toLowerCase()}",
"keyType": "secp256k1",
"walletAddress": "${root.address}",
"walletType": "passkey",
}
`)
expect(key).toMatch(/^0x[0-9a-f]{64}$/i)
expect(keyAuthorization).toMatch(/^0x[0-9a-f]+$/i)
} finally {
await server.closeAsync()
}
})
test('behavior: generates a managed key for wallet_authorizeAccessKey without publicKey', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
const keysPath = await createKeysPath()
try {
const provider = Provider.create({
chains: [chain],
keysPath,
open: async (url) => {
const code = new URL(url).searchParams.get('code')!
await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(await authorizePending(server.url, code)),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
},
host: `${server.url}/cli-auth`,
})
const result = await provider.request({
method: 'wallet_authorizeAccessKey',
params: [{ expiry: expiry_2 }],
})
await fund(result.rootAddress)
const receipt = await provider.request({
method: 'eth_sendTransactionSync',
params: [{ calls: [transferCall] }],
})
const toml = await readFile(keysPath, 'utf8')
expect(receipt.status).toMatchInlineSnapshot(`"0x1"`)
expect(toml).toContain(`wallet_address = "${root.address}"`)
expect(toml).toContain(`chain_id = ${chain.id}`)
} finally {
await server.closeAsync()
}
})
test('behavior: regenerates a managed key when the requested key type changes', async () => {
const handler = createHandler()
const server = await createServer(handler.listener)
const keysPath = await createKeysPath()
try {
const provider = Provider.create({
chains: [chain],
keysPath,
open: async (url) => {
const code = new URL(url).searchParams.get('code')!
await fetch(`${server.url}/cli-auth`, {
body: JSON.stringify(await authorizePending(server.url, code)),
headers: { 'content-type': 'application/json' },
method: 'POST',
})
},
host: `${server.url}/cli-auth`,
})
const first = await provider.request({
method: 'wallet_authorizeAccessKey',
params: [{ expiry }],
})
const second = await provider.request({
method: 'wallet_authorizeAccessKey',
params: [{ expiry: expiry_2, keyType: 'p256' }],
})
await fund(second.rootAddress)
const receipt = await provider.request({
method: 'eth_sendTransactionSync',
params: [{ calls: [transferCall] }],
})
const keys = await Keyring.load({ path: keysPath })
expect({
first: {
keyId: first.keyAuthorization.keyId,
keyType: first.keyAuthorization.keyType,
},
second: {
keyId: second.keyAuthorization.keyId,
keyType: second.keyAuthorization.keyType,
},
stored: keys.map((key) => ({
keyAddress: key.keyAddress.toLowerCase(),
keyType: key.keyType,
})),
}).toMatchInlineSnapshot(`
{
"first": {
"keyId": "${first.keyAuthorization.keyId}",
"keyType": "secp256k1",
},
"second": {
"keyId": "${second.keyAuthorization.keyId}",
"keyType": "p256",
},
"stored": [
{
"keyAddress": "${first.keyAuthorization.keyId}",
"keyType": "secp256k1",
},
{
"keyAddress": "${second.keyAuthorization.keyId}",
"keyType": "p256",
},
],
}
`)
expect(receipt.status).toMatchInlineSnapshot(`"0x1"`)
} finally {
await server.closeAsync()
}
})
})