accounts
Version:
Tempo Accounts SDK
362 lines • 16.4 kB
JavaScript
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { Address, Base64, Hash, Hex, P256, Provider as core_Provider, PublicKey, RpcResponse, } from 'ox';
import { KeyAuthorization } from 'ox/tempo';
import { prepareTransactionRequest } from 'viem/actions';
import { Account as TempoAccount, Secp256k1 } from 'viem/tempo';
import * as z from 'zod/mini';
import * as AccessKey from '../core/AccessKey.js';
import * as Adapter from '../core/Adapter.js';
import * as CliAuth from '../server/CliAuth.js';
import * as Keyring from './keyring.js';
/**
* Creates a CLI bootstrap adapter backed by the device-code protocol.
*/
export function cli(options) {
const { name = 'Tempo CLI', rdns = 'xyz.tempo.cli' } = options;
return Adapter.define({ name, rdns }, ({ getAccount, getClient, store }) => {
async function loadManagedKey(address, parameters = {}) {
const { keyType } = parameters;
const { chainId } = store.getState();
const entry = await Keyring.find({
chainId,
...(keyType ? { keyType } : {}),
...(options.keysPath ? { path: options.keysPath } : {}),
walletAddress: address,
});
if (!entry)
return;
const deserialized = KeyAuthorization.deserialize(entry.keyAuthorization);
if (!deserialized.signature)
throw new Error('Managed access key is missing a signature.');
const keyAuthorization = deserialized;
AccessKey.save({
address,
keyAuthorization,
privateKey: entry.key,
store,
});
return entry;
}
async function resolveManagedKey(options = {}) {
const { address, keyType } = options;
const requestedKeyType = keyType === 'p256' || keyType === 'secp256k1' ? keyType : undefined;
const entry = address
? await loadManagedKey(address, requestedKeyType ? { keyType: requestedKeyType } : {})
: undefined;
if (entry) {
const account = entry.keyType === 'p256'
? TempoAccount.fromP256(entry.key, { access: address })
: TempoAccount.fromSecp256k1(entry.key, { access: address });
return {
account,
key: entry.key,
keyAddress: entry.keyAddress,
keyType: entry.keyType,
publicKey: account.publicKey,
};
}
const nextKeyType = requestedKeyType === 'p256' ? 'p256' : 'secp256k1';
const key = nextKeyType === 'p256' ? P256.randomPrivateKey() : Secp256k1.randomPrivateKey();
const account = nextKeyType === 'p256'
? TempoAccount.fromP256(key, address ? { access: address } : undefined)
: TempoAccount.fromSecp256k1(key, address ? { access: address } : undefined);
return {
account,
key,
keyAddress: Address.fromPublicKey(PublicKey.from(account.publicKey)),
keyType: nextKeyType,
publicKey: account.publicKey,
};
}
async function saveManagedKey(address, managedKey, keyAuthorization) {
if (!managedKey)
return;
const signed = KeyAuthorization.fromRpc(z.encode(CliAuth.keyAuthorization, keyAuthorization));
AccessKey.save({
address,
keyAuthorization: signed,
privateKey: managedKey.key,
store,
});
await Keyring.upsert({
chainId: Number(keyAuthorization.chainId),
expiry: keyAuthorization.expiry ?? 0,
key: managedKey.key,
keyAddress: managedKey.keyAddress,
keyAuthorization: KeyAuthorization.serialize(signed),
keyType: managedKey.keyType,
...(keyAuthorization.limits
? { limits: keyAuthorization.limits.map((limit) => ({ ...limit })) }
: {}),
walletAddress: address,
walletType: 'passkey',
}, options.keysPath ? { path: options.keysPath } : {});
}
async function withManagedAccessKey(fn) {
const rootAddress = store.getState().accounts[store.getState().activeAccount]?.address;
if (rootAddress)
await loadManagedKey(rootAddress);
const account = getAccount({ signable: true });
const keyAuthorization = AccessKey.getPending(account, { store });
try {
return await fn(account, keyAuthorization ?? undefined);
}
catch (error) {
AccessKey.remove(account, { store });
throw error;
}
}
async function authorize(request) {
const { host, open = defaultOpen, pollIntervalMs = 2_000, timeoutMs = 5 * 60 * 1_000, } = options;
const { account, authorizeAccessKey, method } = request;
const managedKey = authorizeAccessKey && !authorizeAccessKey.publicKey && !authorizeAccessKey.address
? await resolveManagedKey({
...(account ? { address: account } : {}),
...(authorizeAccessKey.keyType ? { keyType: authorizeAccessKey.keyType } : {}),
})
: undefined;
const publicKey = authorizeAccessKey?.publicKey ?? managedKey?.publicKey;
const keyType = authorizeAccessKey?.keyType ?? managedKey?.keyType;
if (!publicKey)
throw new RpcResponse.InvalidParamsError({
message: method === 'wallet_connect'
? '`wallet_connect` on the CLI adapter requires `capabilities.authorizeAccessKey`.'
: '`wallet_authorizeAccessKey` on the CLI adapter requires key parameters.',
});
const codeVerifier = createCodeVerifier();
const codeChallenge = createCodeChallenge(codeVerifier);
const body = {
...(account ? { account } : {}),
chainId: BigInt(store.getState().chainId),
codeChallenge,
...(typeof authorizeAccessKey?.expiry !== 'undefined'
? { expiry: authorizeAccessKey.expiry }
: {}),
...(keyType ? { keyType } : {}),
...(authorizeAccessKey?.limits ? { limits: authorizeAccessKey.limits } : {}),
pubKey: publicKey,
};
const created = await post({
body,
request: CliAuth.createRequest,
response: CliAuth.createResponse,
url: getApiUrl(host, 'code'),
});
const url = getBrowserUrl(host, created.code);
try {
await open(url);
}
catch (error) {
throw new OpenError(url, created.code, error);
}
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const result = await post({
body: {
codeVerifier,
},
request: CliAuth.pollRequest,
response: CliAuth.pollResponse,
url: getApiUrl(host, `poll/${created.code}`),
});
if (result.status === 'pending') {
await sleep(pollIntervalMs);
continue;
}
if (result.status === 'expired')
throw new Error('Device code expired before authorization completed.');
if (managedKey)
await saveManagedKey(result.accountAddress, managedKey, result.keyAuthorization);
return result;
}
throw new TimeoutError(url, created.code);
}
return {
actions: {
async authorizeAccessKey(parameters) {
const { accounts, activeAccount } = store.getState();
const account = accounts[activeAccount]?.address;
const result = await authorize({
...(account ? { account } : {}),
authorizeAccessKey: parameters,
method: 'wallet_authorizeAccessKey',
});
if (!account)
store.setState({
accounts: [{ address: result.accountAddress }],
activeAccount: 0,
});
return {
keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization),
rootAddress: result.accountAddress,
};
},
async createAccount(params, request) {
return this.loadAccounts(params, request);
},
async loadAccounts(parameters) {
if (parameters?.digest)
throw unsupported('`wallet_connect` digest signing not supported by CLI adapter.');
const result = await authorize({
authorizeAccessKey: parameters?.authorizeAccessKey,
method: 'wallet_connect',
});
return {
accounts: [
{
address: result.accountAddress,
capabilities: {},
},
],
keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization),
};
},
async revokeAccessKey() {
throw unsupported('`wallet_revokeAccessKey` not supported by CLI adapter.');
},
async sendTransaction(parameters) {
const { feePayer, ...rest } = parameters;
const client = getClient(typeof feePayer === 'string' ? { feePayer } : {});
const { account, prepared } = await withManagedAccessKey(async (account, keyAuthorization) => ({
account,
prepared: await prepareTransactionRequest(client, {
account,
...rest,
...(feePayer ? { feePayer: true } : {}),
...(keyAuthorization ? { keyAuthorization } : {}),
type: 'tempo',
}),
}));
const signed = await account.signTransaction(prepared);
const result = await client.request({
method: 'eth_sendRawTransaction',
params: [signed],
});
AccessKey.removePending(account, { store });
return result;
},
async sendTransactionSync(parameters) {
const { feePayer, ...rest } = parameters;
const client = getClient(typeof feePayer === 'string' ? { feePayer } : {});
const { account, prepared } = await withManagedAccessKey(async (account, keyAuthorization) => ({
account,
prepared: await prepareTransactionRequest(client, {
account,
...rest,
...(feePayer ? { feePayer: true } : {}),
...(keyAuthorization ? { keyAuthorization } : {}),
type: 'tempo',
}),
}));
const signed = await account.signTransaction(prepared);
const result = await client.request({
method: 'eth_sendRawTransactionSync',
params: [signed],
});
AccessKey.removePending(account, { store });
return result;
},
async signPersonalMessage({ address, data }) {
await loadManagedKey(address);
const account = getAccount({ address, signable: true });
return await account.signMessage({ message: { raw: data } });
},
async signTransaction(parameters) {
const { feePayer, ...rest } = parameters;
const client = getClient(typeof feePayer === 'string' ? { feePayer } : {});
const { account, prepared } = await withManagedAccessKey(async (account, keyAuthorization) => ({
account,
prepared: await prepareTransactionRequest(client, {
account,
...rest,
...(feePayer ? { feePayer: true } : {}),
...(keyAuthorization ? { keyAuthorization } : {}),
type: 'tempo',
}),
}));
return await account.signTransaction(prepared);
},
async signTypedData({ address, data }) {
await loadManagedKey(address);
const account = getAccount({ address, signable: true });
return await account.signTypedData(JSON.parse(data));
},
},
};
});
}
class OpenError extends Error {
code;
cause;
url;
constructor(url, code, cause) {
super(`Failed to open browser for device code ${formatCode(code)}. Open ${url} manually.`);
this.name = 'OpenError';
this.code = code;
this.cause = cause;
this.url = url;
}
}
class TimeoutError extends Error {
code;
url;
constructor(url, code) {
super(`Timed out waiting for device code ${formatCode(code)}. Continue at ${url}.`);
this.name = 'TimeoutError';
this.code = code;
this.url = url;
}
}
function createCodeChallenge(codeVerifier) {
return Base64.fromBytes(Hash.sha256(Hex.fromString(codeVerifier), { as: 'Bytes' }), {
pad: false,
url: true,
});
}
function createCodeVerifier() {
return Base64.fromBytes(Hex.toBytes(Hex.random(32)), { pad: false, url: true });
}
function formatCode(code) {
return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
}
function defaultOpen(url) {
const command = process.platform === 'darwin'
? { command: 'open', args: [url] }
: process.platform === 'win32'
? { command: 'cmd', args: ['/c', 'start', '', url] }
: { command: 'xdg-open', args: [url] };
const child = spawn(command.command, command.args, {
detached: true,
stdio: 'ignore',
});
child.unref();
}
function getApiUrl(serviceUrl, path) {
const url = new URL(serviceUrl);
url.pathname = `${url.pathname.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
url.search = '';
return url.toString();
}
function getBrowserUrl(serviceUrl, code) {
const url = new URL(serviceUrl);
url.searchParams.set('code', code);
return url.toString();
}
async function post(options) {
const result = await fetch(options.url, {
body: JSON.stringify(z.encode(options.request, options.body)),
headers: { 'content-type': 'application/json' },
method: 'POST',
});
const json = (await result.json().catch(() => ({})));
if (!result.ok) {
const error = json.error;
throw new Error(typeof error === 'string' ? error : `Request failed: ${result.status}`);
}
return z.decode(options.response, json);
}
function unsupported(message) {
return new core_Provider.UnsupportedMethodError({ message });
}
//# sourceMappingURL=adapter.js.map