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),
]);
}