diff --git a/config-overrides.js b/config-overrides.js index c028a553..c7f5b4a7 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -39,9 +39,9 @@ module.exports = function override(config, env) { process: stdLibBrowser.process, stream: stdLibBrowser.stream, os: stdLibBrowser.os, - vm: false, events: stdLibBrowser.events, - util: stdLibBrowser.util + util: stdLibBrowser.util, + vm: false, }, mainFields: ['browser', 'module', 'main'], conditionNames: ['import', 'require', 'node', 'default'], diff --git a/lavamoat/webpack/policy.json b/lavamoat/webpack/policy.json index 76f5b1b7..fe01fcfa 100644 --- a/lavamoat/webpack/policy.json +++ b/lavamoat/webpack/policy.json @@ -19,7 +19,6 @@ "setTimeout": true }, "packages": { - "@hathor/hathor-rpc-handler>@hathor/wallet-lib>long": true, "@hathor/wallet-lib>bitcore-lib": true, "@hathor/wallet-lib>bitcore-mnemonic": true, "@hathor/wallet-lib>crypto-js": true, @@ -28,18 +27,13 @@ "axios": true, "buffer": true, "cypress>lodash": true, + "hathor-rpc-handler-test>@hathor/wallet-lib>long": true, "node-stdlib-browser>assert": true, "node-stdlib-browser>crypto-browserify": true, "node-stdlib-browser>path-browserify": true, "react-redux>@babel/runtime": true } }, - "@hathor/hathor-rpc-handler>@hathor/wallet-lib>long": { - "globals": { - "WebAssembly.Instance": true, - "WebAssembly.Module": true - } - }, "@hathor/wallet-lib": { "globals": { "AbortController": true, @@ -1115,6 +1109,12 @@ "npm-run-all>string.prototype.padend>es-abstract>object-inspect": true } }, + "hathor-rpc-handler-test>@hathor/wallet-lib>long": { + "globals": { + "WebAssembly.Instance": true, + "WebAssembly.Module": true + } + }, "jquery": { "globals": { "define": true diff --git a/package-lock.json b/package-lock.json index 306afe21..0fe13697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.31.0", "hasInstallScript": true, "dependencies": { - "@hathor/hathor-rpc-handler": "0.0.3-experimental-alpha", + "@hathor/hathor-rpc-handler": "0.4.0-experimental-alpha", "@hathor/wallet-lib": "2.0.1", "@ledgerhq/hw-transport-node-hid": "6.28.1", "@reduxjs/toolkit": "2.2.3", @@ -3780,70 +3780,25 @@ } }, "node_modules/@hathor/hathor-rpc-handler": { - "version": "0.0.3-experimental-alpha", - "resolved": "https://registry.npmjs.org/@hathor/hathor-rpc-handler/-/hathor-rpc-handler-0.0.3-experimental-alpha.tgz", - "integrity": "sha512-ZnesczIAuVGYwqDkm9VJH+cXkOULfWaskXrbtxNrhUVODbKNXpTsirlhNSSB931vZIFYO7SD8T7ToJxo+qgStw==", + "version": "0.4.0-experimental-alpha", + "resolved": "https://registry.npmjs.org/@hathor/hathor-rpc-handler/-/hathor-rpc-handler-0.4.0-experimental-alpha.tgz", + "integrity": "sha512-1ju2fGxBBcxW3nMTLKMqER27oLVzUi0weSvdNtPjo3gG7ekIxOnJJVWzRJ4IkWJKK/4E6o9IJ+Y3nQ7kcv8Tjw==", "license": "MIT", "dependencies": { - "@hathor/wallet-lib": "1.11.0" + "@hathor/wallet-lib": "2.0.1", + "zod": "3.24.1" }, "engines": { "node": ">=20" } }, - "node_modules/@hathor/hathor-rpc-handler/node_modules/@hathor/wallet-lib": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@hathor/wallet-lib/-/wallet-lib-1.11.0.tgz", - "integrity": "sha512-TkVS5QPSj7AKJkxh2GmZNE1ubg4t4xJkg0HnXACNTGefQ+rRDF3CLxqSOAqq9wXhlxhSbppJoksRh7M5Udp3Sg==", - "license": "MIT", - "dependencies": { - "abstract-level": "1.0.4", - "axios": "1.7.2", - "bitcore-lib": "8.25.10", - "bitcore-mnemonic": "8.25.10", - "buffer": "6.0.3", - "crypto-js": "4.2.0", - "isomorphic-ws": "5.0.0", - "level": "8.0.1", - "lodash": "4.17.21", - "long": "5.2.3", - "ws": "8.17.1" - }, - "engines": { - "node": ">=20.0.0", - "npm": ">=10.0.0" - } - }, - "node_modules/@hathor/hathor-rpc-handler/node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "node_modules/@hathor/hathor-rpc-handler/node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/@hathor/hathor-rpc-handler/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@hathor/wallet-lib": { @@ -19253,12 +19208,6 @@ "node": ">=8" } }, - "node_modules/long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", - "license": "Apache-2.0" - }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", diff --git a/package.json b/package.json index 869db227..1e94733c 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,13 @@ "resolutions": { "axios": "1.7.7", "@hathor/wallet-lib/axios": "1.7.7", - "@hathor/hathor-rpc-handler/@hathor/wallet-lib/axios": "1.7.7" + "@hathor/hathor-rpc-handler/@hathor/wallet-lib/axios": "1.7.7", + "bitcore-lib": "8.25.10", + "@hathor/wallet-lib/bitcore-lib": "8.25.10", + "@hathor/hathor-rpc-handler/@hathor/wallet-lib/bitcore-lib": "8.25.10" }, "dependencies": { - "@hathor/hathor-rpc-handler": "0.0.3-experimental-alpha", + "@hathor/hathor-rpc-handler": "0.4.0-experimental-alpha", "@hathor/wallet-lib": "2.0.1", "@ledgerhq/hw-transport-node-hid": "6.28.1", "@reduxjs/toolkit": "2.2.3", diff --git a/src/actions/index.js b/src/actions/index.js index f5e3ceab..6738c69a 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -85,6 +85,8 @@ export const types = { REOWN_CREATE_TOKEN_STATUS_FAILED: 'REOWN_CREATE_TOKEN_STATUS_FAILED', REOWN_CREATE_TOKEN_RETRY: 'REOWN_CREATE_TOKEN_RETRY', REOWN_CREATE_TOKEN_RETRY_DISMISS: 'REOWN_CREATE_TOKEN_RETRY_DISMISS', + REOWN_SEND_TX_RETRY: 'REOWN_SEND_TX_RETRY', + REOWN_SEND_TX_RETRY_DISMISS: 'REOWN_SEND_TX_RETRY_DISMISS', REOWN_SIGN_MESSAGE_RETRY: 'REOWN_SIGN_MESSAGE_RETRY', REOWN_SIGN_MESSAGE_RETRY_DISMISS: 'REOWN_SIGN_MESSAGE_RETRY_DISMISS', REOWN_ACCEPT: 'REOWN_ACCEPT', @@ -95,6 +97,7 @@ export const types = { SHOW_CREATE_TOKEN_REQUEST_MODAL: 'SHOW_CREATE_TOKEN_REQUEST_MODAL', SHOW_SIGN_MESSAGE_REQUEST_MODAL: 'SHOW_SIGN_MESSAGE_REQUEST_MODAL', SHOW_NANO_CONTRACT_SEND_TX_MODAL: 'SHOW_NANO_CONTRACT_SEND_TX_MODAL', + SHOW_SEND_TRANSACTION_REQUEST_MODAL: 'SHOW_SEND_TRANSACTION_REQUEST_MODAL', REOWN_SESSION_PROPOSAL: 'REOWN_SESSION_PROPOSAL', REOWN_SESSION_REQUEST: 'REOWN_SESSION_REQUEST', REOWN_SESSION_DELETE: 'REOWN_SESSION_DELETE', @@ -103,6 +106,14 @@ export const types = { SHOW_GLOBAL_MODAL: 'SHOW_GLOBAL_MODAL', HIDE_GLOBAL_MODAL: 'HIDE_GLOBAL_MODAL', SERVER_INFO_UPDATED: 'SERVER_INFO_UPDATED', + REOWN_SEND_TX_STATUS_LOADING: 'REOWN_SEND_TX_STATUS_LOADING', + REOWN_SEND_TX_STATUS_READY: 'REOWN_SEND_TX_STATUS_READY', + REOWN_SEND_TX_STATUS_SUCCESS: 'REOWN_SEND_TX_STATUS_SUCCESS', + REOWN_SEND_TX_STATUS_FAILED: 'REOWN_SEND_TX_STATUS_FAILED', + UNREGISTERED_TOKENS_DOWNLOAD_REQUESTED: 'UNREGISTERED_TOKENS_DOWNLOAD_REQUESTED', + UNREGISTERED_TOKENS_DOWNLOAD_SUCCESS: 'UNREGISTERED_TOKENS_DOWNLOAD_SUCCESS', + UNREGISTERED_TOKENS_DOWNLOAD_FAILED: 'UNREGISTERED_TOKENS_DOWNLOAD_FAILED', + UNREGISTERED_TOKENS_DOWNLOAD_END: 'UNREGISTERED_TOKENS_DOWNLOAD_END', }; /** @@ -309,6 +320,14 @@ export const tokenFetchBalanceFailed = (tokenId) => ({ tokenId, }); +/** + * tokenId: The tokenId to request metadata from + */ +export const tokenFetchMetadataRequested = (tokenId) => ({ + type: types.TOKEN_FETCH_METADATA_REQUESTED, + tokenId, +}); + /** * Flag indicating if we are using the atomic swap feature */ @@ -784,7 +803,7 @@ export const setNewNanoContractStatusSuccess = () => ({ }); /** - * Set nano contract status to failed + * Set nano contract status to failure */ export const setNewNanoContractStatusFailure = () => ({ type: types.REOWN_NEW_NANOCONTRACT_STATUS_FAILED, @@ -880,6 +899,19 @@ export const showNanoContractSendTxModal = (onAccept, onReject, data, metadata) payload: { accept: onAccept, deny: onReject, data, dapp: metadata }, }); +/** + * Show modal for sending a transaction + * + * @param {Function} onAccept Callback function when user accepts the request + * @param {Function} onReject Callback function when user rejects the request + * @param {Object} data The transaction data + * @param {Object} metadata Metadata about the dapp requesting the transaction + */ +export const showSendTransactionModal = (onAccept, onReject, data, metadata) => ({ + type: types.SHOW_SEND_TRANSACTION_REQUEST_MODAL, + payload: { accept: onAccept, deny: onReject, data, dapp: metadata }, +}); + /** * @param {string} modalType The type of the modal to show * @param {Object} modalProps The props to pass to the modal @@ -895,3 +927,65 @@ export const showGlobalModal = (modalType, modalProps = {}) => ({ export const hideGlobalModal = () => ({ type: types.HIDE_GLOBAL_MODAL }); + +/** + * Set send transaction status to loading + */ +export const setSendTxStatusLoading = () => ({ + type: types.REOWN_SEND_TX_STATUS_LOADING, +}); + +/** + * Set send transaction status to ready + */ +export const setSendTxStatusReady = () => ({ + type: types.REOWN_SEND_TX_STATUS_READY, +}); + +/** + * Set send transaction status to success + */ +export const setSendTxStatusSuccess = () => ({ + type: types.REOWN_SEND_TX_STATUS_SUCCESS, +}); + +/** + * Set send transaction status to failed + */ +export const setSendTxStatusFailed = () => ({ + type: types.REOWN_SEND_TX_STATUS_FAILED, +}); + +/** + * Request download of unregistered tokens details + * @param {string[]} uids Array of token UIDs to fetch details for + */ +export const unregisteredTokensDownloadRequested = (uids) => ({ + type: types.UNREGISTERED_TOKENS_DOWNLOAD_REQUESTED, + payload: { uids }, +}); + +/** + * Success downloading unregistered tokens details + * @param {Object} tokens Object with token details + */ +export const unregisteredTokensDownloadSuccess = (tokens) => ({ + type: types.UNREGISTERED_TOKENS_DOWNLOAD_SUCCESS, + payload: { tokens }, +}); + +/** + * Failure downloading unregistered tokens details + * @param {string} error Error message + */ +export const unregisteredTokensDownloadFailed = (error) => ({ + type: types.UNREGISTERED_TOKENS_DOWNLOAD_FAILED, + payload: { error }, +}); + +/** + * End of unregistered tokens download process + */ +export const unregisteredTokensDownloadEnd = () => ({ + type: types.UNREGISTERED_TOKENS_DOWNLOAD_END, +}); diff --git a/src/components/CopyButton.js b/src/components/CopyButton.js new file mode 100644 index 00000000..0f304a5b --- /dev/null +++ b/src/components/CopyButton.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +/** + * A reusable button component that copies text to clipboard + * @param {Object} props Component props + * @param {string} props.text Text to be copied + * @param {string} [props.className] Additional CSS classes + */ +export const CopyButton = ({ text, className = '' }) => { + if (!text) return null; + + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/GlobalModal.js b/src/components/GlobalModal.js index 49ba2a10..7095a24b 100644 --- a/src/components/GlobalModal.js +++ b/src/components/GlobalModal.js @@ -36,6 +36,7 @@ import { PinPad } from './PinPad'; import { NanoContractFeedbackModal } from './Reown/NanoContractFeedbackModal'; import { TokenCreationFeedbackModal } from './Reown/TokenCreationFeedbackModal'; import { MessageSigningFeedbackModal } from './Reown/MessageSigningFeedbackModal'; +import { TransactionFeedbackModal } from './Reown/TransactionFeedbackModal'; import ModalError from './ModalError'; const initialState = { @@ -71,6 +72,7 @@ export const MODAL_TYPES = { 'REOWN': 'REOWN', 'PIN_PAD': 'PIN_PAD', 'NANO_CONTRACT_FEEDBACK': 'NANO_CONTRACT_FEEDBACK', + 'TRANSACTION_FEEDBACK': 'TRANSACTION_FEEDBACK', 'TOKEN_CREATION_FEEDBACK': 'TOKEN_CREATION_FEEDBACK', 'MESSAGE_SIGNING_FEEDBACK': 'MESSAGE_SIGNING_FEEDBACK', 'ERROR_MODAL': 'ERROR_MODAL', @@ -103,6 +105,7 @@ export const MODAL_COMPONENTS = { [MODAL_TYPES.REOWN]: ReownModal, [MODAL_TYPES.PIN_PAD]: PinPad, [MODAL_TYPES.NANO_CONTRACT_FEEDBACK]: NanoContractFeedbackModal, + [MODAL_TYPES.TRANSACTION_FEEDBACK]: TransactionFeedbackModal, [MODAL_TYPES.TOKEN_CREATION_FEEDBACK]: TokenCreationFeedbackModal, [MODAL_TYPES.MESSAGE_SIGNING_FEEDBACK]: MessageSigningFeedbackModal, [MODAL_TYPES.ERROR_MODAL]: ModalError, diff --git a/src/components/Reown/ReownModal.js b/src/components/Reown/ReownModal.js index d8b8c661..03c81955 100644 --- a/src/components/Reown/ReownModal.js +++ b/src/components/Reown/ReownModal.js @@ -10,6 +10,7 @@ import { ConnectModal } from './modals/ConnectModal'; import { SignMessageModal } from './modals/SignMessageModal'; import { SignOracleDataModal } from './modals/SignOracleDataModal'; import { SendNanoContractTxModal } from './modals/SendNanoContractTxModal'; +import { SendTransactionModal } from './modals/SendTransactionModal'; import { CreateTokenModal } from './modals/CreateTokenModal'; export const ReownModalTypes = { @@ -17,6 +18,7 @@ export const ReownModalTypes = { SIGN_MESSAGE: 'SIGN_MESSAGE', SIGN_ORACLE_DATA: 'SIGN_ORACLE_DATA', SEND_NANO_CONTRACT_TX: 'SEND_NANO_CONTRACT_TX', + SEND_TRANSACTION: 'SEND_TRANSACTION', CREATE_TOKEN: 'CREATE_TOKEN', }; @@ -39,13 +41,10 @@ export function ReownModal({ manageDomLifecycle, data, type, onAcceptAction, onR return ; case ReownModalTypes.SEND_NANO_CONTRACT_TX: - return ( - - ); + return ; + + case ReownModalTypes.SEND_TRANSACTION: + return case ReownModalTypes.CREATE_TOKEN: return ; diff --git a/src/components/Reown/TransactionFeedbackModal.js b/src/components/Reown/TransactionFeedbackModal.js new file mode 100644 index 00000000..34fceb97 --- /dev/null +++ b/src/components/Reown/TransactionFeedbackModal.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { t } from 'ttag'; +import { types } from '../../actions'; +import { FeedbackModal } from './FeedbackModal'; + +export const MODAL_ID = 'transactionFeedbackModal'; + +/** + * Component that shows a modal with feedback for transactions + * Shows loading, success or error message and provides retry option on failure + */ +export function TransactionFeedbackModal({ isError, isLoading = true, errorMessage, onClose, manageDomLifecycle }) { + return ( + + ); +} \ No newline at end of file diff --git a/src/components/Reown/modals/CreateTokenModal.js b/src/components/Reown/modals/CreateTokenModal.js index 3affdd95..6fe273eb 100644 --- a/src/components/Reown/modals/CreateTokenModal.js +++ b/src/components/Reown/modals/CreateTokenModal.js @@ -5,23 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { t } from 'ttag'; import { useDispatch } from 'react-redux'; import { setCreateTokenStatusReady } from '../../../actions'; +import { JSONBigInt } from '@hathor/wallet-lib/lib/utils/bigint'; -/** - * Custom replacer function for JSON.stringify to handle BigInt values - * @param {string} key - The key of the value being stringified - * @param {any} value - The value being stringified - * @returns {any} - The processed value - */ -const bigIntReplacer = (_key, value) => { - if (typeof value === 'bigint') { - return value.toString(); - } - return value; -}; /** * Modal for handling token creation requests from dApps @@ -44,7 +33,7 @@ export function CreateTokenModal({ data, onAccept, onReject }) { // Process the data to handle BigInt values const processedData = React.useMemo(() => { try { - return JSON.stringify(data.data, bigIntReplacer, 2); + return JSONBigInt.stringify(data.data, 2); } catch (error) { console.error('Error stringifying token data:', error); return 'Error displaying token data. Please check console for details.'; diff --git a/src/components/Reown/modals/SendNanoContractTxModal.js b/src/components/Reown/modals/SendNanoContractTxModal.js index 6f103d92..4dc8bbbb 100644 --- a/src/components/Reown/modals/SendNanoContractTxModal.js +++ b/src/components/Reown/modals/SendNanoContractTxModal.js @@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'; import { t } from 'ttag'; +import { get } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { types, setNewNanoContractStatusReady } from '../../../actions'; import helpers from '../../../utils/helpers'; @@ -47,13 +48,13 @@ export function SendNanoContractTxModal({ data, onAccept, onReject }) { } const methodInfo = blueprintInfo.public_methods?.[data.data.method]; - const argEntries = methodInfo - ? data.data.args.map((arg, idx) => [ - methodInfo.args[idx].name, - arg, - methodInfo.args[idx].type - ]) - : data.data.args.map((arg, idx) => [t`Position ${idx}`, arg]); + const methodInfoArgs = get(methodInfo, 'args', []); + const dataArgs = get(data.data, 'args', []); + const argEntries = dataArgs.map((arg, idx) => [ + methodInfoArgs[idx]?.name || t`Position ${idx}`, + arg, + methodInfoArgs[idx]?.type + ]); return ( <> diff --git a/src/components/Reown/modals/SendTransactionModal.js b/src/components/Reown/modals/SendTransactionModal.js new file mode 100644 index 00000000..5eb37f60 --- /dev/null +++ b/src/components/Reown/modals/SendTransactionModal.js @@ -0,0 +1,212 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect } from 'react'; +import { t } from 'ttag'; +import { useSelector, useDispatch } from 'react-redux'; +import { constants, numberUtils } from '@hathor/wallet-lib'; +import { unregisteredTokensDownloadRequested } from '../../../actions'; +import { CopyButton } from '../../CopyButton'; +import helpers from '../../../utils/helpers'; + +export function SendTransactionModal({ data, onAccept, onReject }) { + const dispatch = useDispatch(); + const { tokenMetadata, tokens: registeredTokens, decimalPlaces } = useSelector((state) => ({ + tokenMetadata: state.tokenMetadata, + tokens: state.tokens, + decimalPlaces: state.serverInfo.decimalPlaces + })); + + useEffect(() => { + // Collect all unregistered token UIDs + const unregisteredTokens = new Set(); + + data?.data?.inputs?.forEach(input => { + // Skip if it's the native token + if (input.token === constants.NATIVE_TOKEN_UID || !input.token) { + return; + } + + const token = registeredTokens.find(t => t.uid === input.token); + if (!token) { + // If we can't find this token in our registered tokens list, we need to fetch its details + unregisteredTokens.add(input.token); + } + }); + + data?.data?.outputs?.forEach(output => { + // Skip if it's the native token or if token is not specified + if (output.token === constants.NATIVE_TOKEN_UID || !output.token) { + return; + } + + const token = registeredTokens.find(t => t.uid === output.token); + if (!token) { + // If we can't find this token in our registered tokens list, we need to fetch its details + unregisteredTokens.add(output.token); + } + }); + + // Only dispatch if we actually have unregistered tokens to fetch + if (unregisteredTokens.size > 0) { + dispatch(unregisteredTokensDownloadRequested(Array.from(unregisteredTokens))); + } + }, [data, registeredTokens, dispatch]); + + const getTokenSymbol = (tokenId) => { + // Check if it's explicitly the native token UID + if (tokenId === constants.NATIVE_TOKEN_UID) { + return constants.DEFAULT_NATIVE_TOKEN_CONFIG.symbol; + } + + const token = registeredTokens.find(t => t.uid === tokenId); + if (token) { + return token.symbol; + } + + // We return an empty string as a fallback for tokens that are not yet + // loaded or recognized + // This should be temporary until the token details are fetched + // The unregisteredTokensDownloadRequested action should be loading these + // details + return ''; + }; + + const formatValue = (value, tokenId) => { + if (value == null) { + return '-'; + } + + // Check if the token is an NFT using the helpers utility + const isNFT = tokenId && helpers.isTokenNFT(tokenId, tokenMetadata); + + return numberUtils.prettyValue(value, isNFT ? 0 : decimalPlaces); + }; + + const truncateTxId = (txId) => { + if (!txId) return '-'; + return `${txId.slice(0, 8)}...${txId.slice(-8)}`; + }; + + const renderInputs = () => { + if (!data?.data?.inputs || data.data.inputs.length === 0) { + return null; + } + + return ( +
+
{t`Inputs`}
+ {data.data.inputs.map((input, index) => ( +
+
+
+ {t`Input ${index + 1}`} +
+
+ {formatValue(input?.value, input?.token)} {getTokenSymbol(input?.token)} +
+
+
+ {truncateTxId(input?.txId)} ({input?.index}) + {input?.txId && ( + + )} +
+
+ {input?.address} + {input?.address && ( + + )} +
+
+ ))} +
+ ); + }; + + const renderOutputs = () => { + if (!data?.data?.outputs || data.data.outputs.length === 0) { + return null; + } + + return ( +
+
{t`Outputs`}
+ {data.data.outputs.map((output, index) => ( +
+
+
+ {t`Output ${index + 1}`} +
+
+ {formatValue(output?.value, output?.token)} {getTokenSymbol(output?.token)} +
+
+
+ {output?.address} + +
+ {output?.data && ( + <> +
{t`Data field:`}
+
+ {output.data} +
+ + )} +
+ ))} +
+ ); + }; + + const handleAccept = () => { + onAccept(data?.data); + }; + + const handleReject = () => { + onReject(); + }; + + return ( + <> +
+
{t`NEW TRANSACTION`}
+ +
+
+
+
{data?.dapp?.proposer}
+ {data?.dapp?.url} +
+ + {renderInputs()} + {renderOutputs()} + + {data?.data?.changeAddress && ( +
+
{t`Change Address`}
+
+
+ {data.data.changeAddress} + {data.data.changeAddress && ( + + )} +
+
+
+ )} +
+
+ + +
+ + ); +} diff --git a/src/reducers/index.js b/src/reducers/index.js index e9b2fecc..4b8f504b 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -456,6 +456,9 @@ const rootReducer = (state = initialState, action) => { ...state, reown: reownReducer(state.reown, action), }; + case types.UNREGISTERED_TOKENS_DOWNLOAD_SUCCESS: + return onUnregisteredTokensDownloadSuccess(state, action); + case types.UNREGISTERED_TOKENS_DOWNLOAD_FAILED: default: return state; } @@ -1501,4 +1504,24 @@ export const onUpdateNetworkSettings = (state, { payload }) => { } } +/** + * Handle successful download of unregistered token details + * @param {Object} state Current state + * @param {Object} action Action with token details + * @param {Object} action.payload.tokens Object with token details + */ +export const onUnregisteredTokensDownloadSuccess = (state, action) => { + const { tokens } = action.payload; + const newTokens = Object.values(tokens).map(token => ({ + uid: token.uid, + name: token.name, + symbol: token.symbol + })); + + return { + ...state, + tokens: [...state.tokens, ...newTokens], + }; +}; + export default rootReducer; diff --git a/src/reducers/reown.js b/src/reducers/reown.js index 56f3be16..7e4d7b4c 100644 --- a/src/reducers/reown.js +++ b/src/reducers/reown.js @@ -19,6 +19,7 @@ const initialState = { }, firstAddress: null, sessions: {}, + sendTxStatus: BASE_STATUS.READY, connectionState: REOWN_CONNECTION_STATE.IDLE, nanoContractStatus: BASE_STATUS.READY, createTokenStatus: BASE_STATUS.READY, @@ -106,6 +107,30 @@ export default function reownReducer(state = initialState, action) { createTokenStatus: BASE_STATUS.ERROR, }; + case types.REOWN_SEND_TX_STATUS_LOADING: + return { + ...state, + sendTxStatus: BASE_STATUS.LOADING, + }; + + case types.REOWN_SEND_TX_STATUS_READY: + return { + ...state, + sendTxStatus: BASE_STATUS.READY, + }; + + case types.REOWN_SEND_TX_STATUS_SUCCESS: + return { + ...state, + sendTxStatus: BASE_STATUS.SUCCESS + }; + + case types.REOWN_SEND_TX_STATUS_FAILED: + return { + ...state, + sendTxStatus: BASE_STATUS.ERROR + }; + default: return state; } diff --git a/src/sagas/reown.js b/src/sagas/reown.js index 22216cce..b0956a04 100644 --- a/src/sagas/reown.js +++ b/src/sagas/reown.js @@ -29,8 +29,10 @@ import { handleRpcRequest, CreateTokenError, SendNanoContractTxError, + SendTransactionError, + InsufficientFundsError, SignMessageWithAddressError, -} from 'hathor-rpc-handler-test'; +} from '@hathor/hathor-rpc-handler'; import { isWalletServiceEnabled } from './wallet'; import { ReownModalTypes } from '../components/Reown/ReownModal'; import { @@ -50,15 +52,18 @@ import { setCreateTokenStatusReady, setCreateTokenStatusSuccessful, setCreateTokenStatusFailed, + setSendTxStatusSuccess, + setSendTxStatusFailed, showGlobalModal, hideGlobalModal, setReownFirstAddress, } from '../actions'; -import { checkForFeatureFlag, getNetworkSettings, retryHandler, showPinScreenForResult } from './helpers'; +import { checkForFeatureFlag, getNetworkSettings, retryHandler } from './helpers'; import { logger } from '../utils/logger'; import { getGlobalReown, setGlobalReown } from '../modules/reown'; import { MODAL_TYPES } from '../components/GlobalModal'; import { getGlobalWallet } from '../modules/wallet'; +import { t } from 'ttag'; import { REOWN_CONNECTION_STATE } from '../constants'; const log = logger('reown'); @@ -68,6 +73,7 @@ const AVAILABLE_METHODS = { HATHOR_SEND_NANO_TX: 'htr_sendNanoContractTx', HATHOR_SIGN_ORACLE_DATA: 'htr_signOracleData', HATHOR_CREATE_TOKEN: 'htr_createToken', + HATHOR_SEND_TRANSACTION: 'htr_sendTransaction', }; const AVAILABLE_EVENTS = []; @@ -395,6 +401,10 @@ export function* processRequest(action) { yield put(setCreateTokenStatusSuccessful()); yield put(showGlobalModal(MODAL_TYPES.TOKEN_CREATION_FEEDBACK, { isLoading: false, isError: false })); break; + case RpcResponseTypes.SendTransactionResponse: + yield put(setSendTxStatusSuccess()); + yield put(showGlobalModal(MODAL_TYPES.TRANSACTION_FEEDBACK, { isLoading: false, isError: false })); + break; case RpcResponseTypes.SignWithAddressResponse: // Show success feedback for message signing yield put(showGlobalModal(MODAL_TYPES.MESSAGE_SIGNING_FEEDBACK, { isLoading: false, isError: false })); @@ -448,6 +458,26 @@ export function* processRequest(action) { yield put(setCreateTokenStatusReady()); // Reset status if not retrying } } break; + case InsufficientFundsError: + case SendTransactionError: { + yield put(setSendTxStatusFailed()); + yield put(showGlobalModal(MODAL_TYPES.TRANSACTION_FEEDBACK, { + isLoading: false, + isError: true, + errorMessage: e instanceof InsufficientFundsError ? t`Insufficient funds to complete the transaction.` : null + })); + + const retry = yield call( + retryHandler, + types.REOWN_SEND_TX_RETRY, + types.REOWN_SEND_TX_RETRY_DISMISS, + ); + + if (retry) { + shouldAnswer = false; + yield* processRequest(action); + } + } break; case SignMessageWithAddressError: { yield put(showGlobalModal(MODAL_TYPES.MESSAGE_SIGNING_FEEDBACK, { isLoading: false, isError: true })); @@ -463,6 +493,7 @@ export function* processRequest(action) { } } break; default: + log.error('Unknown error type:', e); break; } @@ -489,6 +520,28 @@ export function* processRequest(action) { const promptHandler = (dispatch) => (request, requestMetadata) => new Promise(async (resolve, reject) => { switch (request.type) { + case TriggerTypes.SendTransactionConfirmationPrompt: { + const sendTransactionResponseTemplate = (accepted) => () => { + dispatch(hideGlobalModal()); + resolve({ + type: TriggerResponseTypes.SendTransactionConfirmationResponse, + data: { + accepted, + } + }); + }; + + dispatch(showGlobalModal(MODAL_TYPES.REOWN, { + type: ReownModalTypes.SEND_TRANSACTION, + data: { + data: request.data, + dapp: requestMetadata, + }, + onAcceptAction: sendTransactionResponseTemplate(true), + onRejectAction: sendTransactionResponseTemplate(false), + })); + } break; + case TriggerTypes.SignOracleDataConfirmationPrompt: { const signOracleDataResponseTemplate = (accepted) => () => { dispatch(hideGlobalModal()); @@ -599,6 +652,16 @@ const promptHandler = (dispatch) => (request, requestMetadata) => resolve(); break; + case TriggerTypes.SendTransactionLoadingTrigger: + dispatch(showGlobalModal(MODAL_TYPES.TRANSACTION_FEEDBACK, { isLoading: true })); + resolve(); + break; + + case TriggerTypes.SendTransactionLoadingFinishedTrigger: + dispatch(hideGlobalModal()); + resolve(); + break; + case TriggerTypes.PinConfirmationPrompt: { const pinPromise = new Promise((pinResolve, pinReject) => { dispatch(showGlobalModal(MODAL_TYPES.PIN_PAD, { @@ -709,6 +772,10 @@ export function* onSendNanoContractTxRequest(action) { yield* handleDAppRequest(action, ReownModalTypes.SEND_NANO_CONTRACT_TX); } +export function* onSendTransactionRequest(action) { + yield* handleDAppRequest(action, ReownModalTypes.SEND_TRANSACTION); +} + /** * Handles a request to create a token * Shows a modal to the user for confirmation @@ -987,6 +1054,7 @@ export function* saga() { takeLatest(types.SHOW_SIGN_MESSAGE_REQUEST_MODAL, onSignMessageRequest), takeLatest(types.SHOW_SIGN_ORACLE_DATA_REQUEST_MODAL, onSignOracleDataRequest), takeLatest(types.SHOW_CREATE_TOKEN_REQUEST_MODAL, onCreateTokenRequest), + takeLatest(types.SHOW_SEND_TRANSACTION_REQUEST_MODAL, onSendTransactionRequest), takeEvery(types.REOWN_SESSION_PROPOSAL, onSessionProposal), takeEvery(types.REOWN_SESSION_DELETE, onSessionDelete), takeEvery(types.REOWN_CANCEL_SESSION, onCancelSession), diff --git a/src/sagas/tokens.js b/src/sagas/tokens.js index e4f533da..612d1fd5 100644 --- a/src/sagas/tokens.js +++ b/src/sagas/tokens.js @@ -7,6 +7,8 @@ import { take, all, put, + join, + takeLatest, } from 'redux-saga/effects'; import { metadataApi } from '@hathor/wallet-lib'; import { channel } from 'redux-saga'; @@ -24,13 +26,21 @@ import { tokenFetchHistoryFailed, proposalTokenFetchSuccess, proposalTokenFetchFailed, + unregisteredTokensDownloadSuccess, + unregisteredTokensDownloadFailed, + unregisteredTokensDownloadEnd, + onExceptionCaptured, + tokenFetchMetadataRequested, } from '../actions'; import { t } from "ttag"; import { getGlobalWallet } from "../modules/wallet"; +import { logger } from '../utils/logger'; const CONCURRENT_FETCH_REQUESTS = 5; const METADATA_MAX_RETRIES = 3; +const log = logger('tokens'); + export const TOKEN_DOWNLOAD_STATUS = { READY: 'ready', FAILED: 'failed', @@ -363,6 +373,81 @@ function* fetchProposalTokenData(action) { } } +const NODE_RATE_LIMIT_CONF = { + thin_wallet_token: { + burst: 10, + delay: 3, + } +}; + +const splitInGroups = (array, size) => { + const result = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +}; + +/** + * Get token details from wallet. + * + * @param {Object} wallet The application wallet. + * @param {string} uid Token UID. + */ +export function* getTokenDetails(wallet, uid) { + try { + const { tokenInfo: { symbol, name } } = yield call([wallet, wallet.getTokenDetails], uid); + + // Register the token in the wallet storage + yield call([wallet.storage, wallet.storage.registerToken], { uid, name, symbol }); + + yield put(unregisteredTokensDownloadSuccess({ [uid]: { uid, symbol, name } })); + } catch (e) { + log.error(`Fail getting token data for token ${uid}.`, e); + yield put(unregisteredTokensDownloadFailed('Some tokens could not be loaded')); + } +} + +/** + * Request token details of unregistered tokens to feed new + * nano contract request actions. + */ +export function* requestUnregisteredTokensDownload({ payload }) { + const { uids } = payload; + + if (uids.length === 0) { + yield put(unregisteredTokensDownloadEnd()); + return; + } + + const wallet = getGlobalWallet(); + if (!wallet.isReady()) { + log.error('Fail updating loading tokens data because wallet is not ready yet.'); + yield put(onExceptionCaptured(new Error('Wallet is not ready'), true)); + return; + } + + const perBurst = NODE_RATE_LIMIT_CONF.thin_wallet_token.burst; + const burstDelay = NODE_RATE_LIMIT_CONF.thin_wallet_token.delay; + const uidGroups = splitInGroups(uids, perBurst); + + for (const group of uidGroups) { + const tasks = yield all(group.map((uid) => fork(getTokenDetails, wallet, uid))); + yield join(tasks); + + if (uidGroups.length === 1 || group === uidGroups[uidGroups.length - 1]) { + continue; + } + yield delay(burstDelay * 1000); + } + + for (const uid of uids) { + yield put(tokenFetchMetadataRequested(uid)); + } + + yield put(unregisteredTokensDownloadEnd()); +} + export function* saga() { yield all([ fork(fetchTokenMetadataQueue), @@ -371,5 +456,6 @@ export function* saga() { fork(fetchProposalTokenDataQueue), takeEvery(types.TOKEN_FETCH_HISTORY_REQUESTED, fetchTokenHistory), takeEvery('new_tokens', routeTokenChange), + takeLatest(types.UNREGISTERED_TOKENS_DOWNLOAD_REQUESTED, requestUnregisteredTokensDownload), ]); }