UNPKG

@lukasrosario/account

Version:

Base Account SDK

593 lines (532 loc) 16.9 kB
import { PublicClient, WalletSendCallsParameters, hexToBigInt, isAddress } from 'viem'; import { InsufficientBalanceErrorData } from ':core/error/errors.js'; import { Hex, keccak256, numberToHex, slice, toHex } from 'viem'; import { standardErrors } from ':core/error/errors.js'; import { Attribution, RequestArguments } from ':core/provider/interface.js'; import { EmptyFetchPermissionsRequest, FetchPermissionsRequest, } from ':core/rpc/coinbase_fetchSpendPermissions.js'; import { WalletConnectRequest, WalletConnectResponse } from ':core/rpc/wallet_connect.js'; import { logDialogActionClicked, logDialogDismissed, logDialogShown, } from ':core/telemetry/events/dialog.js'; import { Address } from ':core/type/index.js'; import { config, store } from ':store/store.js'; import { initDialog } from ':ui/Dialog/index.js'; import { get } from ':util/get.js'; import { waitForCallsStatus } from 'viem/actions'; import { getCryptoKeyAccount } from '../../kms/crypto-key/index.js'; import { spendPermissionManagerAddress } from './utils/constants.js'; // *************************************************************** // Utility // *************************************************************** export function getSenderFromRequest(request: RequestArguments) { if (!Array.isArray(request.params)) { return null; } switch (request.method) { case 'personal_sign': return request.params[1] as Address; case 'eth_signTypedData_v4': return request.params[0] as Address; case 'eth_signTransaction': case 'eth_sendTransaction': case 'wallet_sendCalls': return request.params[0]?.from as Address; default: return null; } } export function addSenderToRequest(request: RequestArguments, sender: Address) { if (!Array.isArray(request.params)) { throw standardErrors.rpc.invalidParams(); } const params = [...request.params]; switch (request.method) { case 'eth_signTransaction': case 'eth_sendTransaction': case 'wallet_sendCalls': params[0].from = sender; break; case 'eth_signTypedData_v4': params[0] = sender; break; case 'personal_sign': params[1] = sender; break; default: break; } return { ...request, params }; } export function assertParamsChainId(params: unknown): asserts params is [ { chainId: `0x${string}`; }, ] { if (!params || !Array.isArray(params) || !params[0]?.chainId) { throw standardErrors.rpc.invalidParams(); } if (typeof params[0].chainId !== 'string' && typeof params[0].chainId !== 'number') { throw standardErrors.rpc.invalidParams(); } } export function assertGetCapabilitiesParams( params: unknown ): asserts params is [`0x${string}`, `0x${string}`[]?] { if (!params || !Array.isArray(params) || (params.length !== 1 && params.length !== 2)) { throw standardErrors.rpc.invalidParams(); } if (typeof params[0] !== 'string' || !isAddress(params[0])) { throw standardErrors.rpc.invalidParams(); } if (params.length === 2) { if (!Array.isArray(params[1])) { throw standardErrors.rpc.invalidParams(); } for (const param of params[1]) { if (typeof param !== 'string' || !param.startsWith('0x')) { throw standardErrors.rpc.invalidParams(); } } } } export function injectRequestCapabilities<T extends RequestArguments>( request: T, capabilities: Record<string, unknown> ) { // Modify request to include auto sub account capabilities const modifiedRequest = { ...request }; if (capabilities && request.method.startsWith('wallet_')) { let requestCapabilities = get(modifiedRequest, 'params.0.capabilities'); if (typeof requestCapabilities === 'undefined') { requestCapabilities = {}; } if (typeof requestCapabilities !== 'object') { throw standardErrors.rpc.invalidParams(); } requestCapabilities = { ...capabilities, ...requestCapabilities, }; if (modifiedRequest.params && Array.isArray(modifiedRequest.params)) { modifiedRequest.params[0] = { ...modifiedRequest.params[0], capabilities: requestCapabilities, }; } } return modifiedRequest as T; } /** * Initializes the `subAccountConfig` store with the owner account function and capabilities * @returns void */ export async function initSubAccountConfig() { const config = store.subAccountsConfig.get() ?? {}; const capabilities: WalletConnectRequest['params'][0]['capabilities'] = {}; if (config.enableAutoSubAccounts) { // Get the owner account const { account: owner } = config.toOwnerAccount ? await config.toOwnerAccount() : await getCryptoKeyAccount(); if (!owner) { throw standardErrors.provider.unauthorized('No owner account found'); } capabilities.addSubAccount = { account: { type: 'create', keys: [ { type: owner.address ? 'address' : 'webauthn-p256', publicKey: owner.address || owner.publicKey, }, ], }, }; } // Store the owner account and capabilities in the non-persisted config store.subAccountsConfig.set({ capabilities, }); } export type PermissionDetails = { spender: Address; token: Address; allowance: Hex; salt: Hex; extraData: Hex; }; export type SpendPermission = PermissionDetails & { account: Address; period: number; start: number; end: number; }; export type SpendPermissionBatch = { account: Address; period: number; start: number; end: number; permissions: PermissionDetails[]; }; export function assertFetchPermissionsRequest( request: RequestArguments ): asserts request is FetchPermissionsRequest | EmptyFetchPermissionsRequest { if (request.method === 'coinbase_fetchPermissions' && request.params === undefined) { return; } if ( request.method === 'coinbase_fetchPermissions' && Array.isArray(request.params) && request.params.length === 1 && typeof request.params[0] === 'object' ) { if ( typeof request.params[0].account !== 'string' || !request.params[0].chainId.startsWith('0x') ) { throw standardErrors.rpc.invalidParams( 'FetchPermissions - Invalid params: params[0].account must be a hex string' ); } if ( typeof request.params[0].chainId !== 'string' || !request.params[0].chainId.startsWith('0x') ) { throw standardErrors.rpc.invalidParams( 'FetchPermissions - Invalid params: params[0].chainId must be a hex string' ); } if ( typeof request.params[0].spender !== 'string' || !request.params[0].spender.startsWith('0x') ) { throw standardErrors.rpc.invalidParams( 'FetchPermissions - Invalid params: params[0].spender must be a hex string' ); } return; } throw standardErrors.rpc.invalidParams(); } export function fillMissingParamsForFetchPermissions( request: FetchPermissionsRequest | EmptyFetchPermissionsRequest ): FetchPermissionsRequest { if (request.params !== undefined) { return request as FetchPermissionsRequest; } // this is based on the assumption that the first account is the active account // it could change in the context of multi-(universal)-account const accountFromStore = store.getState().account.accounts?.[0]; const chainId = store.getState().account.chain?.id; const subAccountFromStore = store.getState().subAccount?.address; if (!accountFromStore || !subAccountFromStore || !chainId) { throw standardErrors.rpc.invalidParams( 'FetchPermissions - one or more of account, sub account, or chain id is missing, connect to sub account via wallet_connect first' ); } return { method: 'coinbase_fetchPermissions', params: [ { account: accountFromStore, chainId: numberToHex(chainId), spender: subAccountFromStore, }, ], }; } export function createSpendPermissionMessage({ spendPermission, chainId, }: { spendPermission: SpendPermission; chainId: number; }) { return { domain: { name: 'Spend Permission Manager', version: '1', chainId: chainId, verifyingContract: spendPermissionManagerAddress, }, types: { SpendPermission: [ { name: 'account', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'token', type: 'address' }, { name: 'allowance', type: 'uint160' }, { name: 'period', type: 'uint48' }, { name: 'start', type: 'uint48' }, { name: 'end', type: 'uint48' }, { name: 'salt', type: 'uint256' }, { name: 'extraData', type: 'bytes' }, ], }, primaryType: 'SpendPermission', message: { account: spendPermission.account, spender: spendPermission.spender, token: spendPermission.token, allowance: spendPermission.allowance, period: spendPermission.period, start: spendPermission.start, end: spendPermission.end, salt: spendPermission.salt, extraData: spendPermission.extraData, }, } as const; } export function createSpendPermissionBatchMessage({ spendPermissionBatch, chainId, }: { spendPermissionBatch: SpendPermissionBatch; chainId: number; }) { return { domain: { name: 'Spend Permission Manager', version: '1', chainId, verifyingContract: spendPermissionManagerAddress, }, types: { SpendPermissionBatch: [ { name: 'account', type: 'address' }, { name: 'period', type: 'uint48' }, { name: 'start', type: 'uint48' }, { name: 'end', type: 'uint48' }, { name: 'permissions', type: 'PermissionDetails[]' }, ], PermissionDetails: [ { name: 'spender', type: 'address' }, { name: 'token', type: 'address' }, { name: 'allowance', type: 'uint160' }, { name: 'salt', type: 'uint256' }, { name: 'extraData', type: 'bytes' }, ], }, primaryType: 'SpendPermissionBatch', message: { account: spendPermissionBatch.account, period: spendPermissionBatch.period, start: spendPermissionBatch.start, end: spendPermissionBatch.end, permissions: spendPermissionBatch.permissions.map((p) => ({ spender: p.spender, token: p.token, allowance: p.allowance, salt: p.salt, extraData: p.extraData, })), }, } as const; } export async function waitForCallsTransactionHash({ client, id, }: { client: PublicClient; id: string }) { const result = await waitForCallsStatus(client, { id, }); if (result.status === 'success') { return result.receipts?.[0].transactionHash; } throw standardErrors.rpc.internal('failed to send transaction'); } export function createWalletSendCallsRequest({ calls, from, chainId, capabilities, }: { calls: { to: Address; data: Hex; value: Hex }[]; from: Address; chainId: number; capabilities?: Record<string, unknown>; }) { const paymasterUrls = config.get().paymasterUrls; let request: { method: 'wallet_sendCalls'; params: WalletSendCallsParameters } = { method: 'wallet_sendCalls', params: [ { version: '1.0', calls, chainId: numberToHex(chainId), from, atomicRequired: true, capabilities, }, ], }; if (paymasterUrls?.[chainId]) { request = injectRequestCapabilities(request, { paymasterService: { url: paymasterUrls?.[chainId] }, }); } return request; } export async function presentSubAccountFundingDialog() { const dialog = initDialog(); const userChoice = await new Promise<'update_permission' | 'continue_popup' | 'cancel'>( (resolve) => { logDialogShown({ dialogContext: 'sub_account_insufficient_balance' }); dialog.presentItem({ title: 'Insufficient spend permission', message: "Your spend permission's remaining balance cannot cover this transaction. Please choose how to proceed:", onClose: () => { logDialogDismissed({ dialogContext: 'sub_account_insufficient_balance' }); dialog.clear(); }, actionItems: [ { text: 'Edit spend permission', variant: 'primary', onClick: () => { logDialogActionClicked({ dialogContext: 'sub_account_insufficient_balance', dialogAction: 'create_permission', }); dialog.clear(); resolve('update_permission'); }, }, { text: 'Use primary account', variant: 'secondary', onClick: () => { logDialogActionClicked({ dialogContext: 'sub_account_insufficient_balance', dialogAction: 'continue_in_popup', }); dialog.clear(); resolve('continue_popup'); }, }, ], }); } ); return userChoice; } export function parseFundingOptions({ errorData, sourceAddress, }: { errorData: InsufficientBalanceErrorData; sourceAddress: Address }) { const spendPermissionRequests: { token: Address; requiredAmount: bigint; }[] = []; for (const [token, { amount, sources }] of Object.entries(errorData?.required ?? {})) { const sourcesWithSufficientBalance = sources.filter((source) => { return ( hexToBigInt(source.balance) >= hexToBigInt(amount) && source.address.toLowerCase() === sourceAddress?.toLowerCase() ); }); if (sourcesWithSufficientBalance.length === 0) { throw new Error('Source address has insufficient balance for a token'); } spendPermissionRequests.push({ token: token as `0x${string}`, requiredAmount: hexToBigInt(amount), }); } return spendPermissionRequests; } export function isSendCallsParams(params: unknown): params is WalletSendCallsParameters { return typeof params === 'object' && params !== null && 'calls' in params; } export function isEthSendTransactionParams(params: unknown): params is [ { to: Address; data: Hex; from: Address; value: Hex; }, ] { return ( Array.isArray(params) && params.length === 1 && typeof params[0] === 'object' && params[0] !== null && 'to' in params[0] ); } export function compute16ByteHash(input: string): Hex { return slice(keccak256(toHex(input)), 0, 16); } export function makeDataSuffix({ attribution, dappOrigin, }: { attribution?: Attribution; dappOrigin: string }): Hex | undefined { if (!attribution) { return; } if ('auto' in attribution && attribution.auto && dappOrigin) { return compute16ByteHash(dappOrigin); } if ('dataSuffix' in attribution) { return attribution.dataSuffix; } return; } /** * Checks if a specific capability is present in a request's params * @param request The request object to check * @param capabilityName The name of the capability to check for * @returns boolean indicating if the capability is present */ export function requestHasCapability(request: RequestArguments, capabilityName: string): boolean { if (!Array.isArray(request?.params)) return false; const capabilities = request.params[0]?.capabilities; if (!capabilities || typeof capabilities !== 'object') return false; return capabilityName in capabilities; } /** * Prepends an item to an array without duplicates * @param array The array to prepend to * @param item The item to prepend * @returns The array with the item prepended */ export function prependWithoutDuplicates<T>(array: T[], item: T): T[] { const filtered = array.filter((i) => i !== item); return [item, ...filtered]; } /** * Appends an item to an array without duplicates * @param array The array to append to * @param item The item to append * @returns The array with the item appended */ export function appendWithoutDuplicates<T>(array: T[], item: T): T[] { const filtered = array.filter((i) => i !== item); return [...filtered, item]; } export async function getCachedWalletConnectResponse(): Promise<WalletConnectResponse | null> { const spendPermissions = store.spendPermissions.get(); const subAccount = store.subAccounts.get(); const accounts = store.account.get().accounts; if (!accounts) { return null; } const walletConnectAccounts = accounts?.map<WalletConnectResponse['accounts'][number]>( (account) => ({ address: account, capabilities: { subAccounts: subAccount ? [subAccount] : undefined, spendPermissions: spendPermissions.length > 0 ? { permissions: spendPermissions } : undefined, }, }) ); return { accounts: walletConnectAccounts, }; }