@lukasrosario/account
Version:
Base Account SDK
593 lines (532 loc) • 16.9 kB
text/typescript
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,
};
}