UNPKG

@base-org/account

Version:
660 lines 31.2 kB
import { CB_WALLET_RPC_URL } from '../../core/constants.js'; import { hexToNumber, isAddressEqual, numberToHex } from 'viem'; import { isActionableHttpRequestError, isViemError, standardErrors } from '../../core/error/errors.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 { ensureIntNumber, hexStringFromNumber } from '../../core/type/util.js'; import { createClients, getClient } from '../../store/chain-clients/utils.js'; import { correlationIds } from '../../store/correlation-ids/store.js'; import { spendPermissions, 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, getSenderFromRequest, initSubAccountConfig, injectRequestCapabilities, makeDataSuffix, prependWithoutDuplicates, } 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'; import { routeThroughGlobalAccount } from './utils/routeThroughGlobalAccount.js'; export class Signer { communicator; keyManager; callback; accounts; chain; constructor(params) { 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, }; // Initialize chain clients if chains are provided if (chains) { createClients(chains); } // Note: getClient will automatically create fallback clients when needed } get isConnected() { return this.accounts.length > 0; } async handshake(args) { 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 = 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) { 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) { 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(); const subAccountsConfig = store.subAccountsConfig.get(); // Inject capabilities from config (e.g., addSubAccount when creation: 'on-connect') const modifiedRequest = injectRequestCapabilities(request, subAccountsConfig?.capabilities ?? {}); return this.sendRequestToPopup(modifiedRequest); } case 'experimental_requestInfo': 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; } } // Handle all experimental methods if (request.method.startsWith('experimental_')) { return this.sendRequestToPopup(request); } switch (request.method) { case 'eth_requestAccounts': case 'eth_accounts': { const subAccount = store.subAccounts.get(); const subAccountsConfig = store.subAccountsConfig.get(); if (subAccount?.address) { // if defaultAccount is 'sub' and we have a sub account, we need to return it as the first account // otherwise, we just append it to the accounts array this.accounts = subAccountsConfig?.defaultAccount === 'sub' ? 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': { // 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)); 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)); const requestedChainId = hexToNumber(completeRequest.params?.[0].chainId); store.spendPermissions.set(permissions.permissions.map((permission) => ({ ...permission, chainId: requestedChainId, }))); return permissions; } case 'coinbase_fetchPermission': { const fetchPermissionRequest = request; const response = (await fetchRPCRequest(fetchPermissionRequest, CB_WALLET_RPC_URL)); // Store the single permission if it has a chainId if (response.permission && response.permission.chainId) { store.spendPermissions.set([response.permission]); } return response; } default: if (!this.chain.rpcUrl) { throw standardErrors.rpc.internal('No RPC URL set for chain'); } return fetchRPCRequest(request, this.chain.rpcUrl); } } async sendRequestToPopup(request) { // 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); } async handleResponse(request, decrypted) { const result = decrypted.result; if ('error' in result) throw result.error; switch (request.method) { case 'eth_requestAccounts': { const accounts = result.value; this.accounts = accounts; store.account.set({ accounts, chain: this.chain, }); this.callback?.('accountsChanged', accounts); break; } case 'wallet_connect': { const response = result.value; 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, }); } const subAccount = store.subAccounts.get(); const subAccountsConfig = store.subAccountsConfig.get(); if (subAccount?.address) { // Sub account should be returned as the default account if defaultAccount is 'sub' this.accounts = subAccountsConfig?.defaultAccount === 'sub' ? 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', this.accounts); break; } case 'wallet_addSubAccount': { assertSubAccount(result.value); const subAccount = result.value; store.subAccounts.set(subAccount); const subAccountsConfig = store.subAccountsConfig.get(); this.accounts = subAccountsConfig?.defaultAccount === 'sub' ? 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 */ async handleSwitchChainRequest(request) { 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; } async handleGetCapabilitiesRequest(request) { 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); return filterChainNumbers.has(capabilityChainNumber); } catch { // If capabilityKey is not a valid hex string, exclude it return false; } })); return filteredCapabilities; } async sendEncryptedRequest(request) { 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); } async createRequestMessage(content, correlationId) { const publicKey = await exportKeyToHexString('public', await this.keyManager.getOwnPublicKey()); return { id: crypto.randomUUID(), correlationId, sender: publicKey, content, timestamp: new Date(), }; } async decryptResponseMessage(message) { 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 = await decryptContent(content.encrypted, sharedSecret); const availableChains = response.data?.chains; if (availableChains) { const nativeCurrencies = response.data?.nativeCurrencies; const chains = 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; } updateChain(chainId, newAvailableChains) { 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; } async addSubAccount(request) { const state = store.getState(); const cachedSubAccount = state.subAccount; const subAccountsConfig = store.subAccountsConfig.get(); // Extract requested address from params (for deployed/undeployed types) const requestedAddress = Array.isArray(request.params) && request.params.length > 0 && request.params[0]?.account?.address ? request.params[0].account.address : undefined; // Only return cached if: // 1. Cache exists AND // 2. No specific address requested (create type) OR requested address matches cached if (cachedSubAccount?.address) { const shouldUseCache = !requestedAddress || isAddressEqual(requestedAddress, cachedSubAccount.address); if (shouldUseCache) { this.accounts = subAccountsConfig?.defaultAccount === 'sub' ? prependWithoutDuplicates(this.accounts, cachedSubAccount.address) : appendWithoutDuplicates(this.accounts, cachedSubAccount.address); this.callback?.('accountsChanged', this.accounts); return cachedSubAccount; } } // 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; 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; } shouldRequestUseSubAccountSigner(request) { const sender = getSenderFromRequest(request); const subAccount = store.subAccounts.get(); if (sender) { return sender.toLowerCase() === subAccount?.address.toLowerCase(); } return false; } async sendRequestToSubAccountSigner(request) { 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 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, }); // Determine effective chainId - use request chainId for wallet_sendCalls, default otherwise const walletSendCallsChainId = request.method === 'wallet_sendCalls' && request.params?.[0]?.chainId; const chainId = walletSendCallsChainId ? hexToNumber(walletSendCallsChainId) : this.chain.id; const client = getClient(chainId); assertPresence(client, standardErrors.rpc.internal(`client not found for chainId ${chainId} when sending request to sub account signer`)); if (['eth_sendTransaction', 'wallet_sendCalls'].includes(request.method)) { // If we have never had a spend permission, we need to do this tx through the global account // Only perform this check if funding mode is 'spend-permissions' const subAccountsConfig = store.subAccountsConfig.get(); if (subAccountsConfig?.funding === 'spend-permissions') { const storedSpendPermissions = spendPermissions.get(); if (storedSpendPermissions.length === 0) { const result = await routeThroughGlobalAccount({ request, globalAccountAddress, subAccountAddress: subAccount.address, client, globalAccountRequest: this.sendRequestToPopup.bind(this), chainId, }); return result; } } } 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), chainId: chainId, }); 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) { // Skip insufficient balance error handling if funding mode is 'manual' const subAccountsConfig = store.subAccountsConfig.get(); if (subAccountsConfig?.funding === 'manual') { throw error; } let errorObject; 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, 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; } } } } //# sourceMappingURL=Signer.js.map