diff --git a/package.json b/package.json index a4035a7..85544e4 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ }, "peerDependencies": { "@multiversx/sdk-core": ">= 13.5.0", - "@multiversx/sdk-dapp-utils": ">= 1.0.0", + "@multiversx/sdk-dapp-utils": ">= 1.0.2", "@multiversx/sdk-web-wallet-cross-window-provider": ">= 2.0.4", "axios": ">=1.6.5", "bignumber.js": "9.x", @@ -65,7 +65,7 @@ "devDependencies": { "@eslint/js": "9.15.0", "@multiversx/sdk-core": ">= 13.5.0", - "@multiversx/sdk-dapp-utils": "1.0.0", + "@multiversx/sdk-dapp-utils": ">= 1.0.2", "@multiversx/sdk-web-wallet-cross-window-provider": ">= 2.0.4", "@swc/core": "^1.4.17", "@swc/jest": "^0.2.36", diff --git a/src/apiCalls/account/getAccountFromApi.ts b/src/apiCalls/account/getAccountFromApi.ts index 79a1dbb..e4b687c 100644 --- a/src/apiCalls/account/getAccountFromApi.ts +++ b/src/apiCalls/account/getAccountFromApi.ts @@ -1,6 +1,7 @@ import { ACCOUNTS_ENDPOINT } from 'apiCalls/endpoints'; import { axiosInstance } from 'apiCalls/utils/axiosInstance'; import { getCleanApiAddress } from 'apiCalls/utils/getCleanApiAddress'; +import { TIMEOUT } from 'constants/network.constants'; import { AccountType } from 'types/account.types'; export const accountFetcher = (address: string | null) => { @@ -8,7 +9,8 @@ export const accountFetcher = (address: string | null) => { const url = `${apiAddress}/${ACCOUNTS_ENDPOINT}/${address}?withGuardianInfo=true`; // we need to get it with an axios instance because of cross-window user interaction issues return axiosInstance.get(url, { - baseURL: apiAddress + baseURL: apiAddress, + timeout: TIMEOUT }); }; diff --git a/src/apiCalls/account/getScamAddressData.ts b/src/apiCalls/account/getScamAddressData.ts new file mode 100644 index 0000000..ee1a55e --- /dev/null +++ b/src/apiCalls/account/getScamAddressData.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import { getCleanApiAddress } from 'apiCalls/utils'; +import { TIMEOUT } from 'constants/index'; +import { ScamInfoType } from 'types/account.types'; +import { ACCOUNTS_ENDPOINT } from '../endpoints'; + +export async function getScamAddressData(addressToVerify: string) { + const apiAddress = getCleanApiAddress(); + + const { data } = await axios.get<{ + scamInfo?: ScamInfoType; + code?: string; + }>(`${apiAddress}/${ACCOUNTS_ENDPOINT}/${addressToVerify}`, { + timeout: TIMEOUT + }); + + return data; +} diff --git a/src/apiCalls/configuration/getServerConfiguration.ts b/src/apiCalls/configuration/getServerConfiguration.ts index 862c6f6..6e2ccdd 100644 --- a/src/apiCalls/configuration/getServerConfiguration.ts +++ b/src/apiCalls/configuration/getServerConfiguration.ts @@ -7,7 +7,26 @@ export async function getServerConfiguration(apiAddress: string) { try { const { data } = await axios.get(configUrl); - return data; + if (data != null) { + // TODO: egldDenomination will be removed from API when dapp-core v1 will be discontinued + const egldDenomination = 'egldDenomination'; + if (egldDenomination in data) { + const { + [egldDenomination]: decimals, + decimals: digits, + ...rest + } = data as NetworkType & { + [egldDenomination]: string; + }; + const networkConfig: NetworkType = { + ...rest, + decimals, + digits + }; + return networkConfig; + } + return data; + } } catch (_err) { console.error('error fetching configuration for ', configUrl); } diff --git a/src/apiCalls/economics/getEconomics.ts b/src/apiCalls/economics/getEconomics.ts new file mode 100644 index 0000000..f977aeb --- /dev/null +++ b/src/apiCalls/economics/getEconomics.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; +import { ECONOMICS_ENDPOINT } from 'apiCalls/endpoints'; +import { getCleanApiAddress } from 'apiCalls/utils/getCleanApiAddress'; + +export interface EconomicsInfoType { + totalSupply: number; + circulatingSupply: number; + staked: number; + price: number; + marketCap: number; + apr: number; + topUpApr: number; +} + +export async function getEconomics(url = ECONOMICS_ENDPOINT) { + const apiAddress = getCleanApiAddress(); + const configUrl = `${apiAddress}/${url}`; + try { + const { data } = await axios.get(configUrl); + return data; + } catch (err) { + console.error('err fetching economics info', err); + return null; + } +} diff --git a/src/apiCalls/tokens/getPersistedToken.ts b/src/apiCalls/tokens/getPersistedToken.ts new file mode 100644 index 0000000..952a11f --- /dev/null +++ b/src/apiCalls/tokens/getPersistedToken.ts @@ -0,0 +1,25 @@ +import { getCleanApiAddress } from 'apiCalls/utils'; +import { axiosInstance } from 'apiCalls/utils/axiosInstance'; +import { TIMEOUT } from 'constants/network.constants'; +import { tokenDataStorage } from './tokenDataStorage'; + +export async function getPersistedToken(url: string): Promise { + const apiAddress = getCleanApiAddress(); + + const config = { + baseURL: apiAddress, + timeout: TIMEOUT + }; + + const cachedToken: T | null = await tokenDataStorage.getItem(url); + + if (cachedToken) { + return cachedToken; + } + + const response = await axiosInstance.get(url, config); + + await tokenDataStorage.setItem(url, response.data); + + return response.data; +} diff --git a/src/apiCalls/tokens/getPersistedTokenDetails.ts b/src/apiCalls/tokens/getPersistedTokenDetails.ts new file mode 100644 index 0000000..21e59c6 --- /dev/null +++ b/src/apiCalls/tokens/getPersistedTokenDetails.ts @@ -0,0 +1,104 @@ +import { NFTS_ENDPOINT, TOKENS_ENDPOINT } from 'apiCalls/endpoints'; +import { getPersistedToken } from 'apiCalls/tokens/getPersistedToken'; +import { networkSelector } from 'store/selectors/networkSelectors'; +import { getState } from 'store/store'; + +import { NftEnumType } from 'types/tokens.types'; +import { getIdentifierType } from 'utils/validation/getIdentifierType'; + +export interface TokenAssets { + description: string; + status: string; + svgUrl: string; + website?: string; + pngUrl?: string; + social?: any; + extraTokens?: string[]; + lockedAccounts?: { [key: string]: string }; +} + +export interface TokenMediaType { + url?: string; + originalUrl?: string; + thumbnailUrl?: string; + fileType?: string; + fileSize?: number; +} + +export interface TokenOptionType { + tokenLabel: string; + tokenDecimals: number; + tokenImageUrl: string; + assets?: TokenAssets; + type?: NftEnumType; + error?: string; + esdtPrice?: number; + ticker?: string; + identifier?: string; + name?: string; +} + +interface TokenInfoResponse { + identifier: string; + name: string; + ticker: string; + decimals: number; + type?: NftEnumType; + assets: TokenAssets; + media?: TokenMediaType[]; + price: number; +} + +export async function getPersistedTokenDetails({ + tokenId +}: { + tokenId?: string; +}): Promise { + const network = networkSelector(getState()); + + const noData = { + tokenDecimals: Number(network.decimals), + tokenLabel: '', + tokenImageUrl: '' + }; + + const { isNft } = getIdentifierType(tokenId); + + const tokenIdentifier = tokenId; + const tokenEndpoint = isNft ? NFTS_ENDPOINT : TOKENS_ENDPOINT; + + if (!tokenIdentifier) { + return noData; + } + + try { + const selectedToken = await getPersistedToken( + `${network.apiAddress}/${tokenEndpoint}/${tokenIdentifier}` + ); + + const tokenDecimals = selectedToken + ? selectedToken?.decimals + : Number(network.decimals); + const tokenLabel = selectedToken ? selectedToken?.name : ''; + const tokenImageUrl = selectedToken + ? selectedToken?.assets?.svgUrl ?? selectedToken?.media?.[0]?.thumbnailUrl + : ''; + + return { + tokenDecimals: tokenDecimals, + tokenLabel, + type: selectedToken?.type, + tokenImageUrl, + identifier: selectedToken?.identifier, + assets: selectedToken?.assets, + esdtPrice: selectedToken?.price, + ticker: selectedToken?.ticker, + name: selectedToken?.name + }; + } catch (error) { + return { + ...noData, + error: `${error}` + }; + } +} diff --git a/src/apiCalls/tokens/tokenDataStorage.ts b/src/apiCalls/tokens/tokenDataStorage.ts new file mode 100644 index 0000000..e30a7d9 --- /dev/null +++ b/src/apiCalls/tokens/tokenDataStorage.ts @@ -0,0 +1,33 @@ +let memoryCache: Record = {}; + +export let tokenDataStorage = { + setItem: async (key: string, tokenData: T) => { + try { + memoryCache[key] = JSON.stringify(tokenData); + } catch (e) { + console.error('tokenDataStorage unable to serialize', e); + } + }, + getItem: async (key: string) => { + if (!memoryCache[key]) { + return null; + } + try { + return JSON.parse(memoryCache[key]); + } catch (e) { + console.error('tokenDataStorage unable to parse', e); + } + }, + clear: async () => { + memoryCache = {}; + }, + removeItem: async (key: string) => { + delete memoryCache[key]; + } +}; + +export const setTokenDataStorage = ( + tokenDataCacheStorage: typeof tokenDataStorage +) => { + tokenDataStorage = tokenDataCacheStorage; +}; diff --git a/src/core/managers/SignTransactionsStateManager/SignTransactionsStateManager.ts b/src/core/managers/SignTransactionsStateManager/SignTransactionsStateManager.ts index 41fb895..6eb0aa5 100644 --- a/src/core/managers/SignTransactionsStateManager/SignTransactionsStateManager.ts +++ b/src/core/managers/SignTransactionsStateManager/SignTransactionsStateManager.ts @@ -1,6 +1,9 @@ +import { NftEnumType } from 'types/tokens.types'; import { + FungibleTransactionType, ISignTransactionsModalData, - SignEventsEnum + SignEventsEnum, + TokenType } from './types/signTransactionsModal.types'; interface IEventBus { @@ -22,7 +25,10 @@ export class SignTransactionsStateManager { // whole data to be sent on update events private initialData: ISignTransactionsModalData = { - transaction: null + commonData: { transactionsCount: 0, egldLabel: '', currentIndex: 0 }, + tokenTransaction: null, + nftTransaction: null, + sftTransaction: null }; private data: ISignTransactionsModalData = { ...this.initialData }; @@ -46,9 +52,11 @@ export class SignTransactionsStateManager { return SignTransactionsStateManager.instance as SignTransactionsStateManager; } - public updateTransaction(members: Partial): void { - this.data = { - ...this.data, + public updateCommonData( + members: Partial + ): void { + this.data.commonData = { + ...this.data.commonData, ...members }; this.notifyDataUpdate(); @@ -67,4 +75,36 @@ export class SignTransactionsStateManager { private notifyDataUpdate(): void { this.eventBus.publish(SignEventsEnum.DATA_UPDATE, this.data); } + + public updateTokenTransaction( + tokenData: ISignTransactionsModalData['tokenTransaction'] + ): void { + this.data.tokenTransaction = tokenData; + this.data.sftTransaction = null; + this.data.nftTransaction = null; + + this.notifyDataUpdate(); + } + + public updateFungibleTransaction( + type: TokenType, + fungibleData: FungibleTransactionType + ): void { + switch (type) { + case NftEnumType.NonFungibleESDT: + this.data.nftTransaction = fungibleData; + this.data.tokenTransaction = null; + this.data.sftTransaction = null; + break; + case NftEnumType.SemiFungibleESDT: + this.data.sftTransaction = fungibleData; + this.data.nftTransaction = null; + this.data.tokenTransaction = null; + break; + default: + break; + } + + this.notifyDataUpdate(); + } } diff --git a/src/core/managers/SignTransactionsStateManager/types/signTransactionsModal.types.ts b/src/core/managers/SignTransactionsStateManager/types/signTransactionsModal.types.ts index c865aa5..10e8c95 100644 --- a/src/core/managers/SignTransactionsStateManager/types/signTransactionsModal.types.ts +++ b/src/core/managers/SignTransactionsStateManager/types/signTransactionsModal.types.ts @@ -1,11 +1,44 @@ export interface ITransactionData { receiver?: string; + data?: string; value?: string; } +export type FungibleTransactionType = { + amount: string; + identifier?: string; + imageURL: string; +}; + +export type TokenType = + | 'SemiFungibleESDT' + | 'NonFungibleESDT' + | 'FungibleESDT' + | null; + export interface ISignTransactionsModalData { - transaction: ITransactionData | null; shouldClose?: true; + commonData: { + receiver?: string; + data?: string; + transactionsCount: number; + /** + * Token type of the transaction. + * @param {string} `null` - if is EGLD or MultiEsdt transaction. + */ + tokenType?: TokenType; + egldLabel: string; + feeLimit?: string; + feeInFiatLimit?: string | null; + currentIndex: number; + }; + tokenTransaction: { + identifier?: string; + amount: string; + usdValue: string; + } | null; + nftTransaction: FungibleTransactionType | null; + sftTransaction: FungibleTransactionType | null; } export enum SignEventsEnum { diff --git a/src/core/managers/ToastManager/ToastManager.ts b/src/core/managers/ToastManager/ToastManager.ts index fd8546f..a69b961 100644 --- a/src/core/managers/ToastManager/ToastManager.ts +++ b/src/core/managers/ToastManager/ToastManager.ts @@ -20,10 +20,12 @@ import { export class ToastManager { private transactionToastsList: TransactionToastList | undefined; - private unsubscribe: () => void; + private unsubscribe: () => void = () => null; store = getStore(); - constructor() { + constructor() {} + + public init() { const { toasts, trackedTransactions } = this.store.getState(); this.onToastListChange(toasts); diff --git a/src/core/managers/TransactionManager/TransactionManager.ts b/src/core/managers/TransactionManager/TransactionManager.ts index 9a7540b..091ebf8 100644 --- a/src/core/managers/TransactionManager/TransactionManager.ts +++ b/src/core/managers/TransactionManager/TransactionManager.ts @@ -6,9 +6,10 @@ import { addTransactionToast } from 'store/actions/toasts/toastsActions'; import { createTrackedTransactionsSession } from 'store/actions/trackedTransactions/trackedTransactionsActions'; import { networkSelector } from 'store/selectors'; import { getState } from 'store/store'; -import { GuardianActionsEnum, TransactionServerStatusesEnum } from 'types'; +import { TransactionServerStatusesEnum } from 'types/enums.types'; import { BatchTransactionsResponseType } from 'types/serverTransactions.types'; import { SignedTransactionType } from 'types/transactions.types'; +import { isGuardianTx } from 'utils/transactions/isGuardianTx'; export class TransactionManager { private static instance: TransactionManager | null = null; @@ -154,15 +155,11 @@ export class TransactionManager { }; // TODO: Remove when the protocol supports usernames for guardian transactions - if (this.isGuardianTx(parsedTransaction.data)) { + if (isGuardianTx({ data: parsedTransaction.data })) { delete parsedTransaction.senderUsername; delete parsedTransaction.receiverUsername; } return parsedTransaction; }; - - private isGuardianTx = (transactionData?: string) => - transactionData && - transactionData.startsWith(GuardianActionsEnum.SetGuardian); } diff --git a/src/core/methods/initApp/initApp.ts b/src/core/methods/initApp/initApp.ts index 560c6ce..a746c7e 100644 --- a/src/core/methods/initApp/initApp.ts +++ b/src/core/methods/initApp/initApp.ts @@ -65,7 +65,8 @@ export async function initApp({ } trackTransactions(); - new ToastManager(); // TODO: change to something more clear + const toastManager = new ToastManager(); + toastManager.init(); const isLoggedIn = getIsLoggedIn(); diff --git a/src/core/methods/network/getEgldLabel.ts b/src/core/methods/network/getEgldLabel.ts new file mode 100644 index 0000000..4cf0398 --- /dev/null +++ b/src/core/methods/network/getEgldLabel.ts @@ -0,0 +1,6 @@ +import { networkSelector } from 'store/selectors'; +import { getState } from 'store/store'; + +export function getEgldLabel(state = getState()) { + return networkSelector(state).egldLabel; +} diff --git a/src/core/providers/DappProvider/helpers/signTransactions/signTransactions.ts b/src/core/providers/DappProvider/helpers/signTransactions/signTransactions.ts index a9f78d6..79b050b 100644 --- a/src/core/providers/DappProvider/helpers/signTransactions/signTransactions.ts +++ b/src/core/providers/DappProvider/helpers/signTransactions/signTransactions.ts @@ -5,7 +5,10 @@ import { TransactionVersion } from '@multiversx/sdk-core/out'; import { getAccount } from 'core/methods/account/getAccount'; -import { IProvider } from 'core/providers/types/providerFactory.types'; +import { + IProvider, + ProviderTypeEnum +} from 'core/providers/types/providerFactory.types'; export type SignTransactionsOptionsType = { skipGuardian?: boolean; @@ -23,15 +26,19 @@ export async function signTransactions({ options = {} }: SignTransactionsType): Promise { const { isGuarded, activeGuardianAddress } = getAccount(); + const isLedger = provider.getType() === ProviderTypeEnum.ledger; const transactionsToSign = activeGuardianAddress && isGuarded && !options.skipGuardian ? transactions?.map((transaction) => { transaction.setVersion(TransactionVersion.withTxOptions()); - transaction.setOptions( - TransactionOptions.withOptions({ guarded: true }) - ); + const options = { + guarded: true, + ...(isLedger ? { hashSign: true } : {}) + }; + transaction.setOptions(TransactionOptions.withOptions(options)); transaction.setGuardian(Address.fromBech32(activeGuardianAddress)); + return transaction; }) : transactions; diff --git a/src/core/providers/strategies/ExtensionProviderStrategy/ExtensionProviderStrategy.ts b/src/core/providers/strategies/ExtensionProviderStrategy/ExtensionProviderStrategy.ts index d37c0eb..131101e 100644 --- a/src/core/providers/strategies/ExtensionProviderStrategy/ExtensionProviderStrategy.ts +++ b/src/core/providers/strategies/ExtensionProviderStrategy/ExtensionProviderStrategy.ts @@ -1,4 +1,5 @@ -import { Message, Transaction } from '@multiversx/sdk-core/out'; +import { Transaction, Message } from '@multiversx/sdk-core/out'; +import { IDAppProviderOptions } from '@multiversx/sdk-dapp-utils/out'; import { ExtensionProvider } from '@multiversx/sdk-extension-provider/out/extensionProvider'; import { PendingTransactionsStateManager, @@ -7,17 +8,13 @@ import { import { getAccount } from 'core/methods/account/getAccount'; import { getAddress } from 'core/methods/account/getAddress'; import { IProvider } from 'core/providers/types/providerFactory.types'; - import { PendingTransactionsModal } from 'lib/sdkDappCoreUi'; -import { ProviderErrorsEnum } from 'types'; +import { Nullable, ProviderErrorsEnum } from 'types'; import { createModalElement } from 'utils/createModalElement'; export class ExtensionProviderStrategy { private address: string = ''; private provider: ExtensionProvider | null = null; - private _signTransactions: - | ((transactions: Transaction[]) => Promise) - | null = null; private _signMessage: ((message: Message) => Promise) | null = null; constructor(address?: string) { @@ -32,7 +29,6 @@ export class ExtensionProviderStrategy { await this.provider.init(); } - this._signTransactions = this.provider.signTransactions.bind(this.provider); this._signMessage = this.provider.signMessage.bind(this.provider); return this.buildProvider(); @@ -46,7 +42,6 @@ export class ExtensionProviderStrategy { } const provider = this.provider as unknown as IProvider; - provider.signTransactions = this.signTransactions; provider.signMessage = this.signMessage; provider.setAccount({ address: this.address || address }); @@ -67,38 +62,6 @@ export class ExtensionProviderStrategy { this.address = address; }; - private signTransactions = async (transactions: Transaction[]) => { - if (!this.provider || !this._signTransactions) { - throw new Error(ProviderErrorsEnum.notInitialized); - } - - const modalElement = await createModalElement( - 'pending-transactions-modal' - ); - const { eventBus, manager, onClose } = - await this.getModalHandlers(modalElement); - - eventBus.subscribe(PendingTransactionsEventsEnum.CLOSE, onClose); - - manager.updateData({ - isPending: true, - title: 'Confirm on MultiversX DeFi Wallet', - subtitle: 'Check your MultiversX Wallet Extension to sign the transaction' - }); - try { - const signedTransactions: Transaction[] = - await this._signTransactions(transactions); - - return signedTransactions; - } catch (error) { - this.provider.cancelAction(); - throw error; - } finally { - onClose(false); - eventBus.unsubscribe(PendingTransactionsEventsEnum.CLOSE, onClose); - } - }; - private signMessage = async (message: Message) => { if (!this.provider || !this._signMessage) { throw new Error(ProviderErrorsEnum.notInitialized); diff --git a/src/core/providers/strategies/LedgerProviderStrategy/LedgerProviderStrategy.ts b/src/core/providers/strategies/LedgerProviderStrategy/LedgerProviderStrategy.ts index a264637..76de1f2 100644 --- a/src/core/providers/strategies/LedgerProviderStrategy/LedgerProviderStrategy.ts +++ b/src/core/providers/strategies/LedgerProviderStrategy/LedgerProviderStrategy.ts @@ -4,8 +4,6 @@ import { HWProvider, IProviderAccount } from '@multiversx/sdk-hw-provider'; import BigNumber from 'bignumber.js'; import { safeWindow } from 'constants/index'; import { LedgerConnectStateManager } from 'core/managers'; -import { SignTransactionsStateManager } from 'core/managers'; -import { SignEventsEnum } from 'core/managers/SignTransactionsStateManager/types/signTransactionsModal.types'; import { getAddress } from 'core/methods/account/getAddress'; import { getIsLoggedIn } from 'core/methods/account/getIsLoggedIn'; import { @@ -13,11 +11,7 @@ import { IProvider, ProviderTypeEnum } from 'core/providers/types/providerFactory.types'; -import { - defineCustomElements, - LedgerConnectModal, - SignTransactionsModal -} from 'lib/sdkDappCoreUi'; +import { defineCustomElements, LedgerConnectModal } from 'lib/sdkDappCoreUi'; import { setLedgerAccount } from 'store/actions'; import { setLedgerLogin } from 'store/actions/loginInfo/loginInfoActions'; import { ProviderErrorsEnum } from 'types'; @@ -29,6 +23,7 @@ import { getAuthTokenText } from './helpers'; import { ILedgerAccount, LedgerConnectEventsEnum } from './types'; +import { signTransactions } from '../helpers/signTransactions/signTransactions'; const failInitializeErrorText = 'Check if the MultiversX App is open on Ledger'; @@ -163,73 +158,15 @@ export class LedgerProviderStrategy { }; private signTransactions = async (transactions: Transaction[]) => { - const signModalElement = await createModalElement( - 'sign-transactions-modal' - ); - const eventBus = await signModalElement.getEventBus(); - - if (!eventBus) { - throw new Error(ProviderErrorsEnum.eventBusError); + if (!this._signTransactions) { + throw new Error('Sign transactions method is not initialized'); } - const manager = SignTransactionsStateManager.getInstance(eventBus); - if (!manager) { - throw new Error('Unable to establish connection with sign screens'); - } - - return new Promise(async (resolve, reject) => { - const signedTransactions: Transaction[] = []; - let currentTransactionIndex = 0; - - const signNextTransaction = async () => { - const currentTransaction = transactions[currentTransactionIndex]; - - manager.updateTransaction({ - transaction: currentTransaction.toPlainObject() - }); - - const onCancel = () => { - reject(new Error('Transaction signing cancelled by user')); - signModalElement.remove(); - }; - - const onSign = async () => { - if (!this._signTransactions) { - throw new Error('Sign transactions is not initialized.'); - } - - try { - // TODO: check if it's a real transaction or multitransfer step - const [signedTransaction] = await this._signTransactions([ - currentTransaction - ]); - - if (signedTransaction) { - signedTransactions.push(signedTransaction); - } - - eventBus.unsubscribe(SignEventsEnum.SIGN_TRANSACTION, onSign); - eventBus.unsubscribe(SignEventsEnum.CLOSE, onCancel); - - if (signedTransactions.length == transactions.length) { - signModalElement.remove(); - resolve(signedTransactions); - } else { - currentTransactionIndex++; - signNextTransaction(); - } - } catch (error) { - reject('Error signing transactions: ' + error); - signModalElement.remove(); - } - }; - - eventBus.subscribe(SignEventsEnum.SIGN_TRANSACTION, onSign); - eventBus.subscribe(SignEventsEnum.CLOSE, onCancel); - }; - - signNextTransaction(); + const signedTransactions = await signTransactions({ + transactions, + handleSign: this._signTransactions }); + return signedTransactions; }; private login = async (options?: { diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/calculateFeeInFiat.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/calculateFeeInFiat.ts new file mode 100644 index 0000000..86606bb --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/calculateFeeInFiat.ts @@ -0,0 +1,34 @@ +import { DIGITS, DECIMALS } from 'constants/index'; +import { formatAmount } from 'lib/sdkDappUtils'; +import { getUsdValue } from './getUsdValue'; + +export interface CalculateFeeInFiatType { + feeLimit: string; + egldPriceInUsd: number; + hideEqualSign?: boolean; +} + +export const calculateFeeInFiat = ({ + feeLimit, + egldPriceInUsd, + hideEqualSign +}: CalculateFeeInFiatType) => { + const amount = formatAmount({ + input: feeLimit, + decimals: DECIMALS, + digits: DIGITS, + showLastNonZeroDecimal: true + }); + + const feeAsUsdValue = getUsdValue({ + amount, + usd: egldPriceInUsd, + decimals: DIGITS + }); + + if (hideEqualSign) { + return feeAsUsdValue; + } + + return `≈ ${feeAsUsdValue}`; +}; diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/calculateFeeLimit.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/calculateFeeLimit.ts new file mode 100755 index 0000000..ad1ee86 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/calculateFeeLimit.ts @@ -0,0 +1,76 @@ +import { + Transaction, + TransactionPayload, + TransactionVersion, + Address, + TokenPayment +} from '@multiversx/sdk-core'; +import BigNumber from 'bignumber.js'; +import { + EXTRA_GAS_LIMIT_GUARDED_TX, + GAS_LIMIT, + GAS_PRICE, + ZERO +} from 'constants/index'; +import { isGuardianTx } from 'utils/transactions/isGuardianTx'; +import { stringIsFloat, stringIsInteger } from 'utils/validation'; + +export interface CalculateFeeLimitType { + gasLimit: string; + gasPrice: string; + data: string; + gasPerDataByte: string; + gasPriceModifier: string; + chainId: string; + minGasLimit?: string; + defaultGasPrice?: string; +} +const placeholderData = { + from: 'erd12dnfhej64s6c56ka369gkyj3hwv5ms0y5rxgsk2k7hkd2vuk7rvqxkalsa', + to: 'erd12dnfhej64s6c56ka369gkyj3hwv5ms0y5rxgsk2k7hkd2vuk7rvqxkalsa' +}; +export function calculateFeeLimit({ + minGasLimit = String(GAS_LIMIT), + gasLimit, + gasPrice, + data: inputData, + gasPerDataByte, + gasPriceModifier, + defaultGasPrice = String(GAS_PRICE), + chainId +}: CalculateFeeLimitType) { + const data = inputData || ''; + const validGasLimit = stringIsInteger(gasLimit) ? gasLimit : minGasLimit; + + // We need to add extra gas fee for guardian transactions + const extraGasLimit = isGuardianTx({ data }) ? EXTRA_GAS_LIMIT_GUARDED_TX : 0; + const usedGasLimit = new BigNumber(validGasLimit) + .plus(extraGasLimit) + .toNumber(); + + const validGasPrice = stringIsFloat(gasPrice) ? gasPrice : defaultGasPrice; + const transaction = new Transaction({ + nonce: 0, + value: TokenPayment.egldFromAmount('0'), + receiver: new Address(placeholderData.to), + sender: new Address(placeholderData.to), + gasPrice: parseInt(validGasPrice), + gasLimit: usedGasLimit, + data: new TransactionPayload(data.trim()), + chainID: chainId, + version: new TransactionVersion(1) + }); + + try { + const bNfee = transaction.computeFee({ + GasPerDataByte: parseInt(gasPerDataByte), + MinGasLimit: parseInt(minGasLimit), + GasPriceModifier: parseFloat(gasPriceModifier), + ChainID: chainId + }); + return bNfee.toString(10); + } catch (err) { + console.error(err); + return ZERO; + } +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/checkIsValidSender.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/checkIsValidSender.ts new file mode 100644 index 0000000..ed420ee --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/checkIsValidSender.ts @@ -0,0 +1,25 @@ +import { AccountType } from 'types/account.types'; + +// Don't allow signing if the logged in account's address +// is neither the sender or the sender account's active guardian +export const checkIsValidSender = ( + senderAccount: Partial | null, + address: string | string[] +) => { + if (!senderAccount) { + return true; + } + + if (Array.isArray(address)) { + return address.some( + (addr) => + senderAccount.address === addr || + senderAccount.activeGuardianAddress === addr + ); + } + + return ( + senderAccount.address === address || + senderAccount.activeGuardianAddress === address + ); +}; diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getExtractTransactionsInfo.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getExtractTransactionsInfo.ts new file mode 100644 index 0000000..d719f74 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getExtractTransactionsInfo.ts @@ -0,0 +1,84 @@ +import { getAccountFromApi } from 'apiCalls/account'; +import { getScamAddressData } from 'apiCalls/utils/getScamAddressData'; +import { SENDER_DIFFERENT_THAN_LOGGED_IN_ADDRESS } from 'constants/errorMessages.constants'; + +import { MultiSignTransactionType } from 'types/transactions.types'; +import { checkIsValidSender } from './checkIsValidSender'; +import { getMultiEsdtTransferData } from './getMultiEsdtTransferData/getMultiEsdtTransferData'; +import { isTokenTransfer } from './isTokenTransfer'; + +interface VerifiedAddressesType { + [address: string]: { type: string; info: string }; +} +let verifiedAddresses: VerifiedAddressesType = {}; + +type ExtractTransactionsInfoType = { + getTxInfoByDataField: ReturnType< + typeof getMultiEsdtTransferData + >['getTxInfoByDataField']; + sender: string; + address: string; + egldLabel: string; +}; + +export function getExtractTransactionsInfo({ + getTxInfoByDataField, + egldLabel, + sender, + address +}: ExtractTransactionsInfoType) { + const extractTransactionsInfo = async ( + currentTx: MultiSignTransactionType + ) => { + if (currentTx == null) { + return; + } + + const senderAccount = + !sender || sender === address ? null : await getAccountFromApi(sender); + + const { transaction, multiTxData, transactionIndex } = currentTx; + const dataField = transaction.getData().toString(); + const transactionTokenInfo = getTxInfoByDataField( + transaction.getData().toString(), + multiTxData + ); + + const { tokenId } = transactionTokenInfo; + const receiver = transaction.getReceiver().toString(); + + if (sender && sender !== address) { + const isValidSender = checkIsValidSender(senderAccount, address); + + if (!isValidSender) { + console.error(SENDER_DIFFERENT_THAN_LOGGED_IN_ADDRESS); + throw SENDER_DIFFERENT_THAN_LOGGED_IN_ADDRESS; + } + } + + const notSender = address !== receiver; + const verified = receiver in verifiedAddresses; + + if (receiver && notSender && !verified) { + const data = await getScamAddressData(receiver); + verifiedAddresses = { + ...verifiedAddresses, + ...(data?.scamInfo ? { [receiver]: data.scamInfo } : {}) + }; + } + + const isTokenTransaction = Boolean( + tokenId && isTokenTransfer({ tokenId, egldLabel }) + ); + + return { + transaction, + receiverScamInfo: verifiedAddresses[receiver]?.info || null, + transactionTokenInfo, + isTokenTransaction, + dataField, + transactionIndex + }; + }; + return extractTransactionsInfo; +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/getMultiEsdtTransferData.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/getMultiEsdtTransferData.ts new file mode 100644 index 0000000..9b9cfd3 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/getMultiEsdtTransferData.ts @@ -0,0 +1,59 @@ +import { Transaction } from '@multiversx/sdk-core'; +import { + MultiSignTransactionType, + TransactionDataTokenType, + TransactionsDataTokensType +} from 'types/transactions.types'; +import { parseMultiEsdtTransferDataForMultipleTransactions } from './helpers/parseMultiEsdtTransferDataForMultipleTransactions'; + +const defaultTransactionInfo: TransactionDataTokenType = { + tokenId: '', + amount: '', + type: '', + multiTxData: '', + receiver: '' +}; + +export type MultiEsdtTransferDataReturnType = ReturnType< + typeof getMultiEsdtTransferData +>; + +export function getMultiEsdtTransferData(transactions?: Transaction[]): { + parsedTransactionsByDataField: TransactionsDataTokensType; + getTxInfoByDataField: ( + data: string, + multiTransactionData?: string + ) => TransactionDataTokenType; + allTransactions: MultiSignTransactionType[]; +} { + const { allTransactions, parsedTransactionsByDataField } = + parseMultiEsdtTransferDataForMultipleTransactions({ transactions }); + + function getTxInfoByDataField( + data: string, + multiTransactionData?: string + ): TransactionDataTokenType { + if (parsedTransactionsByDataField == null) { + return defaultTransactionInfo; + } + + if (data in parsedTransactionsByDataField) { + return parsedTransactionsByDataField[data]; + } + + if ( + multiTransactionData != null && + String(multiTransactionData) in parsedTransactionsByDataField + ) { + return parsedTransactionsByDataField[multiTransactionData]; + } + + return defaultTransactionInfo; + } + + return { + parsedTransactionsByDataField, + getTxInfoByDataField, + allTransactions + }; +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/getAllStringOccurrences.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/getAllStringOccurrences.ts new file mode 100644 index 0000000..df64bb0 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/getAllStringOccurrences.ts @@ -0,0 +1,15 @@ +export const getAllStringOccurrences = ( + sourceStr: string, + searchStr: string +) => { + const startingIndices = []; + + let indexOccurence = sourceStr.indexOf(searchStr, 0); + + while (indexOccurence >= 0) { + startingIndices.push(indexOccurence); + indexOccurence = sourceStr.indexOf(searchStr, indexOccurence + 1); + } + + return startingIndices; +}; diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/getTokenFromData.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/getTokenFromData.ts new file mode 100644 index 0000000..a8e3467 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/getTokenFromData.ts @@ -0,0 +1,99 @@ +import { Address } from '@multiversx/sdk-core'; +import BigNumber from 'bignumber.js'; +import { TransactionTypesEnum } from 'types/enums.types'; +import { decodePart } from 'utils/decoders/decodePart'; +import { addressIsValid } from 'utils/validation/addressIsValid'; + +const noData = { + tokenId: '', + amount: '' +}; + +export const decodeData = (data: string) => { + const nonceIndex = 2; + const amountIndex = 3; + const parts = data.split('@'); + const decodedParts = parts.map((part, i) => + [nonceIndex, amountIndex].includes(i) ? part : decodePart(part) + ); + return decodedParts; +}; + +export function getTokenFromData(data?: string): { + tokenId: string; + amount: string; + collection?: string; + nonce?: string; + receiver?: string; +} { + if (!data) { + return noData; + } + + const isTokenTransfer = data.startsWith(TransactionTypesEnum.ESDTTransfer); + const isNftTransfer = + data.startsWith(TransactionTypesEnum.ESDTNFTTransfer) && data.includes('@'); + const isNftBurn = + data.startsWith(TransactionTypesEnum.ESDTNFTBurn) && data.includes('@'); + + if (isTokenTransfer) { + const [, encodedToken, encodedAmount] = data.split('@'); + try { + const tokenId = Buffer.from(encodedToken, 'hex').toString('ascii'); + + if (!tokenId) { + return noData; + } + + const amount = new BigNumber( + '0x' + encodedAmount.replace('0x', '') + ).toString(10); + + return { + tokenId, + amount + }; + } catch (e) { + console.error('Error getting token from transaction data', e); + } + } + + if (isNftTransfer) { + try { + const [, /*ESDTNFTTransfer*/ collection, nonce, quantity, receiver] = + decodeData(data); + if ( + [collection, nonce, quantity, receiver].every((el) => Boolean(el)) && + addressIsValid(new Address(receiver).bech32()) + ) { + return { + tokenId: `${collection}-${nonce}`, + amount: new BigNumber(quantity, 16).toString(10), + collection, + nonce, + receiver: new Address(receiver).bech32() + }; + } + } catch (_err) { + /* empty */ + } + } + + if (isNftBurn) { + try { + const [, /*ESDTNFTBurn*/ collection, nonce, quantity] = decodeData(data); + if ([collection, nonce, quantity].every((el) => Boolean(el))) { + return { + tokenId: `${collection}-${nonce}`, + amount: new BigNumber(quantity, 16).toString(10), + collection, + nonce + }; + } + } catch (_err) { + /* empty */ + } + } + + return noData; +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/parseMultiEsdtTransferData.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/parseMultiEsdtTransferData.ts new file mode 100644 index 0000000..ec7c0b3 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/parseMultiEsdtTransferData.ts @@ -0,0 +1,98 @@ +import BigNumber from 'bignumber.js'; +import { TransactionTypesEnum } from 'types/enums.types'; +import { MultiEsdtTransactionType } from 'types/transactions.types'; +import { decodePart } from 'utils/decoders/decodePart'; +import { getAllStringOccurrences } from './getAllStringOccurrences'; + +// TODO: add tests +export function parseMultiEsdtTransferData(data?: string) { + const transactions: MultiEsdtTransactionType[] = []; + let contractCallDataIndex = 0; + try { + if ( + data?.startsWith(TransactionTypesEnum.MultiESDTNFTTransfer) && + data?.includes('@') + ) { + const [, receiver, encodedTxCount, ...rest] = data.split('@'); + + if (receiver) { + const txCount = new BigNumber(encodedTxCount, 16).toNumber(); + + if (txCount >= Number.MAX_SAFE_INTEGER) { + return []; + } + + let itemIndex = 0; + + for (let txIndex = 0; txIndex < txCount; txIndex++) { + const transaction: MultiEsdtTransactionType = { + type: TransactionTypesEnum.nftTransaction, + data: '', + receiver + }; + + for (let index = 0; index < 3; index++) { + switch (index) { + case 0: + transaction.token = decodePart(rest[itemIndex]); + transaction.data = rest[itemIndex]; + break; + case 1: { + const encodedNonce = + rest[itemIndex] && rest[itemIndex].length + ? rest[itemIndex] + : ''; + if (encodedNonce && encodedNonce !== '00') { + transaction.nonce = encodedNonce; + } else { + transaction.type = TransactionTypesEnum.esdtTransaction; + } + transaction.data = `${transaction.data}@${rest[itemIndex]}`; + break; + } + case 2: + transaction.amount = new BigNumber( + rest[itemIndex], + 16 + ).toString(10); + transaction.data = `${transaction.data}@${rest[itemIndex]}`; + break; + default: + break; + } + contractCallDataIndex = itemIndex + 1; + itemIndex++; + } + transactions[txIndex] = transaction; + } + + const isDifferentFromTxCount = transactions.length !== txCount; + const hasInvalidNoOfAdSigns = transactions.some((tx) => { + const adSignOccurences = getAllStringOccurrences(tx.data, '@').length; + return adSignOccurences !== 2; + }); + + const hasAdStart = transactions.some((tx) => tx.data.startsWith('@')); + if (isDifferentFromTxCount || hasInvalidNoOfAdSigns || hasAdStart) { + return []; + } + + if (rest[contractCallDataIndex]) { + let scCallData = rest[contractCallDataIndex]; + for (let i = contractCallDataIndex + 1; i < rest.length; i++) { + scCallData += '@' + rest[i]; + } + transactions[txCount] = { + type: TransactionTypesEnum.scCall, + data: scCallData, + receiver + }; + } + } + } + } catch (err) { + console.error('failed parsing tx', err); + return transactions; + } + return transactions; +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/parseMultiEsdtTransferDataForMultipleTransactions.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/parseMultiEsdtTransferDataForMultipleTransactions.ts new file mode 100644 index 0000000..7265ffb --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/helpers/parseMultiEsdtTransferDataForMultipleTransactions.ts @@ -0,0 +1,72 @@ +import type { Transaction } from '@multiversx/sdk-core'; + +import { + MultiSignTransactionType, + TransactionsDataTokensType +} from 'types/transactions.types'; +import { getTokenFromData } from './getTokenFromData'; +import { parseMultiEsdtTransferData } from './parseMultiEsdtTransferData'; + +export function parseMultiEsdtTransferDataForMultipleTransactions({ + transactions +}: { + transactions?: Transaction[]; +}) { + const allTransactions: MultiSignTransactionType[] = []; + const parsedTransactionsByDataField: TransactionsDataTokensType = {}; + + if (!transactions || transactions.length === 0) { + return { + allTransactions, + parsedTransactionsByDataField + }; + } + + transactions.forEach((transaction, transactionIndex) => { + const txData = transaction.getData().toString(); + const multiTxs = parseMultiEsdtTransferData(txData); + + if (multiTxs.length > 0) { + multiTxs.forEach((trx, idx) => { + const newTx: MultiSignTransactionType = { + transaction, + multiTxData: trx.data, + transactionIndex: idx + }; + + parsedTransactionsByDataField[trx.data] = { + tokenId: trx.token ? trx.token : '', + amount: trx.amount ? trx.amount : '', + type: trx.type, + nonce: trx.nonce ? trx.nonce : '', + multiTxData: trx.data, + receiver: trx.receiver + }; + + allTransactions.push(newTx); + }); + } else { + const transactionData = transaction.getData().toString(); + + const { tokenId, amount } = getTokenFromData(transactionData); + + if (tokenId) { + parsedTransactionsByDataField[transactionData] = { + tokenId, + amount, + receiver: transaction.getReceiver().bech32() + }; + } + allTransactions.push({ + transaction, + transactionIndex, + multiTxData: transactionData + }); + } + }); + + return { + allTransactions, + parsedTransactionsByDataField + }; +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/tests/getMultiEsdtTransferData.test.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/tests/getMultiEsdtTransferData.test.ts new file mode 100644 index 0000000..bf0ec43 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getMultiEsdtTransferData/tests/getMultiEsdtTransferData.test.ts @@ -0,0 +1,9 @@ +import { getMultiEsdtTransferData } from '../getMultiEsdtTransferData'; + +describe('getMultiEsdtTransferData', () => { + it('should extract transaction information', async () => { + const data = getMultiEsdtTransferData([]); + // Assert the result is correct based on your mock data + expect(data).toBeTruthy(); + }); +}); diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/getUsdValue.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/getUsdValue.ts new file mode 100644 index 0000000..8558ad8 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/getUsdValue.ts @@ -0,0 +1,24 @@ +export const getUsdValue = ({ + amount, + usd, + decimals = 2, + addEqualSign +}: { + amount: string; + usd: number; + decimals?: number; + addEqualSign?: boolean; +}) => { + let sum = (parseFloat(amount) * usd).toFixed(decimals); + if (isNaN(Number(sum))) { + sum = '0'; + } + + const formattedValue = parseFloat(sum).toLocaleString('en', { + maximumFractionDigits: decimals, + minimumFractionDigits: decimals + }); + const equalSign = parseFloat(amount) > 0 ? '≈' : '='; + const equalSignPrefix = addEqualSign ? `${equalSign} ` : ''; + return `${equalSignPrefix}$${formattedValue}`; +}; diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/isTokenTransfer.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/isTokenTransfer.ts new file mode 100644 index 0000000..c68d6c9 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/isTokenTransfer.ts @@ -0,0 +1,9 @@ +export function isTokenTransfer({ + tokenId, + egldLabel +}: { + tokenId: string | undefined; + egldLabel: string; +}) { + return Boolean(tokenId && tokenId !== egldLabel); +} diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/tests/calculateFeeInFiat.test.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/tests/calculateFeeInFiat.test.ts new file mode 100644 index 0000000..1d8257a --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/tests/calculateFeeInFiat.test.ts @@ -0,0 +1,11 @@ +import { calculateFeeInFiat } from '../calculateFeeInFiat'; + +describe('calculateFeeInFiat tests', () => { + it('computes correct fee in fiat', () => { + const fee = calculateFeeInFiat({ + feeLimit: '50000000000000', + egldPriceInUsd: 135.78 + }); + expect(fee).toBe('≈ $0.0068'); + }); +}); diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/tests/calculateFeeLimit.test.tsx b/src/core/providers/strategies/helpers/signTransactions/helpers/tests/calculateFeeLimit.test.tsx new file mode 100644 index 0000000..009caad --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/tests/calculateFeeLimit.test.tsx @@ -0,0 +1,96 @@ +import { GAS_PER_DATA_BYTE, GAS_PRICE_MODIFIER } from 'constants/index'; +import { calculateFeeLimit } from '../calculateFeeLimit'; + +describe('calculateFeeLimit tests', () => { + it('computes correct fee', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: '62000', + gasPrice: '1000000000', + data: 'testdata', + chainId: 'T', + gasPerDataByte: String(GAS_PER_DATA_BYTE), + gasPriceModifier: String(GAS_PRICE_MODIFIER) + }); + expect(feeLimit).toBe('62000000000000'); + }); + + it('computes correct fee for larger data', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: '11100000', + gasPrice: '1000000000', + data: 'bid@0d59@43525a502d333663366162@25', + gasPerDataByte: String(GAS_PER_DATA_BYTE), + gasPriceModifier: String(GAS_PRICE_MODIFIER), + defaultGasPrice: '1000000000', + chainId: 'T' + }); + + expect(feeLimit).toBe('210990000000000'); + }); + + it('computes correct fee for SetGuardian tx', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: '', + gasPrice: (1_000_000).toString(), + data: 'SetGuardian@qwerty@12345', + chainId: 'T', + gasPerDataByte: '1', + gasPriceModifier: '1' + }); + + expect(feeLimit).toBe((100_000_000_000).toString()); // (minGasLimit + extra guardian gas) * gasPrice + }); + + it('computes correct fee for GuardAccount tx', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: '', + gasPrice: (1_000_000).toString(), + data: 'GuardAccount@qwerty@12345', + chainId: 'T', + gasPerDataByte: '1', + gasPriceModifier: '1' + }); + + expect(feeLimit).toBe((100_000_000_000).toString()); // (minGasLimit + extra guardian gas) * gasPrice + }); + + it('computes correct fee for UnGuardAccount tx', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: '', + gasPrice: (1_000_000).toString(), + data: 'UnGuardAccount@qwerty@12345', + chainId: 'T', + gasPerDataByte: '1', + gasPriceModifier: '1' + }); + + expect(feeLimit).toBe((100_000_000_000).toString()); // (minGasLimit + extra guardian gas) * gasPrice + }); + + it('computes correct fee for UnGuardAccount tx and gas limit specified', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: (1_000_000).toString(), + gasPrice: (1_000_000).toString(), + data: 'UnGuardAccount@qwerty@12345', + chainId: 'T', + gasPerDataByte: '1', + gasPriceModifier: '1' + }); + + expect(feeLimit).toBe((1_050_000_000_000).toString()); // (gasLimit + extra guardian gas) * gasPrice + }); + + it('computes correct fee for UnGuardAccount tx and min gas limit specified', () => { + const feeLimit = calculateFeeLimit({ + gasLimit: '', + minGasLimit: (1_000_000).toString(), + gasPrice: (1_000_000).toString(), + data: 'UnGuardAccount@qwerty@12345', + chainId: 'T', + gasPerDataByte: '1', + gasPriceModifier: '1' + }); + + expect(feeLimit).toBe((1_050_000_000_000).toString()); // (minGasLimit + extra guardian gas) * gasPrice + }); +}); diff --git a/src/core/providers/strategies/helpers/signTransactions/helpers/tests/getUsdValue.test.ts b/src/core/providers/strategies/helpers/signTransactions/helpers/tests/getUsdValue.test.ts new file mode 100644 index 0000000..927d6f4 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/helpers/tests/getUsdValue.test.ts @@ -0,0 +1,23 @@ +import { getUsdValue } from '../getUsdValue'; + +describe('getUsdValue tests', () => { + it('formats amount', () => { + expect( + getUsdValue({ + amount: '2', + usd: 40, + decimals: 4, + addEqualSign: true + }) + ).toBe('≈ $80.0000'); + }); + it('shows = for 0', () => { + expect( + getUsdValue({ + amount: '0', + usd: 40, + addEqualSign: true + }) + ).toBe('= $0.00'); + }); +}); diff --git a/src/core/providers/strategies/helpers/signTransactions/signTransactions.ts b/src/core/providers/strategies/helpers/signTransactions/signTransactions.ts new file mode 100644 index 0000000..c8720d8 --- /dev/null +++ b/src/core/providers/strategies/helpers/signTransactions/signTransactions.ts @@ -0,0 +1,222 @@ +import { Transaction } from '@multiversx/sdk-core/out'; +import { getEconomics } from 'apiCalls/economics/getEconomics'; +import { getPersistedTokenDetails } from 'apiCalls/tokens/getPersistedTokenDetails'; +import { GAS_PER_DATA_BYTE, GAS_PRICE_MODIFIER } from 'constants/mvx.constants'; +import { SignTransactionsStateManager } from 'core/managers/SignTransactionsStateManager/SignTransactionsStateManager'; +import { + TokenType, + SignEventsEnum +} from 'core/managers/SignTransactionsStateManager/types'; +import { getAddress } from 'core/methods/account/getAddress'; +import { getEgldLabel } from 'core/methods/network/getEgldLabel'; +import { IProvider } from 'core/providers/types/providerFactory.types'; +import { SignTransactionsModal } from 'lib/sdkDappCoreUi'; +import { formatAmount } from 'lib/sdkDappUtils'; +import { networkSelector } from 'store/selectors/networkSelectors'; +import { getState } from 'store/store'; +import { EsdtEnumType, NftEnumType } from 'types/tokens.types'; +import { createModalElement } from 'utils/createModalElement'; +import { calculateFeeInFiat } from './helpers/calculateFeeInFiat'; +import { calculateFeeLimit } from './helpers/calculateFeeLimit'; +import { getExtractTransactionsInfo } from './helpers/getExtractTransactionsInfo'; +import { getMultiEsdtTransferData } from './helpers/getMultiEsdtTransferData/getMultiEsdtTransferData'; +import { getUsdValue } from './helpers/getUsdValue'; + +export async function signTransactions({ + transactions = [], + handleSign +}: { + transactions?: Transaction[]; + handleSign: IProvider['signTransactions']; +}) { + const address = getAddress(); + const network = networkSelector(getState()); + + const egldLabel = getEgldLabel(); + const signModalElement = await createModalElement( + 'sign-transactions-modal' + ); + + const { allTransactions, getTxInfoByDataField } = + getMultiEsdtTransferData(transactions); + + const eventBus = await signModalElement.getEventBus(); + + if (!eventBus) { + throw new Error('Event bus not provided for Ledger provider'); + } + + const manager = SignTransactionsStateManager.getInstance(eventBus); + if (!manager) { + throw new Error('Unable to establish connection with sign screens'); + } + + return new Promise(async (resolve, reject) => { + const signedTransactions: Transaction[] = []; + let currentTransactionIndex = 0; + const economics = await getEconomics(); + + const signNextTransaction = async () => { + const currentTransaction = allTransactions[currentTransactionIndex]; + const sender = currentTransaction?.transaction?.getSender().toString(); + const transaction = currentTransaction?.transaction; + const price = economics?.price; + + const feeLimit = calculateFeeLimit({ + gasPerDataByte: String(GAS_PER_DATA_BYTE), + gasPriceModifier: String(GAS_PRICE_MODIFIER), + gasLimit: transaction.getGasLimit().valueOf().toString(), + gasPrice: transaction.getGasPrice().valueOf().toString(), + data: transaction.getData().toString(), + chainId: transaction.getChainID().valueOf() + }); + + const feeLimitFormatted = formatAmount({ + input: feeLimit, + showLastNonZeroDecimal: true + }); + + const feeInFiatLimit = price + ? calculateFeeInFiat({ + feeLimit, + egldPriceInUsd: price, + hideEqualSign: true + }) + : null; + + const extractTransactionsInfo = getExtractTransactionsInfo({ + getTxInfoByDataField, + egldLabel, + sender, + address + }); + + const plainTransaction = currentTransaction.transaction.toPlainObject(); + + const txInfo = await extractTransactionsInfo(currentTransaction); + + let tokenIdForTokenDetails = txInfo?.transactionTokenInfo?.tokenId; + const isEgld = !tokenIdForTokenDetails; + let tokenAmount = ''; + + let tokenType: TokenType = null; + + if (txInfo?.transactionTokenInfo) { + const { tokenId, nonce, amount } = txInfo.transactionTokenInfo; + const isNftOrSft = nonce && nonce.length > 0; + tokenIdForTokenDetails = isNftOrSft ? `${tokenId}-${nonce}` : tokenId; + tokenType = isNftOrSft ? null : EsdtEnumType.FungibleESDT; + tokenAmount = amount; + } + + const tokenDetails = await getPersistedTokenDetails({ + tokenId: tokenIdForTokenDetails + }); + + const { esdtPrice, tokenDecimals, type, identifier, tokenImageUrl } = + tokenDetails; + + const isNft = + type === NftEnumType.SemiFungibleESDT || + type === NftEnumType.NonFungibleESDT; + + if (isNft) { + manager.updateFungibleTransaction(type, { + identifier, + amount: tokenAmount, + imageURL: tokenImageUrl + }); + } else { + const getFormattedAmount = ({ addCommas }: { addCommas: boolean }) => + formatAmount({ + input: isEgld + ? currentTransaction.transaction.getValue().toString() + : tokenAmount, + decimals: isEgld ? Number(network.decimals) : tokenDecimals, + digits: Number(network.digits), + showLastNonZeroDecimal: false, + addCommas + }); + + const formattedAmount = getFormattedAmount({ addCommas: true }); + const rawAmount = getFormattedAmount({ addCommas: false }); + const tokenPrice = Number(isEgld ? price : esdtPrice); + const usdValue = getUsdValue({ + amount: rawAmount, + usd: tokenPrice, + addEqualSign: true + }); + manager.updateTokenTransaction({ + identifier: identifier ?? egldLabel, + amount: formattedAmount, + usdValue + }); + } + + manager.updateCommonData({ + receiver: plainTransaction.receiver.toString(), + data: currentTransaction.transaction.getData().toString(), + egldLabel, + tokenType, + feeLimit: feeLimitFormatted, + feeInFiatLimit, + transactionsCount: allTransactions.length, + currentIndex: currentTransactionIndex + }); + + const onCancel = () => { + reject(new Error('Transaction signing cancelled by user')); + signModalElement.remove(); + }; + + const onSign = async () => { + const shouldContinueWithoutSigning = Boolean( + txInfo?.transactionTokenInfo?.type && + txInfo?.transactionTokenInfo?.multiTxData && + !txInfo?.dataField.endsWith( + txInfo?.transactionTokenInfo?.multiTxData + ) + ); + + const removeEvents = () => { + eventBus.unsubscribe(SignEventsEnum.SIGN_TRANSACTION, onSign); + eventBus.unsubscribe(SignEventsEnum.CLOSE, onCancel); + }; + + if (shouldContinueWithoutSigning) { + currentTransactionIndex++; + removeEvents(); + return signNextTransaction(); + } + + try { + const signedTransaction = await handleSign([ + currentTransaction.transaction + ]); + + if (signedTransaction) { + signedTransactions.push(signedTransaction[0]); + } + + removeEvents(); + + if (signedTransactions.length == transactions.length) { + signModalElement.remove(); + resolve(signedTransactions); + } else { + currentTransactionIndex++; + signNextTransaction(); + } + } catch (error) { + reject('Error signing transactions: ' + error); + signModalElement.remove(); + } + }; + + eventBus.subscribe(SignEventsEnum.SIGN_TRANSACTION, onSign); + eventBus.subscribe(SignEventsEnum.CLOSE, onCancel); + }; + + signNextTransaction(); + }); +} diff --git a/src/core/providers/types/providerFactory.types.ts b/src/core/providers/types/providerFactory.types.ts index 64e30a1..da87c05 100644 --- a/src/core/providers/types/providerFactory.types.ts +++ b/src/core/providers/types/providerFactory.types.ts @@ -1,8 +1,7 @@ import type { IDAppProviderBase } from '@multiversx/sdk-dapp-utils'; // @ts-ignore -export interface IProvider - extends IDAppProviderBase { +export interface IProvider extends IDAppProviderBase { init: () => Promise; login: (options?: { callbackUrl?: string; token?: string }) => Promise<{ address: string; @@ -21,7 +20,6 @@ export interface IProvider } export interface IEventBus { - getInstance(): IEventBus; subscribe(event: string, callback: Function): void; publish(event: string, data?: any): void; unsubscribe(event: string, callback: Function): void; @@ -41,18 +39,14 @@ export enum ProviderTypeEnum { opera = 'opera', metamask = 'metamask', passkey = 'passkey', - none = '' + none = '', } -export interface IProviderFactory< - T extends ProviderTypeEnum = ProviderTypeEnum -> { +export interface IProviderFactory { type: T[keyof T]; } -export interface ICustomProvider< - T extends ProviderTypeEnum = ProviderTypeEnum -> { +export interface ICustomProvider { name: string; type: T[keyof T]; icon: string; diff --git a/src/lib/sdkDappUtils.ts b/src/lib/sdkDappUtils.ts new file mode 100644 index 0000000..aea8fe8 --- /dev/null +++ b/src/lib/sdkDappUtils.ts @@ -0,0 +1 @@ +export { formatAmount } from '@multiversx/sdk-dapp-utils/out/helpers/formatAmount'; diff --git a/src/types/enums.types.ts b/src/types/enums.types.ts index da0044c..d1a2487 100644 --- a/src/types/enums.types.ts +++ b/src/types/enums.types.ts @@ -14,6 +14,16 @@ export enum TransactionServerStatusesEnum { rewardReverted = 'reward-reverted' } +export enum TransactionTypesEnum { + MultiESDTNFTTransfer = 'MultiESDTNFTTransfer', + ESDTTransfer = 'ESDTTransfer', + ESDTNFTBurn = 'ESDTNFTBurn', + ESDTNFTTransfer = 'ESDTNFTTransfer', + esdtTransaction = 'esdtTransaction', + nftTransaction = 'nftTransaction', + scCall = 'scCall' +} + export enum TransactionBatchStatusesEnum { signed = 'signed', cancelled = 'cancelled', diff --git a/src/types/transactions.types.ts b/src/types/transactions.types.ts index d23db8d..d182a10 100644 --- a/src/types/transactions.types.ts +++ b/src/types/transactions.types.ts @@ -1,7 +1,8 @@ -import { IPlainTransactionObject } from '@multiversx/sdk-core/out'; +import { IPlainTransactionObject, Transaction } from '@multiversx/sdk-core/out'; import { TransactionBatchStatusesEnum, - TransactionServerStatusesEnum + TransactionServerStatusesEnum, + TransactionTypesEnum } from 'types/enums.types'; export interface SignedTransactionType extends IPlainTransactionObject { @@ -10,6 +11,47 @@ export interface SignedTransactionType extends IPlainTransactionObject { inTransit?: boolean; } +export interface MultiSignTransactionType { + multiTxData?: string; + transactionIndex: number; + transaction: Transaction; +} + +interface MultiEsdtType { + type: + | TransactionTypesEnum.esdtTransaction + | TransactionTypesEnum.nftTransaction; + receiver: string; + token?: string; + nonce?: string; + amount?: string; + data: string; +} + +interface MultiEsdtScCallType { + type: TransactionTypesEnum.scCall; + receiver: string; + token?: string; + nonce?: string; + amount?: string; + data: string; +} + +export type MultiEsdtTransactionType = MultiEsdtType | MultiEsdtScCallType; + +export interface TransactionDataTokenType { + tokenId: string; + amount: string; + receiver: string; + type?: MultiEsdtTransactionType['type'] | ''; + nonce?: string; + multiTxData?: string; +} + +export type TransactionsDataTokensType = + | Record + | undefined; + export type PendingTransactionsType = { hash: string; previousStatus: string; diff --git a/src/utils/transactions/isGuardianTx.ts b/src/utils/transactions/isGuardianTx.ts new file mode 100644 index 0000000..bdeef01 --- /dev/null +++ b/src/utils/transactions/isGuardianTx.ts @@ -0,0 +1,21 @@ +import { GuardianActionsEnum } from 'types/enums.types'; + +export const isGuardianTx = ({ + data, + onlySetGuardian +}: { + data?: string; + onlySetGuardian?: boolean; +}) => { + if (!data) { + return false; + } + + if (onlySetGuardian) { + return data.startsWith(GuardianActionsEnum.SetGuardian); + } + + return Object.values(GuardianActionsEnum).some((action) => + data.startsWith(action) + ); +};