From e7a89c90be6df38379ead3fa8b20b350b07201b4 Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:34:04 -0400 Subject: [PATCH] XLS-33 Multi Purpose token (#989) ## High Level Overview of Change - add MPT transactions: MPTokenIssuance, MPTokenIssuanceDestroy, MPTokenAuthorize, MPTokenIssuanceSet - add MPT page - support search by MPTID - updates transactions: Payment, Clawback - modified Currency to support MPTID ### Context of Change Spec: https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0033d-multi-purpose-tokens --- public/locales/ca-CA/translations.json | 25 ++- public/locales/en-US/translations.json | 25 ++- public/locales/es-ES/translations.json | 25 ++- public/locales/fr-FR/translations.json | 25 ++- public/locales/ja-JP/translations.json | 25 ++- public/locales/ko-KR/translations.json | 25 ++- .../test/AMMAccountHeader.test.tsx | 14 +- .../AccountAssetTab/AccountAssetTab.tsx | 11 +- .../AccountMPTTable/AccountMPTTable.tsx | 125 +++++++++++++++ .../test/AccountMPTRow.test.tsx | 63 ++++++++ .../test/AccountMPTTable.test.tsx | 114 ++++++++++++++ src/containers/App/index.tsx | 3 + src/containers/App/routes.ts | 8 +- src/containers/Header/Search.tsx | 12 +- src/containers/Header/test/Search.test.js | 4 + src/containers/Ledger/index.tsx | 4 +- src/containers/MPT/MPT.tsx | 75 +++++++++ src/containers/MPT/MPTHeader/Details.tsx | 48 ++++++ src/containers/MPT/MPTHeader/MPTHeader.tsx | 117 ++++++++++++++ src/containers/MPT/MPTHeader/Settings.tsx | 41 +++++ src/containers/MPT/MPTHeader/styles.scss | 144 ++++++++++++++++++ .../MPT/MPTHeader/test/Details.test.js | 58 +++++++ .../MPT/MPTHeader/test/MPTHeader.test.js | 82 ++++++++++ .../MPT/MPTHeader/test/Settings.test.js | 48 ++++++ src/containers/MPT/styles.scss | 15 ++ src/containers/MPT/test/MPT.test.js | 46 ++++++ src/containers/NFT/NFTHeader/NFTHeader.tsx | 4 +- .../Transactions/DetailTab/Meta/MPToken.jsx | 43 ++++++ .../DetailTab/Meta/MPTokenIssuance.jsx | 43 ++++++ .../DetailTab/Meta/RippleState.jsx | 7 +- .../Transactions/DetailTab/Meta/index.tsx | 6 + src/containers/Transactions/index.tsx | 9 +- .../Transactions/test/Description.test.tsx | 10 +- .../Transactions/test/DetailTab.test.tsx | 10 +- .../Transactions/test/Meta.test.tsx | 29 ++++ .../Transactions/test/SimpleTab.test.tsx | 16 +- .../test/mock_data/DirectMPTPayment.json | 95 ++++++++++++ src/containers/shared/Interfaces.tsx | 14 ++ src/containers/shared/analytics.ts | 1 + src/containers/shared/components/Amount.tsx | 51 ++++++- src/containers/shared/components/Currency.tsx | 56 ++++--- .../shared/components/MPTokenLink.tsx | 12 ++ .../shared/components/Tooltip/Tooltip.tsx | 3 + .../Transaction/Clawback/Description.tsx | 2 +- .../components/Transaction/Clawback/parser.ts | 38 ++++- .../Clawback/test/ClawbackSimple.test.tsx | 48 ++++++ .../Clawback/test/mock_data/ClawbackMPT.json | 85 +++++++++++ .../test/mock_data/ClawbackMPT_Failure.json | 46 ++++++ .../components/Transaction/Clawback/types.ts | 1 + .../Transaction/MPTokenAuthorize/Simple.tsx | 26 ++++ .../Transaction/MPTokenAuthorize/index.ts | 13 ++ .../test/MPTokenAuthorizeSimple.test.jsx | 66 ++++++++ .../test/mock_data/MPTokenAuthorize.json | 70 +++++++++ .../test/mock_data/MPTokenAuthorize_Fail.json | 49 ++++++ .../MPTokenAuthorize_WithHolder.json | 67 ++++++++ .../MPTokenAuthorize_WithHolderFail.json | 50 ++++++ .../Transaction/MPTokenAuthorize/types.ts | 6 + .../MPTokenIssuanceCreate/Simple.tsx | 63 ++++++++ .../MPTokenIssuanceCreate/index.ts | 15 ++ .../MPTokenIssuanceCreate/parser.ts | 21 +++ .../test/MPTokenIssuanceCreateSimple.test.jsx | 27 ++++ .../test/mock_data/MPTokenIssuanceCreate.json | 79 ++++++++++ .../MPTokenIssuanceCreate/types.ts | 16 ++ .../MPTokenIssuanceDestroy/Simple.tsx | 18 +++ .../MPTokenIssuanceDestroy/index.ts | 13 ++ .../MPTokenIssuanceDestroySimple.test.jsx | 29 ++++ .../mock_data/MPTokenIssuanceDestroy.json | 76 +++++++++ .../MPTokenIssuanceDestroy_Fail.json | 49 ++++++ .../MPTokenIssuanceDestroy/types.ts | 5 + .../Transaction/MPTokenIssuanceSet/Simple.tsx | 26 ++++ .../Transaction/MPTokenIssuanceSet/index.ts | 13 ++ .../test/MPTokenIssuanceSetSimple.test.jsx | 53 +++++++ .../test/mock_data/MPTokenIssuanceSet.json | 67 ++++++++ .../mock_data/MPTokenIssuanceSet_Fail.json | 50 ++++++ .../MPTokenIssuanceSet_NoHolder.json | 67 ++++++++ .../Transaction/MPTokenIssuanceSet/types.ts | 6 + .../Transaction/NFTokenAcceptOffer/types.ts | 4 +- .../test/NFTokenCancelOfferSimple.test.jsx | 18 ++- .../Transaction/NFTokenCreateOffer/types.ts | 4 +- .../Transaction/OfferCreate/parser.ts | 2 +- .../Transaction/OracleSet/parser.ts | 15 +- .../OracleSet/test/ConvertScalePrice.test.ts | 2 +- .../components/Transaction/Payment/parser.ts | 5 +- .../Payment/test/PaymentSimple.test.tsx | 35 +++++ .../Payment/test/mock_data/PaymentMPT.json | 91 +++++++++++ .../shared/components/Transaction/index.ts | 8 + .../Transaction/test/createWrapperFactory.tsx | 10 +- .../shared/components/Transaction/types.ts | 1 + .../test/TransactionTable.test.js | 26 ++-- .../shared/components/test/Amount.test.tsx | 36 +++++ .../shared/components/test/Currency.test.tsx | 22 +++ src/containers/shared/css/global.scss | 1 + src/containers/shared/css/variables.scss | 1 + src/containers/shared/transactionUtils.ts | 15 ++ src/containers/shared/types.ts | 10 +- src/containers/shared/utils.js | 59 ++++++- src/rippled/lib/rippled.js | 54 ++++++- src/rippled/lib/txSummary/formatAmount.ts | 28 +++- src/rippled/lib/utils.js | 45 +++++- 99 files changed, 3225 insertions(+), 118 deletions(-) create mode 100644 src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx create mode 100644 src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx create mode 100644 src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx create mode 100644 src/containers/MPT/MPT.tsx create mode 100644 src/containers/MPT/MPTHeader/Details.tsx create mode 100644 src/containers/MPT/MPTHeader/MPTHeader.tsx create mode 100644 src/containers/MPT/MPTHeader/Settings.tsx create mode 100644 src/containers/MPT/MPTHeader/styles.scss create mode 100644 src/containers/MPT/MPTHeader/test/Details.test.js create mode 100644 src/containers/MPT/MPTHeader/test/MPTHeader.test.js create mode 100644 src/containers/MPT/MPTHeader/test/Settings.test.js create mode 100644 src/containers/MPT/styles.scss create mode 100644 src/containers/MPT/test/MPT.test.js create mode 100644 src/containers/Transactions/DetailTab/Meta/MPToken.jsx create mode 100644 src/containers/Transactions/DetailTab/Meta/MPTokenIssuance.jsx create mode 100644 src/containers/Transactions/test/mock_data/DirectMPTPayment.json create mode 100644 src/containers/shared/components/MPTokenLink.tsx create mode 100644 src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT.json create mode 100644 src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT_Failure.json create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/Simple.tsx create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/index.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/test/MPTokenAuthorizeSimple.test.jsx create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize.json create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_Fail.json create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolder.json create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolderFail.json create mode 100644 src/containers/shared/components/Transaction/MPTokenAuthorize/types.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceCreate/index.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate.json create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/Simple.tsx create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/index.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/MPTokenIssuanceDestroySimple.test.jsx create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy.json create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy_Fail.json create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/types.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/index.ts create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/MPTokenIssuanceSetSimple.test.jsx create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet.json create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Fail.json create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_NoHolder.json create mode 100644 src/containers/shared/components/Transaction/MPTokenIssuanceSet/types.ts create mode 100644 src/containers/shared/components/Transaction/Payment/test/mock_data/PaymentMPT.json diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index d5e305960..a39916bf5 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -530,5 +530,28 @@ "last_update_time": null, "asset_class": null, "trading_pairs": null, - "deleted": null + "deleted": null, + "assets.mpt_tab_title": null, + "assets.no_mpts_message": null, + "transaction_type_name_MPTokenIssuanceCreate": null, + "transaction_type_name_MPTokenIssuanceDestroy": null, + "transaction_type_name_MPTokenIssuanceSet": null, + "transaction_type_name_MPTokenAuthorize": null, + "transaction_outstanding_balance_line_two": null, + "transaction_mptoken_line_one": null, + "transaction_mpt_issuance_line_one":null, + "mpt_issuance_id": null, + "asset_scale": null, + "metadata": null, + "max_amount": null, + "mpt_holder": null, + "check_mpt_id": null, + "outstanding_amount": null, + "locked": null, + "can_lock": null, + "require_auth": null, + "can_escrow": null, + "can_trade": null, + "can_transfer": null, + "can_clawback": null } diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 369edfa7a..58453bb65 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -2,9 +2,11 @@ "action": "action", "assets": "Assets", "assets.nft_tab_title": "Non-Fungible Tokens", + "assets.mpt_tab_title": "Multi-Purpose Tokens", "assets.issued_tab_title": "Issued Tokens", "assets.no_issued_message": "No tokens found.", "assets.no_nfts_message": "No NFTs found.", + "assets.no_mpts_message": "No MPTs found.", "network": "Network", "amendments": "Amendments", "network_name": "Unknown Network", @@ -166,6 +168,10 @@ "transaction_type_name_EscrowCreate": "Escrow Create", "transaction_type_name_EscrowFinish": "Escrow Finish", "transaction_type_name_Invoke": "Invoke", + "transaction_type_name_MPTokenIssuanceCreate": "MPT Issuance Create", + "transaction_type_name_MPTokenIssuanceDestroy": "MPT Issuance Destroy", + "transaction_type_name_MPTokenIssuanceSet": "MPT Issuance Set", + "transaction_type_name_MPTokenAuthorize": "MPT Authorize", "transaction_type_name_NFTokenAcceptOffer": "NFT Accept Offer", "transaction_type_name_NFTokenBurn": "NFT Burn", "transaction_type_name_NFTokenCancelOffer": "NFT Cancel Offer", @@ -248,8 +254,11 @@ "node_meta_type": "It {{action}} a node with type", "transaction_balance_line_one": "It <1><0>{{action}} a <3><0>{{currency}} RippleState node between <5><0>{{account}} and <7><0>{{counterAccount}}", "transaction_balance_line_two": "Balance changed by <1><0>{{change}} from <3><0>{{previousBalance}} to <5><0>{{finalBalance}}", + "transaction_outstanding_balance_line_two": "Outstanding balance changed by <1><0>{{change}} from <3><0>{{previousBalance}} to <5><0>{{finalBalance}}", "transaction_owned_directory": "It {{action}} a DirectoryNode node owned by", "transaction_unowned_directory": "It {{action}} a DirectoryNode node", + "transaction_mptoken_line_one": "It <1><0>{{action}} an MPToken node of <3><0>{{account}}", + "transaction_mpt_issuance_line_one": "It <1><0>{{action}} an MPTokenIssuance node of <3><0>{{account}}", "owned_account_root": "It {{action}} the AccountRoot node of", "unowned_account_root": "It {{action}} the AccountRoot node", "account_balance_increased": "Balance increased by <1><0>{{difference}}<1><0>{{currency}} from <3><0>{{previous}}<1><0>{{currency}} to <5><0>{{final}}<1><0>{{currency}}", @@ -530,5 +539,19 @@ "last_update_time": "Last Update Time", "asset_class": "Asset Class", "trading_pairs": "Trading Pairs", - "deleted": "Deleted" + "deleted": "Deleted", + "mpt_issuance_id": "MPT Issuance ID", + "asset_scale": "Asset Scale", + "metadata": "Metadata", + "max_amount": "Max Amount", + "mpt_holder": "MPT Holder", + "check_mpt_id": "Please check your MPT Issuance ID", + "outstanding_amount": "Issued Amount", + "locked": "Locked", + "can_lock": "Can Lock", + "require_auth": "Require Auth", + "can_escrow": "Can Escrow", + "can_trade": "Can Trade", + "can_transfer": "Can Transfer", + "can_clawback": "Can Clawback" } diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index bde04d337..d694f1db3 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -526,5 +526,28 @@ "last_update_time": null, "asset_class": null, "trading_pairs": null, - "deleted": null + "deleted": null, + "assets.mpt_tab_title": null, + "assets.no_mpts_message": null, + "transaction_type_name_MPTokenIssuanceCreate": null, + "transaction_type_name_MPTokenIssuanceDestroy": null, + "transaction_type_name_MPTokenIssuanceSet": null, + "transaction_type_name_MPTokenAuthorize": null, + "transaction_outstanding_balance_line_two": null, + "transaction_mptoken_line_one": null, + "transaction_mpt_issuance_line_one":null, + "mpt_issuance_id": null, + "asset_scale": null, + "metadata": null, + "max_amount": null, + "mpt_holder": null, + "check_mpt_id": null, + "outstanding_amount": null, + "locked": null, + "can_lock": null, + "require_auth": null, + "can_escrow": null, + "can_trade": null, + "can_transfer": null, + "can_clawback": null } diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 9cbdc4293..d6683c855 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -527,5 +527,28 @@ "last_update_time": null, "asset_class": null, "trading_pairs": null, - "deleted": null + "deleted": null, + "assets.mpt_tab_title": null, + "assets.no_mpts_message": null, + "transaction_type_name_MPTokenIssuanceCreate": null, + "transaction_type_name_MPTokenIssuanceDestroy": null, + "transaction_type_name_MPTokenIssuanceSet": null, + "transaction_type_name_MPTokenAuthorize": null, + "transaction_outstanding_balance_line_two": null, + "transaction_mptoken_line_one": null, + "transaction_mpt_issuance_line_one":null, + "mpt_issuance_id": null, + "asset_scale": null, + "metadata": null, + "max_amount": null, + "mpt_holder": null, + "check_mpt_id": null, + "outstanding_amount": null, + "locked": null, + "can_lock": null, + "require_auth": null, + "can_escrow": null, + "can_trade": null, + "can_transfer": null, + "can_clawback": null } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 953a54eba..9ed8133ee 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -526,5 +526,28 @@ "last_update_time": null, "asset_class": null, "trading_pairs": null, - "deleted": null + "deleted": null, + "assets.mpt_tab_title": null, + "assets.no_mpts_message": null, + "transaction_type_name_MPTokenIssuanceCreate": null, + "transaction_type_name_MPTokenIssuanceDestroy": null, + "transaction_type_name_MPTokenIssuanceSet": null, + "transaction_type_name_MPTokenAuthorize": null, + "transaction_outstanding_balance_line_two": null, + "transaction_mptoken_line_one": null, + "transaction_mpt_issuance_line_one":null, + "mpt_issuance_id": null, + "asset_scale": null, + "metadata": null, + "max_amount": null, + "mpt_holder": null, + "check_mpt_id": null, + "outstanding_amount": null, + "locked": null, + "can_lock": null, + "require_auth": null, + "can_escrow": null, + "can_trade": null, + "can_transfer": null, + "can_clawback": null } diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index 2094a1fe5..148714cd5 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -524,5 +524,28 @@ "last_update_time": null, "asset_class": null, "trading_pairs": null, - "deleted": null + "deleted": null, + "assets.mpt_tab_title": null, + "assets.no_mpts_message": null, + "transaction_type_name_MPTokenIssuanceCreate": null, + "transaction_type_name_MPTokenIssuanceDestroy": null, + "transaction_type_name_MPTokenIssuanceSet": null, + "transaction_type_name_MPTokenAuthorize": null, + "transaction_outstanding_balance_line_two": null, + "transaction_mptoken_line_one": null, + "transaction_mpt_issuance_line_one":null, + "mpt_issuance_id": null, + "asset_scale": null, + "metadata": null, + "max_amount": null, + "mpt_holder": null, + "check_mpt_id": null, + "outstanding_amount": null, + "locked": null, + "can_lock": null, + "require_auth": null, + "can_escrow": null, + "can_trade": null, + "can_transfer": null, + "can_clawback": null } diff --git a/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/test/AMMAccountHeader.test.tsx b/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/test/AMMAccountHeader.test.tsx index bc38fa370..74ad7ef95 100644 --- a/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/test/AMMAccountHeader.test.tsx +++ b/src/containers/Accounts/AMM/AMMAccounts/AMMAccountHeader/test/AMMAccountHeader.test.tsx @@ -1,20 +1,24 @@ import { mount } from 'enzyme' import { I18nextProvider } from 'react-i18next' import { MemoryRouter } from 'react-router' +import { QueryClientProvider } from 'react-query' import i18n from '../../../../../../i18n/testConfig' import { AMMAccountHeader, AmmDataType } from '../AMMAccountHeader' import { flushPromises } from '../../../../../test/utils' +import { queryClient } from '../../../../../shared/QueryClient' describe('AMM Account Header', () => { const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT' const createWrapper = (state: AmmDataType) => mount( - - - - - , + + + + + + + , ) it('renders AMM account page', async () => { diff --git a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx b/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx index c58f6e6da..2832e2834 100644 --- a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx +++ b/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router' import { useRouteParams } from '../../shared/routing' import { AccountIssuedTokenTable } from '../AccountIssuedTokenTable' import { AccountNFTTable } from '../AccountNFTTable/AccountNFTTable' +import { AccountMPTTable } from '../AccountMPTTable/AccountMPTTable' import { ACCOUNT_ROUTE } from '../../App/routes' // TODO: Add state types or convert to react query @@ -12,11 +13,17 @@ interface Props { account: any } -const assetTypes = ['issued', 'nft'] +let assetTypes = ['issued', 'nft'] const AccountAssetTabDisconnected = ({ account }: Props) => { const { id: accountId = '', assetType = assetTypes[0] } = useRouteParams(ACCOUNT_ROUTE) + + const supportsMPT = ['mpt_sandbox', 'devnet'].includes( + process.env.VITE_ENVIRONMENT as string, + ) + if (supportsMPT) assetTypes = ['issued', 'nft', 'mpt'] + const navigate = useNavigate() const { t } = useTranslation() function switchAsset(event: ChangeEvent) { @@ -48,11 +55,13 @@ const AccountAssetTabDisconnected = ({ account }: Props) => { ) })} +
{assetType === 'issued' && ( )} {assetType === 'nft' && } + {assetType === 'mpt' && }
) diff --git a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx new file mode 100644 index 000000000..d1ab69f6c --- /dev/null +++ b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx @@ -0,0 +1,125 @@ +import { useContext } from 'react' +import { useTranslation } from 'react-i18next' +import { useInfiniteQuery, useQuery } from 'react-query' +import { Loader } from '../../shared/components/Loader' +import SocketContext from '../../shared/SocketContext' +import { useAnalytics } from '../../shared/analytics' +import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' +import { getAccountMPTs, getMPTIssuance } from '../../../rippled/lib/rippled' +import { Account } from '../../shared/components/Account' +import { LoadMoreButton } from '../../shared/LoadMoreButton' +import { MPTokenLink } from '../../shared/components/MPTokenLink' +import { + formatMPTokenInfo, + formatMPTIssuanceInfo, +} from '../../../rippled/lib/utils' +import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' +import { convertScaledPrice } from '../../shared/utils' + +export interface AccountMPTTableProps { + accountId: string +} + +export const AccountMPTRow = ({ mpt }: any) => { + const rippledSocket = useContext(SocketContext) + const { trackException } = useAnalytics() + const { data: mptIssuanceData } = useQuery( + ['getMPTIssuanceScale', mpt.mptIssuanceID], + async () => { + const info = await getMPTIssuance(rippledSocket, mpt.mptIssuanceID) + return formatMPTIssuanceInfo(info) + }, + { + onError: (e: any) => { + trackException( + `mptIssuance ${mpt.mptIssuanceID} --- ${JSON.stringify(e)}`, + ) + }, + }, + ) + + if (!mptIssuanceData) return null + + const scale = mptIssuanceData?.assetScale ?? 0 + + return ( + + + + + + + + + {convertScaledPrice( + parseInt(mpt.mptAmount as string, 10).toString(16), + scale, + )} + + + ) +} + +export const AccountMPTTable = ({ accountId }: AccountMPTTableProps) => { + const rippledSocket = useContext(SocketContext) + const { trackException } = useAnalytics() + const { + data: pages, + isFetching: loading, + hasNextPage, + fetchNextPage, + } = useInfiniteQuery( + ['account_objects', accountId], + ({ pageParam = '' }) => + getAccountMPTs(rippledSocket, accountId, pageParam).catch( + (errorResponse) => { + const errorLocation = `account MPTs ${accountId} at ${pageParam}` + trackException( + `${errorLocation} --- ${JSON.stringify(errorResponse)}`, + ) + }, + ), + { + getNextPageParam: (data) => data.marker, + }, + ) + const { t } = useTranslation() + + function renderNoResults() { + return ( + + {t('assets.no_mpts_message')} + + ) + } + + const renderLoadMoreButton = () => + hasNextPage && fetchNextPage()} /> + + const mpts = pages?.pages + .flatMap((page: any) => page.account_objects) + .map((mpt) => formatMPTokenInfo(mpt)) + + return ( +
+ + + + + + + + + + {!loading && + (mpts?.length + ? mpts.map((mpt) => ( + + )) + : renderNoResults())} + +
{t('mpt_issuance_id')} {t('issuer')}{t('amount')}
+ {loading ? : renderLoadMoreButton()} +
+ ) +} diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx new file mode 100644 index 000000000..04d9eb120 --- /dev/null +++ b/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx @@ -0,0 +1,63 @@ +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter } from 'react-router-dom' +import { mount } from 'enzyme' +import { QueryClientProvider } from 'react-query' +import { AccountMPTRow } from '../AccountMPTTable' +import i18n from '../../../../i18n/testConfig' +import { testQueryClient } from '../../../test/QueryClient' +import { getMPTIssuance } from '../../../../rippled/lib/rippled' +import { flushPromises } from '../../../test/utils' + +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled', () => ({ + __esModule: true, + getMPTIssuance: jest.fn(), +})) + +const mockedFetMPTIssuance = getMPTIssuance as Mock + +const mptData = { + account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', + flags: [], + mptIssuanceID: '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', + mptIssuer: 'rKFgd9FNzwu7a7iVYa2Me4dmBC3zzUepSC', + mptAmount: '100', +} + +describe('AccountMPTRow', () => { + const createWrapper = (component: JSX.Element) => + mount( + + + {component} + + , + ) + + it('handles Account MPT row', async () => { + const issuanceData = { + node: { + AssetScale: 3, + }, + } + + mockedFetMPTIssuance.mockReset() + + mockedFetMPTIssuance.mockImplementation(() => + Promise.resolve({ ...issuanceData }), + ) + + const wrapper = createWrapper() + await flushPromises() + wrapper.update() + expect(wrapper.find('td').at(0).html()).toBe( + '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', + ) + expect(wrapper.find('td').at(1).html()).toBe( + '', + ) + expect(wrapper.find('td').at(2).html()).toBe('0.100') + wrapper.unmount() + }) +}) diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx new file mode 100644 index 000000000..5ad642fb2 --- /dev/null +++ b/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx @@ -0,0 +1,114 @@ +import { mount } from 'enzyme' +import { QueryClientProvider } from 'react-query' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter } from 'react-router-dom' +import { getAccountMPTs } from '../../../../rippled/lib/rippled' +import { AccountMPTTable } from '../AccountMPTTable' +import i18n from '../../../../i18n/testConfig' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { testQueryClient } from '../../../test/QueryClient' +import { flushPromises } from '../../../test/utils' + +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled', () => ({ + __esModule: true, + getAccountMPTs: jest.fn(), +})) + +const mockedGetAccountMPTs = getAccountMPTs as Mock + +const data = { + account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', + account_objects: [ + { + Account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', + Flags: 0, + LedgerEntryType: 'MPToken', + MPTAmount: '100', + MPTokenIssuanceID: '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', + OwnerNode: '0', + PreviousTxnID: + '98646C9E7C6D7B8461DC92712B83EFF6CDA4203CE3FF3FF7E0B86FD57907949F', + PreviousTxnLgrSeq: 969, + index: '3BAA73912496683A414494218D3CCA33D02F80D588F80C1257C691448E00E486', + }, + ], + ledger_current_index: 970, + status: 'success', + validated: false, +} + +describe('AccountMPTTable component', () => { + const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT' + + const createWrapper = () => + mount( + + + + + + + , + ) + + afterEach(() => { + mockedGetAccountMPTs.mockReset() + }) + + it('should render a table of mpts', async () => { + mockedGetAccountMPTs.mockReset() + + mockedGetAccountMPTs.mockImplementation(() => Promise.resolve(data)) + + const wrapper = createWrapper() + await flushPromises() + wrapper.update() + expect(wrapper.find('.load-more-btn')).not.toExist() + wrapper.unmount() + }) + + it('should handle load more', async () => { + mockedGetAccountMPTs.mockReset() + + mockedGetAccountMPTs.mockImplementation(() => + Promise.resolve({ + ...data, + marker: 'hello', + }), + ) + + const wrapper = createWrapper() + await flushPromises() + wrapper.update() + + expect(wrapper.find('.load-more-btn')).toExist() + wrapper.find('.load-more-btn').simulate('click') + expect(mockedGetAccountMPTs.mock.calls[1][2]).toEqual('hello') + wrapper.unmount() + }) + + it(`should handle no results`, async () => { + mockedGetAccountMPTs.mockReset() + + mockedGetAccountMPTs.mockImplementation(() => + Promise.resolve({ + account: 'rLEr9C6kwybj1BFE1iYbhJXNFVz8rnLu8C', + account_objects: [], + ledger_hash: + '7CC58622B7071E675AF63F39AAF81BDB4E87FDFEBB0BA8CE7D753F3C702D2886', + ledger_index: 6086, + validated: true, + marker: 'hello', + }), + ) + + const wrapper = createWrapper() + await flushPromises() + wrapper.update() + + expect(wrapper.find(EmptyMessageTableRow)).toExist() + wrapper.unmount() + }) +}) diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx index d03150b9e..1b5734800 100644 --- a/src/containers/App/index.tsx +++ b/src/containers/App/index.tsx @@ -25,6 +25,7 @@ import { VALIDATOR_ROUTE, AMENDMENTS_ROUTE, AMENDMENT_ROUTE, + MPT_ROUTE, } from './routes' import { LedgersPage as Ledgers } from '../Ledgers' import { Ledger } from '../Ledger' @@ -39,6 +40,7 @@ import { legacyRedirect } from './legacyRedirects' import { useCustomNetworks } from '../shared/hooks' import { Amendments } from '../Amendments' import { Amendment } from '../Amendment' +import { MPT } from '../MPT/MPT' export const AppWrapper = () => { const mode = process.env.VITE_ENVIRONMENT @@ -72,6 +74,7 @@ export const AppWrapper = () => { [TOKEN_ROUTE, Token], [NFT_ROUTE, NFT], [AMENDMENT_ROUTE, Amendment], + [MPT_ROUTE, MPT], ] const redirect = legacyRedirect(basename, location) diff --git a/src/containers/App/routes.ts b/src/containers/App/routes.ts index a7ece5ec3..b94707689 100644 --- a/src/containers/App/routes.ts +++ b/src/containers/App/routes.ts @@ -3,7 +3,7 @@ import { RouteDefinition } from '../shared/routing' export const ACCOUNT_ROUTE: RouteDefinition<{ id?: string tab?: 'assets' | 'transactions' - assetType?: 'issued' | 'nfts' + assetType?: 'issued' | 'nfts' | 'mpts' }> = { path: '/accounts/:id?/:tab?/:assetType?', } @@ -66,3 +66,9 @@ export const AMENDMENT_ROUTE: RouteDefinition<{ }> = { path: `/amendment/:identifier`, } + +export const MPT_ROUTE: RouteDefinition<{ + id: string +}> = { + path: '/mpt/:id', +} diff --git a/src/containers/Header/Search.tsx b/src/containers/Header/Search.tsx index 98eed5b17..65f45e3f7 100644 --- a/src/containers/Header/Search.tsx +++ b/src/containers/Header/Search.tsx @@ -14,9 +14,10 @@ import { CURRENCY_REGEX, DECIMAL_REGEX, FULL_CURRENCY_REGEX, - HASH_REGEX, + HASH256_REGEX, VALIDATORS_REGEX, CTID_REGEX, + HASH192_REGEX, } from '../shared/utils' import './search.scss' import { isValidPayString } from '../../rippled/payString' @@ -30,6 +31,7 @@ import { TOKEN_ROUTE, TRANSACTION_ROUTE, VALIDATOR_ROUTE, + MPT_ROUTE, } from '../App/routes' const determineHashType = async (id: string, rippledContext: XrplClient) => { @@ -60,7 +62,7 @@ const getRoute = async ( path: buildPath(ACCOUNT_ROUTE, { id: normalizeAccount(id) }), } } - if (HASH_REGEX.test(id)) { + if (HASH256_REGEX.test(id)) { // Transactions and NFTs share the same syntax // We must make an api call to ensure if it's one or the other const type = await determineHashType(id, rippledContext) @@ -76,6 +78,12 @@ const getRoute = async ( type, } } + if (HASH192_REGEX.test(id)) { + return { + path: buildPath(MPT_ROUTE, { id: id.toUpperCase() }), + type: 'mpt', + } + } if (isValidXAddress(id) || isValidClassicAddress(id.split(':')[0])) { return { type: 'accounts', diff --git a/src/containers/Header/test/Search.test.js b/src/containers/Header/test/Search.test.js index c694eab6b..027075dfc 100644 --- a/src/containers/Header/test/Search.test.js +++ b/src/containers/Header/test/Search.test.js @@ -83,6 +83,8 @@ describe('Search component', () => { const nftoken = '000800011C7D8ED1D715A0017E41BF9499ECC17E7FB666320000099B00000000' + + const mptoken = '00002AF2588C244FE5F74BF48B5C5E2823235B243AA34634' const invalidString = '123invalid' // mock getNFTInfo api to test transactions and nfts @@ -167,6 +169,8 @@ describe('Search component', () => { // handle lower case ctid await testValue(ctid.toLowerCase(), `/transactions/${ctid}`) + + await testValue(mptoken, `/mpt/${mptoken}`) wrapper.unmount() }) diff --git a/src/containers/Ledger/index.tsx b/src/containers/Ledger/index.tsx index 840a6059d..a1ec8eea6 100644 --- a/src/containers/Ledger/index.tsx +++ b/src/containers/Ledger/index.tsx @@ -14,7 +14,7 @@ import { NOT_FOUND, BAD_REQUEST, DECIMAL_REGEX, - HASH_REGEX, + HASH256_REGEX, } from '../shared/utils' import LeftArrow from '../shared/images/ic_left_arrow.svg' @@ -69,7 +69,7 @@ export const Ledger = () => { } = useQuery(['ledger', identifier], () => { if ( !DECIMAL_REGEX.test(identifier.toString()) && - !HASH_REGEX.test(identifier.toString()) + !HASH256_REGEX.test(identifier.toString()) ) { return Promise.reject(BAD_REQUEST) } diff --git a/src/containers/MPT/MPT.tsx b/src/containers/MPT/MPT.tsx new file mode 100644 index 000000000..83e55e443 --- /dev/null +++ b/src/containers/MPT/MPT.tsx @@ -0,0 +1,75 @@ +import { FC, PropsWithChildren, useEffect, useState } from 'react' +import { useParams } from 'react-router' +import { Helmet } from 'react-helmet-async' +import NoMatch from '../NoMatch' +import { MPTHeader } from './MPTHeader/MPTHeader' +import { useAnalytics } from '../shared/analytics' +import { NOT_FOUND, BAD_REQUEST } from '../shared/utils' +import { ErrorMessage } from '../shared/Interfaces' +import './styles.scss' + +const ERROR_MESSAGES: { [code: number]: ErrorMessage } = { + [NOT_FOUND]: { + title: 'assets.no_mpts_message', + hints: ['check_mpt_id'], + }, + [BAD_REQUEST]: { + title: 'invalid_xrpl_address', + hints: ['check_mpt_id'], + }, +} + +const DEFAULT_ERROR: ErrorMessage = { + title: 'generic_error', + hints: ['not_your_fault'], +} + +const getErrorMessage = (error: any) => ERROR_MESSAGES[error] ?? DEFAULT_ERROR + +const Page: FC> = ({ + tokenId, + children, +}) => ( +
+ + {children} +
+) + +export const MPT = () => { + const { trackScreenLoaded } = useAnalytics() + const { id: tokenId = '' } = useParams<{ id: string }>() + const [error, setError] = useState(null) + + useEffect(() => { + trackScreenLoaded({ + mpt_issuance_id: tokenId, + }) + return () => { + window.scrollTo(0, 0) + } + }, [tokenId, trackScreenLoaded]) + + const renderError = () => { + const message = getErrorMessage(error) + return ( +
+ +
+ ) + } + + if (error) { + return {renderError()} + } + return ( + + {tokenId && } + {!tokenId && ( +
+

Enter a MPT Issuance ID in the search box

+
+ )} +
+ ) +} diff --git a/src/containers/MPT/MPTHeader/Details.tsx b/src/containers/MPT/MPTHeader/Details.tsx new file mode 100644 index 000000000..b980380c0 --- /dev/null +++ b/src/containers/MPT/MPTHeader/Details.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next' +import './styles.scss' +import { useLanguage } from '../../shared/hooks' +import { localizeNumber } from '../../shared/utils' +import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' +import { TokenTableRow } from '../../shared/components/TokenTableRow' + +interface Props { + data: MPTIssuanceFormattedInfo +} + +export const Details = ({ data }: Props) => { + const { + assetScale, + maxAmt, + outstandingAmt, + transferFee, + sequence, + metadata, + } = data + const { t } = useTranslation() + const language = useLanguage() + const formattedFee = + transferFee && + `${localizeNumber((transferFee / 1000).toPrecision(5), language, { + minimumFractionDigits: 3, + })}%` + + return ( + + + {assetScale && ( + + )} + {maxAmt && } + {outstandingAmt && ( + + )} + + + {metadata && } + +
+ ) +} diff --git a/src/containers/MPT/MPTHeader/MPTHeader.tsx b/src/containers/MPT/MPTHeader/MPTHeader.tsx new file mode 100644 index 000000000..354268991 --- /dev/null +++ b/src/containers/MPT/MPTHeader/MPTHeader.tsx @@ -0,0 +1,117 @@ +import { useEffect, useContext, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { Loader } from '../../shared/components/Loader' +import './styles.scss' +import SocketContext from '../../shared/SocketContext' +import { Tooltip, TooltipInstance } from '../../shared/components/Tooltip' +import { BAD_REQUEST, HASH192_REGEX } from '../../shared/utils' +import { Account } from '../../shared/components/Account' +import { useAnalytics } from '../../shared/analytics' +import { getMPTIssuance } from '../../../rippled/lib/rippled' +import { formatMPTIssuanceInfo } from '../../../rippled/lib/utils' +import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' +import { Details } from './Details' +import { Settings } from './Settings' + +interface Props { + tokenId: string + setError: (error: number | null) => void +} + +export const MPTHeader = (props: Props) => { + const { t } = useTranslation() + const { tokenId, setError } = props + const rippledSocket = useContext(SocketContext) + const { trackException } = useAnalytics() + const [tooltip, setTooltip] = useState(undefined) + + const { data, isFetching: loading } = useQuery( + ['getMPTIssuance', tokenId], + async () => { + const info = await getMPTIssuance(rippledSocket, tokenId) + return formatMPTIssuanceInfo(info) + }, + { + onError: (e: any) => { + trackException(`mptIssuance ${tokenId} --- ${JSON.stringify(e)}`) + setError(e.code) + }, + }, + ) + + useEffect(() => { + if (!HASH192_REGEX.test(tokenId)) { + setError(BAD_REQUEST) + } + }, [setError, tokenId]) + + const showTooltip = (event: any, d: any) => { + setTooltip({ + data: d, + mode: 'mptId', + x: event.currentTarget.offsetLeft, + y: event.currentTarget.offsetTop, + }) + } + + const hideTooltip = () => { + setTooltip(undefined) + } + + const renderHeaderContent = () => { + const { issuer } = data! + + return ( +
+
+
+
{t('issuer_address')}
+
+
+ +
+
+
+
+
+
+

{t('details')}

+
+
+
+

{t('settings')}

+ +
+
+
+ ) + } + + return ( +
+
+ {!loading && ( +
+
+ MPT Issuance ID +
mpt
+
+
showTooltip(e, { tokenId })} + onFocus={() => {}} + onMouseLeave={hideTooltip} + > + {tokenId} +
+
+ )} +
+
+ {loading ? : renderHeaderContent()} +
+ +
+ ) +} diff --git a/src/containers/MPT/MPTHeader/Settings.tsx b/src/containers/MPT/MPTHeader/Settings.tsx new file mode 100644 index 000000000..8fadc5333 --- /dev/null +++ b/src/containers/MPT/MPTHeader/Settings.tsx @@ -0,0 +1,41 @@ +import './styles.scss' +import { useTranslation } from 'react-i18next' +import { TokenTableRow } from '../../shared/components/TokenTableRow' + +interface Props { + flags: string[] +} + +export const Settings = ({ flags }: Props) => { + const { t } = useTranslation() + + const locked = flags.includes('lsfMPTLocked') ? 'enabled' : 'disabled' + const canLock = flags.includes('lsfMPTCanLock') ? 'enabled' : 'disabled' + const requireAuth = flags.includes('lsfMPTRequireAuth') + ? 'enabled' + : 'disabled' + + const canEscrow = flags.includes('lsfMPTCanEscrow') ? 'enabled' : 'disabled' + const canTrade = flags.includes('lsfMPTCanTrade') ? 'enabled' : 'disabled' + const canTransfer = flags.includes('lsfMPTCanTransfer') + ? 'enabled' + : 'disabled' + + const canClawback = flags.includes('lsfMPTCanClawback') + ? 'enabled' + : 'disabled' + + return ( + + + + + + + + + + +
+ ) +} diff --git a/src/containers/MPT/MPTHeader/styles.scss b/src/containers/MPT/MPTHeader/styles.scss new file mode 100644 index 000000000..fc1b25e01 --- /dev/null +++ b/src/containers/MPT/MPTHeader/styles.scss @@ -0,0 +1,144 @@ +@import '../../shared/css/variables'; +@import '../../shared/css/table'; + +.mpt-header-container { + .mpt-bottom-container { + display: flex; + flex-direction: column; + padding-top: 64px; + + @include for-size(desktop-up) { + flex-direction: row; + padding-top: 80px; + } + + .details { + width: 100%; + @include for-size(desktop-up) { + width: 490px; + } + } + + .settings { + width: 100%; + @include for-size(desktop-up) { + width: 650px; + } + } + } + + .mpt-info-container { + display: flex; + flex-direction: column; + justify-content: space-between; + margin-top: 68px; + + @include for-size(desktop-up) { + flex-direction: row; + margin-top: 80px; + } + + .values { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 18px; + color: $white; + + @include for-size(desktop-up) { + flex-direction: column; + margin-bottom: 0; + } + + .title { + padding-bottom: 4px; + margin-bottom: 4.5px; + color: $black-40; + font-size: 14px; + text-transform: uppercase; + @include semibold; + } + + .value { + display: flex; + + .mpt-issuer { + margin: auto; + color: $white; + font-size: 18px; + font-style: normal; + line-height: 125%; + text-align: right; + text-decoration: none; + @include bold; + } + + .copy { + width: 24px; + height: 24px; + flex: none; + flex-grow: 0; + order: 1; + margin-left: 10px; + } + } + } + } +} + +.mpt-token-header { + width: 100%; + margin-bottom: 16px; + + .mpt-box-header { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: 0; + text-align: left; + + .title-content { + overflow: hidden; + width: 100%; + min-width: 0; + min-height: 0; + padding-top: 0; + margin-top: 0; + margin-bottom: 0; + color: $white; + font-size: 24px; + text-overflow: ellipsis; + white-space: nowrap; + @include bold; + } + + .token-title { + display: flex; + flex-direction: row; + align-items: center; + padding-bottom: 4px; + color: $black-40; + font-size: 14px; + text-transform: uppercase; + + @include semibold; + + .badge { + background: $mpt; + color: $white; + } + } + + img { + width: 24px; + height: 24px; + margin-left: 16px; + object-fit: contain; + } + } + + .box-content { + min-height: 100px; + padding-bottom: 20px; + } +} diff --git a/src/containers/MPT/MPTHeader/test/Details.test.js b/src/containers/MPT/MPTHeader/test/Details.test.js new file mode 100644 index 000000000..9ccd42740 --- /dev/null +++ b/src/containers/MPT/MPTHeader/test/Details.test.js @@ -0,0 +1,58 @@ +import { mount } from 'enzyme' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter } from 'react-router-dom' +import { Details } from '../Details' +import i18n from '../../../../i18n/testConfig' + +describe('MPT Details container', () => { + const dataDefault = { + issuer: 'r3SnSE9frruxwsC9qGHFiUJShda62fNFGQ', + assetScale: 2, + maxAmt: '256', + outstandingAmt: '64', + transferFee: 3, + sequence: 3949, + metadata: 'https://www.google.com/', + flags: ['lsfMPTCanClawback', 'lsfMPTCanTransfer'], + } + + const createWrapper = (data = dataDefault) => + mount( + + +
+ + , + ) + + it('renders without crashing', () => { + const wrapper = createWrapper() + wrapper.unmount() + }) + + it('renders defined fields', () => { + const wrapper = createWrapper() + expect(wrapper.find('.row').length).toEqual(6) + + expect(wrapper.find('.row').at(0).html()).toBe( + 'asset_scale2', + ) + expect(wrapper.find('.row').at(1).html()).toBe( + 'max_amount256', + ) + expect(wrapper.find('.row').at(2).html()).toBe( + 'outstanding_amount64', + ) + expect(wrapper.find('.row').at(3).html()).toBe( + 'transfer_fee0.003%', + ) + expect(wrapper.find('.row').at(4).html()).toBe( + 'sequence_number_short3949', + ) + expect(wrapper.find('.row').at(5).html()).toBe( + 'metadatahttps://www.google.com/', + ) + + wrapper.unmount() + }) +}) diff --git a/src/containers/MPT/MPTHeader/test/MPTHeader.test.js b/src/containers/MPT/MPTHeader/test/MPTHeader.test.js new file mode 100644 index 000000000..295110078 --- /dev/null +++ b/src/containers/MPT/MPTHeader/test/MPTHeader.test.js @@ -0,0 +1,82 @@ +import { mount } from 'enzyme' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter } from 'react-router-dom' +import { useQuery, QueryClientProvider } from 'react-query' +import { MPTHeader } from '../MPTHeader' +import i18n from '../../../../i18n/testConfig' +import { queryClient } from '../../../shared/QueryClient' + +const data = { + issuer: 'r3SnSE9frruxwsC9qGHFiUJShda62fNFGQ', + assetScale: 2, + maxAmt: '100', + outstandingAmt: '64', + transferFee: 3, + sequence: 3949, + metadata: 'https://www.google.com/', + flags: ['lsfMPTCanClawback', 'lsfMPTCanTransfer'], +} + +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQuery: jest.fn(), +})) +const setError = jest.fn() + +describe('MPT header container', () => { + const createWrapper = () => + mount( + + + + + + + , + ) + + it('renders without crashing', async () => { + useQuery.mockImplementation(() => ({ + data, + isFetching: false, + })) + const wrapper = createWrapper() + wrapper.unmount() + }) + + it('renders MPT content', async () => { + useQuery.mockImplementation(() => ({ + data, + isFetching: false, + })) + const wrapper = createWrapper() + + expect( + wrapper + .text() + .includes('00000F6D5186FB5C90A8112419BED54193EDC7218835C6F5'), + ).toBe(true) + expect(wrapper.text().includes('r3SnSE9frruxwsC9qGHFiUJShda62fNFGQ')).toBe( + true, + ) + expect(wrapper.find('Settings').length).toBe(1) + expect(wrapper.find('Details').length).toBe(1) + wrapper.find('.title-content').first().simulate('mouseOver') + expect(wrapper.find('.tooltip').length).toBe(1) + wrapper.unmount() + }) + + it('renders loader', async () => { + useQuery.mockImplementation(() => ({ + data, + isFetching: true, + error: {}, + })) + const wrapper = createWrapper() + expect(wrapper.find('Loader').length).toEqual(1) + wrapper.unmount() + }) +}) diff --git a/src/containers/MPT/MPTHeader/test/Settings.test.js b/src/containers/MPT/MPTHeader/test/Settings.test.js new file mode 100644 index 000000000..a94ee80af --- /dev/null +++ b/src/containers/MPT/MPTHeader/test/Settings.test.js @@ -0,0 +1,48 @@ +import { mount } from 'enzyme' +import { I18nextProvider } from 'react-i18next' +import { Settings } from '../Settings' +import i18n from '../../../../i18n/testConfig' + +describe('MPT Setttings container', () => { + const flags = ['lsfMPTCanClawback', 'lsfMPTCanTransfer'] + + const createWrapper = () => + mount( + + + , + ) + + it('renders without crashing', () => { + const wrapper = createWrapper() + wrapper.unmount() + }) + + it('renders defined fields', () => { + const wrapper = createWrapper() + expect(wrapper.find('.row').length).toEqual(7) + + expect(wrapper.find('.row').at(0).html()).toBe( + 'lockeddisabled', + ) + expect(wrapper.find('.row').at(1).html()).toBe( + 'can_lockdisabled', + ) + expect(wrapper.find('.row').at(2).html()).toBe( + 'require_authdisabled', + ) + expect(wrapper.find('.row').at(3).html()).toBe( + 'can_escrowdisabled', + ) + expect(wrapper.find('.row').at(4).html()).toBe( + 'can_tradedisabled', + ) + expect(wrapper.find('.row').at(5).html()).toBe( + 'can_transferenabled', + ) + expect(wrapper.find('.row').at(6).html()).toBe( + 'can_clawbackenabled', + ) + wrapper.unmount() + }) +}) diff --git a/src/containers/MPT/styles.scss b/src/containers/MPT/styles.scss new file mode 100644 index 000000000..51c0c4fc3 --- /dev/null +++ b/src/containers/MPT/styles.scss @@ -0,0 +1,15 @@ +@import '../shared/css/variables'; + +.mpt-page { + width: 100%; + margin-top: 100px; + + .loader { + min-height: 100px; + } +} + +.mpt-warning { + font-size: 14px; + text-align: center; +} diff --git a/src/containers/MPT/test/MPT.test.js b/src/containers/MPT/test/MPT.test.js new file mode 100644 index 000000000..ae924fc10 --- /dev/null +++ b/src/containers/MPT/test/MPT.test.js @@ -0,0 +1,46 @@ +import * as React from 'react' +import { mount } from 'enzyme' +import { Route } from 'react-router-dom' +import { MPT } from '../MPT' +import i18n from '../../../i18n/testConfig' +import { QuickHarness } from '../../test/utils' +import { MPT_ROUTE } from '../../App/routes' + +describe('MPT container', () => { + const mptID = '00000F6D5186FB5C90A8112419BED54193EDC7218835C6F5' + + const createWrapper = (mpt = undefined) => + mount( + + } /> + , + ) + + it('renders without crashing', () => { + const wrapper = createWrapper(mptID) + wrapper.unmount() + }) + + it('renders children', () => { + const wrapper = createWrapper(mptID) + expect(wrapper.find('MPTHeader').length).toBe(1) + wrapper.unmount() + }) + + it('does not render when no mpt provided', () => { + const wrapper = createWrapper() + expect(wrapper.find('MPTHeader').length).toBe(0) + wrapper.unmount() + }) + + it('renders error', () => { + jest.mock('../MPTHeader/MPTHeader', () => ({ + NFTHeader: ({ setError }) => { + setError(404) + }, + })) + + const wrapper = createWrapper('something') + expect(wrapper.find('NoMatch').length).toBe(1) + }) +}) diff --git a/src/containers/NFT/NFTHeader/NFTHeader.tsx b/src/containers/NFT/NFTHeader/NFTHeader.tsx index 1a46f7ac6..d53e2c233 100644 --- a/src/containers/NFT/NFTHeader/NFTHeader.tsx +++ b/src/containers/NFT/NFTHeader/NFTHeader.tsx @@ -7,7 +7,7 @@ import SocketContext from '../../shared/SocketContext' import { Tooltip, TooltipInstance } from '../../shared/components/Tooltip' import { getNFTInfo, getAccountInfo } from '../../../rippled/lib/rippled' import { formatNFTInfo, formatAccountInfo } from '../../../rippled/lib/utils' -import { localizeDate, BAD_REQUEST, HASH_REGEX } from '../../shared/utils' +import { localizeDate, BAD_REQUEST, HASH256_REGEX } from '../../shared/utils' import { Details } from './Details' import { Settings } from './Settings' import { Account } from '../../shared/components/Account' @@ -56,7 +56,7 @@ export const NFTHeader = (props: Props) => { ) useEffect(() => { - if (!HASH_REGEX.test(tokenId)) { + if (!HASH256_REGEX.test(tokenId)) { setError(BAD_REQUEST) } }, [setError, tokenId]) diff --git a/src/containers/Transactions/DetailTab/Meta/MPToken.jsx b/src/containers/Transactions/DetailTab/Meta/MPToken.jsx new file mode 100644 index 000000000..b94856e40 --- /dev/null +++ b/src/containers/Transactions/DetailTab/Meta/MPToken.jsx @@ -0,0 +1,43 @@ +import { Trans } from 'react-i18next' +import { Account } from '../../../shared/components/Account' +import { computeMPTokenBalanceChange } from '../../../shared/utils' + +const render = (t, language, action, node, index) => { + const { previousBalance, finalBalance, account, change } = + computeMPTokenBalanceChange(node) + const previousBalanceStr = previousBalance.toString(10) + const finalBalanceStr = finalBalance.toString(10) + const changeStr = change.toString(10) + + const line1 = ( + + It {action} an MPToken node of + + + ) + + const line2 = + change !== BigInt(0) ? ( +
    +
  • + + Balance changed by + {changeStr} + from + {previousBalanceStr} + to + {finalBalanceStr} + +
  • +
+ ) : null + + return ( +
  • + {line1} + {line2} +
  • + ) +} + +export default render diff --git a/src/containers/Transactions/DetailTab/Meta/MPTokenIssuance.jsx b/src/containers/Transactions/DetailTab/Meta/MPTokenIssuance.jsx new file mode 100644 index 000000000..99305b59c --- /dev/null +++ b/src/containers/Transactions/DetailTab/Meta/MPTokenIssuance.jsx @@ -0,0 +1,43 @@ +import { Trans } from 'react-i18next' +import { Account } from '../../../shared/components/Account' +import { computeMPTIssuanceBalanceChange } from '../../../shared/utils' + +const render = (t, language, action, node, index) => { + const { previousBalance, finalBalance, account, change } = + computeMPTIssuanceBalanceChange(node) + const previousBalanceStr = previousBalance.toString(10) + const finalBalanceStr = finalBalance.toString(10) + const changeStr = change.toString(10) + + const line1 = ( + + It {action} an MPTokenIssuance node of + + + ) + + const line2 = + change !== BigInt(0) ? ( +
      +
    • + + Outstanding balance changed by + {changeStr} + from + {previousBalanceStr} + to + {finalBalanceStr} + +
    • +
    + ) : null + + return ( +
  • + {line1} + {line2} +
  • + ) +} + +export default render diff --git a/src/containers/Transactions/DetailTab/Meta/RippleState.jsx b/src/containers/Transactions/DetailTab/Meta/RippleState.jsx index 6d50ce114..2ea9539eb 100644 --- a/src/containers/Transactions/DetailTab/Meta/RippleState.jsx +++ b/src/containers/Transactions/DetailTab/Meta/RippleState.jsx @@ -1,6 +1,9 @@ import { Trans } from 'react-i18next' import { Account } from '../../../shared/components/Account' -import { localizeNumber, computeBalanceChange } from '../../../shared/utils' +import { + localizeNumber, + computeRippleStateBalanceChange, +} from '../../../shared/utils' const render = (t, language, action, node, index) => { const { @@ -11,7 +14,7 @@ const render = (t, language, action, node, index) => { currency, account, counterAccount, - } = computeBalanceChange(node) + } = computeRippleStateBalanceChange(node) const line1 = ( diff --git a/src/containers/Transactions/DetailTab/Meta/index.tsx b/src/containers/Transactions/DetailTab/Meta/index.tsx index 24335e480..a7a6cc226 100644 --- a/src/containers/Transactions/DetailTab/Meta/index.tsx +++ b/src/containers/Transactions/DetailTab/Meta/index.tsx @@ -5,6 +5,8 @@ import renderDirectoryNode from './DirectoryNode' import renderOffer from './Offer' import renderRippleState from './RippleState' import renderPayChannel from './PayChannel' +import renderMPToken from './MPToken' +import renderMPTokenIssuance from './MPTokenIssuance' import { groupAffectedNodes } from '../../../shared/transactionUtils' import { useLanguage } from '../../../shared/hooks' @@ -31,6 +33,10 @@ export const TransactionMeta: FC<{ data: any }> = ({ data }) => { return renderRippleState(t, language, action, node, index) case 'PayChannel': return renderPayChannel(t, language, action, node, index) + case 'MPTokenIssuance': + return renderMPTokenIssuance(t, language, action, node, index) + case 'MPToken': + return renderMPToken(t, language, action, node, index) default: return renderDefault(t, action, node, index) } diff --git a/src/containers/Transactions/index.tsx b/src/containers/Transactions/index.tsx index fa34e0e4f..2918e7674 100644 --- a/src/containers/Transactions/index.tsx +++ b/src/containers/Transactions/index.tsx @@ -6,7 +6,12 @@ import { useWindowSize } from 'usehooks-ts' import NoMatch from '../NoMatch' import { Loader } from '../shared/components/Loader' import { Tabs } from '../shared/components/Tabs' -import { NOT_FOUND, BAD_REQUEST, HASH_REGEX, CTID_REGEX } from '../shared/utils' +import { + NOT_FOUND, + BAD_REQUEST, + HASH256_REGEX, + CTID_REGEX, +} from '../shared/utils' import { SimpleTab } from './SimpleTab' import { DetailTab } from './DetailTab' import './transaction.scss' @@ -54,7 +59,7 @@ export const Transaction = () => { if (identifier === '') { return undefined } - if (HASH_REGEX.test(identifier) || CTID_REGEX.test(identifier)) { + if (HASH256_REGEX.test(identifier) || CTID_REGEX.test(identifier)) { return getTransaction(identifier, rippledSocket).catch( (transactionRequestError) => { const status = transactionRequestError.code diff --git a/src/containers/Transactions/test/Description.test.tsx b/src/containers/Transactions/test/Description.test.tsx index 04ae40ccb..4c6074494 100644 --- a/src/containers/Transactions/test/Description.test.tsx +++ b/src/containers/Transactions/test/Description.test.tsx @@ -1,19 +1,23 @@ import { mount } from 'enzyme' import { BrowserRouter as Router } from 'react-router-dom' import { I18nextProvider } from 'react-i18next' +import { QueryClientProvider } from 'react-query' import i18n from '../../../i18n/testConfigEnglish' import { TransactionDescription } from '../DetailTab/Description' import Transaction from './mock_data/Transaction.json' import OfferCreateTicket from './mock_data/OfferCreateTicket.json' import EmittedPayment from './mock_data/EmittedPayment.json' +import { queryClient } from '../../shared/QueryClient' describe('Description container', () => { const createWrapper = (data = {}) => mount( - - - + + + + + , ) diff --git a/src/containers/Transactions/test/DetailTab.test.tsx b/src/containers/Transactions/test/DetailTab.test.tsx index 0e734c06c..46ff5488c 100644 --- a/src/containers/Transactions/test/DetailTab.test.tsx +++ b/src/containers/Transactions/test/DetailTab.test.tsx @@ -1,6 +1,7 @@ import { mount } from 'enzyme' import { BrowserRouter as Router } from 'react-router-dom' import { I18nextProvider } from 'react-i18next' +import { QueryClientProvider } from 'react-query' import Transaction from '../../shared/components/Transaction/EscrowCreate/test/mock_data/EscrowCreate.json' import FailedTransaction from '../../shared/components/Transaction/SignerListSet/test/mock_data/SignerListSet.json' import HookPayment from './mock_data/HookPayment.json' @@ -8,14 +9,17 @@ import EmittedPayment from './mock_data/EmittedPayment.json' import { DetailTab } from '../DetailTab' import i18n from '../../../i18n/testConfigEnglish' import { convertHexToString } from '../../../rippled/lib/utils' +import { queryClient } from '../../shared/QueryClient' describe('DetailTab container', () => { const createWrapper = (transaction: any = Transaction) => mount( - - - + + + + + , ) diff --git a/src/containers/Transactions/test/Meta.test.tsx b/src/containers/Transactions/test/Meta.test.tsx index e6e2fe903..ac4195799 100644 --- a/src/containers/Transactions/test/Meta.test.tsx +++ b/src/containers/Transactions/test/Meta.test.tsx @@ -6,6 +6,7 @@ import Transaction from './mock_data/Transaction.json' import OfferCancel from '../../shared/components/Transaction/OfferCancel/test/mock_data/OfferCancel.json' import OfferCreateWithMissingPreviousFields from '../../shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateWithMissingPreviousFields.json' import PaymentChannelClaim from '../../shared/components/Transaction/PaymentChannelClaim/test/mock_data/PaymentChannelClaim.json' +import DirectMPTPayment from './mock_data/DirectMPTPayment.json' import { TransactionMeta } from '../DetailTab/Meta' describe('TransactionMeta container', () => { @@ -160,4 +161,32 @@ describe('TransactionMeta container', () => { ) expect(w.find('li').length).toBe(4) }) + + it('renders MPT Payment Meta', () => { + const w = createWrapper(DirectMPTPayment) + + expect(w.find('.title').length).toBe(1) + expect(w.find('.detail-subsection').length).toBe(1) + expect(w.contains(
    number_of_affected_node
    )).toBe(true) + expect(w.contains(
    nodes_type
    )).toBe( + true, + ) + expect(w.find('li').length).toBe(6) + + expect(w.find('li').at(2).html()).toBe( + '
  • It modified an MPToken node of
    • Balance changed by100from0to100
  • ', + ) + + expect(w.find('li').at(3).html()).toBe( + '
  • Balance changed by100from0to100
  • ', + ) + + expect(w.find('li').at(4).html()).toBe( + '
  • It modified an MPTokenIssuance node of
    • Outstanding balance changed by100from0to100
  • ', + ) + + expect(w.find('li').at(5).html()).toBe( + '
  • Outstanding balance changed by100from0to100
  • ', + ) + }) }) diff --git a/src/containers/Transactions/test/SimpleTab.test.tsx b/src/containers/Transactions/test/SimpleTab.test.tsx index 8fe7794eb..652ded5b7 100644 --- a/src/containers/Transactions/test/SimpleTab.test.tsx +++ b/src/containers/Transactions/test/SimpleTab.test.tsx @@ -2,23 +2,27 @@ import { mount } from 'enzyme' import { I18nextProvider } from 'react-i18next' import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' import EnableAmendment from './mock_data/EnableAmendment.json' import Payment from '../../shared/components/Transaction/Payment/test/mock_data/Payment.json' import { SimpleTab } from '../SimpleTab' import summarize from '../../../rippled/lib/txSummary' import i18n from '../../../i18n/testConfig' import { expectSimpleRowText } from '../../shared/components/Transaction/test' +import { queryClient } from '../../shared/QueryClient' describe('SimpleTab container', () => { const createWrapper = (tx, width = 1200) => mount( - - - + + + + + , ) diff --git a/src/containers/Transactions/test/mock_data/DirectMPTPayment.json b/src/containers/Transactions/test/mock_data/DirectMPTPayment.json new file mode 100644 index 000000000..87d3c374b --- /dev/null +++ b/src/containers/Transactions/test/mock_data/DirectMPTPayment.json @@ -0,0 +1,95 @@ +{ + "tx": { + "Account": "rwREfyDU1SbcjN3xXZDbm8uNJV77T2ruKw", + "Amount": { + "mpt_issuance_id": "0000301C674EE6ECD0374A628E2C442EF8E3BBBEE8C58CF3", + "value": "100" + }, + "DeliverMax": { + "mpt_issuance_id": "0000301C674EE6ECD0374A628E2C442EF8E3BBBEE8C58CF3", + "value": "100" + }, + "Destination": "rnNkvddM96FE2QsaFztLAn5xicjq5d6d8d", + "Fee": "10", + "Flags": 2147483648, + "Sequence": 12317, + "SigningPubKey": "ED1E43F90700506F98E45CC8E77563ACB8FF0338739229AC98F0E1AEB409E786F9", + "TransactionType": "Payment", + "TxnSignature": "9949EF3E718A6776586A1DD91256C4055E761CBF1CE7A351912C08FE1BB415F6638FA096CAAE88D30B32E684448CDC52DEB022A7E3576AFA0C6E5ABB7BE2FE02", + "ctid": "C000302000000000", + "date": 1712087804000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rwREfyDU1SbcjN3xXZDbm8uNJV77T2ruKw", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 12318 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "80909CC95DCD7E5E08631635C559E218D42C49721A1285192885B46E4737CF60", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 12317 + }, + "PreviousTxnID": "C9D0965E7A13F54186B6501D08ED54D74AA946A856D96BF7AAE18EA5FD5E93C2", + "PreviousTxnLgrSeq": 12318 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnNkvddM96FE2QsaFztLAn5xicjq5d6d8d", + "Flags": 0, + "MPTAmount": "100", + "MPTokenIssuanceID": "0000301C674EE6ECD0374A628E2C442EF8E3BBBEE8C58CF3", + "OwnerNode": "0" + }, + "LedgerEntryType": "MPToken", + "LedgerIndex": "9B2E5EA9ACF16B591B941CAE5323EBED55E42495B16C92DC9FBEC0997E8E6804", + "PreviousFields": {}, + "PreviousTxnID": "4C41449D0C083746CC93DDA78F00E97AB8B857188E805F39C1A250F8C9467982", + "PreviousTxnLgrSeq": 12319 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 64, + "Issuer": "rwREfyDU1SbcjN3xXZDbm8uNJV77T2ruKw", + "OutstandingAmount": "100", + "OwnerNode": "0", + "Sequence": 12316 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "C38E54FD2C98FE848FE31CDA95F6F990A9A8715987171DA342A3296B7A9123B6", + "PreviousFields": { + "OutstandingAmount": "0" + }, + "PreviousTxnID": "C9D0965E7A13F54186B6501D08ED54D74AA946A856D96BF7AAE18EA5FD5E93C2", + "PreviousTxnLgrSeq": 12318 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "mpt_issuance_id": "0000301C674EE6ECD0374A628E2C442EF8E3BBBEE8C58CF3", + "value": "100" + } + }, + "hash": "5E74603F7C2E11030E644E681508FD1F24CAEB4CC0CE1F35A6230689D9694E85", + "ledger_index": 12320, + "date": 1712087804000 +} diff --git a/src/containers/shared/Interfaces.tsx b/src/containers/shared/Interfaces.tsx index 201b0daea..83c03903f 100644 --- a/src/containers/shared/Interfaces.tsx +++ b/src/containers/shared/Interfaces.tsx @@ -36,6 +36,20 @@ export interface NFTFormattedInfo { warnings?: string[] } +/** + * Values returned by 'formatMPTIssuanceInfo' from /src/rippled/lib/utils.js + */ +export interface MPTIssuanceFormattedInfo { + issuer: string + sequence: number + assetScale?: number + maxAmt?: string + outstandingAmt?: string + flags?: string[] + transferFee?: number + metadata?: string +} + export interface ErrorMessage { title: string hints: string[] diff --git a/src/containers/shared/analytics.ts b/src/containers/shared/analytics.ts index bff40dcfb..b11d5c349 100644 --- a/src/containers/shared/analytics.ts +++ b/src/containers/shared/analytics.ts @@ -27,6 +27,7 @@ export interface AnalyticsFields { search_term?: string search_category?: string validator?: string + mpt_issuance_id?: string description?: string page_title?: string diff --git a/src/containers/shared/components/Amount.tsx b/src/containers/shared/components/Amount.tsx index 164b06737..e7fe6ecd5 100644 --- a/src/containers/shared/components/Amount.tsx +++ b/src/containers/shared/components/Amount.tsx @@ -1,8 +1,15 @@ +import { useQuery } from 'react-query' +import { useContext } from 'react' import { CURRENCY_OPTIONS, XRP_BASE } from '../transactionUtils' import { useLanguage } from '../hooks' -import { localizeNumber } from '../utils' +import { localizeNumber, convertScaledPrice } from '../utils' import Currency from './Currency' import { ExplorerAmount } from '../types' +import { MPTIssuanceFormattedInfo } from '../Interfaces' +import { getMPTIssuance } from '../../../rippled/lib/rippled' +import { formatMPTIssuanceInfo } from '../../../rippled/lib/utils' +import SocketContext from '../SocketContext' +import { useAnalytics } from '../analytics' export interface AmountProps { value: ExplorerAmount | string @@ -16,15 +23,17 @@ export const Amount = ({ value, }: AmountProps) => { const language = useLanguage() + const rippledSocket = useContext(SocketContext) + const { trackException } = useAnalytics() const issuer = typeof value === 'string' ? undefined : value.issuer const currency = typeof value === 'string' ? 'XRP' : value.currency const amount = typeof value === 'string' ? parseInt(value, 10) / XRP_BASE : value.amount + const isMPT = typeof value === 'string' ? false : value.isMPT const options = { ...CURRENCY_OPTIONS, currency } - const localizedAmount = localizeNumber(amount, language, options) - return ( + const renderAmount = (localizedAmount) => ( {modifier && {modifier}} @@ -35,7 +44,43 @@ export const Amount = ({ currency={currency} link displaySymbol={false} + isMPT={isMPT} /> ) + + const mptID = isMPT ? (value as ExplorerAmount).currency : null + + // fetch MPTIssuance only if isMPT is true + const { data: mptIssuanceData } = + useQuery( + ['getMPTIssuanceScale', mptID], + async () => { + const info = await getMPTIssuance(rippledSocket, mptID) + return formatMPTIssuanceInfo(info) + }, + { + onError: (e: any) => { + trackException(`mptIssuance ${mptID} --- ${JSON.stringify(e)}`) + }, + enabled: isMPT, + }, + ) || {} + + // if amount is MPT type, we need to fetch the scale from the MPTokenIssuance + // object so we can show the scaled amount + if (isMPT && typeof value !== 'string') { + if (mptIssuanceData) { + const scale = mptIssuanceData.assetScale ?? 0 + const scaledAmount = convertScaledPrice( + parseInt(amount as string, 10).toString(16), + scale, + ) + + return renderAmount(localizeNumber(scaledAmount, language, {}, true)) + } + return null + } + + return renderAmount(localizeNumber(amount, language, options)) } diff --git a/src/containers/shared/components/Currency.tsx b/src/containers/shared/components/Currency.tsx index 64727ca0a..ac48efa7c 100644 --- a/src/containers/shared/components/Currency.tsx +++ b/src/containers/shared/components/Currency.tsx @@ -1,5 +1,5 @@ import { RouteLink } from '../routing' -import { TOKEN_ROUTE } from '../../App/routes' +import { TOKEN_ROUTE, MPT_ROUTE } from '../../App/routes' // https://xrpl.org/currency-formats.html#nonstandard-currency-codes const NON_STANDARD_CODE_LENGTH = 40 @@ -12,6 +12,7 @@ export interface Props { link?: boolean shortenIssuer?: boolean displaySymbol?: boolean + isMPT?: boolean } /* @@ -25,33 +26,46 @@ const Currency = (props: Props) => { link = true, shortenIssuer = false, displaySymbol = true, + isMPT = false, } = props + let content - const currencyCode = - currency?.length === NON_STANDARD_CODE_LENGTH && - currency?.substring(0, 2) !== LP_TOKEN_IDENTIFIER - ? hexToString(currency) - : currency - - let display = `${currencyCode}` - - if (currencyCode === XRP && displaySymbol) { - display = `\uE900 ${display}` - } - - if (issuer) { - display += '.' - display += shortenIssuer ? issuer.substring(0, 4) : issuer - } - - const content = - link && issuer ? ( - + if (isMPT) { + const display = `MPT (${currency})` + content = link ? ( + {display} ) : ( display ) + } else { + const currencyCode = + currency?.length === NON_STANDARD_CODE_LENGTH && + currency?.substring(0, 2) !== LP_TOKEN_IDENTIFIER + ? hexToString(currency) + : currency + + let display = `${currencyCode}` + + if (currencyCode === XRP && displaySymbol) { + display = `\uE900 ${display}` + } + + if (issuer) { + display += '.' + display += shortenIssuer ? issuer.substring(0, 4) : issuer + } + + content = + link && issuer ? ( + + {display} + + ) : ( + display + ) + } return {content} } diff --git a/src/containers/shared/components/MPTokenLink.tsx b/src/containers/shared/components/MPTokenLink.tsx new file mode 100644 index 000000000..f1c2198bb --- /dev/null +++ b/src/containers/shared/components/MPTokenLink.tsx @@ -0,0 +1,12 @@ +import { RouteLink } from '../routing' +import { MPT_ROUTE } from '../../App/routes' + +export interface MPTokenLinkProps { + tokenID: string +} + +export const MPTokenLink = ({ tokenID }: MPTokenLinkProps) => ( + + {tokenID} + +) diff --git a/src/containers/shared/components/Tooltip/Tooltip.tsx b/src/containers/shared/components/Tooltip/Tooltip.tsx index 7154eb8ca..673cd72de 100644 --- a/src/containers/shared/components/Tooltip/Tooltip.tsx +++ b/src/containers/shared/components/Tooltip/Tooltip.tsx @@ -91,6 +91,8 @@ export const Tooltip = ({ tooltip }: { tooltip?: TooltipInstance }) => { ) + const renderMPTId = () =>
    {data.tokenId}
    + const { x, y, mode } = tooltip const style: CSSProperties = { top: y + PADDING_Y, left: x } const modeMap = { @@ -100,6 +102,7 @@ export const Tooltip = ({ tooltip }: { tooltip?: TooltipInstance }) => { missing: renderMissingValidators, paystring: renderPayStringToolTip, nftId: renderNFTId, + mptId: renderMPTId, } return modeMap[mode] ? ( diff --git a/src/containers/shared/components/Transaction/Clawback/Description.tsx b/src/containers/shared/components/Transaction/Clawback/Description.tsx index ec59c0a96..c92397c35 100644 --- a/src/containers/shared/components/Transaction/Clawback/Description.tsx +++ b/src/containers/shared/components/Transaction/Clawback/Description.tsx @@ -6,8 +6,8 @@ import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount' export const Description = ({ data }: TransactionDescriptionProps) => { const issuer = data.tx.Account - const holder = data.tx.Amount.issuer const amount = formatAmount(data.tx.Amount) + const holder = amount.isMPT ? data.tx.MPTokenHolder : data.tx.Amount.issuer amount.issuer = issuer return ( <> diff --git a/src/containers/shared/components/Transaction/Clawback/parser.ts b/src/containers/shared/components/Transaction/Clawback/parser.ts index 0da7126b7..f5ce6f6a0 100644 --- a/src/containers/shared/components/Transaction/Clawback/parser.ts +++ b/src/containers/shared/components/Transaction/Clawback/parser.ts @@ -1,7 +1,10 @@ import { Clawback, ClawbackInstructions } from './types' import { TransactionParser } from '../types' import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount' -import { computeBalanceChange } from '../../../utils' +import { + computeRippleStateBalanceChange, + computeMPTokenBalanceChange, +} from '../../../utils' export const parser: TransactionParser = ( tx, @@ -9,6 +12,37 @@ export const parser: TransactionParser = ( ) => { const account = tx.Account const amount = formatAmount(tx.Amount) + + if (amount.isMPT === true) { + const holder = tx.MPTokenHolder + + const filteredMptNode = meta.AffectedNodes.filter( + (node: any) => node.ModifiedNode?.LedgerEntryType === 'MPToken', + ) + + // If no mpt is modified, it means the tx failed. + // We just return the amount that was attempted to claw. + if (!filteredMptNode || filteredMptNode.length !== 1) + return { + amount, + account, + holder, + } + + const mptNode = filteredMptNode[0].ModifiedNode + const { change } = computeMPTokenBalanceChange(mptNode) + amount.amount = + BigInt(change) < 0 + ? BigInt(-change).toString(10) + : BigInt(change).toString(10) + + return { + account, + amount, + holder, + } + } + const holder = amount.issuer amount.issuer = account @@ -30,7 +64,7 @@ export const parser: TransactionParser = ( holder, } - const { change } = computeBalanceChange( + const { change } = computeRippleStateBalanceChange( trustlineNode[0].ModifiedNode ?? trustlineNode[0].DeletedNode, ) diff --git a/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx b/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx index 6110b2b41..1a7e19ee7 100644 --- a/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx +++ b/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx @@ -1,7 +1,15 @@ +import { useQuery } from 'react-query' import { createSimpleWrapperFactory, expectSimpleRowText } from '../../test' import { Simple } from '../Simple' import transaction from './mock_data/Clawback.json' import transactionFailure from './mock_data/Clawback_Failure.json' +import transactionMPT from './mock_data/ClawbackMPT.json' +import transactionMPTFailure from './mock_data/ClawbackMPT_Failure.json' + +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQuery: jest.fn(), +})) const createWrapper = createSimpleWrapperFactory(Simple) @@ -17,6 +25,26 @@ describe('Clawback', () => { wrapper.unmount() }) + it('handles MPT Clawback simple view ', () => { + const data = { + assetScale: 3, + } + + // @ts-ignore + useQuery.mockImplementation(() => ({ + data, + })) + const wrapper = createWrapper(transactionMPT) + expectSimpleRowText(wrapper, 'holder', 'rUZTPFN7MBJkjiZ48rak6q7MbhT4ur2kAD') + expectSimpleRowText( + wrapper, + 'amount', + '0.05 MPT (00000D668E702F54A27C42EF98C13B0787D1766CC9162A47)', + ) + + wrapper.unmount() + }) + it('handles failed Clawback simple view ', () => { const wrapper = createWrapper(transactionFailure) expectSimpleRowText(wrapper, 'holder', 'rDZ713igKfedN4hhY6SjQse4Mv3ZrBxnn9') @@ -27,4 +55,24 @@ describe('Clawback', () => { ) wrapper.unmount() }) + + it('handles failed MPT Clawback simple view ', () => { + const data = { + assetScale: 3, + } + + // @ts-ignore + useQuery.mockImplementation(() => ({ + data, + })) + const wrapper = createWrapper(transactionMPTFailure) + + expectSimpleRowText(wrapper, 'holder', 'r9rAqX8Jjo4uACsimYDVsy5thHDPivujqf') + expectSimpleRowText( + wrapper, + 'amount', + '0.05 MPT (000010952ECE2AFC727F1C67EF568F360A2D92CB7C29FF7C)', + ) + wrapper.unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT.json b/src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT.json new file mode 100644 index 000000000..2a7d4513a --- /dev/null +++ b/src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT.json @@ -0,0 +1,85 @@ +{ + "tx": { + "Account": "rDz9LyymZh4C1jJvFK6v6qXeeARLdYKuEW", + "Amount": { + "mpt_issuance_id": "00000D668E702F54A27C42EF98C13B0787D1766CC9162A47", + "value": "50" + }, + "Fee": "10", + "Flags": 2147483648, + "MPTokenHolder": "rUZTPFN7MBJkjiZ48rak6q7MbhT4ur2kAD", + "Sequence": 3432, + "SigningPubKey": "ED0C1DE70A8762E6C98EC78CF13D278D6950ECDFE8E87BAD3732730845E2D9AB6A", + "TransactionType": "Clawback", + "TxnSignature": "8099CED925A463A2A24F55A496D2BB40108B75840770DFBA9796FBBD40AA482126EE9DAF2512D5D2E8268BCBFC277828E66A28CF3394702611290B45FBA88109", + "ctid": "C0000D6D00000000", + "date": 1728575981000 + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rDz9LyymZh4C1jJvFK6v6qXeeARLdYKuEW", + "Balance": "99999970", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 3433 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "06A3654A4A8829FD0575ADDD068BD04F7483C407E027CB43F77C2A5CA575368B", + "PreviousFields": { + "Balance": "99999980", + "Sequence": 3432 + }, + "PreviousTxnID": "4B6D63C7AA15899EC1CB3D84C923B08A62D6643A75D28B254F7A0C082B2C0D75", + "PreviousTxnLgrSeq": 3436 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "AssetScale": 3, + "Flags": 98, + "Issuer": "rDz9LyymZh4C1jJvFK6v6qXeeARLdYKuEW", + "MPTokenMetadata": "7B226E616D65223A2255532054726561737572792042696C6C20546F6B656E222C2273796D626F6C223A225553544254222C22646563696D616C73223A322C22746F74616C537570706C79223A313030303030302C22697373756572223A225553205472656173757279222C22697373756544617465223A22323032342D30332D3235222C226D6174757269747944617465223A22323032352D30332D3235222C226661636556616C7565223A2231303030222C22696E74657265737452617465223A22322E35222C22696E7465726573744672657175656E6379223A22517561727465726C79222C22636F6C6C61746572616C223A22555320476F7665726E6D656E74222C226A7572697364696374696F6E223A22556E6974656420537461746573222C22726567756C61746F7279436F6D706C69616E6365223A2253454320526567756C6174696F6E73222C22736563757269747954797065223A2254726561737572792042696C6C222C2265787465726E616C5F75726C223A2268747470733A2F2F6578616D706C652E636F6D2F742D62696C6C2D746F6B656E2D6D657461646174612E6A736F6E227D", + "MaximumAmount": "9223372036854775807", + "OutstandingAmount": "50", + "OwnerNode": "0", + "Sequence": 3430 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "0EABDC95DBBC52F8A95A5F49C38211A30B916BC329774F46CC081D502F9E1895", + "PreviousFields": { + "OutstandingAmount": "100" + }, + "PreviousTxnID": "4B6D63C7AA15899EC1CB3D84C923B08A62D6643A75D28B254F7A0C082B2C0D75", + "PreviousTxnLgrSeq": 3436 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUZTPFN7MBJkjiZ48rak6q7MbhT4ur2kAD", + "Flags": 0, + "MPTAmount": "50", + "MPTokenIssuanceID": "00000D668E702F54A27C42EF98C13B0787D1766CC9162A47", + "OwnerNode": "0" + }, + "LedgerEntryType": "MPToken", + "LedgerIndex": "DA40BA069F110465BD90BF5732163836F011E3E761CCF7B6949FAA24D97132F6", + "PreviousFields": { + "MPTAmount": "100" + }, + "PreviousTxnID": "4B6D63C7AA15899EC1CB3D84C923B08A62D6643A75D28B254F7A0C082B2C0D75", + "PreviousTxnLgrSeq": 3436 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "46B686335794B911B3B76C2F4B76AF424F9978C3E82B2F6488801C359AA71856", + "ledger_index": 3437, + "date": 1728575981000 +} diff --git a/src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT_Failure.json b/src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT_Failure.json new file mode 100644 index 000000000..b0c7d31c8 --- /dev/null +++ b/src/containers/shared/components/Transaction/Clawback/test/mock_data/ClawbackMPT_Failure.json @@ -0,0 +1,46 @@ +{ + "tx": { + "Account": "rnGVhdnWv7g3fW8UNJyFHj6eyngsMdwA8c", + "Amount": { + "mpt_issuance_id": "000010952ECE2AFC727F1C67EF568F360A2D92CB7C29FF7C", + "value": "50" + }, + "Fee": "10", + "Flags": 2147483648, + "MPTokenHolder": "r9rAqX8Jjo4uACsimYDVsy5thHDPivujqf", + "Sequence": 4246, + "SigningPubKey": "ED4F6FF2241860884D4DD6C5797BDA553155D194F07B1BFC67129F183322DA7DC3", + "TransactionType": "Clawback", + "TxnSignature": "C54F175F0AFD950507C059E0EA5E6FA0079E7CDE5DF62BB122B56DC34A351C2369E208B31F7C27B1E9D21753506E195B147500D033884C44373899C84A97680B", + "ctid": "C000109B00000000", + "date": 1728577343000 + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnGVhdnWv7g3fW8UNJyFHj6eyngsMdwA8c", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 4247 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "95A16157D164CD90D64BC94DE3EA7758AE3088391C9AC44AFCAC90C5153D83D5", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 4246 + }, + "PreviousTxnID": "8ACD0682CB1EDDCF6C61F15E6B9637D2719FDA2EC32EB384A68F36F0A0297D91", + "PreviousTxnLgrSeq": 4248 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tecINSUFFICIENT_FUNDS" + }, + "hash": "26E6E7AEA4F78801EB0408D802FEBA11B962BFD680501DF0D0C58F30C6EA8951", + "ledger_index": 4251, + "date": 1728577343000 +} diff --git a/src/containers/shared/components/Transaction/Clawback/types.ts b/src/containers/shared/components/Transaction/Clawback/types.ts index 81930d4c2..bbfaa9067 100644 --- a/src/containers/shared/components/Transaction/Clawback/types.ts +++ b/src/containers/shared/components/Transaction/Clawback/types.ts @@ -3,6 +3,7 @@ import { Amount, ExplorerAmount } from '../../../types' export interface Clawback extends TransactionCommonFields { Amount: Amount + MPTokenHolder?: string } export interface ClawbackInstructions { diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenAuthorize/Simple.tsx new file mode 100644 index 000000000..9823dfe4f --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/Simple.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next' +import { SimpleRow } from '../SimpleRow' +import { TransactionSimpleComponent, TransactionSimpleProps } from '../types' +import { MPTokenAuthorize } from './types' +import { Account } from '../../Account' +import { MPTokenLink } from '../../MPTokenLink' + +export const Simple: TransactionSimpleComponent = ({ + data, +}: TransactionSimpleProps) => { + const { MPTokenIssuanceID, MPTokenHolder } = data.instructions + const { t } = useTranslation() + + return ( + <> + + + + {MPTokenHolder && ( + + + + )} + + ) +} diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/index.ts b/src/containers/shared/components/Transaction/MPTokenAuthorize/index.ts new file mode 100644 index 000000000..707282fdf --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/index.ts @@ -0,0 +1,13 @@ +import { + TransactionAction, + TransactionCategory, + TransactionMapping, +} from '../types' + +import { Simple } from './Simple' + +export const MPTokenAuthorizeTransaction: TransactionMapping = { + Simple, + action: TransactionAction.MODIFY, + category: TransactionCategory.MPT, +} diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/test/MPTokenAuthorizeSimple.test.jsx b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/MPTokenAuthorizeSimple.test.jsx new file mode 100644 index 000000000..932a18cc1 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/MPTokenAuthorizeSimple.test.jsx @@ -0,0 +1,66 @@ +import { createSimpleWrapperFactory } from '../../test/createWrapperFactory' +import { Simple } from '../Simple' +import { expectSimpleRowText, expectSimpleRowNotToExist } from '../../test' +import transactionSuccess from './mock_data/MPTokenAuthorize.json' +import transactionFail from './mock_data/MPTokenAuthorize_Fail.json' +import transactionWithHolder from './mock_data/MPTokenAuthorize_WithHolder.json' +import transactionWithHolderFail from './mock_data/MPTokenAuthorize_WithHolderFail.json' + +const createWrapper = createSimpleWrapperFactory(Simple) + +describe('MPTokenAuthorize', () => { + it('handles MPTokenAuthorize w/o holder simple view ', () => { + const wrapper = createWrapper(transactionSuccess) + + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '000005F398B624EBD06822198649C920C8B20ADB8EBE745E', + ) + expectSimpleRowNotToExist(wrapper, 'mpt-holder') + wrapper.unmount() + }) + + it('handles MPTokenAuthorize view w/ holder simple view ', () => { + const wrapper = createWrapper(transactionWithHolder) + + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '0000130B63FC523E33FDF4D1318D8D484B0D1111098CFD0B', + ) + expectSimpleRowText( + wrapper, + 'mpt-holder', + 'rK3bB9myvWoMaLbLnpksGx2Zz58BL225am', + ) + wrapper.unmount() + }) + + it('handles failed MPTokenAuthorize view w/ holder simple view ', () => { + const wrapper = createWrapper(transactionWithHolderFail) + + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '00000F76D46440EE21F74E5B2398315BC1CFEB9A7EB48A14', + ) + expectSimpleRowText( + wrapper, + 'mpt-holder', + 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + ) + wrapper.unmount() + }) + + it('handles failed MPTokenAuthorize w/o holder simple view ', () => { + const wrapper = createWrapper(transactionFail) + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '0000098410531B842DEECCF4ABB1268C931EB71D9F6A1B64', + ) + expectSimpleRowNotToExist(wrapper, 'mpt-holder') + wrapper.unmount() + }) +}) diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize.json b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize.json new file mode 100644 index 000000000..e41a47854 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize.json @@ -0,0 +1,70 @@ +{ + "tx": { + "Account": "rnLz9TWQAvaLpdyrtb1WbMgp7jZdNQ47Ny", + "Fee": "10", + "Flags": 2147483648, + "MPTokenIssuanceID": "000005F398B624EBD06822198649C920C8B20ADB8EBE745E", + "Sequence": 1524, + "SigningPubKey": "ED97BAFB2D380AF67DA2C1968C3A1DC38797E9BA0653CE620F6BC97FFD66925EBB", + "TransactionType": "MPTokenAuthorize", + "TxnSignature": "28879892AF0D465063993BD1DDCA147C7CA0AB9C8429DAB3A0030AA4AC57AA80F725F295622913C07E4CAFB3160DEF7E8D0209429390B0FBD78F96B28E700A07", + "ctid": "C00005F600000000", + "date": 1711397033000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rnLz9TWQAvaLpdyrtb1WbMgp7jZdNQ47Ny", + "Balance": "99999990", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 1525 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "424AAE60FE8A4B7EF77DA492F9561AAFA1D09DB56BE5804B055235BCD662C9FE", + "PreviousFields": { + "Balance": "100000000", + "OwnerCount": 0, + "Sequence": 1524 + }, + "PreviousTxnID": "B6301327D79A93DC211043ABF66A60DC9C70BD2962FC42E0EAD0A829680ABAE8", + "PreviousTxnLgrSeq": 1524 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "65BCF554A41D30521B876D012D3DC167F9E886E02D88231E9DEBD2501A4A7BB5", + "NewFields": { + "Owner": "rnLz9TWQAvaLpdyrtb1WbMgp7jZdNQ47Ny", + "RootIndex": "65BCF554A41D30521B876D012D3DC167F9E886E02D88231E9DEBD2501A4A7BB5" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "91D261494BB3D64D5D3D12BD480EB58C5E2B21F3222B12FE442BC73276C27266", + "NewFields": { + "Account": "rnLz9TWQAvaLpdyrtb1WbMgp7jZdNQ47Ny", + "MPTokenIssuanceID": "000005F398B624EBD06822198649C920C8B20ADB8EBE745E" + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "9592E76A725CF4A5A441024EE80596DFE8809D1AD1EC28A8D9DB2CEC2CB81EDC", + "ledger_index": 1526, + "date": 1711397033000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_Fail.json b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_Fail.json new file mode 100644 index 000000000..8d305ae95 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_Fail.json @@ -0,0 +1,49 @@ +{ + "tx": { + "Account": "rJtok1j4okh4HkKxC3ArAvZbMD1vcDSteo", + "Fee": "10", + "Flags": 2147483648, + "MPTokenIssuanceID": "0000098410531B842DEECCF4ABB1268C931EB71D9F6A1B64", + "Sequence": 2438, + "SigningPubKey": "EDE493F0B7846A102A5C6EF4FDD9E95D1A84B0BEB99DED06C4436C0D61E5FA0B67", + "TransactionType": "MPTokenAuthorize", + "TxnSignature": "C903E4BA935BA27424D05CA230385FBA392CB4B48C136E37EF5DABE07814A2A8FA0AAEE34FCAB994D1AED7A663EB4CA121FA07B83E3C5A74289ABA258AA45F00", + "ctid": "C000098800000000", + "date": 1711398512000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rJtok1j4okh4HkKxC3ArAvZbMD1vcDSteo", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 2439 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "FE496E6B5CBE4778460846E5B93648B41E15463E691311EA4CD7E578561CA20E", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 2438 + }, + "PreviousTxnID": "26BB8D3B11AA0C967470DBA5D6B09A10608B4D1DADE0408668A45C010F4B8DDC", + "PreviousTxnLgrSeq": 2439 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tecMPTOKEN_EXISTS" + }, + "hash": "7B785C2D172D8FAE35DBBA66868D147747F32B5D6AA41F62D698E872643CE2B6", + "ledger_index": 2440, + "date": 1711398512000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolder.json b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolder.json new file mode 100644 index 000000000..3cce1b4e2 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolder.json @@ -0,0 +1,67 @@ +{ + "tx": { + "Account": "rwfgw2dWqUAexB46z5QRq2dJcgTK9piw5w", + "Fee": "10", + "Flags": 2147483648, + "MPTokenHolder": "rK3bB9myvWoMaLbLnpksGx2Zz58BL225am", + "MPTokenIssuanceID": "0000130B63FC523E33FDF4D1318D8D484B0D1111098CFD0B", + "Sequence": 4876, + "SigningPubKey": "ED936E848B8E37D20991C2E1C5C76ABAEC0625D693CEB85BA495B58E16712DA627", + "TransactionType": "MPTokenAuthorize", + "TxnSignature": "3F31AD3682B9261975E27895DFCB9F373C33C174A076445C33AE13A6713D7FC8C8305A4D05C4918979C9EAD0230A61CE9998B71BEE21653D6BFCCC65F599100E", + "ctid": "C000130F00000000", + "date": 1711400951000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rwfgw2dWqUAexB46z5QRq2dJcgTK9piw5w", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 4877 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "C688ECD4065B909634121581E792188424F29B48C062F1D1D4FED180DEAF3A23", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 4876 + }, + "PreviousTxnID": "D12E5ED52F495449A537DB9293174209CC132CDCD4EFBBACCEB7F8E8FC582BBC", + "PreviousTxnLgrSeq": 4877 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rK3bB9myvWoMaLbLnpksGx2Zz58BL225am", + "Flags": 2, + "MPTokenIssuanceID": "0000130B63FC523E33FDF4D1318D8D484B0D1111098CFD0B", + "OwnerNode": "0" + }, + "LedgerEntryType": "MPToken", + "LedgerIndex": "E6BC3F027146E5A2A50C01C37E7C5320E608C9D1D5BE763F32748865DB6EF3DE", + "PreviousFields": { + "Flags": 0 + }, + "PreviousTxnID": "FA5F2B8CE18C33D09E0243A2D20319AB9AF9D6CF5F1C2568B0CC4764DEC31F7A", + "PreviousTxnLgrSeq": 4878 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "5F92E78273BCF8A71E129F2CC9B8B0D5611E79D4CF81B530BF7B69892A579060", + "ledger_index": 4879, + "date": 1711400951000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolderFail.json b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolderFail.json new file mode 100644 index 000000000..69b7eaa8c --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/test/mock_data/MPTokenAuthorize_WithHolderFail.json @@ -0,0 +1,50 @@ +{ + "tx": { + "Account": "rL4pMQAa3V7s9QNw1wEk2znnhjbfYo4GQC", + "Fee": "10", + "Flags": 2147483648, + "MPTokenHolder": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "MPTokenIssuanceID": "00000F76D46440EE21F74E5B2398315BC1CFEB9A7EB48A14", + "Sequence": 3959, + "SigningPubKey": "EDF7A3D93CE3AA46168649283C20C2D4FC36642FDD87449F1CCF068638BF17B10E", + "TransactionType": "MPTokenAuthorize", + "TxnSignature": "2795F1DC9C54493ADE475800A67FD5B3BC7B65F4E343CEEA0950E994F0FC10D0DAED13B4B0FD345E92BFD2B4F42A09A44906D5B2CD1D8FD7A4B3D28983F51806", + "ctid": "C0000F7900000000", + "date": 1711400033000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rL4pMQAa3V7s9QNw1wEk2znnhjbfYo4GQC", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 3960 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "00BFE84169F6CAA5D03348856B57D47788B6856ABA9FA6EC7A16E6DA1B99B9D7", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 3959 + }, + "PreviousTxnID": "E07D68B9728EE8954C66219FF713782933612A7D5EF44B50F5485557629DFE3D", + "PreviousTxnLgrSeq": 3960 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tecNO_AUTH" + }, + "hash": "95AE2E382D6CFBFCECC012DDC52458E753FB9208A5040D2F441B5DE5BEA535CF", + "ledger_index": 3961, + "date": 1711400033000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenAuthorize/types.ts b/src/containers/shared/components/Transaction/MPTokenAuthorize/types.ts new file mode 100644 index 000000000..878129a9f --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenAuthorize/types.ts @@ -0,0 +1,6 @@ +import { TransactionCommonFields } from '../types' + +export interface MPTokenAuthorize extends TransactionCommonFields { + MPTokenIssuanceID: string + MPTokenHolder?: string +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx new file mode 100644 index 000000000..dc80d5625 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next' +import { SimpleRow } from '../SimpleRow' +import { TransactionSimpleComponent, TransactionSimpleProps } from '../types' +import { MPTokenIssuanceCreateInstructions } from './types' +import { useLanguage } from '../../../hooks' +import { localizeNumber } from '../../../utils' +import { MPTokenLink } from '../../MPTokenLink' + +export const Simple: TransactionSimpleComponent = ({ + data, +}: TransactionSimpleProps) => { + const { issuanceID, metadata, assetScale, transferFee, maxAmount } = + data.instructions + const { t } = useTranslation() + const language = useLanguage() + const formattedFee = + transferFee && + `${localizeNumber((transferFee / 1000).toPrecision(5), language, { + minimumFractionDigits: 3, + })}%` + + return ( + <> + {issuanceID && ( + + + + )} + {assetScale && ( + + {assetScale} + + )} + {transferFee && ( + + {formattedFee} + + )} + {maxAmount && ( + + {maxAmount} + + )} + {metadata && ( + + {metadata} + + )} + + ) +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/index.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/index.ts new file mode 100644 index 000000000..03275f8f7 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/index.ts @@ -0,0 +1,15 @@ +import { + TransactionAction, + TransactionCategory, + TransactionMapping, +} from '../types' + +import { Simple } from './Simple' +import { parser } from './parser' + +export const MPTokenIssuanceCreateTransaction: TransactionMapping = { + Simple, + action: TransactionAction.CREATE, + category: TransactionCategory.MPT, + parser, +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts new file mode 100644 index 000000000..d24770bc7 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts @@ -0,0 +1,21 @@ +import { + MPTokenIssuanceCreate, + MPTokenIssuanceCreateInstructions, +} from './types' +import { TransactionParser } from '../types' +import { convertHexToString } from '../../../../../rippled/lib/utils' + +export const parser: TransactionParser< + MPTokenIssuanceCreate, + MPTokenIssuanceCreateInstructions +> = (tx, meta) => ({ + issuanceID: meta.mpt_issuance_id, + metadata: tx.MPTokenMetadata + ? convertHexToString(tx.MPTokenMetadata) + : undefined, + transferFee: tx.TransferFee, + assetScale: tx.AssetScale, + maxAmount: tx.MaximumAmount + ? BigInt(tx.MaximumAmount).toString(10) + : undefined, +}) diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx new file mode 100644 index 000000000..0c82efab4 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx @@ -0,0 +1,27 @@ +import { createSimpleWrapperFactory } from '../../test/createWrapperFactory' +import { Simple } from '../Simple' +import { expectSimpleRowText } from '../../test' +import transactionSuccess from './mock_data/MPTokenIssuanceCreate.json' + +const createWrapper = createSimpleWrapperFactory(Simple) + +describe('MPTokenIssuanceCreate', () => { + it('handles MPTokenIssuanceCreate simple view ', () => { + const wrapper = createWrapper(transactionSuccess) + + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '0000157844C3F3B57A8B579FEE1033CC8E8498729D063617', + ) + expectSimpleRowText(wrapper, 'mpt-asset-scale', '2') + expectSimpleRowText(wrapper, 'mpt-max-amount', '9223372036854775807') + expectSimpleRowText( + wrapper, + 'mpt-metadata', + 'https://ipfs.io/ipfs/QmZnjmB9Tk4xaA9E679ytrPXda3beWMLUnMB5RFj1eStLp', + ) + expectSimpleRowText(wrapper, 'mpt-fee', '0.010%') + wrapper.unmount() + }) +}) diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate.json b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate.json new file mode 100644 index 000000000..2b5b91b7d --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate.json @@ -0,0 +1,79 @@ +{ + "tx": { + "Account": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "AssetScale": 2, + "Fee": "10", + "Flags": 34, + "MPTokenMetadata": "68747470733A2F2F697066732E696F2F697066732F516D5A6E6A6D4239546B34786141394536373979747250586461336265574D4C556E4D423552466A316553744C70", + "MaximumAmount": "9223372036854775807", + "Sequence": 5496, + "SigningPubKey": "EDAD408FAEE57EB4A347E6FE395B834DD47C6531C3C37B09ACC35528161CAD4B0E", + "TransactionType": "MPTokenIssuanceCreate", + "TransferFee": 10, + "TxnSignature": "F7AA8083EE7D7EFD10E11FF5A12B73D2D45A80094AEED4B41FBF2A90C9E03E5E4D162E91FA1EC156BF94E770E70E7633DF09665A5C2D5408178FC376BFC9B100", + "ctid": "C000157A00000000", + "date": 1710949602000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "Balance": "99999990", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 5497 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1996F74D57092C1AC261F55CB16A45A63C785993691869A431D72A5BF8AF47A0", + "PreviousFields": { + "Balance": "100000000", + "OwnerCount": 0, + "Sequence": 5496 + }, + "PreviousTxnID": "531254FC0F1599CCAF9ABCDBE0854B6BFBBA225ADD7CA341D2897CBDC3E78E5E", + "PreviousTxnLgrSeq": 5496 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "265CEA78D8246B8B51D5CCC20AF4DB95569DE09E53115C888B176A3D1D05048A", + "NewFields": { + "AssetScale": 2, + "Flags": 34, + "Issuer": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "MPTokenMetadata": "68747470733A2F2F697066732E696F2F697066732F516D5A6E6A6D4239546B34786141394536373979747250586461336265574D4C556E4D423552466A316553744C70", + "MaximumAmount": "9223372036854775807", + "Sequence": 5496, + "TransferFee": 10 + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "3A6F809498F6C0E3664BD8451BBAE5F972E45AF17537354D1C28F3A00B35BDFE", + "NewFields": { + "Owner": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "RootIndex": "3A6F809498F6C0E3664BD8451BBAE5F972E45AF17537354D1C28F3A00B35BDFE" + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "mpt_issuance_id": "0000157844C3F3B57A8B579FEE1033CC8E8498729D063617" + }, + "hash": "9686DA1322D2D8F9CD97C5848A8E3CADB9D3F73154DA59BB3A3CACC4CA43671C", + "ledger_index": 5498, + "date": 1710949602000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts new file mode 100644 index 000000000..2cce5aa21 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts @@ -0,0 +1,16 @@ +import { TransactionCommonFields } from '../types' + +export interface MPTokenIssuanceCreate extends TransactionCommonFields { + AssetScale?: number + MaximumAmount?: string + TransferFee?: number + MPTokenMetadata?: string +} + +export interface MPTokenIssuanceCreateInstructions { + issuanceID?: string + metadata?: string + transferFee?: number + assetScale?: number + maxAmount?: string +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/Simple.tsx new file mode 100644 index 000000000..2b9bb6676 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/Simple.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from 'react-i18next' +import { SimpleRow } from '../SimpleRow' +import { TransactionSimpleComponent, TransactionSimpleProps } from '../types' +import { MPTokenIssuanceDestroy } from './types' +import { MPTokenLink } from '../../MPTokenLink' + +export const Simple: TransactionSimpleComponent = ({ + data, +}: TransactionSimpleProps) => { + const { MPTokenIssuanceID } = data.instructions + const { t } = useTranslation() + + return ( + + + + ) +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/index.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/index.ts new file mode 100644 index 000000000..52f730f68 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/index.ts @@ -0,0 +1,13 @@ +import { + TransactionAction, + TransactionCategory, + TransactionMapping, +} from '../types' + +import { Simple } from './Simple' + +export const MPTokenIssuanceDestroyTransaction: TransactionMapping = { + Simple, + action: TransactionAction.CANCEL, + category: TransactionCategory.MPT, +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/MPTokenIssuanceDestroySimple.test.jsx b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/MPTokenIssuanceDestroySimple.test.jsx new file mode 100644 index 000000000..ee9a5b7c0 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/MPTokenIssuanceDestroySimple.test.jsx @@ -0,0 +1,29 @@ +import { createSimpleWrapperFactory } from '../../test/createWrapperFactory' +import { Simple } from '../Simple' +import { expectSimpleRowText } from '../../test' +import transactionSuccess from './mock_data/MPTokenIssuanceDestroy.json' +import transactionFail from './mock_data/MPTokenIssuanceDestroy_Fail.json' + +const createWrapper = createSimpleWrapperFactory(Simple) + +describe('MPTokenIssuanceDestroy', () => { + it('handles MPTokenIssuanceDestroy simple view ', () => { + const wrapper = createWrapper(transactionSuccess) + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '0000071E15A457415B9A921957CA1958F0E3B8A049BE8627', + ) + wrapper.unmount() + }) + + it('handles failed MPTokenIssuanceDestroy simple view ', () => { + const wrapper = createWrapper(transactionFail) + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '0000097E2ACB52C693EABBB156034140B2ED5E9522C4ACF4', + ) + wrapper.unmount() + }) +}) diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy.json b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy.json new file mode 100644 index 000000000..3ac12863b --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy.json @@ -0,0 +1,76 @@ +{ + "tx": { + "Account": "rpyShdZBMVC9p6tesouh97JEEWZgYGYTW1", + "Fee": "10", + "Flags": 2147483648, + "MPTokenIssuanceID": "0000071E15A457415B9A921957CA1958F0E3B8A049BE8627", + "Sequence": 1823, + "SigningPubKey": "ED31ED6E308C928DA72935A03526C3C5422353EB686908D3ADAD9D573921DBDFB5", + "TransactionType": "MPTokenIssuanceDestroy", + "TxnSignature": "432417F93E86710B50619E4B9EAA43A7F636546A70D4A854E619AF01108A400519A4022B7BABE4807263CDD7EB43217B9ABB8F9745B988981B1A556D11C83200", + "ctid": "C000072100000000", + "date": 1710968140000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rpyShdZBMVC9p6tesouh97JEEWZgYGYTW1", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 1824 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "27F062565B9B9226F10C5AA25E1AD5C3E70A6A93FF2AB4851614A2C43D083850", + "PreviousFields": { + "Balance": "99999990", + "OwnerCount": 1, + "Sequence": 1823 + }, + "PreviousTxnID": "E6DFD28EDD7213F43B7D0DB1296D09460458709583BEEA29F17C2F63B4DA9FC4", + "PreviousTxnLgrSeq": 1824 + } + }, + { + "DeletedNode": { + "FinalFields": { + "Flags": 0, + "Issuer": "rpyShdZBMVC9p6tesouh97JEEWZgYGYTW1", + "OutstandingAmount": "0", + "OwnerNode": "0", + "PreviousTxnID": "E6DFD28EDD7213F43B7D0DB1296D09460458709583BEEA29F17C2F63B4DA9FC4", + "PreviousTxnLgrSeq": 1824, + "Sequence": 1822 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "9C882DA4DF7B92FC968A0ADCA8BAFB7842264F98A5E147348C6E6077EAB24AA8" + } + }, + { + "DeletedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rpyShdZBMVC9p6tesouh97JEEWZgYGYTW1", + "RootIndex": "B8124B2AD6A85560C73E1748E2C8B0E5C0871F4439F10B042DD7E4017D864287" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "B8124B2AD6A85560C73E1748E2C8B0E5C0871F4439F10B042DD7E4017D864287" + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "9EB556D18BFB67F31C8716C7F3CBBB070E1E7B120DEDDC423D25DFAD850BD93A", + "ledger_index": 1825, + "date": 1710968140000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy_Fail.json b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy_Fail.json new file mode 100644 index 000000000..88b5cc651 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/test/mock_data/MPTokenIssuanceDestroy_Fail.json @@ -0,0 +1,49 @@ +{ + "tx": { + "Account": "rJbNXmT1uhmbghSQAFcgxAAN9yCRCu9y7g", + "Fee": "10", + "Flags": 2147483648, + "MPTokenIssuanceID": "0000097E2ACB52C693EABBB156034140B2ED5E9522C4ACF4", + "Sequence": 2431, + "SigningPubKey": "EDF5A4F08EDD12BB89658B8DE56558600342AD92D42FEDFAD682F4DAD9647EF5AA", + "TransactionType": "MPTokenIssuanceDestroy", + "TxnSignature": "C3B5F7D9A21A5EF85663058790DE7F458EA903C0010F3C2E1FEA45647052C2D85231ED4C17EB324A97AC2D4DE9A71F46D277B8A0A7AFB42D30A3EDA42E8E4106", + "ctid": "C000098100000000", + "date": 1710970247000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rJbNXmT1uhmbghSQAFcgxAAN9yCRCu9y7g", + "Balance": "99999990", + "Flags": 0, + "OwnerCount": 0, + "Sequence": 2432 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "BA6DF5388FF6BF026D3F8C91893534890F87309A32D30749CC70BCB1C6F1BEF6", + "PreviousFields": { + "Balance": "100000000", + "Sequence": 2431 + }, + "PreviousTxnID": "07F74CD4BD3E54410E436F9895BC2EC98D35E05F7584CB95D6660AF0411E9283", + "PreviousTxnLgrSeq": 2431 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tecNO_PERMISSION" + }, + "hash": "2F31C86C343B2D2DB3D8B01BDE84E5BFA0BCB86321365A16D93DF806B79B16FD", + "ledger_index": 2433, + "date": 1710970247000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/types.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/types.ts new file mode 100644 index 000000000..db9b812f9 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceDestroy/types.ts @@ -0,0 +1,5 @@ +import { TransactionCommonFields } from '../types' + +export interface MPTokenIssuanceDestroy extends TransactionCommonFields { + MPTokenIssuanceID: string +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx new file mode 100644 index 000000000..9f0b13ca8 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next' +import { SimpleRow } from '../SimpleRow' +import { TransactionSimpleComponent, TransactionSimpleProps } from '../types' +import { MPTokenIssuanceSet } from './types' +import { Account } from '../../Account' +import { MPTokenLink } from '../../MPTokenLink' + +export const Simple: TransactionSimpleComponent = ({ + data, +}: TransactionSimpleProps) => { + const { MPTokenIssuanceID, MPTokenHolder } = data.instructions + const { t } = useTranslation() + + return ( + <> + + + + {MPTokenHolder && ( + + + + )} + + ) +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/index.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/index.ts new file mode 100644 index 000000000..acbe8d389 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/index.ts @@ -0,0 +1,13 @@ +import { + TransactionAction, + TransactionCategory, + TransactionMapping, +} from '../types' + +import { Simple } from './Simple' + +export const MPTokenIssuanceSetTransaction: TransactionMapping = { + Simple, + action: TransactionAction.MODIFY, + category: TransactionCategory.MPT, +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/MPTokenIssuanceSetSimple.test.jsx b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/MPTokenIssuanceSetSimple.test.jsx new file mode 100644 index 000000000..2083e158c --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/MPTokenIssuanceSetSimple.test.jsx @@ -0,0 +1,53 @@ +import { createSimpleWrapperFactory } from '../../test/createWrapperFactory' +import { Simple } from '../Simple' +import { expectSimpleRowText, expectSimpleRowNotToExist } from '../../test' +import transactionSuccess from './mock_data/MPTokenIssuanceSet.json' +import transactionNoHolder from './mock_data/MPTokenIssuanceSet_NoHolder.json' +import transactionFail from './mock_data/MPTokenIssuanceSet_Fail.json' + +const createWrapper = createSimpleWrapperFactory(Simple) + +describe('MPTokenIssuanceSet', () => { + it('handles MPTokenIssuanceSet simple view ', () => { + const wrapper = createWrapper(transactionSuccess) + + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '00000BED9E4ADA3DCC1BE78683C4B623A74013818160590C', + ) + expectSimpleRowText( + wrapper, + 'mpt-holder', + 'r9hF4e3e6kLuxLobPwfQa2wzXZMDvBDeUg', + ) + wrapper.unmount() + }) + + it('handles MPTokenIssuanceSet simple view w/o holder ', () => { + const wrapper = createWrapper(transactionNoHolder) + + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '000002609BB39CEC721B5AB337B6BD862ACD2811CBBB5F18', + ) + expectSimpleRowNotToExist(wrapper, 'mpt-holder') + wrapper.unmount() + }) + + it('handles failed MPTokenIssuanceSet simple view ', () => { + const wrapper = createWrapper(transactionFail) + expectSimpleRowText( + wrapper, + 'mpt-issuance-id', + '00000F83146C83112AED215CD345F8E7327459BFCF6B8062', + ) + expectSimpleRowText( + wrapper, + 'mpt-holder', + 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + ) + wrapper.unmount() + }) +}) diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet.json b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet.json new file mode 100644 index 000000000..e78513a6c --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet.json @@ -0,0 +1,67 @@ +{ + "tx": { + "Account": "rERyS9qtwky94UMMjjmbku3uo5aQwAoJ58", + "Fee": "10", + "Flags": 1, + "MPTokenHolder": "r9hF4e3e6kLuxLobPwfQa2wzXZMDvBDeUg", + "MPTokenIssuanceID": "00000BED9E4ADA3DCC1BE78683C4B623A74013818160590C", + "Sequence": 3054, + "SigningPubKey": "EDF73A1C528F5BFBD6FF2B05D0C71760D7D2DF1DE3496935612E47BCB440F28040", + "TransactionType": "MPTokenIssuanceSet", + "TxnSignature": "5BC9ABE91A10F86440E301F17DFADD08D2E55E0699441372BE73D843B4481869BD6224ED461BC4F9894E97F1F99562D3CF2CD1A4E991BD7993DC24EDA63F5B05", + "ctid": "C0000BF100000000", + "date": 1711047580000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "r9hF4e3e6kLuxLobPwfQa2wzXZMDvBDeUg", + "Flags": 1, + "MPTokenIssuanceID": "00000BED9E4ADA3DCC1BE78683C4B623A74013818160590C", + "OwnerNode": "0" + }, + "LedgerEntryType": "MPToken", + "LedgerIndex": "DDA698915F22D7CEA45896CB70DCC0DF803E1F573B92B6F0178F1688208EED04", + "PreviousFields": { + "Flags": 0 + }, + "PreviousTxnID": "39709CA66D9103354D09070234A14253EC779846BB73477EEB21C5A65144C844", + "PreviousTxnLgrSeq": 3056 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rERyS9qtwky94UMMjjmbku3uo5aQwAoJ58", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 3055 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "EDD52DA05DAB16BAF6A3B7D47CCB9FEAB7AAC2BDD9CB007F6A3B8E0DBCE50A45", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 3054 + }, + "PreviousTxnID": "41A99F7E107F813C132B105AB930FD8C6960530DDFA5D98FCEF5A5600DA39D38", + "PreviousTxnLgrSeq": 3055 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "4993E5B875E0217ABC92EFC395805F1344D8A9A3D75437EEA457C05EDB3AB20B", + "ledger_index": 3057, + "date": 1711047580000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Fail.json b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Fail.json new file mode 100644 index 000000000..60062ffc1 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Fail.json @@ -0,0 +1,50 @@ +{ + "tx": { + "Account": "rpizWPf4g8JLWFUT7143Zn9A1n2Dy9bnji", + "Fee": "10", + "Flags": 1, + "MPTokenHolder": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "MPTokenIssuanceID": "00000F83146C83112AED215CD345F8E7327459BFCF6B8062", + "Sequence": 3972, + "SigningPubKey": "ED4EC06184C745D99AEAAA16526C900DC181C8546899F462C3D105C11A6677A65A", + "TransactionType": "MPTokenIssuanceSet", + "TxnSignature": "2A9D1795983016A162F05CEBBD35E65B955BD67AB96B8A2DB2E31027EA67DDF082B1C4D67F9219CBF9893520ACB0ACB80E2ED3CE96496AA4668BCCF4A46EAE0A", + "ctid": "C0000F8700000000", + "date": 1711048704000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rpizWPf4g8JLWFUT7143Zn9A1n2Dy9bnji", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 3973 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "F937A006AB86775D475946D43BA1612F3BC24A2C144550D4EC0266C2F08303D5", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 3972 + }, + "PreviousTxnID": "8D322CBF8A965E64FB903551C80A2E1DF9E2480A7AD0B1D98BE56574BC9FE215", + "PreviousTxnLgrSeq": 3973 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tecOBJECT_NOT_FOUND" + }, + "hash": "E47D5242B7E210B9E1DAEFF90DC19DA9310C04CF4444C76F2C76A44533EEC48F", + "ledger_index": 3975, + "date": 1711048704000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_NoHolder.json b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_NoHolder.json new file mode 100644 index 000000000..165596d2a --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_NoHolder.json @@ -0,0 +1,67 @@ +{ + "tx": { + "Account": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "Fee": "10", + "Flags": 1, + "MPTokenIssuanceID": "000002609BB39CEC721B5AB337B6BD862ACD2811CBBB5F18", + "Sequence": 609, + "SigningPubKey": "ED92DDE49AA689EC63589623067968B85E4885A9874B3CAB89E07D192EBFA42FF9", + "TransactionType": "MPTokenIssuanceSet", + "TxnSignature": "6678BF017A62360DAA39156112960D934440467D2E4098958F053B6758A2D237DA82CD27FDA3B35BAB610D74669BF6C4DEB12D77984E48158B2D79C8BAEC3303", + "ctid": "C000026400000000", + "date": 1711133159000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 610 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1D54D263727856612FC7C7A27D93532ED0C41B74FA651992C125AD19C900669D", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 609 + }, + "PreviousTxnID": "CF68A8D929F089F6F53B071250935DD7C3F52F6E175D82E53118265D776D4BF7", + "PreviousTxnLgrSeq": 610 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 35, + "Issuer": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "OutstandingAmount": "0", + "OwnerNode": "0", + "Sequence": 608 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "E8DF796110B1FBD9086A4637491E49843D33D897FBC32F03A33140F76378EE86", + "PreviousFields": { + "Flags": 34 + }, + "PreviousTxnID": "CF68A8D929F089F6F53B071250935DD7C3F52F6E175D82E53118265D776D4BF7", + "PreviousTxnLgrSeq": 610 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "73C21D8B5DFBF5DE03FCAF0D69C00E9E2918280561E9898C556A1C743A566D47", + "ledger_index": 612, + "date": 1711133159000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/types.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/types.ts new file mode 100644 index 000000000..5e15d49e8 --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/types.ts @@ -0,0 +1,6 @@ +import { TransactionCommonFields } from '../types' + +export interface MPTokenIssuanceSet extends TransactionCommonFields { + MPTokenIssuanceID: string + MPTokenHolder?: string +} diff --git a/src/containers/shared/components/Transaction/NFTokenAcceptOffer/types.ts b/src/containers/shared/components/Transaction/NFTokenAcceptOffer/types.ts index 249a9632d..5091a827e 100644 --- a/src/containers/shared/components/Transaction/NFTokenAcceptOffer/types.ts +++ b/src/containers/shared/components/Transaction/NFTokenAcceptOffer/types.ts @@ -1,6 +1,8 @@ +import { ExplorerAmount } from '../../../types' + export interface NFTokenAcceptOfferInstructions { acceptedOfferIDs: string[] - amount?: { currency: string; amount: number; issuer?: string } + amount?: ExplorerAmount tokenID?: string seller?: string buyer?: string diff --git a/src/containers/shared/components/Transaction/NFTokenCancelOffer/test/NFTokenCancelOfferSimple.test.jsx b/src/containers/shared/components/Transaction/NFTokenCancelOffer/test/NFTokenCancelOfferSimple.test.jsx index b16576254..ec58e6280 100644 --- a/src/containers/shared/components/Transaction/NFTokenCancelOffer/test/NFTokenCancelOfferSimple.test.jsx +++ b/src/containers/shared/components/Transaction/NFTokenCancelOffer/test/NFTokenCancelOfferSimple.test.jsx @@ -1,21 +1,25 @@ import { BrowserRouter as Router } from 'react-router-dom' import { mount } from 'enzyme' import { I18nextProvider } from 'react-i18next' +import { QueryClientProvider } from 'react-query' import { Simple as NFTokenCancelOffer } from '../Simple' import transaction from './mock_data/NFTokenCancelOffer.json' import summarizeTransaction from '../../../../../../rippled/lib/txSummary' import i18n from '../../../../../../i18n/testConfig' +import { queryClient } from '../../../../QueryClient' describe('NFTokenCancelOffer', () => { it.only('handles NFTokenCancelOffer simple view ', () => { const wrapper = mount( - - - - - , + + + + + + + , ) expect(wrapper.find('[data-test="token-id"] .value')).toHaveText( '000800006203F49C21D5D6E022CB16DE3538F248662FC73C258BA1B200000018', diff --git a/src/containers/shared/components/Transaction/NFTokenCreateOffer/types.ts b/src/containers/shared/components/Transaction/NFTokenCreateOffer/types.ts index 624c2dc47..e25ed0359 100644 --- a/src/containers/shared/components/Transaction/NFTokenCreateOffer/types.ts +++ b/src/containers/shared/components/Transaction/NFTokenCreateOffer/types.ts @@ -1,6 +1,8 @@ +import { ExplorerAmount } from '../../../types' + export interface NFTokenCreateOfferInstructions { account: string - amount: { currency: string; amount: number; issuer?: string } + amount: ExplorerAmount tokenID: string isSellOffer: boolean owner?: string diff --git a/src/containers/shared/components/Transaction/OfferCreate/parser.ts b/src/containers/shared/components/Transaction/OfferCreate/parser.ts index e895c2b5c..238bfd6a5 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/parser.ts +++ b/src/containers/shared/components/Transaction/OfferCreate/parser.ts @@ -6,7 +6,7 @@ export function parser(tx: any) { const base = tx.TakerGets.currency ? tx.TakerGets : { currency: 'XRP' } const counter = tx.TakerPays.currency ? tx.TakerPays : { currency: 'XRP' } const pays = formatAmount(tx.TakerPays) - const price = pays.amount / gets.amount + const price = Number(pays.amount) / Number(gets.amount) const invert = CURRENCY_ORDER.indexOf(counter.currency) > CURRENCY_ORDER.indexOf(base.currency) diff --git a/src/containers/shared/components/Transaction/OracleSet/parser.ts b/src/containers/shared/components/Transaction/OracleSet/parser.ts index bca503f5f..b27a3ba04 100644 --- a/src/containers/shared/components/Transaction/OracleSet/parser.ts +++ b/src/containers/shared/components/Transaction/OracleSet/parser.ts @@ -1,19 +1,6 @@ import { convertHexToString } from '../../../../../rippled/lib/utils' import { OracleSet } from './types' - -// Convert scaled price (assetPrice) to original price using formula: -// originalPrice = assetPrice / 10**scale -// More details: https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-47d-PriceOracles -export function convertScaledPrice(assetPrice: string, scale: number) { - const scaledPriceInBigInt = BigInt(`0x${assetPrice}`) - const divisor = BigInt(10 ** scale) - const integerPart = scaledPriceInBigInt / divisor - const remainder = scaledPriceInBigInt % divisor - const fractionalPart = (remainder * BigInt(10 ** scale)) / divisor - return fractionalPart > 0 - ? `${integerPart}.${fractionalPart.toString().padStart(scale, '0')}` - : `${integerPart}` -} +import { convertScaledPrice } from '../../../utils' export function parser(tx: OracleSet) { const priceDataSeries = tx.PriceDataSeries.map((priceDataObj) => ({ diff --git a/src/containers/shared/components/Transaction/OracleSet/test/ConvertScalePrice.test.ts b/src/containers/shared/components/Transaction/OracleSet/test/ConvertScalePrice.test.ts index 984c63e2b..2437a40c9 100644 --- a/src/containers/shared/components/Transaction/OracleSet/test/ConvertScalePrice.test.ts +++ b/src/containers/shared/components/Transaction/OracleSet/test/ConvertScalePrice.test.ts @@ -1,4 +1,4 @@ -import { convertScaledPrice } from '../parser' +import { convertScaledPrice } from '../../../../utils' const numberToHex = (number) => number.toString(16) describe('convertScaledPrice', () => { diff --git a/src/containers/shared/components/Transaction/Payment/parser.ts b/src/containers/shared/components/Transaction/Payment/parser.ts index 743c05367..57dc16157 100644 --- a/src/containers/shared/components/Transaction/Payment/parser.ts +++ b/src/containers/shared/components/Transaction/Payment/parser.ts @@ -1,4 +1,4 @@ -import type { Payment } from 'xrpl' +// import type { Payment } from 'xrpl' import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount' import { PaymentInstructions } from './types' import { Amount, ExplorerAmount } from '../../../types' @@ -10,7 +10,8 @@ const formatFailedPartialAmount = (d: Amount): ExplorerAmount => ({ export const isPartialPayment = (flags: any) => 0x00020000 & flags -export const parser = (tx: Payment, meta: any): PaymentInstructions => { +// TODO: use MPTAmount type from xrpl.js +export const parser = (tx: any, meta: any): PaymentInstructions => { const max = tx.SendMax ? formatAmount(tx.SendMax) : undefined const partial = !!isPartialPayment(tx.Flags) const failedPartial = partial && meta.TransactionResult !== 'tesSUCCESS' diff --git a/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx b/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx index 60c9ab4b3..5166c919f 100644 --- a/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx +++ b/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx @@ -1,3 +1,4 @@ +import { useQuery } from 'react-query' import { createSimpleWrapperFactory, expectSimpleRowLabel, @@ -10,6 +11,12 @@ import mockPaymentDestinationTag from './mock_data/PaymentWithDestinationTag.jso import mockPaymentPartial from './mock_data/PaymentWithPartial.json' import mockPaymentSendMax from './mock_data/PaymentWithSendMax.json' import mockPaymentSourceTag from './mock_data/PaymentWithSourceTag.json' +import mockPaymentMPT from './mock_data/PaymentMPT.json' + +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQuery: jest.fn(), +})) const createWrapper = createSimpleWrapperFactory(Simple) @@ -114,4 +121,32 @@ describe('Payment: Simple', () => { wrapper.unmount() }) + + it('renders direct MPT payment', () => { + const data = { + assetScale: 3, + } + + // @ts-ignore + useQuery.mockImplementation(() => ({ + data, + })) + + const wrapper = createWrapper(mockPaymentMPT) + + expectSimpleRowText( + wrapper, + 'amount', + `0.1 MPT (000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F)`, + ) + expectSimpleRowLabel(wrapper, 'amount', `send`) + + expectSimpleRowText( + wrapper, + 'destination', + `rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu`, + ) + + wrapper.unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/Payment/test/mock_data/PaymentMPT.json b/src/containers/shared/components/Transaction/Payment/test/mock_data/PaymentMPT.json new file mode 100644 index 000000000..d35339b88 --- /dev/null +++ b/src/containers/shared/components/Transaction/Payment/test/mock_data/PaymentMPT.json @@ -0,0 +1,91 @@ +{ + "tx": { + "Account": "rsC4dnxCb66FQT4XmCUeuQ7dYeqNio4rWg", + "Amount": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "100" + }, + "DeliverMax": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "100" + }, + "Destination": "rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu", + "Fee": "10", + "Flags": 2147483648, + "Sequence": 964, + "SigningPubKey": "ED35B07F41420220332C35B9F4D1F7AF26E67EBD5AD6C9E106D0F774DA15924169", + "TransactionType": "Payment", + "TxnSignature": "2C5CB9740457222F928667DC1196060EF7E61B4E3A8824727AE63ACCFDE35ED5CBEE69E982423592DF0464C57C2C445B4271573DE7A3346630024287844F2502", + "ctid": "C00003C900000000", + "date": 1727802036000 + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "AssetScale": 3, + "Flags": 34, + "Issuer": "rsC4dnxCb66FQT4XmCUeuQ7dYeqNio4rWg", + "MPTokenMetadata": "7B226E616D65223A2255532054726561737572792042696C6C20546F6B656E222C2273796D626F6C223A225553544254222C22646563696D616C73223A322C22746F74616C537570706C79223A313030303030302C22697373756572223A225553205472656173757279222C22697373756544617465223A22323032342D30332D3235222C226D6174757269747944617465223A22323032352D30332D3235222C226661636556616C7565223A2231303030222C22696E74657265737452617465223A22322E35222C22696E7465726573744672657175656E6379223A22517561727465726C79222C22636F6C6C61746572616C223A22555320476F7665726E6D656E74222C226A7572697364696374696F6E223A22556E6974656420537461746573222C22726567756C61746F7279436F6D706C69616E6365223A2253454320526567756C6174696F6E73222C22736563757269747954797065223A2254726561737572792042696C6C222C2265787465726E616C5F75726C223A2268747470733A2F2F6578616D706C652E636F6D2F742D62696C6C2D746F6B656E2D6D657461646174612E6A736F6E227D", + "MaximumAmount": "9223372036854775807", + "OutstandingAmount": "100", + "OwnerNode": "0", + "Sequence": 963 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "1CFF89335B544E0D6EEC35D74C0D26FF407DC02670F1C4E35A36CC875D34B1C3", + "PreviousFields": { + "OutstandingAmount": "0" + }, + "PreviousTxnID": "6329586F264E4A6E2224318DCFC9B5F28048D84060B78A92CFFE65840DF8D970", + "PreviousTxnLgrSeq": 966 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu", + "Flags": 0, + "MPTAmount": "100", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "OwnerNode": "0" + }, + "LedgerEntryType": "MPToken", + "LedgerIndex": "3BAA73912496683A414494218D3CCA33D02F80D588F80C1257C691448E00E486", + "PreviousFields": {}, + "PreviousTxnID": "60F99C8A23C4A366D19F43EA4BD43414AD4D4B7C21D0228FB7539D1C893E4A74", + "PreviousTxnLgrSeq": 967 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rsC4dnxCb66FQT4XmCUeuQ7dYeqNio4rWg", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 965 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "77ECC02B8A7F16EB19A7BFBE8494E959497B4EC7734088583BD4F6B8C82878A5", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 964 + }, + "PreviousTxnID": "6329586F264E4A6E2224318DCFC9B5F28048D84060B78A92CFFE65840DF8D970", + "PreviousTxnLgrSeq": 966 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "100" + } + }, + "hash": "CD9EC015E68D3027919598E0466CFEF19950D0BC688A568DF8822A8BB0AFF98F", + "ledger_index": 11707, + "date": 1712072515000 +} diff --git a/src/containers/shared/components/Transaction/index.ts b/src/containers/shared/components/Transaction/index.ts index babf265fc..c483a22cb 100644 --- a/src/containers/shared/components/Transaction/index.ts +++ b/src/containers/shared/components/Transaction/index.ts @@ -9,6 +9,10 @@ import { AccountSetTransaction as AccountSet } from './AccountSet' import { DIDSetTransaction as DIDSet } from './DIDSet' import { DepositPreauthTransaction as DepositPreauth } from './DepositPreauth' import { EnableAmendmentTransaction as EnableAmendment } from './EnableAmendment' +import { MPTokenAuthorizeTransaction as MPTokenAuthorize } from './MPTokenAuthorize' +import { MPTokenIssuanceCreateTransaction as MPTokenIssuanceCreate } from './MPTokenIssuanceCreate' +import { MPTokenIssuanceDestroyTransaction as MPTokenIssuanceDestroy } from './MPTokenIssuanceDestroy' +import { MPTokenIssuanceSetTransaction as MPTokenIssuanceSet } from './MPTokenIssuanceSet' import { NFTokenMintTransaction as NFTokenMint } from './NFTokenMint' import { NFTokenCancelOfferTransaction as NFTokenCancelOffer } from './NFTokenCancelOffer' import { NFTokenBurnTransaction as NFTokenBurn } from './NFTokenBurn' @@ -55,6 +59,10 @@ export const transactionTypes: { [key: string]: TransactionMapping } = { DIDSet, DepositPreauth, EnableAmendment, + MPTokenAuthorize, + MPTokenIssuanceCreate, + MPTokenIssuanceDestroy, + MPTokenIssuanceSet, NFTokenMint, NFTokenCancelOffer, NFTokenBurn, diff --git a/src/containers/shared/components/Transaction/test/createWrapperFactory.tsx b/src/containers/shared/components/Transaction/test/createWrapperFactory.tsx index 709d242ee..9c93c4a01 100644 --- a/src/containers/shared/components/Transaction/test/createWrapperFactory.tsx +++ b/src/containers/shared/components/Transaction/test/createWrapperFactory.tsx @@ -3,6 +3,7 @@ import { ReactElement } from 'react' import { I18nextProvider } from 'react-i18next' import { BrowserRouter } from 'react-router-dom' import { i18n } from 'i18next' +import { QueryClientProvider } from 'react-query' import defaultI18nConfig from '../../../../../i18n/testConfig' import summarizeTransaction from '../../../../../rippled/lib/txSummary' import { @@ -10,6 +11,7 @@ import { TransactionSimpleComponent, TransactionTableDetailComponent, } from '../types' +import { testQueryClient } from '../../../../test/QueryClient' /** * Methods that produce createWrapper function for tests @@ -22,9 +24,11 @@ export function createWrapper( i18nConfig?: i18n, ): ReactWrapper { return mount( - - {TestComponent} - , + + + {TestComponent} + + , ) } diff --git a/src/containers/shared/components/Transaction/types.ts b/src/containers/shared/components/Transaction/types.ts index d1154be4a..722687b69 100644 --- a/src/containers/shared/components/Transaction/types.ts +++ b/src/containers/shared/components/Transaction/types.ts @@ -8,6 +8,7 @@ export enum TransactionCategory { PAYMENT = 'PAYMENT', NFT = 'NFT', XCHAIN = 'XCHAIN', + MPT = 'MPT', PSEUDO = 'PSEUDO', UNKNOWN = 'UNKNOWN', } diff --git a/src/containers/shared/components/TransactionTable/test/TransactionTable.test.js b/src/containers/shared/components/TransactionTable/test/TransactionTable.test.js index 54517c301..734e4f0d5 100644 --- a/src/containers/shared/components/TransactionTable/test/TransactionTable.test.js +++ b/src/containers/shared/components/TransactionTable/test/TransactionTable.test.js @@ -1,9 +1,11 @@ import { mount } from 'enzyme' import { I18nextProvider } from 'react-i18next' import { BrowserRouter } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' import { TransactionTable } from '../TransactionTable' import i18n from '../../../../../i18n/testConfig' import mockTx from './mockTransactions.json' +import { queryClient } from '../../../QueryClient' const loadMore = jest.fn() @@ -16,17 +18,19 @@ describe('Transaction Table container', () => { hasAdditionalResults = false, ) => mount( - - - - - , + + + + + + + , ) it('renders without crashing', () => { diff --git a/src/containers/shared/components/test/Amount.test.tsx b/src/containers/shared/components/test/Amount.test.tsx index acbe14315..749f62033 100644 --- a/src/containers/shared/components/test/Amount.test.tsx +++ b/src/containers/shared/components/test/Amount.test.tsx @@ -1,9 +1,15 @@ import { I18nextProvider } from 'react-i18next' import { BrowserRouter } from 'react-router-dom' import { mount } from 'enzyme' +import { useQuery } from 'react-query' import { Amount } from '../Amount' import i18n from '../../../../i18n/testConfig' +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQuery: jest.fn(), +})) + describe('Amount', () => { const createWrapper = (component: JSX.Element) => mount( @@ -117,4 +123,34 @@ describe('Amount', () => { expect(wrapper.find('.amount-localized').text()).toEqual('+\uE9000.009') wrapper.unmount() }) + + it('handles MPT amount', async () => { + const data = { + issuer: 'rL2LzUhsBJMqsaVCXVvzedPjePbjVzBCC', + assetScale: 3, + maxAmt: '100000000', + outstandingAmt: '1043001', + sequence: 2447, + metadata: + '{"name":"US Treasury Bill Token","symbol":"USTBT","decimals":2,"totalSupply":1000000,"issuer":"US Treasury","issueDate":"2024-03-25","maturityDate":"2025-03-25","faceValue":"1000","interestRate":"2.5","interestFrequency":"Quarterly","collateral":"US Government","jurisdiction":"United States","regulatoryCompliance":"SEC Regulations","securityType":"Treasury Bill","external_url":"https://example.com/t-bill-token-metadata.json"}', + flags: [], + } + + // @ts-ignore + useQuery.mockImplementation(() => ({ + data, + })) + + const value = { + amount: '1043001', + currency: '0000098F03B3BCE934EE8CAA1DF25A42032388361B9E5A65', + isMPT: true, + } + const wrapper = createWrapper( + , + ) + + expect(wrapper.find('.amount-localized').text()).toEqual('1,043.001') + wrapper.unmount() + }) }) diff --git a/src/containers/shared/components/test/Currency.test.tsx b/src/containers/shared/components/test/Currency.test.tsx index e3d218090..8d7de259c 100644 --- a/src/containers/shared/components/test/Currency.test.tsx +++ b/src/containers/shared/components/test/Currency.test.tsx @@ -63,4 +63,26 @@ describe('Currency', () => { expect(wrapper.find('.currency').text()).toEqual('\uE900 XRP') wrapper.unmount() }) + + it('handles MPT ID ', () => { + const wrapper = mount( + + + , + ) + const mpt = wrapper.find('.currency').at(0) + + expect(mpt).toHaveText( + 'MPT (00000BDE5B4F868ECE457207E2C1750065987730B8839E0D)', + ) + expect(mpt.find('a')).toHaveProp( + 'href', + '/mpt/00000BDE5B4F868ECE457207E2C1750065987730B8839E0D', + ) + wrapper.unmount() + }) }) diff --git a/src/containers/shared/css/global.scss b/src/containers/shared/css/global.scss index 327c74065..8d7bf9b0d 100644 --- a/src/containers/shared/css/global.scss +++ b/src/containers/shared/css/global.scss @@ -121,6 +121,7 @@ div.react-stockchart div { @include transaction-category(XCHAIN, $yellow, $yellow-30, $yellow-90); @include transaction-category(PSEUDO, $white, $white, $black-80); @include transaction-category(UNKNOWN, $black-50, $black-30, $black-90); +@include transaction-category(MPT, $blue, $blue-30, $blue-90); .tx-result { &.success { diff --git a/src/containers/shared/css/variables.scss b/src/containers/shared/css/variables.scss index a1987f372..91b259bed 100644 --- a/src/containers/shared/css/variables.scss +++ b/src/containers/shared/css/variables.scss @@ -120,6 +120,7 @@ $custom: $yellow-50; // Feature Sets $amm: $blue; $nft: $blue-purple; +$mpt: $blue; // Currency colors $CURRENCY_DEFAULT: #aedbf7; diff --git a/src/containers/shared/transactionUtils.ts b/src/containers/shared/transactionUtils.ts index e39c28ef1..0ccd26bd9 100644 --- a/src/containers/shared/transactionUtils.ts +++ b/src/containers/shared/transactionUtils.ts @@ -41,6 +41,21 @@ export const TX_FLAGS: Record> = { 0x00200000: 'tfOneAssetLPToken', 0x00400000: 'tfLimitLPToken', }, + MPTokenAuthorize: { + 0x00000001: 'tfMPTUnauthorize', + }, + MPTokenIssuanceCreate: { + 0x00000002: 'tfMPTCanLock', + 0x00000004: 'tfMPTRequireAuth', + 0x00000008: 'tfMPTCanEscrow', + 0x00000010: 'tfMPTCanTrade', + 0x00000020: 'tfMPTCanTransfer', + 0x00000040: 'tfMPTCanClawback', + }, + MPTokenIssuanceSet: { + 0x00000001: 'tfMPTLock', + 0x00000002: 'tfMPTUnlock', + }, NFTokenMint: { 0x00000001: 'tfBurnable', 0x00000002: 'tfOnlyXRP', diff --git a/src/containers/shared/types.ts b/src/containers/shared/types.ts index d219bc63e..3ae210e27 100644 --- a/src/containers/shared/types.ts +++ b/src/containers/shared/types.ts @@ -17,12 +17,18 @@ export interface IssuedCurrencyAmount extends IssuedCurrency { value: string } -export type Amount = IssuedCurrencyAmount | string +export interface MPTAmount { + mpt_issuance_id: string + value: string +} + +export type Amount = IssuedCurrencyAmount | MPTAmount | string export type ExplorerAmount = { issuer?: string currency: string - amount: number + amount: number | string + isMPT?: boolean } export interface Tx { diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js index bdeae56e2..ac510adeb 100644 --- a/src/containers/shared/utils.js +++ b/src/containers/shared/utils.js @@ -23,7 +23,8 @@ export const FETCH_INTERVAL_NODES_MILLIS = 60000 export const FETCH_INTERVAL_ERROR_MILLIS = 300 export const DECIMAL_REGEX = /^\d+$/ -export const HASH_REGEX = /[0-9A-Fa-f]{64}/i +export const HASH256_REGEX = /[0-9A-Fa-f]{64}/i +export const HASH192_REGEX = /[0-9A-Fa-f]{48}/i export const CURRENCY_REGEX = /^[a-zA-Z0-9]{3,}[.:+-]r[rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz]{27,35}$/ export const FULL_CURRENCY_REGEX = @@ -129,14 +130,19 @@ export const isEarlierVersion = (source, target) => { } // Document: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat -export const localizeNumber = (num, lang = 'en-US', options = {}) => { +export const localizeNumber = ( + num, + lang = 'en-US', + options = {}, + isMPT = false, +) => { const number = Number.parseFloat(num) const config = { ...NUMBER_DEFAULT_OPTIONS, ...options } if (Number.isNaN(number)) { return null } - if (config.style === 'currency') { + if (config.style === 'currency' && !isMPT) { try { const neg = number < 0 ? '-' : '' const d = new Intl.NumberFormat(lang, config).format(number) @@ -255,6 +261,8 @@ export const formatLargeNumber = (d = 0, digits = 4) => { } } +export const convertHexToBigInt = (s) => BigInt(`0x${s}`) + export const durationToHuman = (s, decimal = 2) => { const d = {} const seconds = Math.abs(s) @@ -301,7 +309,7 @@ export const formatTradingFee = (tradingFee) => }) : undefined -export const computeBalanceChange = (node) => { +export const computeRippleStateBalanceChange = (node) => { const fields = node.FinalFields || node.NewFields const prev = node.PreviousFields const { currency } = fields.Balance @@ -333,7 +341,50 @@ export const computeBalanceChange = (node) => { } } +export const computeMPTokenBalanceChange = (node) => { + const final = node.FinalFields || node.NewFields + const prev = node.PreviousFields + const prevAmount = prev && prev.MPTAmount ? prev.MPTAmount : '0' + const finalAmount = final.MPTAmount ?? '0' + + return { + previousBalance: BigInt(prevAmount), + finalBalance: BigInt(finalAmount), + account: final.Account, + change: BigInt(finalAmount) - BigInt(prevAmount), + } +} + +export const computeMPTIssuanceBalanceChange = (node) => { + const final = node.FinalFields || node.NewFields + const prev = node.PreviousFields + const prevAmount = + prev && prev.OutstandingAmount ? prev.OutstandingAmount : '0' + const finalAmount = final.OutstandingAmount ?? '0' + + return { + previousBalance: BigInt(prevAmount), + finalBalance: BigInt(finalAmount), + account: final.Issuer, + change: BigInt(finalAmount) - BigInt(prevAmount), + } +} + export const renderXRP = (d, language) => { const options = { ...CURRENCY_OPTIONS, currency: 'XRP' } return localizeNumber(d, language, options) } + +// Convert scaled price (assetPrice) in hex string to original price using formula: +// originalPrice = assetPrice / 10**scale +// More details: https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-47d-PriceOracles +export function convertScaledPrice(assetPrice, scale) { + const scaledPriceInBigInt = BigInt(`0x${assetPrice}`) + const divisor = BigInt(10 ** scale) + const integerPart = scaledPriceInBigInt / divisor + const remainder = scaledPriceInBigInt % divisor + const fractionalPart = (remainder * BigInt(10 ** scale)) / divisor + return fractionalPart > 0 + ? `${integerPart}.${fractionalPart.toString().padStart(scale, '0')}` + : `${integerPart}` +} diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index 598a57e63..c77c6584d 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -1,4 +1,4 @@ -import { CTID_REGEX, HASH_REGEX } from '../../containers/shared/utils' +import { CTID_REGEX, HASH256_REGEX } from '../../containers/shared/utils' import { formatAmount } from './txSummary/formatAmount' import { Error, XRP_BASE, convertRippleDate } from './utils' @@ -144,7 +144,7 @@ const getTransaction = (rippledSocket, txId) => { const params = { command: 'tx', } - if (HASH_REGEX.test(txId)) { + if (HASH256_REGEX.test(txId)) { params.transaction = txId } else if (CTID_REGEX.test(txId)) { params.ctid = txId @@ -557,6 +557,54 @@ const getAMMInfo = (rippledSocket, asset, asset2) => { }) } +const getMPTIssuance = (rippledSocket, tokenId) => + query(rippledSocket, { + command: 'ledger_entry', + mpt_issuance: tokenId, + ledger_index: 'validated', + }).then((resp) => { + if ( + resp.error === 'entryNotFound' || + resp.error === 'lgrNotFound' || + resp.error === 'objectNotFound' + ) { + throw new Error('MPT not found', 404) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + return resp + }) + +const getAccountMPTs = ( + rippledSocket, + account, + marker = '', + ledgerIndex = 'validated', +) => + query(rippledSocket, { + command: 'account_objects', + account, + ledger_index: ledgerIndex, + type: 'mptoken', + marker: marker || undefined, + limit: 400, + }).then((resp) => { + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + if (resp.error === 'invalidParams') { + return undefined + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp + }) + export { getLedger, getLedgerEntry, @@ -576,4 +624,6 @@ export { getSellNFToffers, getNFTTransactions, getAMMInfo, + getMPTIssuance, + getAccountMPTs, } diff --git a/src/rippled/lib/txSummary/formatAmount.ts b/src/rippled/lib/txSummary/formatAmount.ts index 7eaf91239..c8cccd4d5 100644 --- a/src/rippled/lib/txSummary/formatAmount.ts +++ b/src/rippled/lib/txSummary/formatAmount.ts @@ -1,18 +1,34 @@ -import { Amount, ExplorerAmount } from '../../../containers/shared/types' +import { + Amount, + ExplorerAmount, + MPTAmount, +} from '../../../containers/shared/types' import { XRP_BASE } from '../utils' +export const isMPTAmount = (amount: Amount): amount is MPTAmount => + (amount as MPTAmount).mpt_issuance_id !== undefined && + (amount as MPTAmount).value !== undefined + export const formatAmount = (d: Amount | number): ExplorerAmount => { if (d == null) { return d } - return typeof d !== 'string' && typeof d !== 'number' + + if (typeof d === 'string' || typeof d === 'number') + return { + currency: 'XRP', + amount: Number(d) / XRP_BASE, + } + + return isMPTAmount(d) ? { + currency: d.mpt_issuance_id, + amount: d.value, + isMPT: true, + } + : { currency: d.currency, issuer: d.issuer, amount: Number(d.value), } - : { - currency: 'XRP', - amount: Number(d) / XRP_BASE, - } } diff --git a/src/rippled/lib/utils.js b/src/rippled/lib/utils.js index 2c95c4db4..a1b19680e 100644 --- a/src/rippled/lib/utils.js +++ b/src/rippled/lib/utils.js @@ -1,4 +1,5 @@ -import { hexToString } from '@xrplf/isomorphic/utils' +import { hexToString, hexToBytes } from '@xrplf/isomorphic/utils' +import { encodeAccountID } from 'ripple-address-codec' import { convertRippleDate } from './convertRippleDate' import { formatSignerList } from './formatSignerList' import { decodeHex } from '../../containers/shared/transactionUtils' @@ -27,6 +28,19 @@ const NFT_FLAGS = { 0x00000002: 'lsfOnlyXRP', 0x00000008: 'lsfTransferable', } +const MPT_ISSUANCE_FLAGS = { + 0x00000001: 'lsfMPTLocked', + 0x00000002: 'lsfMPTCanLock', + 0x00000004: 'lsfMPTRequireAuth', + 0x00000008: 'lsfMPTCanEscrow', + 0x00000010: 'lsfMPTCanTrade', + 0x00000020: 'lsfMPTCanTransfer', + 0x00000040: 'lsfMPTCanClawback', +} +const MPTOKEN_FLAGS = { + 0x00000001: 'lsfMPTLocked', + 0x00000002: 'lsfMPTAuthorized', +} const hex32 = (d) => { const int = d & 0xffffffff const hex = int.toString(16).toUpperCase() @@ -128,6 +142,33 @@ const formatNFTInfo = (info) => ({ warnings: info.warnings, }) +const formatMPTIssuanceInfo = (info) => ({ + issuer: info.node.Issuer, + assetScale: info.node.AssetScale, + maxAmt: info.node.MaximumAmount + ? BigInt(info.node.MaximumAmount).toString(10) + : undefined, // default is undefined because the default maxAmt is the largest 63-bit int + outstandingAmt: info.node.OutstandingAmount + ? BigInt(info.node.OutstandingAmount).toString(10) + : '0', + transferFee: info.node.TransferFee, + sequence: info.node.Sequence, + metadata: info.node.MPTokenMetadata + ? decodeHex(info.node.MPTokenMetadata) + : info.node.MPTokenMetadata, + flags: buildFlags(info.node.Flags, MPT_ISSUANCE_FLAGS), +}) + +const formatMPTokenInfo = (info) => ({ + account: info.Account, + flags: buildFlags(info.Flags, MPTOKEN_FLAGS), + mptIssuanceID: info.MPTokenIssuanceID, + mptIssuer: encodeAccountID( + hexToBytes(info.MPTokenIssuanceID.substring(8, 48)), + ), + mptAmount: info.MPTAmount ? info.MPTAmount.toString(10) : '0', +}) + export { XRP_BASE, RippledError as Error, @@ -137,4 +178,6 @@ export { formatAccountInfo, convertHexToString, formatNFTInfo, + formatMPTIssuanceInfo, + formatMPTokenInfo, }