UNPKG

@ledgerhq/live-common

Version:
544 lines • 24.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.currentAccountAtomFamily = void 0; exports.useDappCurrentAccount = useDappCurrentAccount; exports.useDappLogic = useDappLogic; const react_1 = require("react"); const jotai_1 = require("jotai"); const jotai_family_1 = require("jotai-family"); const account_1 = require("../account"); const bridge_1 = require("../bridge"); const live_env_1 = require("@ledgerhq/live-env"); const network_1 = __importDefault(require("@ledgerhq/live-network/network")); const converters_1 = require("./converters"); const index_1 = require("../hw/signMessage/index"); const bignumber_js_1 = __importDefault(require("bignumber.js")); const utils_1 = require("@ledgerhq/coin-evm/utils"); const SmartWebsocket_1 = require("./SmartWebsocket"); const helpers_1 = require("./helpers"); const txTrackingHelper_1 = require("./utils/txTrackingHelper"); const state_1 = require("@ledgerhq/cryptoassets/state"); const errors = { ParseError: -32700, InvalidRequest: -32600, MethodNotFound: -32601, InvalidParams: -32602, InternalError: -32603, UserRejected: 4001, Unauthorized: 4100, UnsupportedMethod: 4200, Disconnected: 4900, ChainDisconnected: 4901, }; const rejectedError = (code, message, data = {}) => ({ code, message, data: { code, message, ...data, }, }); // TODO remove any usage // eslint-disable-next-line @typescript-eslint/no-explicit-any function convertEthToLiveTX(ethTX) { return { family: "ethereum", amount: ethTX.value !== undefined ? new bignumber_js_1.default(ethTX.value.replace("0x", ""), 16) : new bignumber_js_1.default(0), recipient: (0, utils_1.safeEncodeEIP55)(ethTX.to), gasPrice: ethTX.gasPrice !== undefined ? new bignumber_js_1.default(ethTX.gasPrice.replace("0x", ""), 16) : undefined, gasLimit: ethTX.gas !== undefined ? new bignumber_js_1.default(ethTX.gas.replace("0x", ""), 16) : undefined, data: ethTX.data ? Buffer.from(ethTX.data.replace("0x", ""), "hex") : undefined, }; } // Atom family for manifest-scoped account state - each manifest gets its own isolated atom exports.currentAccountAtomFamily = (0, jotai_family_1.atomFamily)((_manifestId) => (0, jotai_1.atom)(null)); function useDappCurrentAccount(manifestId, currentAccountHistDb) { const atomToUse = (0, exports.currentAccountAtomFamily)(manifestId); const [currentAccount, setCurrentAccount] = (0, jotai_1.useAtom)(atomToUse); // prefer using this setter when the user manually sets a current account const setCurrentAccountHist = (0, react_1.useCallback)((manifestId, account) => { if (!currentAccountHistDb) return; const [_, _setCurrentAccountHist] = currentAccountHistDb; _setCurrentAccountHist(state => { const newState = { ...state, currentAccountHist: { ...state.currentAccountHist, [manifestId]: account.id, }, }; return newState; }); }, [currentAccountHistDb]); return { currentAccount, setCurrentAccount, setCurrentAccountHist }; } const emptyArray = []; function useDappAccountLogic({ manifest, accounts, currentAccountHistDb, initialAccountId, }) { const [initialAccountSelected, setInitialAccountSelected] = (0, react_1.useState)(false); // If the manifest has a wildcard currencyId, we use an empty array to avoid any issues // For dApps, currencies need to be specified explicitly const currencyIds = manifest.currencies === "*" ? emptyArray : manifest.currencies; const { currentAccount, setCurrentAccount, setCurrentAccountHist } = useDappCurrentAccount(manifest.id, currentAccountHistDb); const currentParentAccount = (0, react_1.useMemo)(() => { if (currentAccount) { return (0, account_1.getParentAccount)(currentAccount, accounts); } }, [currentAccount, accounts]); const firstAccountAvailable = (0, react_1.useMemo)(() => { const account = accounts.find(account => { if (account.type === "Account" && currencyIds.includes(account.currency.id)) { return account; } if (account.type === "TokenAccount" && currencyIds.includes(account.token.id)) { return (0, account_1.getParentAccount)(account, accounts); } }); // might not even need to set parent here if (account) { return (0, account_1.getParentAccount)(account, accounts); } }, [accounts, currencyIds]); const storedCurrentAccountIsPermitted = (0, react_1.useCallback)(() => { if (!currentAccount) return false; return accounts.some(account => account.type === "Account" && currencyIds.includes(account.currency.id) && account.id === currentAccount.id); }, [currentAccount, accounts, currencyIds]); const currentAccountIdFromHist = (0, react_1.useMemo)(() => { if (manifest && currentAccountHistDb) { return currentAccountHistDb[0]?.[manifest.id]; } return null; }, [manifest, currentAccountHistDb]); const currentAccountFromHist = (0, react_1.useMemo)(() => { return accounts.find(account => account.id === currentAccountIdFromHist); }, [accounts, currentAccountIdFromHist]); const initialAccount = (0, react_1.useMemo)(() => { if (!initialAccountId) return; return accounts.find(account => account.id === initialAccountId); }, [accounts, initialAccountId]); (0, react_1.useEffect)(() => { if (initialAccountSelected) { return; } if (initialAccount && !initialAccountSelected) { setCurrentAccount(initialAccount); setCurrentAccountHist(manifest.id, initialAccount); setInitialAccountSelected(true); return; } if (currentAccountFromHist) { setCurrentAccount(currentAccountFromHist); return; } if (!currentAccount || !(currentAccount && storedCurrentAccountIsPermitted())) { /** if there is no current account OR if there is a current account but it is not in the manifest currencies then fall back to the first permitted account */ setCurrentAccount(firstAccountAvailable ?? null); } }, [ currentAccount, currentAccountFromHist, firstAccountAvailable, initialAccount, initialAccountSelected, manifest.id, setCurrentAccount, setCurrentAccountHist, storedCurrentAccountIsPermitted, ]); return { currentAccount, setCurrentAccount, currentParentAccount, setCurrentAccountHist, }; } // Type guard function to make typescript happy function isParentAccountPresent(account, parentAccount) { if (account.type === "TokenAccount") { return !!parentAccount; } return true; } function useDappLogic({ manifest, accounts, postMessage, uiHook, tracking, currentAccountHistDb, initialAccountId, mevProtected, }) { const nanoApp = manifest.dapp?.nanoApp; const dependencies = manifest.dapp?.dependencies; const ws = (0, react_1.useRef)(undefined); const { currentAccount, currentParentAccount, setCurrentAccount, setCurrentAccountHist } = useDappAccountLogic({ manifest, accounts, currentAccountHistDb, initialAccountId, }); /** Current network is needed for recognising the current chain id. * If a token account is selected, this depends on the parent currency. */ const currentNetwork = (0, react_1.useMemo)(() => { if (!currentAccount) { return undefined; } // If the current account is a token account, and the chain id is not specified for that specific token, we can also use the network of the parent currency to determine the correct chain id. return manifest.dapp?.networks.find(network => { const accountCurrencyId = currentAccount.type === "TokenAccount" ? currentAccount.token.id : currentAccount.currency.id; const accountNetworkCurrency = currentAccount.type === "TokenAccount" ? currentAccount.token.parentCurrency.id : currentAccount.currency.id; return network.currency === accountCurrencyId || network.currency === accountNetworkCurrency; }); }, [currentAccount, manifest.dapp?.networks]); const currentAddress = (0, react_1.useMemo)(() => { return currentAccount?.type === "Account" ? currentAccount.freshAddress : currentParentAccount?.freshAddress; }, [currentAccount, currentParentAccount?.freshAddress]); (0, react_1.useEffect)(() => { if (!currentAddress) { return; } postMessage(JSON.stringify({ jsonrpc: "2.0", method: "accountsChanged", params: [[currentAddress]], })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentAddress]); (0, react_1.useEffect)(() => { if (!currentNetwork) { return; } postMessage(JSON.stringify({ jsonrpc: "2.0", method: "chainChanged", params: [`0x${currentNetwork.chainID.toString(16)}`], })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentNetwork?.chainID]); (0, react_1.useEffect)(() => { if (currentNetwork?.nodeURL) { const rpcURL = new URL(currentNetwork.nodeURL); if (rpcURL.protocol === "wss:") { const websocket = new SmartWebsocket_1.SmartWebsocket(rpcURL.toString(), { reconnect: true, reconnectMaxAttempts: Infinity, }); websocket.on("message", message => { postMessage(JSON.stringify(message)); }); websocket.connect(); ws.current = websocket; return () => { websocket.close(); ws.current = undefined; }; } } }, [currentNetwork?.nodeURL, postMessage]); const onDappMessage = (0, react_1.useCallback)(async (data) => { if (data.jsonrpc !== "2.0") { console.error("Request is not a jsonrpc 2.0: ", data); return; } if (!currentNetwork) { console.error("No network selected: ", data); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.InternalError, "No network selected"), })); return; } if (!currentAccount) { console.error("No account selected: ", data); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.InternalError, "No account selected"), })); return; } if (!isParentAccountPresent(currentAccount, currentParentAccount)) { console.error("No parent account found for the currentAccount: ", currentAccount, data); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.InternalError, "No parent account found"), })); return; } switch (data.method) { // https://eips.ethereum.org/EIPS/eip-695 case "eth_chainId": { postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", result: `0x${currentNetwork.chainID.toString(16)}`, })); break; } // https://eips.ethereum.org/EIPS/eip-1102 // https://docs.metamask.io/guide/rpc-api.html#eth-requestaccounts case "eth_requestAccounts": // legacy method, cf. https://docs.metamask.io/guide/ethereum-provider.html#legacy-methods // eslint-disable-next-line no-fallthrough case "enable": // https://eips.ethereum.org/EIPS/eip-1474#eth_accounts // https://eth.wiki/json-rpc/API#eth_accounts // eslint-disable-next-line no-fallthrough case "eth_accounts": { const address = currentAccount.type === "Account" ? currentAccount.freshAddress : currentParentAccount.freshAddress; postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", result: [address], })); break; } // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-3326.md case "wallet_switchEthereumChain": { const { chainId } = data.params[0]; // Check chanId is valid hex string const decimalChainId = parseInt(chainId, 16); if (isNaN(decimalChainId)) { postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.InvalidParams, "Invalid chainId"), })); break; } // Check chain ID is known to the wallet const requestedCurrency = manifest.dapp?.networks.find(network => network.chainID === decimalChainId); if (!requestedCurrency) { postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.InvalidParams, `Chain ID ${chainId} is not supported`), })); break; } try { await new Promise((resolve, reject) => uiHook["account.request"]({ currencyIds: [requestedCurrency.currency], areCurrenciesFiltered: true, onSuccess: account => { setCurrentAccountHist(manifest.id, account); setCurrentAccount(account); resolve(); }, onCancel: () => { reject("User canceled"); }, })); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", result: null, })); } catch (error) { postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.UserRejected, `error switching chain: ${error}`), })); } break; } // https://eth.wiki/json-rpc/API#eth_sendtransaction case "eth_sendTransaction": { const ethTX = data.params[0]; const tx = convertEthToLiveTX(ethTX); const address = currentAccount.type === "Account" ? currentAccount.freshAddress : currentParentAccount.freshAddress; if (address.toLowerCase() === ethTX.from.toLowerCase()) { let trackingData; try { const signFlowInfos = (0, converters_1.getWalletAPITransactionSignFlowInfos)({ walletApiTransaction: tx, account: currentAccount, }); const transactionType = (0, txTrackingHelper_1.getTxType)(signFlowInfos.liveTx); const accountCurrencyName = currentAccount.type === "TokenAccount" ? currentAccount.token.name : currentAccount.currency.name; const accountNetwork = currentAccount.type === "TokenAccount" ? currentAccount.token.parentCurrency.id : currentAccount.currency.id; const token = await (0, state_1.getCryptoAssetsStore)().findTokenByAddressInCurrency(tx.recipient, accountNetwork); trackingData = { type: transactionType, currency: token ? token.name : accountCurrencyName, network: token ? token.parentCurrency.id : accountNetwork, }; const options = nanoApp ? { hwAppId: nanoApp, dependencies: dependencies } : undefined; tracking.dappSendTransactionRequested(manifest, trackingData); const signedTransaction = await new Promise((resolve, reject) => uiHook["transaction.sign"]({ account: currentAccount, parentAccount: undefined, signFlowInfos, options, onSuccess: signedOperation => { resolve(signedOperation); }, onError: error => { reject(error); }, })); const bridge = (0, bridge_1.getAccountBridge)(currentAccount, undefined); const mainAccount = (0, account_1.getMainAccount)(currentAccount, undefined); let optimisticOperation = signedTransaction.operation; if (!(0, live_env_1.getEnv)("DISABLE_TRANSACTION_BROADCAST")) { optimisticOperation = await bridge.broadcast({ account: mainAccount, signedOperation: signedTransaction, broadcastConfig: { mevProtected: !!mevProtected, source: { type: "dApp", name: manifest.id }, }, }); } uiHook["transaction.broadcast"](currentAccount, undefined, mainAccount, optimisticOperation); tracking.dappSendTransactionSuccess(manifest, trackingData); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", result: optimisticOperation.hash, })); } catch { tracking.dappSendTransactionFail(manifest, trackingData); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.UserRejected, "Transaction declined"), })); } } break; } // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-191.md // https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_sign // https://docs.walletconnect.com/json-rpc-api-methods/ethereum // Discussion about the diff between eth_sign and personal_sign: // https://github.com/WalletConnect/walletconnect-docs/issues/32#issuecomment-644697172 case "personal_sign": { try { /** * The message is received as a prefixed hex string. * We need to strip the "0x" prefix. */ const message = (0, helpers_1.stripHexPrefix)(data.params[0]); tracking.dappPersonalSignRequested(manifest); const formattedMessage = (0, index_1.prepareMessageToSign)(currentAccount.type === "Account" ? currentAccount : currentParentAccount, message); const options = nanoApp ? { hwAppId: nanoApp, dependencies: dependencies } : undefined; const signedMessage = await new Promise((resolve, reject) => uiHook["message.sign"]({ account: currentAccount, message: formattedMessage, options, onSuccess: resolve, onError: reject, onCancel: () => { reject("Canceled by user"); }, })); tracking.dappPersonalSignSuccess(manifest); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", result: signedMessage, })); } catch { tracking.dappPersonalSignFail(manifest); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.UserRejected, "Personal message signed declined"), })); } break; } // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md case data.method.match(/eth_signTypedData(_v.)?$/)?.input: { try { const message = data.params[1]; tracking.dappSignTypedDataRequested(manifest); const formattedMessage = (0, index_1.prepareMessageToSign)(currentAccount.type === "Account" ? currentAccount : currentParentAccount, Buffer.from(message).toString("hex")); const options = nanoApp ? { hwAppId: nanoApp, dependencies: dependencies } : undefined; const signedMessage = await new Promise((resolve, reject) => uiHook["message.sign"]({ account: currentAccount, message: formattedMessage, options, onSuccess: resolve, onError: reject, onCancel: () => { reject("Canceled by user"); }, })); tracking.dappSignTypedDataSuccess(manifest); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", result: signedMessage, })); } catch { tracking.dappSignTypedDataFail(manifest); postMessage(JSON.stringify({ id: data.id, jsonrpc: "2.0", error: rejectedError(errors.UserRejected, "Typed Data message signed declined"), })); } break; } default: { if (ws.current) { ws.current.send(data); } else if (currentNetwork.nodeURL?.startsWith("https:")) { (0, network_1.default)({ method: "POST", url: currentNetwork.nodeURL, data, }).then(res => { postMessage(JSON.stringify(res.data)); }); } break; } } }, [ currentAccount, currentNetwork, currentParentAccount, dependencies, manifest, mevProtected, nanoApp, postMessage, setCurrentAccount, setCurrentAccountHist, tracking, uiHook, ]); return { onDappMessage, noAccounts: !currentAccount }; } //# sourceMappingURL=useDappLogic.js.map