@lukasrosario/account
Version:
Base Account SDK
796 lines (703 loc) • 26.9 kB
text/typescript
import { CB_WALLET_RPC_URL } from ':core/constants.js';
import { Hex, hexToNumber, isAddressEqual, numberToHex } from 'viem';
import { Communicator } from ':core/communicator/Communicator.js';
import { isActionableHttpRequestError, isViemError, standardErrors } from ':core/error/errors.js';
import { RPCRequestMessage, RPCResponseMessage } from ':core/message/RPCMessage.js';
import { RPCResponse } from ':core/message/RPCResponse.js';
import { AppMetadata, ProviderEventCallback, RequestArguments } from ':core/provider/interface.js';
import { FetchPermissionsResponse } from ':core/rpc/coinbase_fetchSpendPermissions.js';
import { WalletConnectResponse } from ':core/rpc/wallet_connect.js';
import { GetSubAccountsResponse } from ':core/rpc/wallet_getSubAccount.js';
import {
logHandshakeCompleted,
logHandshakeError,
logHandshakeStarted,
logRequestCompleted,
logRequestError,
logRequestStarted,
} from ':core/telemetry/events/scw-signer.js';
import {
logAddOwnerCompleted,
logAddOwnerError,
logAddOwnerStarted,
logInsufficientBalanceErrorHandlingCompleted,
logInsufficientBalanceErrorHandlingError,
logInsufficientBalanceErrorHandlingStarted,
logSubAccountRequestCompleted,
logSubAccountRequestError,
logSubAccountRequestStarted,
} from ':core/telemetry/events/scw-sub-account.js';
import { parseErrorMessageFromAny } from ':core/telemetry/utils.js';
import { Address } from ':core/type/index.js';
import { ensureIntNumber, hexStringFromNumber } from ':core/type/util.js';
import { SDKChain, createClients, getClient } from ':store/chain-clients/utils.js';
import { correlationIds } from ':store/correlation-ids/store.js';
import { store } from ':store/store.js';
import { assertArrayPresence, assertPresence } from ':util/assertPresence.js';
import { assertSubAccount } from ':util/assertSubAccount.js';
import {
decryptContent,
encryptContent,
exportKeyToHexString,
importKeyFromHexString,
} from ':util/cipher.js';
import { fetchRPCRequest } from ':util/provider.js';
import { getCryptoKeyAccount } from '../../kms/crypto-key/index.js';
import { SCWKeyManager } from './SCWKeyManager.js';
import {
addSenderToRequest,
appendWithoutDuplicates,
assertFetchPermissionsRequest,
assertGetCapabilitiesParams,
assertParamsChainId,
fillMissingParamsForFetchPermissions,
getCachedWalletConnectResponse,
getSenderFromRequest,
initSubAccountConfig,
injectRequestCapabilities,
makeDataSuffix,
prependWithoutDuplicates,
requestHasCapability,
} from './utils.js';
import { createSubAccountSigner } from './utils/createSubAccountSigner.js';
import { findOwnerIndex } from './utils/findOwnerIndex.js';
import { handleAddSubAccountOwner } from './utils/handleAddSubAccountOwner.js';
import { handleInsufficientBalanceError } from './utils/handleInsufficientBalance.js';
type ConstructorOptions = {
metadata: AppMetadata;
communicator: Communicator;
callback: ProviderEventCallback | null;
};
export class Signer {
private readonly communicator: Communicator;
private readonly keyManager: SCWKeyManager;
private callback: ProviderEventCallback | null;
private accounts: Address[];
private chain: SDKChain;
constructor(params: ConstructorOptions) {
this.communicator = params.communicator;
this.callback = params.callback;
this.keyManager = new SCWKeyManager();
const { account, chains } = store.getState();
this.accounts = account.accounts ?? [];
this.chain = account.chain ?? {
id: params.metadata.appChainIds?.[0] ?? 1,
};
if (chains) {
createClients(chains);
}
}
public get isConnected() {
return this.accounts.length > 0;
}
async handshake(args: RequestArguments) {
const correlationId = correlationIds.get(args);
logHandshakeStarted({ method: args.method, correlationId });
try {
// Open the popup before constructing the request message.
// This is to ensure that the popup is not blocked by some browsers (i.e. Safari)
await this.communicator.waitForPopupLoaded?.();
const handshakeMessage = await this.createRequestMessage(
{
handshake: {
method: args.method,
params: args.params ?? [],
},
},
correlationId
);
const response: RPCResponseMessage =
await this.communicator.postRequestAndWaitForResponse(handshakeMessage);
// store peer's public key
if ('failure' in response.content) {
throw response.content.failure;
}
const peerPublicKey = await importKeyFromHexString('public', response.sender);
await this.keyManager.setPeerPublicKey(peerPublicKey);
const decrypted = await this.decryptResponseMessage(response);
this.handleResponse(args, decrypted);
logHandshakeCompleted({ method: args.method, correlationId });
} catch (error) {
logHandshakeError({
method: args.method,
correlationId,
errorMessage: parseErrorMessageFromAny(error),
});
throw error;
}
}
async request(request: RequestArguments) {
const correlationId = correlationIds.get(request);
logRequestStarted({ method: request.method, correlationId });
try {
const result = await this._request(request);
logRequestCompleted({ method: request.method, correlationId });
return result;
} catch (error) {
logRequestError({
method: request.method,
correlationId,
errorMessage: parseErrorMessageFromAny(error),
});
throw error;
}
}
async _request(request: RequestArguments) {
if (this.accounts.length === 0) {
switch (request.method) {
case 'wallet_switchEthereumChain': {
assertParamsChainId(request.params);
this.chain.id = Number(request.params[0].chainId);
return;
}
case 'wallet_connect': {
// Wait for the popup to be loaded before making async calls
await this.communicator.waitForPopupLoaded?.();
await initSubAccountConfig();
// Check if addSubAccount capability is present and if so, inject the the sub account capabilities
let capabilitiesToInject: Record<string, unknown> = {};
if (requestHasCapability(request, 'addSubAccount')) {
capabilitiesToInject = store.subAccountsConfig.get()?.capabilities ?? {};
}
const modifiedRequest = injectRequestCapabilities(request, capabilitiesToInject);
return this.sendRequestToPopup(modifiedRequest);
}
case 'wallet_sendCalls':
case 'wallet_sign': {
return this.sendRequestToPopup(request);
}
default:
throw standardErrors.provider.unauthorized();
}
}
if (this.shouldRequestUseSubAccountSigner(request)) {
const correlationId = correlationIds.get(request);
logSubAccountRequestStarted({ method: request.method, correlationId });
try {
const result = await this.sendRequestToSubAccountSigner(request);
logSubAccountRequestCompleted({ method: request.method, correlationId });
return result;
} catch (error) {
logSubAccountRequestError({
method: request.method,
correlationId,
errorMessage: parseErrorMessageFromAny(error),
});
throw error;
}
}
switch (request.method) {
case 'eth_requestAccounts':
case 'eth_accounts': {
const subAccount = store.subAccounts.get();
const subAccountsConfig = store.subAccountsConfig.get();
if (subAccount?.address) {
// if auto sub accounts are enabled and we have a sub account, we need to return it as a top level account
// otherwise, we just append it to the accounts array
this.accounts = subAccountsConfig?.enableAutoSubAccounts
? prependWithoutDuplicates(this.accounts, subAccount.address)
: appendWithoutDuplicates(this.accounts, subAccount.address);
}
this.callback?.('connect', { chainId: numberToHex(this.chain.id) });
return this.accounts;
}
case 'eth_coinbase':
return this.accounts[0];
case 'net_version':
return this.chain.id;
case 'eth_chainId':
return numberToHex(this.chain.id);
case 'wallet_getCapabilities':
return this.handleGetCapabilitiesRequest(request);
case 'wallet_switchEthereumChain':
return this.handleSwitchChainRequest(request);
case 'eth_ecRecover':
case 'personal_sign':
case 'wallet_sign':
case 'personal_ecRecover':
case 'eth_signTransaction':
case 'eth_sendTransaction':
case 'eth_signTypedData_v1':
case 'eth_signTypedData_v3':
case 'eth_signTypedData_v4':
case 'eth_signTypedData':
case 'wallet_addEthereumChain':
case 'wallet_watchAsset':
case 'wallet_sendCalls':
case 'wallet_showCallsStatus':
case 'wallet_grantPermissions':
return this.sendRequestToPopup(request);
case 'wallet_connect': {
// Return cached wallet connect response if available
const cachedResponse = await getCachedWalletConnectResponse();
if (cachedResponse) {
return cachedResponse;
}
// Wait for the popup to be loaded before making async calls
await this.communicator.waitForPopupLoaded?.();
await initSubAccountConfig();
const subAccountsConfig = store.subAccountsConfig.get();
const modifiedRequest = injectRequestCapabilities(
request,
subAccountsConfig?.capabilities ?? {}
);
const result = await this.sendRequestToPopup(modifiedRequest);
this.callback?.('connect', { chainId: numberToHex(this.chain.id) });
return result;
}
// Sub Account Support
case 'wallet_getSubAccounts': {
const subAccount = store.subAccounts.get();
if (subAccount?.address) {
return {
subAccounts: [subAccount],
};
}
if (!this.chain.rpcUrl) {
throw standardErrors.rpc.internal('No RPC URL set for chain');
}
const response = (await fetchRPCRequest(
request,
this.chain.rpcUrl
)) as GetSubAccountsResponse;
assertArrayPresence(response.subAccounts, 'subAccounts');
if (response.subAccounts.length > 0) {
// cache the sub account
assertSubAccount(response.subAccounts[0]);
const subAccount = response.subAccounts[0];
store.subAccounts.set({
address: subAccount.address,
factory: subAccount.factory,
factoryData: subAccount.factoryData,
});
}
return response;
}
case 'wallet_addSubAccount':
return this.addSubAccount(request);
case 'coinbase_fetchPermissions': {
assertFetchPermissionsRequest(request);
const completeRequest = fillMissingParamsForFetchPermissions(request);
const permissions = (await fetchRPCRequest(
completeRequest,
CB_WALLET_RPC_URL
)) as FetchPermissionsResponse;
const requestedChainId = hexToNumber(completeRequest.params?.[0].chainId);
store.spendPermissions.set(
permissions.permissions.map((permission) => ({
...permission,
chainId: requestedChainId,
}))
);
return permissions;
}
default:
if (!this.chain.rpcUrl) {
throw standardErrors.rpc.internal('No RPC URL set for chain');
}
return fetchRPCRequest(request, this.chain.rpcUrl);
}
}
private async sendRequestToPopup(request: RequestArguments) {
// Open the popup before constructing the request message.
// This is to ensure that the popup is not blocked by some browsers (i.e. Safari)
await this.communicator.waitForPopupLoaded?.();
const response = await this.sendEncryptedRequest(request);
const decrypted = await this.decryptResponseMessage(response);
return this.handleResponse(request, decrypted);
}
private async handleResponse(request: RequestArguments, decrypted: RPCResponse) {
const result = decrypted.result;
if ('error' in result) throw result.error;
switch (request.method) {
case 'eth_requestAccounts': {
const accounts = result.value as Address[];
this.accounts = accounts;
store.account.set({
accounts,
chain: this.chain,
});
this.callback?.('accountsChanged', accounts);
break;
}
case 'wallet_connect': {
const response = result.value as WalletConnectResponse;
const accounts = response.accounts.map((account) => account.address);
this.accounts = accounts;
store.account.set({
accounts,
});
const account = response.accounts.at(0);
const capabilities = account?.capabilities;
if (capabilities?.subAccounts) {
const capabilityResponse = capabilities?.subAccounts;
assertArrayPresence(capabilityResponse, 'subAccounts');
assertSubAccount(capabilityResponse[0]);
store.subAccounts.set({
address: capabilityResponse[0].address,
factory: capabilityResponse[0].factory,
factoryData: capabilityResponse[0].factoryData,
});
}
let accounts_ = [this.accounts[0]];
const subAccount = store.subAccounts.get();
const subAccountsConfig = store.subAccountsConfig.get();
if (subAccount?.address) {
// Sub account should be returned as a top level account if auto sub accounts are enabled
this.accounts = subAccountsConfig?.enableAutoSubAccounts
? prependWithoutDuplicates(this.accounts, subAccount.address)
: appendWithoutDuplicates(this.accounts, subAccount.address);
}
const spendPermissions = response?.accounts?.[0].capabilities?.spendPermissions;
if (spendPermissions && 'permissions' in spendPermissions) {
store.spendPermissions.set(spendPermissions?.permissions);
}
this.callback?.('accountsChanged', accounts_);
break;
}
case 'wallet_addSubAccount': {
assertSubAccount(result.value);
const subAccount = result.value;
store.subAccounts.set(subAccount);
const subAccountsConfig = store.subAccountsConfig.get();
this.accounts = subAccountsConfig?.enableAutoSubAccounts
? prependWithoutDuplicates(this.accounts, subAccount.address)
: appendWithoutDuplicates(this.accounts, subAccount.address);
this.callback?.('accountsChanged', this.accounts);
break;
}
default:
break;
}
return result.value;
}
async cleanup() {
const metadata = store.config.get().metadata;
await this.keyManager.clear();
// clear the store
store.account.clear();
store.subAccounts.clear();
store.spendPermissions.clear();
store.chains.clear();
// reset the signer
this.accounts = [];
this.chain = {
id: metadata?.appChainIds?.[0] ?? 1,
};
}
/**
* @returns `null` if the request was successful.
* https://eips.ethereum.org/EIPS/eip-3326#wallet_switchethereumchain
*/
private async handleSwitchChainRequest(request: RequestArguments) {
assertParamsChainId(request.params);
const chainId = ensureIntNumber(request.params[0].chainId);
const localResult = this.updateChain(chainId);
if (localResult) return null;
const popupResult = await this.sendRequestToPopup(request);
if (popupResult === null) {
this.updateChain(chainId);
}
return popupResult;
}
private async handleGetCapabilitiesRequest(request: RequestArguments) {
assertGetCapabilitiesParams(request.params);
const requestedAccount = request.params[0];
const filterChainIds = request.params[1]; // Optional second parameter
if (!this.accounts.some((account) => isAddressEqual(account, requestedAccount))) {
throw standardErrors.provider.unauthorized(
'no active account found when getting capabilities'
);
}
const capabilities = store.getState().account.capabilities;
// Return empty object if capabilities is undefined
if (!capabilities) {
return {};
}
// If no filter is provided, return all capabilities
if (!filterChainIds || filterChainIds.length === 0) {
return capabilities;
}
// Convert filter chain IDs to numbers once for efficient lookup
const filterChainNumbers = new Set(filterChainIds.map((chainId) => hexToNumber(chainId)));
// Filter capabilities
const filteredCapabilities = Object.fromEntries(
Object.entries(capabilities).filter(([capabilityKey]) => {
try {
const capabilityChainNumber = hexToNumber(capabilityKey as `0x${string}`);
return filterChainNumbers.has(capabilityChainNumber);
} catch {
// If capabilityKey is not a valid hex string, exclude it
return false;
}
})
);
return filteredCapabilities;
}
private async sendEncryptedRequest(request: RequestArguments): Promise<RPCResponseMessage> {
const sharedSecret = await this.keyManager.getSharedSecret();
if (!sharedSecret) {
throw standardErrors.provider.unauthorized('No shared secret found when encrypting request');
}
const encrypted = await encryptContent(
{
action: request,
chainId: this.chain.id,
},
sharedSecret
);
const correlationId = correlationIds.get(request);
const message = await this.createRequestMessage({ encrypted }, correlationId);
return this.communicator.postRequestAndWaitForResponse(message);
}
private async createRequestMessage(
content: RPCRequestMessage['content'],
correlationId: string | undefined
): Promise<RPCRequestMessage> {
const publicKey = await exportKeyToHexString('public', await this.keyManager.getOwnPublicKey());
return {
id: crypto.randomUUID(),
correlationId,
sender: publicKey,
content,
timestamp: new Date(),
};
}
private async decryptResponseMessage(message: RPCResponseMessage): Promise<RPCResponse> {
const content = message.content;
// throw protocol level error
if ('failure' in content) {
throw content.failure;
}
const sharedSecret = await this.keyManager.getSharedSecret();
if (!sharedSecret) {
throw standardErrors.provider.unauthorized(
'Invalid session: no shared secret found when decrypting response'
);
}
const response: RPCResponse = await decryptContent(content.encrypted, sharedSecret);
const availableChains = response.data?.chains;
if (availableChains) {
const nativeCurrencies = response.data?.nativeCurrencies;
const chains: SDKChain[] = Object.entries(availableChains).map(([id, rpcUrl]) => {
const nativeCurrency = nativeCurrencies?.[Number(id)];
return {
id: Number(id),
rpcUrl,
...(nativeCurrency ? { nativeCurrency } : {}),
};
});
store.chains.set(chains);
this.updateChain(this.chain.id, chains);
createClients(chains);
}
const walletCapabilities = response.data?.capabilities;
if (walletCapabilities) {
store.account.set({
capabilities: walletCapabilities,
});
}
return response;
}
private updateChain(chainId: number, newAvailableChains?: SDKChain[]): boolean {
const state = store.getState();
const chains = newAvailableChains ?? state.chains;
const chain = chains?.find((chain) => chain.id === chainId);
if (!chain) return false;
if (chain !== this.chain) {
this.chain = chain;
store.account.set({
chain,
});
this.callback?.('chainChanged', hexStringFromNumber(chain.id));
}
return true;
}
private async addSubAccount(request: RequestArguments): Promise<{
address: Address;
factory?: Address;
factoryData?: Hex;
}> {
const state = store.getState();
const subAccount = state.subAccount;
const subAccountsConfig = store.subAccountsConfig.get();
if (subAccount?.address) {
this.accounts = subAccountsConfig?.enableAutoSubAccounts
? prependWithoutDuplicates(this.accounts, subAccount.address)
: appendWithoutDuplicates(this.accounts, subAccount.address);
this.callback?.('accountsChanged', this.accounts);
return subAccount;
}
// Wait for the popup to be loaded before sending the request
await this.communicator.waitForPopupLoaded?.();
if (
Array.isArray(request.params) &&
request.params.length > 0 &&
request.params[0].account &&
request.params[0].account.type === 'create'
) {
let keys: { type: string; publicKey: string }[];
if (request.params[0].account.keys && request.params[0].account.keys.length > 0) {
keys = request.params[0].account.keys;
} else {
const config = store.subAccountsConfig.get() ?? {};
const { account: ownerAccount } = config.toOwnerAccount
? await config.toOwnerAccount()
: await getCryptoKeyAccount();
if (!ownerAccount) {
throw standardErrors.provider.unauthorized(
'could not get subaccount owner account when adding sub account'
);
}
keys = [
{
type: ownerAccount.address ? 'address' : 'webauthn-p256',
publicKey: ownerAccount.address || ownerAccount.publicKey,
},
];
}
request.params[0].account.keys = keys;
}
const response = await this.sendRequestToPopup(request);
assertSubAccount(response);
return response;
}
private shouldRequestUseSubAccountSigner(request: RequestArguments) {
const sender = getSenderFromRequest(request);
const subAccount = store.subAccounts.get();
if (sender) {
return sender.toLowerCase() === subAccount?.address.toLowerCase();
}
return false;
}
private async sendRequestToSubAccountSigner(request: RequestArguments) {
const subAccount = store.subAccounts.get();
const subAccountsConfig = store.subAccountsConfig.get();
const config = store.config.get();
assertPresence(
subAccount?.address,
standardErrors.provider.unauthorized(
'no active sub account when sending request to sub account signer'
)
);
// Get the owner account from the config
const ownerAccount = subAccountsConfig?.toOwnerAccount
? await subAccountsConfig.toOwnerAccount()
: await getCryptoKeyAccount();
assertPresence(
ownerAccount?.account,
standardErrors.provider.unauthorized(
'no active sub account owner when sending request to sub account signer'
)
);
const sender = getSenderFromRequest(request);
// if sender is undefined, we inject the active sub account
// address into the params for the supported request methods
if (sender === undefined) {
request = addSenderToRequest(request, subAccount.address);
}
const client = getClient(this.chain.id);
assertPresence(
client,
standardErrors.rpc.internal(
`client not found for chainId ${this.chain.id} when sending request to sub account signer`
)
);
const globalAccountAddress = this.accounts.find(
(account) => account.toLowerCase() !== subAccount.address.toLowerCase()
);
assertPresence(
globalAccountAddress,
standardErrors.provider.unauthorized(
'no global account found when sending request to sub account signer'
)
);
const dataSuffix = makeDataSuffix({
attribution: config.preference?.attribution,
dappOrigin: window.location.origin,
});
const publicKey =
ownerAccount.account.type === 'local'
? ownerAccount.account.address
: ownerAccount.account.publicKey;
let ownerIndex = await findOwnerIndex({
address: subAccount.address,
factory: subAccount.factory,
factoryData: subAccount.factoryData,
publicKey,
client,
});
if (ownerIndex === -1) {
const correlationId = correlationIds.get(request);
logAddOwnerStarted({ method: request.method, correlationId });
try {
ownerIndex = await handleAddSubAccountOwner({
ownerAccount: ownerAccount.account,
globalAccountRequest: this.sendRequestToPopup.bind(this),
});
logAddOwnerCompleted({ method: request.method, correlationId });
} catch (error) {
logAddOwnerError({
method: request.method,
correlationId,
errorMessage: parseErrorMessageFromAny(error),
});
return standardErrors.provider.unauthorized(
'failed to add sub account owner when sending request to sub account signer'
);
}
}
const { request: subAccountRequest } = await createSubAccountSigner({
address: subAccount.address,
owner: ownerAccount.account,
client: client,
factory: subAccount.factory,
factoryData: subAccount.factoryData,
parentAddress: globalAccountAddress,
attribution: dataSuffix ? { suffix: dataSuffix } : undefined,
ownerIndex,
});
try {
const result = await subAccountRequest(request);
return result;
} catch (error) {
let errorObject: unknown;
if (isViemError(error)) {
errorObject = JSON.parse(error.details);
} else if (isActionableHttpRequestError(error)) {
errorObject = error;
} else {
throw error;
}
if (!(isActionableHttpRequestError(errorObject) && errorObject.data)) {
throw error;
}
if (!errorObject.data) {
throw error;
}
const correlationId = correlationIds.get(request);
logInsufficientBalanceErrorHandlingStarted({ method: request.method, correlationId });
try {
const result = await handleInsufficientBalanceError({
errorData: errorObject.data,
globalAccountAddress,
subAccountAddress: subAccount.address,
client,
request,
subAccountRequest,
globalAccountRequest: this.request.bind(this),
});
logInsufficientBalanceErrorHandlingCompleted({ method: request.method, correlationId });
return result;
} catch (handlingError) {
console.error(handlingError);
logInsufficientBalanceErrorHandlingError({
method: request.method,
correlationId,
errorMessage: parseErrorMessageFromAny(handlingError),
});
throw error;
}
}
}
}