- )
- )
- }
-
- function renderHeaderContent(data: AccountState) {
- const { balances = {}, deleted } = data
- const balance = localizeNumber(
- balances[currencySelected] || 0.0,
- language,
- {
- style: 'currency',
- currency: currencySelected,
- minimumFractionDigits: 0,
- maximumFractionDigits: 2,
- },
- )
- return (
-
-
- {renderExtendedAddress(data)}
-
- {deleted ? (
-
- {' '}
- Account Deleted
-
- ) : (
- <>
-
-
-
-
-
-
{balance}
- >
- )}
- {renderBalancesSelector(data)}
+ {xAddress?.classicAddress && (
+
+
+ {t('account_page_classic_address')}:
+
+
-
-
- {renderInfo(data)}
- {renderEscrows(data)}
- {renderPaymentChannels(data)}
-
-
-
{renderSignerList(data)}
-
-
- )
- }
-
- const xAddress = account?.xAddress ?? false
- const hasBridge = account?.hasBridge ?? false
-
- return (
-
-
-
- Account ID
- {hasBridge &&
Door Account
}
-
-
{accountId}
-
-
- {account != null && renderHeaderContent(account)}
+ )}
+ {xAddress?.tag && (
+
+
+ {t('account_page_address_tag')}:
+
+
{xAddress.tag}
+
+ )}
+ {account?.info?.domain && (
+
+
{t('account_page_domain')}:
+
+
+ )}
)
}
+
+export default AccountHeader
diff --git a/src/containers/Accounts/AccountHeader/styles.scss b/src/containers/Accounts/AccountHeader/styles.scss
index d76a9033d..013810487 100644
--- a/src/containers/Accounts/AccountHeader/styles.scss
+++ b/src/containers/Accounts/AccountHeader/styles.scss
@@ -1,308 +1,133 @@
@use '../../shared/css/variables' as *;
.account-header {
- margin-bottom: 16px;
+ padding: 50px 0 30px;
- .title {
- padding-bottom: 4px;
- color: $black-40;
- font-size: 14px;
- text-transform: uppercase;
- @include semibold;
- }
-
- .box-header {
- margin-top: 106px;
- margin-bottom: 80px;
-
- .title {
+ .deleted-banner {
+ display: flex;
+ flex-direction: column;
+ padding: 25px;
+ border: 1px solid $orange-50;
+ border-radius: 8px;
+ margin-bottom: 30px;
+ background: $black-90;
+ gap: 12px;
+
+ .deleted-label {
display: flex;
- flex-direction: row;
+ max-width: 180px;
align-items: center;
- }
-
- h1 {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .badge {
- border: 1px solid $yellow-50;
- background: $yellow-50;
- box-shadow: 0 1px 1px $yellow-80;
- color: $black;
- }
-
- .classic {
- margin: 0;
- color: $white;
- font-size: 20px;
- @include bold;
-
- @include for-size(tablet-landscape-up) {
- font-size: 24px;
- }
-
- @include for-size(desktop-up) {
- font-size: 36px;
- }
- }
-
- .currency-pair {
- display: inline-block;
- padding: 5px;
- border-width: thin;
- border-style: solid;
- border-color: $black-70;
- margin: 0;
- margin-left: 12px;
- color: $white;
- font-size: 16px;
- text-transform: none;
+ padding: 3px 12px;
+ border: 1px solid $orange-50;
+ border-radius: 100px;
+ margin-bottom: 8px;
+ background: $orange-80;
+ color: $black-0;
+ font-size: 12px;
@include semibold;
- @include for-size(tablet-landscape-up) {
- font-size: 10px;
- }
+ gap: 10px;
+ text-transform: uppercase;
- @include for-size(desktop-up) {
- font-size: 20px;
+ .deleted-info-icon {
+ width: 16px;
+ height: 16px;
+ margin-right: 3px;
}
}
- .amm {
- display: inline-block;
- padding-right: 10px;
- border-bottom: none;
+ .deleted-message {
margin: 0;
- color: $white;
- font-size: 18px;
- @include bold;
-
- @include for-size(tablet-landscape-up) {
- font-size: 10px;
- }
-
- @include for-size(desktop-up) {
- font-size: 23px;
- }
- }
- }
-
- .x-address {
- margin: 0;
- color: $white;
- font-size: 14px;
- @include bold;
-
- @include for-size(tablet-landscape-up) {
- font-size: 18px;
- }
-
- @include for-size(desktop-up) {
- font-size: 36px;
+ color: $black-0;
+ font-size: 16px;
}
}
- .box-content {
- min-height: 100px;
- padding-bottom: 20px;
- }
-
- .info-container {
+ .address-panel {
display: flex;
+ height: auto;
+ box-sizing: border-box;
+ flex: none;
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;
- }
+ flex-grow: 0;
+ align-items: flex-start;
+ align-self: stretch;
+ order: 0;
+ padding: 25px;
+ border: 1px solid $black-70;
+ border-radius: 8px;
+ background: $black-90;
+ gap: 10px;
+
+ .address-label {
+ color: $black-40;
+ font-size: 14px;
+ @include semibold;
- .value {
- color: $white;
- font-size: 18px;
- line-height: 22.5px;
- text-decoration: none;
- @include bold;
- }
+ line-height: 1;
+ text-transform: uppercase;
}
- }
- .lower-header {
- display: flex;
- flex-direction: column;
- gap: 16px;
+ .address-value {
+ display: inline-flex;
+ align-items: center;
+ color: $black-0;
+ font-size: 24px;
+ @include bold;
- @include for-size(desktop-up) {
- flex-direction: row;
+ line-height: 1;
+ word-break: break-all;
}
- }
-
- .column {
- display: inline-block;
- width: 100%;
- vertical-align: top;
- @include for-size(desktop-up) {
- width: 50%;
+ .domain {
+ display: flex;
- &.first {
- padding-right: calc(50% - clamp(47.5%, 500px, 50%));
+ .domain-label {
+ margin-right: 8px;
+ color: $black-40;
+ font-size: 14px;
+ line-height: 1;
}
- &.second {
- padding-left: calc(50% - clamp(47.5%, 500px, 50%));
- text-align: right;
+ a {
+ font-size: 14px;
+ line-height: 1;
+ word-break: break-all;
}
}
- .secondary {
- padding: 0;
- margin-bottom: 20px;
- font-size: 12px;
- @include bold;
-
- @include for-size(desktop-up) {
- border: none;
- }
+ .classic-address {
+ display: flex;
- .label {
+ .classic-address-label {
+ margin-right: 8px;
color: $black-40;
- text-align: left;
- text-transform: uppercase;
- @include medium;
+ font-size: 14px;
+ line-height: 1;
}
- ul {
- width: 100%;
- padding: 0;
- margin: 0;
- list-style: none;
-
- li {
- padding: 1px;
- vertical-align: top;
-
- a {
- display: inline-block;
- text-transform: none;
- vertical-align: middle;
- @include medium;
- @extend %truncate;
- }
-
- b {
- color: $white;
- word-break: break-all;
- @include bold;
- }
-
- &.flags {
- color: $blue-purple-30;
- font-style: italic;
- }
- }
- }
-
- &.x-address ul {
- width: 100%;
-
- li {
- display: flex;
- justify-content: space-between;
-
- @media (width >= 450px) {
- display: block;
- }
-
- .tag {
- font-weight: 500;
- }
-
- .network {
- font-weight: 500;
- }
-
- &.tag-info {
- padding-top: 14px;
- font-size: 14px;
- }
- }
+ a {
+ font-size: 14px;
+ line-height: 1;
+ word-break: break-all;
}
+ }
- &.balance {
- .value {
- margin: -4px 0 5px -0.1em;
- color: $white;
- font-size: 26px;
- line-height: 30px;
- @include regular;
- @include for-size(desktop-up) {
- font-size: 30px;
- line-height: 34px;
- }
- @extend %truncate;
- }
+ .address-tag {
+ display: flex;
- .balance-selector-container {
- position: relative;
- margin-top: 10px;
- }
+ .address-tag-label {
+ margin-right: 8px;
+ color: $black-40;
+ font-size: 14px;
+ line-height: 1;
}
- &.signer-list {
- display: block;
- font-weight: 500;
-
- h2 {
- text-align: left;
- }
-
- ul {
- li {
- display: flex;
-
- .label {
- padding-right: 15px;
- text-align: left;
- white-space: nowrap;
- }
-
- .value {
- overflow: hidden;
- margin-left: auto;
- font-weight: inherit;
- text-overflow: ellipsis;
- }
-
- .account {
- display: block;
- font-weight: inherit;
- @extend %truncate;
- }
-
- .weight {
- overflow: inherit;
- text-align: right;
- text-overflow: inherit;
- white-space: nowrap;
- }
- }
- }
+ .address-tag-value {
+ color: $black-0;
+ font-size: 14px;
+ line-height: 1;
+ word-break: break-all;
}
}
}
diff --git a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx
index 2d730716e..d696e38aa 100644
--- a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx
+++ b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx
@@ -1,242 +1,239 @@
-import { mount } from 'enzyme'
-import { QueryClientProvider } from 'react-query'
+import { render, screen } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
-import { BrowserRouter } from 'react-router-dom'
-import { testQueryClient } from '../../../test/QueryClient'
-import i18n from '../../../../i18n/testConfig'
-import { AccountHeader } from '..'
-import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils'
-
-const TEST_ADDRESS = 'rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv'
-const TEST_X_ADDRESS = 'XV3oNHx95sqdCkTDCBCVsVeuBmvh2dz5fTZvfw8UCcMVsfe'
-
-describe('AccountHeader Actions', () => {
- const createWrapper = (account, accountId) =>
- mount(
-
-
-
-
-
-
- ,
- )
-
- beforeEach(() => {
- jest.resetModules()
+import { BrowserRouter as Router } from 'react-router-dom'
+import i18n from '../../../../i18n/testConfigEnglish'
+import AccountHeader from '../index'
+
+// Mock the DomainLink component
+jest.mock('../../../shared/components/DomainLink', () => ({
+ __esModule: true,
+ default: ({ domain }: { domain: string }) => (
+
+ {domain}
+
+ ),
+}))
+
+// Mock the Account component
+jest.mock('../../../shared/components/Account', () => ({
+ Account: ({ account }: { account: string }) => (
+
{account}
+ ),
+}))
+
+// Mock the SVG import
+jest.mock('../../../shared/images/info-duotone.svg', () => ({
+ __esModule: true,
+ default: ({ className }: { className?: string }) => (
+
+ ),
+}))
+
+// Test wrapper component
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+describe('AccountHeader', () => {
+ const mockAccountId = 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH'
+
+ describe('Basic rendering', () => {
+ it('renders regular account address', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Address')).toBeInTheDocument()
+ expect(screen.getByText(mockAccountId)).toBeInTheDocument()
+ // No deleted banner
+ expect(screen.queryByTestId('info-icon')).not.toBeInTheDocument()
+ expect(screen.queryByText('Account Deleted')).not.toBeInTheDocument()
+ })
})
- it('successful account header', async () => {
- const mockedGetAccountState = {
- account: TEST_ADDRESS,
- ledger_index: 68990183,
- info: {
- sequence: 2148991,
- ticketCount: undefined,
- ownerCount: 0,
- reserve: 10,
- tick: undefined,
- rate: undefined,
- domain: undefined,
- emailHash: undefined,
- flags: [],
- balance: '123456000',
- nftMinter: undefined,
- previousTxn:
- '6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82',
- previousLedger: 68990183,
- },
- deleted: false,
- balances: { XRP: 123.456 },
- signerList: undefined,
- tokens: [],
- escrows: undefined,
- paychannels: null,
- xAddress: undefined,
- hasBridge: false,
- }
-
- const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS)
- await flushPromises()
- wrapper.update()
-
- expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS)
-
- wrapper.unmount()
+ describe('Deleted account banner', () => {
+ it('shows deleted banner when account is deleted', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('info-icon')).toBeInTheDocument()
+ expect(screen.getByText('Account Deleted')).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ 'This account has been deleted from the XRP Ledger. Historical data is shown for reference only.',
+ ),
+ ).toBeInTheDocument()
+ })
})
- it('account with tokens', async () => {
- const mockedGetAccountState = {
- account: TEST_ADDRESS,
- ledger_index: 72338736,
- info: {
- sequence: 1227,
- ticketCount: undefined,
- ownerCount: 28,
- reserve: 66,
- tick: undefined,
- rate: undefined,
- domain: undefined,
- emailHash: undefined,
- flags: [],
- balance: '1172875760329',
- previousTxn:
- '259A84CE4B3B09D5FBCAA133F62FC767CA2B57B3C64CF065F7546AA63D55E070',
- previousLedger: 67657581,
- },
- balances: {
- '0158415500000000C1F76FF6ECB0BAC600000000': 3.692385398244198,
- BTC: 1.075524263886059,
- CHF: 0.7519685210971255,
- CNY: 12.328638002,
- DYM: 95.13258522535791,
- EUR: 53.426387263174405,
- GBP: 79.51188949705619,
- JPY: 4986.30908732758,
- USD: 936.8290046958887,
- XAU: 3.419442510305086,
- XRP: 1172875.760329,
- },
- signerList: undefined,
- tokens: [
- {
- amount: 95.13258522535791,
- currency: 'DYM',
- issuer: 'rGwUWgN5BEg3QGNY3RX2HfYowjUTZdid3E',
+ describe('X-Address support', () => {
+ it('renders extended address label when xAddress is provided', () => {
+ const mockAccount = {
+ xAddress: {
+ classicAddress: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH',
+ tag: 12345,
},
- {
- amount: 20,
- currency: 'USD',
- issuer: 'rME7HanzUymzFvETpoLgAy5rvxGcKiLrYL',
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(
+ screen.getByText('Extended Address (X-Address)'),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText('X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ'),
+ ).toBeInTheDocument()
+
+ // Classic address and tag should also be shown
+ expect(screen.getByText('Classic Address:')).toBeInTheDocument()
+ expect(screen.getByTestId('classic-address')).toBeInTheDocument()
+ expect(
+ screen.getByText('rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH'),
+ ).toBeInTheDocument()
+
+ expect(screen.getByText('Tag:')).toBeInTheDocument()
+ expect(screen.getByText('12345')).toBeInTheDocument()
+ })
+
+ it('does not render classic address when xAddress classicAddress is missing', () => {
+ const mockAccount = {
+ xAddress: {
+ tag: 12345,
},
- {
- amount: 255.7836054268899,
- currency: 'USD',
- issuer: 'rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q',
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('Classic Address:')).not.toBeInTheDocument()
+ })
+
+ it('does not render address tag when xAddress tag is missing', () => {
+ const mockAccount = {
+ xAddress: {
+ classicAddress: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH',
},
- {
- amount: 1.075524263886059,
- currency: 'BTC',
- issuer: 'rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q',
- },
- {
- amount: 12.328638002,
- currency: 'CNY',
- issuer: 'rnuF96W4SZoCJmbHYBFoJZpR8eCaxNvekK',
- },
- {
- amount: 3.419442510305086,
- currency: 'XAU',
- issuer: 'rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67',
- },
- {
- amount: 3.692385398244198,
- currency: '0158415500000000C1F76FF6ECB0BAC600000000',
- issuer: 'rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67',
- },
- {
- amount: 0.7519685210971255,
- currency: 'CHF',
- issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B',
- },
- {
- amount: 78.5098894970562,
- currency: 'GBP',
- issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B',
- },
- {
- amount: 4986.30908732758,
- currency: 'JPY',
- issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B',
- },
- {
- amount: 28.42638726317441,
- currency: 'EUR',
- issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B',
- },
- ],
- escrows: undefined,
- paychannels: null,
- xAddress: undefined,
- hasBridge: false,
- deleted: false,
- }
-
- const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS)
- await flushPromises()
- wrapper.update()
-
- expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS)
- expect(wrapper.find('.balance-selector-container')).toExist()
- expect(wrapper.find('.balance .value').text()).toBe('\uE9001,172,875.76')
-
- wrapper.unmount()
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('Tag:')).not.toBeInTheDocument()
+ })
})
- it('X-Address', async () => {
- const mockedGetAccountState = {
- account: TEST_ADDRESS,
- ledger_index: 68990183,
- info: {
- sequence: 2148991,
- ticketCount: undefined,
- ownerCount: 0,
- reserve: 10,
- tick: undefined,
- rate: undefined,
- domain: undefined,
- emailHash: undefined,
- flags: [],
- balance: '123456000',
- nftMinter: undefined,
- previousTxn:
- '6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82',
- previousLedger: 68990183,
- },
- deleted: false,
- balances: { XRP: 123.456 },
- signerList: undefined,
- tokens: [],
- escrows: undefined,
- hasBridge: false,
- paychannels: null,
- xAddress: {
- classicAddress: TEST_ADDRESS,
- tag: 0,
- test: false,
- },
- }
-
- const wrapper = createWrapper(mockedGetAccountState, TEST_X_ADDRESS)
- await flushPromises()
- wrapper.update()
-
- expect(wrapper.find('h1').text()).toBe(TEST_X_ADDRESS)
-
- wrapper.unmount()
+ describe('Domain display', () => {
+ it('renders domain when account info has domain', () => {
+ const mockAccount = {
+ info: {
+ domain: 'ripple.com',
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Domain:')).toBeInTheDocument()
+ expect(screen.getByTestId('domain-link')).toBeInTheDocument()
+ expect(screen.getByText('ripple.com')).toBeInTheDocument()
+ })
+
+ it('does not render domain when account info is missing', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('Domain:')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('domain-link')).not.toBeInTheDocument()
+ })
+
+ it('does not render domain when domain field is missing', () => {
+ const mockAccount = {
+ info: {},
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('Domain:')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('domain-link')).not.toBeInTheDocument()
+ })
+
+ it('renders with all optional fields missing', () => {
+ render(
+
+
+ ,
+ )
+
+ // Only address should be shown
+ expect(screen.getByText('Address')).toBeInTheDocument()
+ expect(screen.getByText(mockAccountId)).toBeInTheDocument()
+
+ // No optional fields
+ expect(screen.queryByText(/Classic Address/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Tag/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Domain/)).not.toBeInTheDocument()
+ expect(screen.queryByText('Account Deleted')).not.toBeInTheDocument()
+ })
})
- it('deleted account', async () => {
- const mockedGetAccountState = {
- account: TEST_ADDRESS,
- deleted: true,
- xAddress: undefined,
- }
-
- const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS)
- await flushPromises()
- wrapper.update()
-
- expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS)
- expect(wrapper.find('.warning').find('.account-deleted-text').text()).toBe(
- 'Account Deleted',
- )
-
- wrapper.unmount()
+ describe('Address title attribute', () => {
+ it('sets title attribute on address value for tooltip', () => {
+ render(
+
+
+ ,
+ )
+
+ const addressValue = screen.getByTitle(mockAccountId)
+ expect(addressValue).toBeInTheDocument()
+ expect(addressValue).toHaveTextContent(mockAccountId)
+ })
})
})
diff --git a/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json b/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json
deleted file mode 100644
index 58d5c9a1a..000000000
--- a/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json
+++ /dev/null
@@ -1,156 +0,0 @@
-{
- "account_info": {
- "id": 2,
- "status": "error",
- "type": "response",
- "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "error": "actNotFound",
- "error_code": 19,
- "error_message": "Account not found.",
- "ledger_current_index": 80602456,
- "request": {
- "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "command": "account_info",
- "id": 2,
- "ledger_index": "current",
- "queue": true,
- "strict": true
- },
- "validated": false,
- "forwarded": true,
- "warnings": [
- {
- "id": 2001,
- "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request"
- }
- ]
- },
- "account_tx": {
- "id": 2,
- "status": "success",
- "type": "response",
- "result": {
- "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "ledger_index_min": 32570,
- "ledger_index_max": 80602468,
- "transactions": [
- {
- "meta": {
- "AffectedNodes": [
- {
- "ModifiedNode": {
- "FinalFields": {
- "Account": "rEoRCpve6aPs6gg2u5aaeCsBmX9ZB7TzhR",
- "Balance": "1553170065225",
- "Flags": 0,
- "OwnerCount": 0,
- "Sequence": 80463368
- },
- "LedgerEntryType": "AccountRoot",
- "LedgerIndex": "856F60BEC3D95BC1035C2D9691CA4CA2FD455FA0D62783F504277374A6966589",
- "PreviousFields": {
- "Balance": "1553152515225"
- },
- "PreviousTxnID": "005613ADA8AA0618C62B4EC53C181ED8831E694F24F1B77706DE98218522AADF",
- "PreviousTxnLgrSeq": 80537220
- }
- },
- {
- "DeletedNode": {
- "FinalFields": {
- "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "Balance": "0",
- "Flags": 0,
- "OwnerCount": 0,
- "PreviousTxnID": "B5180561D25F1D17672F2FCED48E06F3B72D43E33202660BF57647D1914D940A",
- "PreviousTxnLgrSeq": 29256436,
- "Sequence": 8
- },
- "LedgerEntryType": "AccountRoot",
- "LedgerIndex": "EAE9EC4DC190D2C0A33E8963D86B48D3A18394B04E2C891176E5A78E1EFD7023",
- "PreviousFields": {
- "Balance": "19550000",
- "Sequence": 7
- }
- }
- }
- ],
- "DeliveredAmount": "17550000",
- "TransactionIndex": 25,
- "TransactionResult": "tesSUCCESS",
- "delivered_amount": "17550000"
- },
- "tx": {
- "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "Destination": "rEoRCpve6aPs6gg2u5aaeCsBmX9ZB7TzhR",
- "DestinationTag": 2123456789,
- "Fee": "2000000",
- "Flags": 2147483648,
- "Sequence": 7,
- "SigningPubKey": "029D82602618266B98506884CE4C61CECF5504CBCEDA41CED583FE4C6972455C9E",
- "TransactionType": "AccountDelete",
- "TxnSignature": "3045022100A95A9508F96B5D979BD2AFE1F667733DDFC06A5BF79C5C085439CB39C4B14B04022074D84F5AB63390565A4D12D313A8D4666370A99F05B0B887505A80F431E18682",
- "hash": "BB955E1E2DDC4529A79F6DCB9741013B6D218FFBE441871FC466895DD4300F37",
- "ledger_index": 80537221,
- "date": 740350911
- },
- "validated": true
- },
- {
- "meta": {
- "AffectedNodes": [
- {
- "ModifiedNode": {
- "FinalFields": {
- "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "Balance": "19550000",
- "Flags": 0,
- "OwnerCount": 0,
- "Sequence": 7
- },
- "LedgerEntryType": "AccountRoot",
- "LedgerIndex": "EAE9EC4DC190D2C0A33E8963D86B48D3A18394B04E2C891176E5A78E1EFD7023",
- "PreviousFields": {
- "Balance": "19700000",
- "Sequence": 6
- },
- "PreviousTxnID": "56675612ACAF05BAA0499A0CD2D81E0C2B66C2133A2AC328CE311B0BD8C83401",
- "PreviousTxnLgrSeq": 29256433
- }
- }
- ],
- "TransactionIndex": 22,
- "TransactionResult": "tecUNFUNDED_PAYMENT"
- },
- "tx": {
- "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6",
- "Amount": "4856600000",
- "Destination": "rDCgaaSBAWYfsxUYhCk1n26Na7x8PQGmkq",
- "Fee": "150000",
- "Flags": 2147483648,
- "Sequence": 6,
- "SigningPubKey": "029D82602618266B98506884CE4C61CECF5504CBCEDA41CED583FE4C6972455C9E",
- "TransactionType": "Payment",
- "TxnSignature": "3045022100D84347CBC15152E08D83AD7F50B29E07548DB142E69D23D7B983AFD71E5AE67F02205EA4717A39120489DA26088BAD9F741D9F2B87A78C863CBED47FE518675B8215",
- "hash": "B5180561D25F1D17672F2FCED48E06F3B72D43E33202660BF57647D1914D940A",
- "ledger_index": 29256436,
- "date": 546295030
- },
- "validated": true
- }
- ],
- "validated": true,
- "marker": {
- "ledger": 29256436,
- "seq": 22
- },
- "limit": 2
- },
- "warnings": [
- {
- "id": 2001,
- "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request"
- }
- ]
- }
-}
diff --git a/src/containers/Accounts/AccountHeader/test/accountWithTokens.json b/src/containers/Accounts/AccountHeader/test/accountWithTokens.json
deleted file mode 100644
index 8d7feab70..000000000
--- a/src/containers/Accounts/AccountHeader/test/accountWithTokens.json
+++ /dev/null
@@ -1,219 +0,0 @@
-{
- "account_info": {
- "result": {
- "account_data": {
- "Account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY",
- "Balance": "1172875760329",
- "Flags": 0,
- "LedgerEntryType": "AccountRoot",
- "OwnerCount": 28,
- "PreviousTxnID": "259A84CE4B3B09D5FBCAA133F62FC767CA2B57B3C64CF065F7546AA63D55E070",
- "PreviousTxnLgrSeq": 67657581,
- "Sequence": 1227,
- "index": "9B242A0D59328CE964FFFBFF7D3BBF8B024F9CB1A212923727B42F24ADC93930",
- "signer_lists": []
- },
- "ledger_hash": "5B60A4BE898B7ED490F5126D1CB5FF5DCA64B7974B6DB7DBA7D7F0EC0F8C6D75",
- "ledger_index": 72338736,
- "validated": true,
- "warnings": [
- {
- "id": 1004,
- "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\""
- }
- ]
- },
- "status": "success",
- "type": "response"
- },
- "account_objects": {
- "result": {
- "account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY",
- "account_objects": [],
- "ledger_hash": "1BAA8681189844A207D3C6AAF6F1375663F434B2534A87E8D1821E5E8F670D36",
- "ledger_index": 72338713,
- "validated": true,
- "warnings": [
- {
- "id": 1004,
- "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\""
- }
- ]
- },
- "status": "success",
- "type": "response"
- },
- "gateway_balances": {
- "result": {
- "account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY",
- "assets": {
- "rGwUWgN5BEg3QGNY3RX2HfYowjUTZdid3E": [
- {
- "currency": "DYM",
- "value": "95.13258522535792"
- }
- ],
- "rME7HanzUymzFvETpoLgAy5rvxGcKiLrYL": [
- {
- "currency": "USD",
- "value": "20"
- }
- ],
- "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q": [
- {
- "currency": "USD",
- "value": "255.7836054268899"
- },
- {
- "currency": "BTC",
- "value": "1.075524263886059"
- }
- ],
- "rnuF96W4SZoCJmbHYBFoJZpR8eCaxNvekK": [
- {
- "currency": "CNY",
- "value": "12.328638002"
- }
- ],
- "rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67": [
- {
- "currency": "XAU",
- "value": "3.419442510305086"
- },
- {
- "currency": "0158415500000000C1F76FF6ECB0BAC600000000",
- "value": "3.692385398244198"
- }
- ],
- "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B": [
- {
- "currency": "CHF",
- "value": "0.7519685210971255"
- },
- {
- "currency": "GBP",
- "value": "78.5098894970562"
- },
- {
- "currency": "JPY",
- "value": "4986.30908732758"
- },
- {
- "currency": "EUR",
- "value": "28.42638726317441"
- }
- ]
- },
- "ledger_hash": "A03F61C5E0A3E880BECFE05D2435CF370A0F962FEF574C09B024C3C9BD69C1A4",
- "ledger_index": 72338588,
- "obligations": {
- "EUR": "25",
- "GBP": "1.002",
- "USD": "661.0453992689988"
- },
- "validated": true
- },
- "status": "success",
- "type": "response"
- },
- "server_info": {
- "result": {
-
- "info": {
- "build_version": "1.9.1",
- "complete_ledgers": "32570-72338589",
- "hostid": "HONE",
- "initial_sync_duration_us": "15604",
- "io_latency_ms": 1,
- "last_close": {
- "converge_time_s": 0,
- "proposers": 0
- },
- "load_factor": 1,
- "pubkey_node": "n9Ln87hHwcszpaGqr3L3zTD1psJrMgd6MaFQasXYsNBUhq9De6N8",
- "published_ledger": "none",
- "reporting": {
- "etl_sources": [
- {
- "connected": true,
- "grpc_port": "50051",
- "ip": "172.16.3.21",
- "last_message_arrival_time": "2022-Jun-14 17:28:07.976885 UTC",
- "validated_ledgers_range": "70162870-72338589",
- "websocket_port": "51233"
- },
- {
- "connected": true,
- "grpc_port": "50051",
- "ip": "172.16.3.81",
- "last_message_arrival_time": "2022-Jun-14 17:28:07.968027 UTC",
- "validated_ledgers_range": "70151390-72338589",
- "websocket_port": "51233"
- },
- {
- "connected": true,
- "grpc_port": "50051",
- "ip": "172.16.3.89",
- "last_message_arrival_time": "2022-Jun-14 17:28:07.969184 UTC",
- "validated_ledgers_range": "71108293-72338589",
- "websocket_port": "51233"
- },
- {
- "connected": true,
- "grpc_port": "50051",
- "ip": "172.16.3.106",
- "last_message_arrival_time": "2022-Jun-14 17:28:07.992218 UTC",
- "validated_ledgers_range": "70161864-72338589",
- "websocket_port": "51233"
- }
- ],
- "is_writer": false,
- "last_publish_time": "2022-Jun-14 17:28:04.981829 UTC"
- },
- "server_state": "full",
- "server_state_duration_us": "1817931687725",
- "state_accounting": {
- "connected": {
- "duration_us": "0",
- "transitions": "0"
- },
- "disconnected": {
- "duration_us": "15604",
- "transitions": "1"
- },
- "full": {
- "duration_us": "1817931687725",
- "transitions": "1"
- },
- "syncing": {
- "duration_us": "0",
- "transitions": "0"
- },
- "tracking": {
- "duration_us": "0",
- "transitions": "0"
- }
- },
- "time": "2022-Jun-14 17:28:07.993308 UTC",
- "uptime": 1817935,
- "validated_ledger": {
- "age": 6,
- "base_fee_xrp": 0.00001,
- "hash": "35464A53AEDF0ADBBFD44D49EE58EF0B82D626B122F6338A36C99D395B594D4D",
- "reserve_base_xrp": 10,
- "reserve_inc_xrp": 2,
- "seq": 72338589
- },
- "validation_quorum": 28
- },
- "warnings": [
- {
- "id": 1004,
- "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\""
- }
- ]
- },
- "status": "success",
- "type": "response"
- }
-}
diff --git a/src/containers/Accounts/AccountHeader/test/rippledResponses.json b/src/containers/Accounts/AccountHeader/test/rippledResponses.json
deleted file mode 100644
index 8b10781dd..000000000
--- a/src/containers/Accounts/AccountHeader/test/rippledResponses.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "account_info": {
- "result": {
- "account_data": {
- "Account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv",
- "Balance": "123456000",
- "Flags": 0,
- "LedgerEntryType": "AccountRoot",
- "OwnerCount": 0,
- "PreviousTxnID": "6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82",
- "PreviousTxnLgrSeq": 68990183,
- "Sequence": 2148991,
- "index": "C3B625B296E95A21D7BBBB7E3D343AF423B463B87B5D56EE7F79C8E16A47A6F5",
- "signer_lists": []
- },
- "ledger_hash": "43C4195B2C90423771E6C5DF4AED11BF3D77FFD1E8A153A489E5B00C96318FCA",
- "ledger_index": 68990183,
- "validated": true
- },
- "status": "success",
- "type": "response"
- },
- "account_objects": {
- "result": {
- "account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv",
- "account_objects": [],
- "ledger_current_index": 24402380,
- "validated": false
- },
- "status": "success",
- "type": "response"
- },
- "gateway_balances": {
- "result": {
- "account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv",
- "ledger_hash": "F4023C801B8B4D05F16EFE5D8C4C3C14D02354AABBB94151F581A6BF0E04C20B",
- "ledger_index": 24402706,
- "validated": true
- },
- "status": "success",
- "type": "response"
- },
- "server_info": {
- "result": {
- "info": {
- "validated_ledger": {
- "age": 1,
- "base_fee_xrp": 0.00001,
- "hash": "EA01E248FCA5CFD33A3393DA5EBCCD9219BA8DB6AF6DC28A3B0A968604F46A76",
- "reserve_base_xrp": 10,
- "reserve_inc_xrp": 2,
- "seq": 24402729
- },
- "validation_quorum": 5
- }
- },
- "status": "success",
- "type": "response"
- }
-}
diff --git a/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx b/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx
deleted file mode 100644
index f7c39d2b8..000000000
--- a/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import Currency from '../../shared/components/Currency'
-import { Amount } from '../../shared/components/Amount'
-import { Loader } from '../../shared/components/Loader'
-import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow'
-import { RouteLink } from '../../shared/routing'
-import { TOKEN_ROUTE } from '../../App/routes'
-
-interface Props {
- account: any
-}
-
-export const AccountIssuedTokenTable = (props: Props) => {
- const { account } = props
- const { t } = useTranslation()
-
- function renderNoResults() {
- return (
-
- {t('assets.no_issued_message')}
-
- )
- }
-
- function renderRow(token: any) {
- const tokenName = `${token.currency}.${token.issuer}`
-
- return (
-
-
-
-
-
-
- {token.issuer}
-
-
-
-
-
-
- )
- }
-
- return (
-
-
-
-
- {t('currency_code')}
- {t('issuer')}
- {t('amount')}
-
-
-
- {account.tokens &&
- (account?.tokens?.length
- ? account?.tokens.map(renderRow)
- : renderNoResults())}
-
-
- {!account.tokens &&
}
-
- )
-}
diff --git a/src/containers/Accounts/AccountIssuedTokenTable/index.ts b/src/containers/Accounts/AccountIssuedTokenTable/index.ts
deleted file mode 100644
index 2e1697f56..000000000
--- a/src/containers/Accounts/AccountIssuedTokenTable/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { AccountIssuedTokenTable } from './AccountIssuedTokenTable'
diff --git a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx
deleted file mode 100644
index d1ab69f6c..000000000
--- a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-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 (
-
-
-
-
- {t('mpt_issuance_id')}
- {t('issuer')}
- {t('amount')}
-
-
-
- {!loading &&
- (mpts?.length
- ? mpts.map((mpt) => (
-
- ))
- : renderNoResults())}
-
-
- {loading ?
: renderLoadMoreButton()}
-
- )
-}
diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx
deleted file mode 100644
index 1dd71cbfd..000000000
--- a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-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, V7_FUTURE_ROUTER_FLAGS } 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(
- 'rKFgd9FNzwu7a7iVYa2Me4dmBC3zzUepSC ',
- )
- 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
deleted file mode 100644
index 4c2a1a378..000000000
--- a/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-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, V7_FUTURE_ROUTER_FLAGS } 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/Accounts/AccountNFTTable/AccountNFTTable.tsx b/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx
deleted file mode 100644
index bfcbacffe..000000000
--- a/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useContext } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useInfiniteQuery } 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 { getAccountNFTs } from '../../../rippled/lib/rippled'
-import { Account } from '../../shared/components/Account'
-import { LoadMoreButton } from '../../shared/LoadMoreButton'
-import { NFTokenLink } from '../../shared/components/NFTokenLink'
-
-export interface AccountNFTTableProps {
- accountId: string
-}
-
-export const AccountNFTTable = ({ accountId }: AccountNFTTableProps) => {
- const rippledSocket = useContext(SocketContext)
- const { trackException } = useAnalytics()
- const {
- data: pages,
- isFetching: loading,
- hasNextPage,
- fetchNextPage,
- } = useInfiniteQuery(
- ['account_nfts', accountId],
- ({ pageParam = '' }) =>
- getAccountNFTs(rippledSocket, accountId, pageParam).catch(
- (errorResponse) => {
- const errorLocation = `account NFTs ${accountId} at ${pageParam}`
- trackException(
- `${errorLocation} --- ${JSON.stringify(errorResponse)}`,
- )
- },
- ),
- {
- getNextPageParam: (data) => data.marker,
- },
- )
- const { t } = useTranslation()
-
- function renderNoResults() {
- return (
-
- {t('assets.no_nfts_message')}
-
- )
- }
-
- const renderRow = (nft: any) => (
-
-
-
-
-
-
-
- {nft.NFTokenTaxon}
-
- )
-
- const renderLoadMoreButton = () =>
- hasNextPage && fetchNextPage()} />
-
- const nfts = pages?.pages.flatMap((page: any) => page.account_nfts)
- return (
-
-
-
-
- {t('token_id')}
- {t('issuer')}
- {t('taxon')}
-
-
-
- {!loading && (nfts?.length ? nfts.map(renderRow) : renderNoResults())}
-
-
- {loading ?
: renderLoadMoreButton()}
-
- )
-}
diff --git a/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx b/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx
deleted file mode 100644
index c64a8f417..000000000
--- a/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { mount } from 'enzyme'
-import { QueryClientProvider } from 'react-query'
-import { I18nextProvider } from 'react-i18next'
-import { BrowserRouter } from 'react-router-dom'
-import { getAccountNFTs } from '../../../../rippled/lib/rippled'
-import { AccountNFTTable } from '../AccountNFTTable'
-import i18n from '../../../../i18n/testConfig'
-import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow'
-import { testQueryClient } from '../../../test/QueryClient'
-import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils'
-import Mock = jest.Mock
-
-jest.mock('../../../../rippled/lib/rippled', () => ({
- __esModule: true,
- getAccountNFTs: jest.fn(),
-}))
-
-const mockedGetAccountNFTs = getAccountNFTs as Mock
-
-const data = {
- account: 'rnuweigWrt8Jp1gBmKJT6VLxrVMSJSuu6G',
- account_nfts: [
- {
- Flags: 0,
- Issuer: 'rGymBL8Huct6euA8jtEcLagYXpRgQKh6EC',
- NFTokenID:
- '00000000AF460F0D7BC0F05B626E19498DF690E00C97080B0000099B00000000',
- NFTokenTaxon: 0,
- nft_serial: 0,
- },
- ],
- ledger_current_index: 21174400,
- validated: false,
-}
-
-describe('AccountNFTTable component', () => {
- const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT'
-
- const createWrapper = () =>
- mount(
-
-
-
-
-
-
- ,
- )
-
- afterEach(() => {
- mockedGetAccountNFTs.mockReset()
- })
-
- it('should render a table of nfts', async () => {
- mockedGetAccountNFTs.mockImplementation(() => Promise.resolve(data))
-
- const wrapper = createWrapper()
- await flushPromises()
- wrapper.update()
-
- expect(wrapper.find('tbody tr td').length).toEqual(3)
- expect(wrapper.find('.load-more-btn')).not.toExist()
- wrapper.unmount()
- })
-
- it('should handle load more', async () => {
- mockedGetAccountNFTs.mockImplementation(() =>
- Promise.resolve({
- ...data,
- marker: 'hello',
- }),
- )
-
- const wrapper = createWrapper()
- await flushPromises()
- wrapper.update()
-
- const columns = wrapper.find('tbody tr td')
- expect(columns.at(0).text()).toEqual(
- `00000000AF460F0D7BC0F05B626E19498DF690E00C97080B0000099B00000000`,
- )
- expect(columns.at(1).text()).toEqual(`rGymBL8Huct6euA8jtEcLagYXpRgQKh6EC`)
- expect(columns.at(2).text()).toEqual(`0`)
- expect(wrapper.find('.load-more-btn')).toExist()
- wrapper.find('.load-more-btn').simulate('click')
- expect(mockedGetAccountNFTs.mock.calls[1][2]).toEqual('hello')
- wrapper.unmount()
- })
-
- it(`should handle no results`, async () => {
- mockedGetAccountNFTs.mockImplementation(() =>
- Promise.resolve({
- account: 'rnuweigWrt8Jp1gBmKJT6VLxrVMSJSuu6G',
- account_nfts: [],
- marker: 'hello',
- ledger_current_index: 21174400,
- validated: false,
- }),
- )
-
- const wrapper = createWrapper()
- await flushPromises()
- wrapper.update()
-
- expect(wrapper.find(EmptyMessageTableRow)).toExist()
- wrapper.unmount()
- })
-})
diff --git a/src/containers/Accounts/AccountSummary/Balances.tsx b/src/containers/Accounts/AccountSummary/Balances.tsx
new file mode 100644
index 000000000..dbbbed167
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/Balances.tsx
@@ -0,0 +1,75 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useLanguage } from '../../shared/hooks'
+import { localizeNumber } from '../../shared/utils'
+import { XRP_BASE } from '../../shared/transactionUtils'
+import UsdIcon from '../../shared/images/usd_icon.svg'
+import XrpIcon from '../../shared/images/xrp_balance_icon.svg'
+import ReserveIcon from '../../shared/images/xrp_reserve_balance_icon.svg'
+import {
+ XRP_CURRENCY_OPTIONS,
+ USD_CURRENCY_OPTIONS,
+} from '../../shared/NumberFormattingUtils'
+
+interface BalancesProps {
+ account: any
+ xrpToUSDRate: number
+}
+
+const Balances = ({ account, xrpToUSDRate }: BalancesProps) => {
+ const { t } = useTranslation()
+ const lang = useLanguage()
+ const { xrpBalance, xrpBalanceInUSD } = useMemo(() => {
+ const balance = (account.info?.balance ?? 0) / XRP_BASE
+ return {
+ xrpBalance: balance,
+ xrpBalanceInUSD: balance * xrpToUSDRate,
+ }
+ }, [account.info?.balance, xrpToUSDRate])
+
+ return (
+
+
+
+ {typeof XrpIcon === 'string' ? (
+
+ ) : (
+
+ )}
+ {t('account_page_xrp_balance')}
+
+
+ {localizeNumber(xrpBalance, lang, XRP_CURRENCY_OPTIONS)}
+
+
+
+
+
+ {t('account_page_xrp_balance_in_usd')}
+
+
+ {localizeNumber(xrpBalanceInUSD, lang, USD_CURRENCY_OPTIONS)}
+
+
+
+
+ {typeof ReserveIcon === 'string' ? (
+
+ ) : (
+
+ )}
+ {t('account_page_reserve_balance')}
+
+
+ {localizeNumber(
+ account.info?.reserve ?? 0,
+ lang,
+ XRP_CURRENCY_OPTIONS,
+ )}
+
+
+
+ )
+}
+
+export default Balances
diff --git a/src/containers/Accounts/AccountSummary/DetailsCard.tsx b/src/containers/Accounts/AccountSummary/DetailsCard.tsx
new file mode 100644
index 000000000..a1d7a5752
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/DetailsCard.tsx
@@ -0,0 +1,70 @@
+import { useTranslation } from 'react-i18next'
+import { localizeNumber } from '../../shared/utils'
+import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/NumberFormattingUtils'
+import { Account } from '../../shared/components/Account'
+
+interface DetailsCardProps {
+ account: any
+ lang: string
+}
+
+const DetailsCard = ({ account, lang }: DetailsCardProps) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
{t('account_page_details')}
+
+
+
+
+ {t('account_page_current_sequence')}
+
+
+ {localizeNumber(account.info?.sequence, lang)}
+
+
+
+
{t('account_page_ticket_count')}
+
+ {localizeNumber(account.info?.ticketCount, lang)}
+
+
+ {account.info?.emailHash && (
+
+
{t('account_page_email_hash')}
+
{account.info.emailHash}
+
+ )}
+ {account.paychannels?.total_available && (
+
+
+ {t('account_page_payment_channels')}
+
+
+ {t('account_page_payment_channels_text', {
+ currency: localizeNumber(
+ account.paychannels.total_available,
+ lang,
+ XRP_SMALL_BALANCE_CURRENCY_OPTIONS,
+ ),
+ number: account.paychannels.channels.length,
+ })}
+
+
+ )}
+ {account.info?.nftMinter && (
+
+
{t('account_page_nft_minter')}
+
+
+ )}
+
+
+ )
+}
+
+export default DetailsCard
diff --git a/src/containers/Accounts/AccountSummary/FlagsCard.tsx b/src/containers/Accounts/AccountSummary/FlagsCard.tsx
new file mode 100644
index 000000000..b98515fa4
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/FlagsCard.tsx
@@ -0,0 +1,158 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+
+interface FlagsCardProps {
+ account: any
+}
+
+const FlagsCard = ({ account }: FlagsCardProps) => {
+ const { t } = useTranslation()
+
+ const flags = useMemo<
+ Array<{
+ key: string
+ title: string
+ description: string
+ enabled: boolean
+ }>
+ >(() => {
+ const accountFlags: string[] = account.info?.flags ?? []
+
+ return [
+ {
+ key: 'lsfGlobalFreeze',
+ title: t('account_flag_title_lsf_global_freeze'),
+ description: t('account_flag_description_lsf_global_freeze'),
+ enabled: accountFlags.includes('lsfGlobalFreeze'),
+ },
+ {
+ key: 'lsfDisableMaster',
+ title: t('account_flag_title_lsf_disable_master'),
+ description: t('account_flag_description_lsf_disable_master'),
+ enabled: accountFlags.includes('lsfDisableMaster'),
+ },
+ {
+ key: 'lsfDefaultRipple',
+ title: t('account_flag_title_lsf_default_ripple'),
+ description: t('account_flag_description_lsf_default_ripple'),
+ enabled: accountFlags.includes('lsfDefaultRipple'),
+ },
+ {
+ key: 'lsfAllowTrustLineClawback',
+ title: t('account_flag_title_lsf_allow_trustline_clawback'),
+ description: t('account_flag_description_lsf_allow_trustline_clawback'),
+ enabled: accountFlags.includes('lsfAllowTrustLineClawback'),
+ },
+ {
+ key: 'lsfRequireDestTag',
+ title: t('account_flag_title_lsf_require_destination_tag'),
+ description: t('account_flag_description_lsf_require_destination_tag'),
+ enabled: accountFlags.includes('lsfRequireDestTag'),
+ },
+ {
+ key: 'lsfNoFreeze',
+ title: t('account_flag_title_lsf_no_freeze'),
+ description: t('account_flag_description_lsf_no_freeze'),
+ enabled: accountFlags.includes('lsfNoFreeze'),
+ },
+ {
+ key: 'lsfRequireAuth',
+ title: t('account_flag_title_lsf_require_auth'),
+ description: t('account_flag_description_lsf_require_auth'),
+ enabled: accountFlags.includes('lsfRequireAuth'),
+ },
+ {
+ key: 'lsfDisallowXRP',
+ title: t('account_flag_title_lsf_disallow_xrp'),
+ description: t('account_flag_description_lsf_disallow_xrp'),
+ enabled: accountFlags.includes('lsfDisallowXRP'),
+ },
+ {
+ key: 'lsfDisallowIncomingTrustline',
+ title: t('account_flag_title_lsf_disallow_incoming_trustline'),
+ description: t(
+ 'account_flag_description_lsf_disallow_incoming_trustline',
+ ),
+ enabled: accountFlags.includes('lsfDisallowIncomingTrustline'),
+ },
+ {
+ key: 'lsfDisallowIncomingPayChannel',
+ title: t('account_flag_title_lsf_disallow_incoming_pay_chan'),
+ description: t(
+ 'account_flag_description_lsf_disallow_incoming_pay_chan',
+ ),
+ enabled: accountFlags.includes('lsfDisallowIncomingPayChannel'),
+ },
+ {
+ key: 'lsfDisallowIncomingNFTokenOffer',
+ title: t('account_flag_title_lsf_disallow_incoming_nft_token_offer'),
+ description: t(
+ 'account_flag_description_lsf_disallow_incoming_nft_token_offer',
+ ),
+ enabled: accountFlags.includes('lsfDisallowIncomingNFTokenOffer'),
+ },
+ {
+ key: 'asfAuthorizedNFTokenMinter',
+ title: t('account_flag_title_asf_authorized_nft_token_minter'),
+ description: t(
+ 'account_flag_description_asf_authorized_nft_token_minter',
+ ),
+ // No ledger flag exists for the AccountSet flag `asfAuthorizedNFTokenMinter`.
+ // The NFTokenMinter field's presence or absence is sufficient to determine the flag's status.
+ enabled: !!account.info?.nftMinter,
+ },
+ {
+ key: 'lsfDisallowIncomingCheck',
+ title: t('account_flag_title_lsf_disallow_incoming_check'),
+ description: t('account_flag_description_lsf_disallow_incoming_check'),
+ enabled: accountFlags.includes('lsfDisallowIncomingCheck'),
+ },
+ {
+ key: 'lsfDepositAuth',
+ title: t('account_flag_title_lsf_deposit_auth'),
+ description: t('account_flag_description_lsf_deposit_auth'),
+ enabled: accountFlags.includes('lsfDepositAuth'),
+ },
+ {
+ key: 'asfAccountTxnID',
+ title: t('account_flag_title_asf_account_txn_id'),
+ description: t('account_flag_description_asf_account_txn_id'),
+ // No ledger flag exists for the AccountSet flag `asfAccountTxnID`.
+ // The AccountTxnID field's presence or absence is sufficient to determine the flag's status.
+ enabled: !!account.info?.accountTransactionID,
+ },
+ ]
+ }, [account.info, t])
+
+ return (
+
+
+
{t('account_page_flags')}
+
+
+ {flags.map((flag, i) => (
+
+
+
{flag.title}
+
{flag.description}
+
+
+ {(() => {
+ if (flag.key === 'lsfDisableMaster') {
+ return flag.enabled ? t('yes') : t('no')
+ }
+ return flag.enabled
+ ? t('account_page_flag_status_enabled')
+ : t('account_page_flag_status_disabled')
+ })()}
+
+
+ ))}
+
+
+ )
+}
+
+export default FlagsCard
diff --git a/src/containers/Accounts/AccountSummary/SignersCard.tsx b/src/containers/Accounts/AccountSummary/SignersCard.tsx
new file mode 100644
index 000000000..76e232a13
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/SignersCard.tsx
@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next'
+import { Account } from '../../shared/components/Account'
+import { localizeNumber, shortenAccount } from '../../shared/utils'
+
+interface Signer {
+ account: string
+ weight?: number // Individual weight values cannot exceed 2^16-1.
+}
+
+interface SignersCardProps {
+ signers: Signer[]
+}
+const SignersCard = ({ signers }: SignersCardProps) => {
+ const { t } = useTranslation()
+ return (
+
+
+
{t('account_page_signers')}
+
+
+ {signers.map((signer, i) => (
+
+
+ {signer.weight !== undefined && (
+
+ {t('account_page_signer_weight')}{' '}
+ {localizeNumber(signer.weight)}
+
+ )}
+
+ ))}
+
+
+ )
+}
+
+export default SignersCard
diff --git a/src/containers/Accounts/AccountSummary/index.tsx b/src/containers/Accounts/AccountSummary/index.tsx
new file mode 100644
index 000000000..b045242a3
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/index.tsx
@@ -0,0 +1,59 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { useLanguage } from '../../shared/hooks'
+import ArrowIcon from '../../shared/images/down_arrow.svg'
+import Balances from './Balances'
+import DetailsCard from './DetailsCard'
+import FlagsCard from './FlagsCard'
+import SignersCard from './SignersCard'
+
+import './styles.scss'
+
+interface AccountSummaryProps {
+ account: any
+ xrpToUSDRate: number
+}
+
+export const AccountSummary = ({
+ account = {},
+ xrpToUSDRate,
+}: AccountSummaryProps) => {
+ const { t } = useTranslation()
+ const lang = useLanguage()
+ const [propertiesOpen, setPropertiesOpen] = useState(false)
+
+ return (
+
+
+
+
+
+
{t('account_page_account_properties')}
+
setPropertiesOpen((s) => !s)}
+ aria-expanded={propertiesOpen}
+ aria-label="Toggle account properties"
+ >
+
+
+
+ {propertiesOpen && (
+
+
+ {account.signerList?.signers && (
+
+ )}
+
+
+ )}
+
+
+ )
+}
+
+export default AccountSummary
diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss
new file mode 100644
index 000000000..14b4d044d
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/styles.scss
@@ -0,0 +1,505 @@
+@use '../../shared/css/variables' as *;
+
+.account-summary {
+ .card {
+ padding: 18px;
+ border: 1px solid $black-70;
+ border-radius: 8px;
+ background: $black-90;
+ }
+
+ .balances {
+ /* Frame that encompasses the 3 balance boxes */
+ display: flex;
+
+ /* Inside auto layout */
+ flex: none;
+ flex-direction: row;
+ flex-grow: 0;
+ align-items: center;
+ align-self: stretch;
+ order: 1;
+ padding: 0;
+ gap: 28px;
+
+ /* common base for all balance cards (markup uses .balance-card and modifier classes) */
+ .balance-card {
+ display: flex;
+ min-width: 0; /* allow shrinking for long content */
+ flex: 1 1 0;
+ flex-direction: column;
+ justify-content: center;
+ gap: 12px;
+ }
+
+ .balance-title {
+ display: flex;
+ align-items: center;
+ color: $black-40;
+ font-size: 14px;
+ gap: 6px;
+ text-transform: uppercase;
+ @include semibold;
+ }
+
+ .balance-icon {
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ flex: 0 0 auto;
+ }
+
+ .balance-value {
+ color: $black-0;
+ font-size: 24px;
+ @include bold;
+ }
+
+ /* shared visual box properties for the three balance tiles */
+ .xrp,
+ .usd,
+ .reserve {
+ display: flex;
+ min-width: 0;
+ height: 130px;
+ box-sizing: border-box;
+ flex: 1 1 0;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 38px;
+ border-radius: 8px;
+ gap: 12px;
+ }
+
+ /* individual visuals only set border/background/order */
+ .xrp {
+ order: 0;
+ border: 1px solid #145c35;
+ background: rgb(20 92 53 / 40%);
+ }
+
+ .usd {
+ order: 1;
+ border: 1px solid #6b0080;
+ background: rgb(64 0 76 / 50%);
+ }
+
+ .reserve {
+ order: 2;
+ border: 1px solid #004d80;
+ background: rgb(0 77 128 / 40%);
+ }
+ }
+
+ .properties {
+ /* layout for header and the chevron toggle */
+ .properties-header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ margin: 24px 0;
+ gap: 2px;
+
+ h3 {
+ margin: 0;
+ color: $black-0;
+ font-size: 20px;
+ }
+
+ .properties-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ }
+
+ .properties-arrow {
+ display: inline-block;
+ width: 18px;
+ height: 18px;
+ transform-origin: center;
+ transition: transform 180ms ease;
+ }
+
+ .properties-arrow.open {
+ transform: rotate(180deg);
+ }
+ }
+
+ .properties-grid {
+ display: grid;
+ align-items: start;
+ gap: 28px;
+ grid-template-columns: repeat(3, 1fr);
+
+ /* Custom scrollbars for Flags and Signers lists */
+ .flags-list,
+ .signers-list {
+ scrollbar-color: rgb(255 255 255 / 20%) transparent;
+
+ /* Firefox */
+ scrollbar-width: thin;
+
+ /* WebKit */
+ &::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ border: 2px solid transparent; /* create padding effect */
+ border-radius: 6px;
+ background: rgb(255 255 255 / 20%);
+ background-clip: padding-box;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: rgb(255 255 255 / 12%);
+ }
+ }
+
+ /* Shared visuals for both cards */
+ .flags-card,
+ .signers-card {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ box-sizing: border-box;
+ flex: none;
+ flex-direction: column;
+ flex-grow: 0;
+ align-items: stretch;
+ padding: 20px;
+ gap: 15px;
+ isolation: isolate;
+ }
+
+ /* shared header styles to avoid duplicates */
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+
+ .header-title {
+ color: $black-0;
+ font-size: 20px;
+ @include bold;
+ }
+ }
+
+ .flags-card {
+ grid-column: 1 / span 2;
+
+ .flags-list {
+ display: flex;
+ max-height: calc(5 * 70px);
+ flex-direction: column;
+ padding-right: 6px;
+ gap: 8px;
+ overflow-y: auto;
+ }
+
+ .flag-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px;
+ border-radius: 8px;
+ background: $black-80;
+
+ .flag-meta {
+ display: flex;
+ flex-direction: column;
+
+ .flag-title {
+ margin-bottom: 6px;
+ color: $black-0;
+ font-size: 14px;
+ @include semibold;
+
+ text-transform: uppercase;
+ }
+
+ .flag-desc {
+ color: $black-0;
+ font-size: 14px;
+ }
+ }
+
+ .flag-status {
+ min-width: 85px; // Ensures YES/NO and ENABLED/DISABLED have the same width
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ letter-spacing: 0.06em;
+ text-align: center;
+ text-transform: uppercase;
+ white-space: nowrap;
+ @include semibold;
+ }
+
+ .flag-status.enabled {
+ background: $green-50;
+ color: $black-100;
+ }
+
+ .flag-status.disabled {
+ background: $black-50;
+ color: $black-0;
+ }
+ }
+ }
+
+ .signers-card {
+ grid-column: 3 / 4;
+
+ .signers-list {
+ display: flex;
+ width: 100%;
+ max-height: calc(5 * 44px);
+ flex-direction: column;
+ align-self: stretch;
+ padding-right: 6px; /* match flags-list spacing to keep scrollbar offset consistent */
+ gap: 8px;
+ overflow-y: auto;
+ }
+
+ .signer-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px;
+ border-radius: 8px;
+ background: $black-80;
+
+ .signer-address {
+ font-size: 14px;
+ }
+
+ .signer-weight {
+ color: $blue-50;
+ font-size: 14px;
+ }
+ }
+ }
+ }
+
+ /* Details card styles */
+ @media (min-width: $tablet-landscape-upper-boundary) {
+ .details-card {
+ /* Ensure Details card matches Flags card width in properties grid (desktop only) */
+ grid-column: 1 / span 2;
+ }
+ }
+
+ .details-card {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ background: $black-90;
+ gap: 16px;
+
+ /* Only first two details side by side, rest full width */
+ .details-box {
+ display: flex;
+ min-width: 0;
+ flex: 1 1 180px;
+ flex-direction: row;
+ align-items: center;
+ padding: 10px;
+ border: 1px solid $black-70;
+ border-radius: 8px;
+ background: $black-80;
+ gap: 12px;
+ }
+
+ .details-list {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(2, 1fr);
+
+ /* Only the first two direct .details-box children are side by side on desktop */
+ > .details-box:nth-child(1) {
+ grid-column: 1;
+ grid-row: 1;
+ }
+
+ > .details-box:nth-child(2) {
+ grid-column: 2;
+ grid-row: 1;
+ }
+
+ > .details-box:nth-child(n+3) {
+ grid-column: 1 / span 2;
+ }
+ }
+
+ .details-label {
+ color: $black-0;
+ font-size: 14px;
+ @include medium;
+
+ text-transform: none;
+ }
+
+ .details-value {
+ margin-left: auto;
+ color: $black-0;
+ font-size: 14px;
+ text-align: right;
+ }
+ }
+
+ /* Force vertical stacking for tablet portrait and smaller */
+ @media (max-width: $tablet-portrait-upper-boundary) {
+ .details-card .details-list {
+ grid-template-columns: 1fr; /* Keep grid but force 1 column */
+
+ > .details-box:nth-child(1),
+ > .details-box:nth-child(2) {
+ grid-column: 1;
+ grid-row: auto;
+ }
+
+ /* Ensure all boxes span full width in grid */
+ > .details-box:nth-child(n+3) {
+ grid-column: 1;
+ }
+ }
+ }
+
+ @media (max-width: $tablet-landscape-upper-boundary) {
+ .details-card {
+ padding: 16px;
+ }
+
+ .details-list {
+ /* Keep as grid instead of flex to maintain alignment */
+ display: grid;
+ gap: 10px;
+ grid-template-columns: 1fr;
+ }
+
+ .details-box {
+ width: 100%;
+ flex: none;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 12px;
+ gap: 8px;
+ }
+ .details-label { font-size: 12px; }
+ .details-value { font-size: 14px; }
+ }
+
+ /* Extra narrow screens - force better text wrapping */
+ @media (max-width: $tablet-portrait-upper-boundary) {
+ .details-card {
+ padding: 12px;
+
+ .details-list {
+ gap: 8px;
+ }
+
+ .details-box {
+ overflow: hidden; /* Prevent box expansion */
+ min-height: auto;
+ padding: 10px;
+ }
+
+ .details-label {
+ line-height: 1.2;
+ }
+
+ .details-value {
+ max-width: 100%;
+ hyphens: auto; /* Allow hyphenation if supported */
+ line-height: 1.3;
+ overflow-wrap: anywhere; /* More aggressive wrapping */
+ word-break: break-all;
+ }
+ }
+ }
+ }
+
+ @media (max-width: $tablet-landscape-upper-boundary) {
+ .balances {
+ flex-direction: column;
+ margin-bottom: 24px; /* preserve spacing on mobile */
+ gap: 12px;
+
+ .balance-card {
+ width: 100%;
+ flex: none;
+ }
+
+ .xrp, .usd, .reserve {
+ height: auto;
+ padding: 20px;
+ }
+
+ .balance-value { font-size: 18px; }
+
+ .balance-icon {
+ width: 20px;
+ height: 20px;
+ }
+ }
+
+ .properties {
+ .properties-header {
+ gap: 8px;
+ .properties-toggle { padding: 4px; }
+
+ .properties-arrow {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ .properties-grid {
+ margin-top: 12px;
+ gap: 12px;
+ grid-template-columns: 1fr;
+
+ .flags-card,
+ .signers-card {
+ padding: 16px;
+ }
+
+ .flags-card { grid-column: auto; }
+ .signers-card { grid-column: auto; }
+
+ .flags-list, .signers-list {
+ max-height: calc(5 * 64px);
+ padding-inline: 4px;
+ }
+
+ .flag-item, .signer-item {
+ padding: 10px;
+ }
+ }
+ }
+ }
+
+ @media (max-width: $tablet-portrait-upper-boundary) {
+ .properties {
+ .properties-header {
+ h3 { font-size: 18px; }
+ }
+
+ .properties-grid .card-header .header-title {
+ font-size: 18px;
+ }
+ }
+ }
+}
diff --git a/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx b/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx
new file mode 100644
index 000000000..24ac88370
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx
@@ -0,0 +1,175 @@
+import { render, screen, fireEvent } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import i18n from '../../../../i18n/testConfigEnglish'
+import { AccountSummary } from '../index'
+
+// Mock the child components and SVG imports
+jest.mock('../Balances', () => ({
+ __esModule: true,
+ default: ({ account, xrpToUSDRate }: any) => (
+
+ Balance: {account.info?.balance} Rate: {xrpToUSDRate}
+
+ ),
+}))
+
+jest.mock('../DetailsCard', () => ({
+ __esModule: true,
+ default: ({ account, lang }: any) => (
+
+ Sequence: {account.info?.sequence} Lang: {lang}
+
+ ),
+}))
+
+jest.mock('../FlagsCard', () => ({
+ __esModule: true,
+ default: ({ account }: any) => (
+
+ Flags: {account.info?.flags?.length || 0}
+
+ ),
+}))
+
+jest.mock('../SignersCard', () => ({
+ __esModule: true,
+ default: ({ signers }: any) => (
+ Signers: {signers.length}
+ ),
+}))
+
+jest.mock('../../../shared/images/down_arrow.svg', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('AccountSummary Component', () => {
+ const mockAccount = {
+ info: {
+ balance: 1000000000,
+ sequence: 123,
+ flags: ['lsfGlobalFreeze'],
+ },
+ }
+
+ describe('Rendering and Toggle Behavior', () => {
+ it('renders balances and collapsed properties by default, then expands/collapses on toggle', () => {
+ render(
+
+
+ ,
+ )
+
+ // Renders balances and properties header
+ expect(screen.getByTestId('balances-mock')).toBeInTheDocument()
+ expect(screen.getByText('Account Properties')).toBeInTheDocument()
+
+ // Properties are collapsed by default
+ const toggleButton = screen.getByLabelText('Toggle account properties')
+ expect(toggleButton).toBeInTheDocument()
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByTestId('flags-card-mock')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('details-card-mock')).not.toBeInTheDocument()
+
+ // Expand properties
+ fireEvent.click(toggleButton)
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument()
+ expect(screen.getByTestId('details-card-mock')).toBeInTheDocument()
+
+ // Collapse again
+ fireEvent.click(toggleButton)
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByTestId('flags-card-mock')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Props and Content', () => {
+ it('passes correct props to child components', () => {
+ render(
+
+
+ ,
+ )
+
+ // Balances receives account and rate
+ expect(screen.getByText(/Balance: 1000000000/)).toBeInTheDocument()
+ expect(screen.getByText(/Rate: 0.5/)).toBeInTheDocument()
+
+ // FlagsCard receives account
+ const toggleButton = screen.getByLabelText('Toggle account properties')
+ fireEvent.click(toggleButton)
+ expect(screen.getByText('Flags: 1')).toBeInTheDocument()
+ })
+
+ it('renders SignersCard when signers exist, hides when none', () => {
+ // Without signers
+ render(
+
+
+ ,
+ )
+
+ const toggleButton = screen.getByLabelText('Toggle account properties')
+ fireEvent.click(toggleButton)
+ expect(screen.queryByTestId('signers-card-mock')).not.toBeInTheDocument()
+ })
+
+ it('renders SignersCard when account has signers', () => {
+ const accountWithSigners = {
+ ...mockAccount,
+ signerList: {
+ signers: [
+ { account: 'rSigner1', weight: 1 },
+ { account: 'rSigner2', weight: 2 },
+ ],
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ const toggleButton = screen.getByLabelText('Toggle account properties')
+ fireEvent.click(toggleButton)
+ expect(screen.getByTestId('signers-card-mock')).toBeInTheDocument()
+ expect(screen.getByText('Signers: 2')).toBeInTheDocument()
+ })
+
+ it('handles empty account gracefully', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('balances-mock')).toBeInTheDocument()
+
+ const toggleButton = screen.getByLabelText('Toggle account properties')
+ fireEvent.click(toggleButton)
+ expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument()
+ expect(screen.getByTestId('details-card-mock')).toBeInTheDocument()
+ })
+
+ it('handles undefined account with default value', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('balances-mock')).toBeInTheDocument()
+
+ const toggleButton = screen.getByLabelText('Toggle account properties')
+ fireEvent.click(toggleButton)
+ expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument()
+ expect(screen.getByTestId('details-card-mock')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/containers/Accounts/AccountSummary/test/Balances.test.tsx b/src/containers/Accounts/AccountSummary/test/Balances.test.tsx
new file mode 100644
index 000000000..045063900
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/test/Balances.test.tsx
@@ -0,0 +1,243 @@
+import { render, screen } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import i18n from '../../../../i18n/testConfigEnglish'
+import Balances from '../Balances'
+
+// Mock SVG imports
+jest.mock('../../../shared/images/usd_icon.svg', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+jest.mock('../../../shared/images/xrp_balance_icon.svg', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+jest.mock('../../../shared/images/xrp_reserve_balance_icon.svg', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('Balances Component', () => {
+ describe('Rendering', () => {
+ it('renders all three balance cards with icons', () => {
+ const account = {
+ info: {
+ balance: 1000000000, // 1000 XRP (in drops)
+ reserve: 10000000, // 10 XRP
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ // Verify three balance cards exist
+ const balanceCards = container.querySelectorAll('.balance-card')
+ expect(balanceCards.length).toBe(3)
+
+ // Verify XRP balance card and icon
+ expect(screen.getByText('XRP Balance')).toBeInTheDocument()
+ expect(screen.getByTestId('xrp-icon')).toBeInTheDocument()
+
+ // Verify USD balance card and icon
+ expect(screen.getByText('XRP Balance (USD)')).toBeInTheDocument()
+ expect(screen.getByTestId('usd-icon')).toBeInTheDocument()
+
+ // Verify Reserve balance card and icon
+ expect(screen.getByText('Reserve Balance')).toBeInTheDocument()
+ expect(screen.getByTestId('reserve-icon')).toBeInTheDocument()
+ })
+ })
+
+ describe('Balance Calculations', () => {
+ it('converts drops to XRP correctly (1000 XRP)', () => {
+ const account = {
+ info: {
+ balance: 1000000000, // 1000 XRP in drops
+ reserve: 0,
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ // XRP balance should show 1000
+ const xrpCard = container.querySelector('.balance-card.xrp')
+ expect(xrpCard?.textContent).toContain('1,000')
+ })
+
+ it('calculates USD value correctly', () => {
+ const account = {
+ info: {
+ balance: 2000000000, // 2000 XRP
+ reserve: 0,
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ // USD should be 2000 * 0.5 = 1000
+ const usdCard = container.querySelector('.balance-card.usd')
+ expect(usdCard?.textContent).toContain('$1,000')
+ })
+
+ it('handles zero balance', () => {
+ const account = {
+ info: {
+ balance: 0,
+ reserve: 0,
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const xrpCard = container.querySelector('.balance-card.xrp')
+ expect(xrpCard?.textContent).toContain('0')
+ })
+
+ it('handles missing balance (defaults to 0)', () => {
+ const account = {
+ info: {},
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const xrpCard = container.querySelector('.balance-card.xrp')
+ expect(xrpCard?.textContent).toContain('0')
+ })
+
+ it('displays reserve balance', () => {
+ const account = {
+ info: {
+ balance: 1000000000,
+ reserve: 20, // 20 XRP (already in XRP, not drops)
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const reserveCard = container.querySelector('.balance-card.reserve')
+ expect(reserveCard?.textContent).toContain('20')
+ })
+ })
+
+ describe('XRP to USD Rate', () => {
+ it('updates USD value when rate changes', () => {
+ const account = {
+ info: {
+ balance: 1000000000, // 1000 XRP
+ reserve: 0,
+ },
+ }
+
+ const { container, rerender } = render(
+
+
+ ,
+ )
+
+ // Initial: 1000 XRP * $0.5 = $500
+ let usdCard = container.querySelector('.balance-card.usd')
+ expect(usdCard?.textContent).toContain('$500')
+
+ // Update rate: 1000 XRP * $2 = $2000
+ rerender(
+
+
+ ,
+ )
+
+ usdCard = container.querySelector('.balance-card.usd')
+ expect(usdCard?.textContent).toContain('2,000')
+ })
+
+ it('handles zero USD rate', () => {
+ const account = {
+ info: {
+ balance: 1000000000,
+ reserve: 0,
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const usdCard = container.querySelector('.balance-card.usd')
+ expect(usdCard?.textContent).toContain('$0')
+ })
+ })
+
+ describe('Number Formatting', () => {
+ it('formats all three balances with proper formatting', () => {
+ const account = {
+ info: {
+ balance: 5545000, // 5.545 XRP
+ reserve: 20.538, // 20.54 XRP
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ // XRP balance with decimal
+ const xrpCard = container.querySelector('.balance-card.xrp')
+ expect(xrpCard?.textContent).toContain('5.55')
+
+ // USD balance calculated and formatted
+ const usdCard = container.querySelector('.balance-card.usd')
+ expect(usdCard?.textContent).toContain('$2.77') // 5.545 * 0.5 = 2.7725
+
+ // Reserve balance with decimal
+ const reserveCard = container.querySelector('.balance-card.reserve')
+ expect(reserveCard?.textContent).toContain('20.54')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles missing info property', () => {
+ const account = {}
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const xrpCard = container.querySelector('.balance-card.xrp')
+ expect(xrpCard).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx
new file mode 100644
index 000000000..693eb4e9f
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx
@@ -0,0 +1,304 @@
+import { render, screen } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import { BrowserRouter as Router } from 'react-router-dom'
+import i18n from '../../../../i18n/testConfigEnglish'
+import DetailsCard from '../DetailsCard'
+
+// Mock the Account component
+jest.mock('../../../shared/components/Account', () => ({
+ Account: ({ account }: { account: string }) => (
+ {account}
+ ),
+}))
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+describe('DetailsCard Component', () => {
+ describe('Rendering', () => {
+ it('renders card header and required fields', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 5,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Verify card header
+ expect(screen.getByText('Details')).toBeInTheDocument()
+
+ // Verify current sequence field
+ expect(screen.getByText('Current Sequence')).toBeInTheDocument()
+ expect(screen.getByText('123')).toBeInTheDocument()
+
+ // Verify ticket count field
+ expect(screen.getByText('Ticket Count')).toBeInTheDocument()
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+ })
+
+ describe('Optional Fields', () => {
+ it('renders email hash when present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ emailHash: 'ABC123DEF456',
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Email Hash')).toBeInTheDocument()
+ expect(screen.getByText('ABC123DEF456')).toBeInTheDocument()
+ })
+
+ it('does not render email hash when not present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('Email Hash')).not.toBeInTheDocument()
+ })
+
+ it('renders payment channels when present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ },
+ paychannels: {
+ total_available: 100,
+ channels: [{ id: '1' }, { id: '2' }, { id: '3' }],
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Payment Channels')).toBeInTheDocument()
+ // Should show formatted text with both amount and number of channels
+ expect(screen.getByText(/100\.00.*3 channel\(s\)/i)).toBeInTheDocument()
+ })
+
+ it('does not render payment channels when not present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('Payment Channels')).not.toBeInTheDocument()
+ })
+
+ it('renders NFT minter when present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ nftMinter: 'rNFTMinterAccount123',
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('NFT Minter')).toBeInTheDocument()
+ expect(screen.getByTestId('account-component')).toHaveTextContent(
+ 'rNFTMinterAccount123',
+ )
+ })
+
+ it('does not render NFT minter when not present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByText('NFT Minter')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Number Formatting', () => {
+ it('formats sequence and ticket count with commas for large numbers', () => {
+ const account = {
+ info: {
+ sequence: 1234567,
+ ticketCount: 1000,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('1,234,567')).toBeInTheDocument()
+ expect(screen.getByText('1,000')).toBeInTheDocument()
+ })
+
+ it('handles zero values', () => {
+ const account = {
+ info: {
+ sequence: 0,
+ ticketCount: 0,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ const zeroElements = screen.getAllByText('0')
+ expect(zeroElements.length).toBe(2)
+ })
+ })
+
+ describe('Combined Optional Fields', () => {
+ it('renders all optional fields when present', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 5,
+ emailHash: 'ABC123',
+ nftMinter: 'rMinter123',
+ },
+ paychannels: {
+ total_available: 100,
+ channels: [{ id: '1' }, { id: '2' }],
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Email Hash')).toBeInTheDocument()
+ expect(screen.getByText('Payment Channels')).toBeInTheDocument()
+ expect(screen.getByText('NFT Minter')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles undefined sequence', () => {
+ const account = {
+ info: {
+ ticketCount: 0,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Current Sequence')).toBeInTheDocument()
+ })
+
+ it('handles undefined ticket count', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Ticket Count')).toBeInTheDocument()
+ })
+
+ it('handles empty paychannels array', () => {
+ const account = {
+ info: {
+ sequence: 123,
+ ticketCount: 0,
+ },
+ paychannels: {
+ total_available: 0,
+ channels: [],
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Should not render payment channels if total_available is 0 (falsy)
+ expect(screen.queryByText('Payment Channels')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Language Support', () => {
+ it('accepts different language codes', () => {
+ const account = {
+ info: {
+ sequence: 1234567,
+ ticketCount: 0,
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Details')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx
new file mode 100644
index 000000000..eaf7993d2
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx
@@ -0,0 +1,768 @@
+import { render, screen } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import i18n from '../../../../i18n/testConfigEnglish'
+import FlagsCard from '../FlagsCard'
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('FlagsCard Component', () => {
+ describe('Rendering', () => {
+ it('renders the card with header', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Flags')).toBeInTheDocument()
+ })
+
+ it('renders all 15 flag items', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flagItems = container.querySelectorAll('.flag-item')
+ expect(flagItems.length).toBe(15)
+ })
+ })
+
+ describe('Flag Status - Enabled(Yes)/Disabled(No)', () => {
+ it('shows lsfGlobalFreeze as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfGlobalFreeze'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Global Freeze'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfGlobalFreeze as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Global Freeze'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDisableMaster as enabled with "Yes" when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDisableMaster'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Master Key'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Yes')
+ })
+
+ it('shows lsfDisableMaster as disabled with "No" when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Master Key'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('No')
+ })
+
+ it('shows lsfDefaultRipple as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDefaultRipple'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Rippling'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDefaultRipple as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Rippling'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfAllowTrustLineClawback as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfAllowTrustLineClawback'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Clawback'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfAllowTrustLineClawback as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Clawback'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfRequireDestTag as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfRequireDestTag'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Require Destination Tag'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfRequireDestTag as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Require Destination Tag'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfNoFreeze as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfNoFreeze'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('No Freeze'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfNoFreeze as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('No Freeze'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfRequireAuth as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfRequireAuth'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) =>
+ item.querySelector('.flag-title')?.textContent ===
+ 'Require Authorization',
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfRequireAuth as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) =>
+ item.querySelector('.flag-title')?.textContent ===
+ 'Require Authorization',
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDisallowXRP as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDisallowXRP'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('No XRP Allowed'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDisallowXRP as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('No XRP Allowed'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDisallowIncomingTrustline as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDisallowIncomingTrustline'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block Trustlines'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDisallowIncomingTrustline as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block Trustlines'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDisallowIncomingPayChannel as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDisallowIncomingPayChannel'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block Payment Channels'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDisallowIncomingPayChannel as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block Payment Channels'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDisallowIncomingNFTokenOffer as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDisallowIncomingNFTokenOffer'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block NFT Offers'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDisallowIncomingNFTokenOffer as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block NFT Offers'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows asfAuthorizedNFTokenMinter as enabled when nftMinter field is present', () => {
+ const account = {
+ info: {
+ flags: [],
+ nftMinter: 'rMinterAccount123',
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('NFT Minter'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows asfAuthorizedNFTokenMinter as disabled when nftMinter field is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('NFT Minter'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDisallowIncomingCheck as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDisallowIncomingCheck'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block Checks'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDisallowIncomingCheck as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) => item.textContent?.includes('Block Checks'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows lsfDepositAuth as enabled when flag is present', () => {
+ const account = {
+ info: {
+ flags: ['lsfDepositAuth'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) =>
+ item.querySelector('.flag-title')?.textContent ===
+ 'Deposit Authorization',
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows lsfDepositAuth as disabled when flag is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) =>
+ item.querySelector('.flag-title')?.textContent ===
+ 'Deposit Authorization',
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+
+ it('shows asfAccountTxnID as enabled when accountTransactionID field is present', () => {
+ const account = {
+ info: {
+ flags: [],
+ accountTransactionID: 'ABCD1234',
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) =>
+ item.textContent?.includes('Track Account Latest Transaction'),
+ )
+ expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled')
+ })
+
+ it('shows asfAccountTxnID as disabled when accountTransactionID field is not present', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flag = Array.from(container.querySelectorAll('.flag-item')).find(
+ (item) =>
+ item.textContent?.includes('Track Account Latest Transaction'),
+ )
+ expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument()
+ expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled')
+ })
+ })
+
+ describe('Multiple Flags', () => {
+ it('handles multiple flags enabled simultaneously', () => {
+ const account = {
+ info: {
+ flags: [
+ 'lsfGlobalFreeze',
+ 'lsfDefaultRipple',
+ 'lsfRequireDestTag',
+ 'lsfNoFreeze',
+ ],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const enabledFlags = container.querySelectorAll('.flag-status.enabled')
+ // At least 4 should be enabled (the ones we set)
+ // Note: Some inverted flags might also be enabled by default
+ expect(enabledFlags.length).toBeGreaterThanOrEqual(4)
+ })
+ })
+
+ describe('Flag Structure', () => {
+ it('each flag has non-empty title, description, and status', () => {
+ const account = {
+ info: {
+ flags: ['lsfGlobalFreeze'],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const flagItems = container.querySelectorAll('.flag-item')
+ flagItems.forEach((item) => {
+ // Each flag must have all three elements
+ const title = item.querySelector('.flag-title')
+ const desc = item.querySelector('.flag-desc')
+ const status = item.querySelector('.flag-status')
+
+ expect(title).toBeInTheDocument()
+ expect(desc).toBeInTheDocument()
+ expect(status).toBeInTheDocument()
+
+ // And none should be empty
+ expect(title?.textContent).not.toBe('')
+ expect(desc?.textContent).not.toBe('')
+ expect(status?.textContent).not.toBe('')
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles undefined flags array', () => {
+ const account = {
+ info: {},
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Flags')).toBeInTheDocument()
+ const flagItems = container.querySelectorAll('.flag-item')
+ expect(flagItems.length).toBe(15)
+ })
+
+ it('handles missing info object', () => {
+ const account = {}
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Flags')).toBeInTheDocument()
+ const flagItems = container.querySelectorAll('.flag-item')
+ expect(flagItems.length).toBe(15)
+ })
+
+ it('handles empty account object', () => {
+ const account = {
+ info: {
+ flags: [],
+ },
+ }
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Flags')).toBeInTheDocument()
+ const flagItems = container.querySelectorAll('.flag-item')
+ expect(flagItems.length).toBe(15)
+ })
+ })
+})
diff --git a/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx b/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx
new file mode 100644
index 000000000..668d8ed75
--- /dev/null
+++ b/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx
@@ -0,0 +1,230 @@
+import { render, screen } from '@testing-library/react'
+import { I18nextProvider } from 'react-i18next'
+import { BrowserRouter as Router } from 'react-router-dom'
+import i18n from '../../../../i18n/testConfigEnglish'
+import SignersCard from '../SignersCard'
+
+// Mock the Account component
+jest.mock('../../../shared/components/Account', () => ({
+ Account: ({ account, displayText }: any) => (
+ {displayText || account}
+ ),
+}))
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+describe('SignersCard Component', () => {
+ describe('Rendering', () => {
+ it('renders the card with header', () => {
+ const signers = [{ account: 'rSigner1', weight: 1 }]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Signers')).toBeInTheDocument()
+ expect(container.querySelector('.card-header')).toBeInTheDocument()
+ expect(container.querySelector('.header-title')).toBeInTheDocument()
+ })
+
+ it('renders a single signer', () => {
+ const signers = [{ account: 'rSigner', weight: 1 }]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('account-rSigner')).toBeInTheDocument()
+ const signerItems = container.querySelectorAll('.signer-item')
+ expect(signerItems.length).toBe(1)
+ })
+
+ it('renders multiple signers', () => {
+ const signers = [
+ { account: 'rSigner1', weight: 1 },
+ { account: 'rSigner2', weight: 2 },
+ { account: 'rSigner3', weight: 3 },
+ ]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('account-rSigner1')).toBeInTheDocument()
+ expect(screen.getByTestId('account-rSigner2')).toBeInTheDocument()
+ expect(screen.getByTestId('account-rSigner3')).toBeInTheDocument()
+ const signerItems = container.querySelectorAll('.signer-item')
+ expect(signerItems.length).toBe(3)
+ })
+ })
+
+ describe('Signer Weight', () => {
+ it('displays signer weight when present', () => {
+ const signers = [{ account: 'rSigner1', weight: 5 }]
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText(/Weight/i)).toBeInTheDocument()
+ expect(screen.getByText(/5/)).toBeInTheDocument()
+ })
+
+ it('displays different weights for different signers', () => {
+ const signers = [
+ { account: 'rSigner1', weight: 1 },
+ { account: 'rSigner2', weight: 3 },
+ { account: 'rSigner3', weight: 5 },
+ ]
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText(/Weight.*1/)).toBeInTheDocument()
+ expect(screen.getByText(/Weight.*3/)).toBeInTheDocument()
+ expect(screen.getByText(/Weight.*5/)).toBeInTheDocument()
+ })
+
+ it('does not display weight when undefined', () => {
+ const signers = [{ account: 'rSigner1' }]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.querySelector('.signer-weight')).not.toBeInTheDocument()
+ })
+
+ it('displays weight of zero', () => {
+ const signers = [{ account: 'rSigner1', weight: 0 }]
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText(/Weight.*0/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Account Display', () => {
+ it('uses shortened account address for display', () => {
+ const longAccount = 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH'
+ const signers = [{ account: longAccount, weight: 1 }]
+
+ render(
+
+
+ ,
+ )
+
+ // Should show shortened version: 7 chars...5 chars
+ expect(screen.getByText(/rN7n7ot.*6fzRH/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles empty signers array', () => {
+ const signers: any[] = []
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const signerItems = container.querySelectorAll('.signer-item')
+ expect(signerItems.length).toBe(0)
+ })
+
+ it('uses index as key when account is missing', () => {
+ const signers = [{ weight: 1 } as any, { weight: 2 } as any]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const signerItems = container.querySelectorAll('.signer-item')
+ expect(signerItems.length).toBe(2)
+ })
+
+ it('handles very large weights', () => {
+ const signers = [{ account: 'rSigner1', weight: 999999 }]
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText(/999,999/)).toBeInTheDocument()
+ })
+
+ it('handles mix of signers with and without weights', () => {
+ const signers = [
+ { account: 'rSigner1', weight: 1 },
+ { account: 'rSigner2' },
+ { account: 'rSigner3', weight: 3 },
+ ]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const weights = container.querySelectorAll('.signer-weight')
+ expect(weights.length).toBe(2) // Only two signers have weights
+ })
+ })
+
+ describe('List Structure', () => {
+ it('contains signers-list container', () => {
+ const signers = [{ account: 'rSigner1', weight: 1 }]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(container.querySelector('.signers-list')).toBeInTheDocument()
+ })
+
+ it('each signer has signer-address element', () => {
+ const signers = [
+ { account: 'rSigner1', weight: 1 },
+ { account: 'rSigner2', weight: 2 },
+ ]
+
+ const { container } = render(
+
+
+ ,
+ )
+
+ const addresses = container.querySelectorAll('.signer-address')
+ expect(addresses.length).toBe(2)
+ })
+ })
+})
diff --git a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx
index dadfad7da..e8c220a3d 100644
--- a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx
+++ b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx
@@ -7,17 +7,16 @@ import { useAnalytics } from '../../shared/analytics'
import SocketContext from '../../shared/SocketContext'
import { getAccountTransactions } from '../../../rippled'
+import './styles.scss'
export interface AccountTransactionsTableProps {
accountId: string
- currencySelected?: string
hasTokensColumn: boolean
}
export const AccountTransactionTable = ({
accountId,
hasTokensColumn,
- currencySelected = 'XRP',
}: AccountTransactionsTableProps) => {
const { t } = useTranslation()
const { trackException } = useAnalytics()
@@ -49,40 +48,32 @@ export const AccountTransactionTable = ({
},
)
- const filterTransactions = () => {
- let processedTransactions = data?.pages?.reduce((txArray, page: any) => {
- if (page.transactions) {
- return txArray.concat(page.transactions)
- }
- return txArray
- }, [])
+ const transactions =
+ data?.pages?.reduce(
+ (allTransactions: any[], page: any) =>
+ page.transactions
+ ? allTransactions.concat(page.transactions)
+ : allTransactions,
+ [],
+ ) || []
- if (currencySelected !== 'XRP') {
- processedTransactions = processedTransactions?.filter(
- (tx) =>
- !currencySelected ||
- (currencySelected &&
- JSON.stringify(tx).includes(
- `"currency":"${currencySelected.toUpperCase()}"`,
- )),
- )
- }
- return processedTransactions
- }
-
- const transactions = filterTransactions()
- const tryLoading = transactions?.length === 0 && data?.pages[0]?.transactions
+ const tryLoading = transactions.length === 0 && data?.pages[0]?.transactions
const emptyMessage = tryLoading
? 'get_account_transactions_try'
: error?.message
return (
- fetchNextPage()}
- hasAdditionalResults={hasNextPage}
- />
+
+
+
{t('transactions')}
+
+
fetchNextPage()}
+ hasAdditionalResults={hasNextPage}
+ />
+
)
}
diff --git a/src/containers/Accounts/AccountTransactionTable/styles.scss b/src/containers/Accounts/AccountTransactionTable/styles.scss
new file mode 100644
index 000000000..12c2cda10
--- /dev/null
+++ b/src/containers/Accounts/AccountTransactionTable/styles.scss
@@ -0,0 +1,13 @@
+@use '../../shared/css/variables' as *;
+
+.transactions-header {
+ @include bold;
+
+ font-size: 20px;
+}
+
+@media (max-width: $tablet-portrait-upper-boundary) {
+ .transactions-header {
+ font-size: 18px;
+ }
+}
diff --git a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx
index 71c57c766..9c8c5aa3a 100644
--- a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx
+++ b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx
@@ -22,7 +22,6 @@ describe('AccountTransactionsTable container', () => {
const createWrapper = (
getAccountTransactionsImpl: () => Promise = () =>
new Promise(() => {}),
- currencySelected: string = '',
state = { hasToken: false },
) => {
;(getAccountTransactions as Mock).mockImplementation(
@@ -33,7 +32,6 @@ describe('AccountTransactionsTable container', () => {
,
)
@@ -85,7 +83,6 @@ describe('AccountTransactionsTable container', () => {
await flushPromises()
component.update()
- expect(component.find('.load-more-btn')).not.toExist()
expect(component.find('.transaction-table')).toExist()
expect(component.find('.empty-transactions-message')).toHaveText(
'get_account_transactions_failed',
@@ -94,28 +91,9 @@ describe('AccountTransactionsTable container', () => {
component.unmount()
})
- it('renders try loading more message when no filtered results show but there is a marker', async () => {
- const component = createWrapper(
- () => Promise.resolve(TEST_TRANSACTIONS_DATA),
- 'EUR',
- )
-
- await flushPromises()
- component.update()
-
- expect(component.find('.load-more-btn')).toExist()
- expect(component.find('.transaction-table')).toExist()
- expect(component.find('.empty-transactions-message')).toHaveText(
- 'get_account_transactions_try',
- )
- expect(component.find(Link).length).toBe(0)
- component.unmount()
- })
-
it('renders dynamic content with transaction data and token column', async () => {
const component = createWrapper(
() => Promise.resolve(TEST_TRANSACTIONS_DATA),
- undefined,
{ hasToken: true },
)
@@ -148,7 +126,6 @@ describe('AccountTransactionsTable container', () => {
await flushPromises()
component.update()
- expect(component.find('.load-more-btn')).not.toExist()
expect(component.find('.transaction-table')).toExist()
expect(component.find('.empty-transactions-message')).toHaveText(
'get_account_transactions_failed',
@@ -156,22 +133,4 @@ describe('AccountTransactionsTable container', () => {
expect(component.find(Link).length).toBe(0)
component.unmount()
})
-
- it('renders try loading more message when no filtered results show but there is a marker', async () => {
- const component = createWrapper(
- () => Promise.resolve(TEST_TRANSACTIONS_DATA),
- 'EUR',
- )
-
- await flushPromises()
- component.update()
-
- expect(component.find('.load-more-btn')).toExist()
- expect(component.find('.transaction-table')).toExist()
- expect(component.find('.empty-transactions-message')).toHaveText(
- 'get_account_transactions_try',
- )
- expect(component.find(Link).length).toBe(0)
- component.unmount()
- })
})
diff --git a/src/containers/Accounts/index.tsx b/src/containers/Accounts/index.tsx
index 543598d64..b2c65fe78 100644
--- a/src/containers/Accounts/index.tsx
+++ b/src/containers/Accounts/index.tsx
@@ -1,26 +1,24 @@
-import { useContext, useEffect, useState } from 'react'
+import { useContext, useEffect } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from 'react-query'
import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec'
-import { AccountHeader } from './AccountHeader'
import { AccountTransactionTable } from './AccountTransactionTable'
import './styles.scss'
import { useAnalytics } from '../shared/analytics'
-import { Tabs } from '../shared/components/Tabs'
-import { AccountAssetTab } from './AccountAssetTab/AccountAssetTab'
-import { buildPath, useRouteParams } from '../shared/routing'
+import { useRouteParams } from '../shared/routing'
import { ACCOUNT_ROUTE } from '../App/routes'
import { BAD_REQUEST } from '../shared/utils'
import { getAccountState } from '../../rippled'
import SocketContext from '../shared/SocketContext'
import { Loader } from '../shared/components/Loader'
+import { AccountSummary } from './AccountSummary'
+import { useXRPToUSDRate } from '../shared/hooks/useXRPToUSDRate'
+import AccountAsset from './AccountAsset'
+import AccountHeader from './AccountHeader'
export const Accounts = () => {
const { trackScreenLoaded, trackException } = useAnalytics()
- const { id: accountId = '', tab = 'transactions' } =
- useRouteParams(ACCOUNT_ROUTE)
- const [currencySelected, setCurrencySelected] = useState('XRP')
- const mainPath = buildPath(ACCOUNT_ROUTE, { id: accountId })
+ const { id: accountId = '' } = useRouteParams(ACCOUNT_ROUTE)
const rippledSocket = useContext(SocketContext)
const { data: account, isLoading } = useQuery(
@@ -30,17 +28,13 @@ export const Accounts = () => {
return Promise.reject(BAD_REQUEST)
}
- return getAccountState(accountId, rippledSocket).catch(
- (transactionRequestError) => {
- const status = transactionRequestError.code
- trackException(
- `ledger ${accountId} --- ${JSON.stringify(
- transactionRequestError,
- )}`,
- )
- return Promise.reject(status)
- },
- )
+ return getAccountState(accountId, rippledSocket).catch((requestError) => {
+ const status = requestError.code
+ trackException(
+ `ledger ${accountId} --- ${JSON.stringify(requestError)}`,
+ )
+ return Promise.reject(status)
+ })
},
)
@@ -50,9 +44,13 @@ export const Accounts = () => {
return () => {
window.scrollTo(0, 0)
}
- }, [tab, trackScreenLoaded])
+ }, [trackScreenLoaded])
- const tabs = ['transactions', 'assets']
+ const xrpToUSDRate = useXRPToUSDRate()
+
+ const isAccountDeleted = account?.deleted === true
+ // Show account data only after account.info is loaded and account isn't deleted
+ const showAccount = !!account?.info && !isAccountDeleted
return (
@@ -60,20 +58,27 @@ export const Accounts = () => {
{accountId && (
<>
setCurrencySelected(currency)}
- currencySelected={currencySelected}
+ account={account}
/>
-
- {tab === 'transactions' && (
-
+ {showAccount && (
+ <>
+
+
+ >
)}
- {tab === 'assets' && account && }
+
+ {/* Show account transactions regardless of account delete status */}
+
>
)}
{isLoading && }
diff --git a/src/containers/Accounts/test/index.test.tsx b/src/containers/Accounts/test/index.test.tsx
index 555cff680..734981887 100644
--- a/src/containers/Accounts/test/index.test.tsx
+++ b/src/containers/Accounts/test/index.test.tsx
@@ -1,11 +1,9 @@
-import { mount } from 'enzyme'
+import { render, screen, waitFor } from '@testing-library/react'
import { Route } from 'react-router'
import i18n from '../../../i18n/testConfig'
import { Accounts } from '../index'
-import { AccountHeader } from '../AccountHeader'
-import { AccountTransactionTable } from '../AccountTransactionTable'
import mockAccountState from './mockAccountState.json'
-import { QuickHarness, flushPromises } from '../../test/utils'
+import { QuickHarness } from '../../test/utils'
import { ACCOUNT_ROUTE } from '../../App/routes'
import { getAccountState } from '../../../rippled'
import Mock = jest.Mock
@@ -16,13 +14,37 @@ jest.mock('../../../rippled', () => ({
getAccountTransactions: jest.fn(() => []),
}))
+jest.mock('../AccountHeader', () => ({
+ __esModule: true,
+ default: () => Account Header
,
+}))
+
+jest.mock('../AccountSummary', () => ({
+ __esModule: true,
+ AccountSummary: () => (
+ Account Summary
+ ),
+}))
+
+jest.mock('../AccountAsset', () => ({
+ __esModule: true,
+ default: () => Account Asset
,
+}))
+
+jest.mock('../AccountTransactionTable', () => ({
+ __esModule: true,
+ AccountTransactionTable: () => (
+ Account Transaction Table
+ ),
+}))
+
const mockedGetAccountState = getAccountState as Mock
describe('Account container', () => {
const TEST_ACCOUNT_ID = 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM'
- const createWrapper = () =>
- mount(
+ const renderComponent = () =>
+ render(
{
)
beforeEach(() => {
- jest.resetModules()
+ jest.clearAllMocks()
})
it('renders without crashing', () => {
- const wrapper = createWrapper()
- wrapper.unmount()
+ const { unmount } = renderComponent()
+ unmount()
})
it('renders static parts', async () => {
@@ -45,13 +67,15 @@ describe('Account container', () => {
Promise.resolve(mockAccountState),
)
- const wrapper = createWrapper()
- await flushPromises()
- wrapper.update()
+ renderComponent()
- expect(wrapper.find(AccountHeader).length).toBe(1)
- expect(wrapper.find(AccountTransactionTable).length).toBe(1)
- wrapper.find('.balance-selector button').simulate('click')
- wrapper.unmount()
+ await waitFor(() => {
+ expect(screen.getByTestId('account-header')).toBeInTheDocument()
+ expect(screen.getByTestId('account-summary')).toBeInTheDocument()
+ expect(screen.getByTestId('account-asset')).toBeInTheDocument()
+ expect(
+ screen.getByTestId('account-transaction-table'),
+ ).toBeInTheDocument()
+ })
})
})
diff --git a/src/containers/App/test/App.test.jsx b/src/containers/App/test/App.test.jsx
index 1525bc321..dc3888625 100644
--- a/src/containers/App/test/App.test.jsx
+++ b/src/containers/App/test/App.test.jsx
@@ -333,7 +333,7 @@ describe('App container', () => {
network: 'mainnet',
},
])
- expect(mockGetAccountInfo).toBeCalledWith(
+ expect(mockGetAccountInfo).toHaveBeenCalledWith(
expect.anything(),
'r35jYntLwkrbc3edisgavDbEdNRSKgcQE6',
)
@@ -354,7 +354,7 @@ describe('App container', () => {
network: 'mainnet',
},
])
- expect(mockGetAccountInfo).toBeCalledWith(
+ expect(mockGetAccountInfo).toHaveBeenCalledWith(
expect.anything(),
'rKV8HEL3vLc6q9waTiJcewdRdSFyx67QFb',
)
diff --git a/src/containers/MPT/MPTHeader/MPTHeader.tsx b/src/containers/MPT/MPTHeader/MPTHeader.tsx
index 354268991..972f50674 100644
--- a/src/containers/MPT/MPTHeader/MPTHeader.tsx
+++ b/src/containers/MPT/MPTHeader/MPTHeader.tsx
@@ -9,7 +9,7 @@ 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 { formatMPTIssuance } from '../../../rippled/lib/utils'
import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces'
import { Details } from './Details'
import { Settings } from './Settings'
@@ -30,7 +30,7 @@ export const MPTHeader = (props: Props) => {
['getMPTIssuance', tokenId],
async () => {
const info = await getMPTIssuance(rippledSocket, tokenId)
- return formatMPTIssuanceInfo(info)
+ return formatMPTIssuance(info.node)
},
{
onError: (e: any) => {
diff --git a/src/containers/Token/TokenHeader/index.tsx b/src/containers/Token/TokenHeader/index.tsx
index c22ba9e7c..725be2729 100644
--- a/src/containers/Token/TokenHeader/index.tsx
+++ b/src/containers/Token/TokenHeader/index.tsx
@@ -45,9 +45,7 @@ export const TokenHeader = ({
value={ }
/>
)}
- {rate && (
-
- )}
+
{previousLedger && (
{
+ if (value === 0) {
+ return '--'
+ }
+
+ let options
+ if (value >= USD_REGULAR_BALANCE_LOWER_BOUND) {
+ options = USD_CURRENCY_OPTIONS
+ } else if (value >= USD_SMALL_BALANCE_LOWER_BOUND) {
+ options = USD_SMALL_BALANCE_CURRENCY_OPTIONS
+ } else {
+ options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS
+ }
+
+ return localizeNumber(value, lang, options) || '--'
+}
+
+/**
+ * Formats token balances with conditional precision based on value
+ * @param balance - The token balance to format
+ * @param lang - Language for localization
+ * @returns Formatted balance string
+ */
+export const formatTokenBalance = (balance: number, lang: string): string => {
+ const options =
+ balance > TOKEN_BALANCE_LARGE_LOWER_BOUND
+ ? NUMBER_DEFAULT_OPTIONS
+ : NUMBER_SMALL_OPTIONS
+ return localizeNumber(balance, lang, options) || '0'
+}
+
+/**
+ * Three-step calculation for USD balance to ensure mathematical consistency
+ * Formats USD price, formats token balance, then calculates USD balance using displayed values
+ * @param tokenBalance - The token balance
+ * @param priceInUSD - Price in USD
+ * @param lang - Language for localization
+ * @returns Object with formatted USD price, token balance, and calculated USD balance
+ */
+export const calculateFormattedUsdBalance = (
+ tokenBalance: number,
+ priceInUSD: number,
+ lang: string,
+): {
+ formattedUsdPrice: string
+ formattedBalance: string
+ formattedBalanceUsd: string
+} => {
+ let formattedUsdPrice = '--'
+ let formattedBalance = '--'
+ let formattedBalanceUsd = '--'
+
+ if (priceInUSD !== 0) {
+ // Step 1: Format USD Price
+ formattedUsdPrice = formatUsdValue(priceInUSD, lang)
+ const displayedUsdPrice = parseFloat(
+ (formattedUsdPrice || '0').replace(/[$,]/g, ''), // Removes dollar signs and commas from USD prices like "$4,321.30" → "4321.30"
+ )
+
+ // Step 2: Format Balance
+ formattedBalance = formatTokenBalance(tokenBalance, lang)
+ const displayedBalance = parseFloat(
+ (formattedBalance || '0').replace(/[,]/g, ''), // Removes commas from token balances like "1,234.5678" → "1234.5678"
+ )
+
+ // Step 3: Calculate USD Balance using displayed values
+ const calculatedBalanceUSD = displayedUsdPrice * displayedBalance
+ formattedBalanceUsd = formatUsdValue(calculatedBalanceUSD, lang)
+ } else {
+ // If no price, still format the balance
+ formattedBalance = formatTokenBalance(tokenBalance, lang)
+ }
+
+ return {
+ formattedUsdPrice,
+ formattedBalance,
+ formattedBalanceUsd,
+ }
+}
diff --git a/src/containers/shared/components/Account.tsx b/src/containers/shared/components/Account.tsx
index c27f17ec3..cafaa5925 100644
--- a/src/containers/shared/components/Account.tsx
+++ b/src/containers/shared/components/Account.tsx
@@ -3,12 +3,13 @@ import { ACCOUNT_ROUTE } from '../../App/routes'
export interface AccountProps {
account: string
+ displayText?: string
link?: boolean
tag?: number
}
export const Account = (props: AccountProps) => {
- const { account, link = true, tag } = props
+ const { account, displayText, link = true, tag } = props
const parts = account.split(':')
const computedTag = tag || parts[1]
@@ -22,11 +23,11 @@ export const Account = (props: AccountProps) => {
to={ACCOUNT_ROUTE}
params={{ id: parts[0] }}
>
- {parts[0]}
+ {displayText || parts[0]}
) : (
- {parts[0]}
+ {displayText || parts[0]}
)}
{computedTag && (
diff --git a/src/containers/shared/components/Amount.tsx b/src/containers/shared/components/Amount.tsx
index 8c7607ca0..aaec18ce1 100644
--- a/src/containers/shared/components/Amount.tsx
+++ b/src/containers/shared/components/Amount.tsx
@@ -7,7 +7,7 @@ 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 { formatMPTIssuance } from '../../../rippled/lib/utils'
import SocketContext from '../SocketContext'
import { useAnalytics } from '../analytics'
@@ -57,7 +57,7 @@ export const Amount = ({
['getMPTIssuanceScale', mptID],
async () => {
const info = await getMPTIssuance(rippledSocket, mptID)
- return formatMPTIssuanceInfo(info)
+ return formatMPTIssuance(info.node)
},
{
onError: (e: any) => {
diff --git a/src/containers/shared/components/Currency.tsx b/src/containers/shared/components/Currency.tsx
index cd959538e..3c5ccbdea 100644
--- a/src/containers/shared/components/Currency.tsx
+++ b/src/containers/shared/components/Currency.tsx
@@ -4,7 +4,8 @@ import { TOKEN_ROUTE, MPT_ROUTE } from '../../App/routes'
// https://xrpl.org/currency-formats.html#nonstandard-currency-codes
const NON_STANDARD_CODE_LENGTH = 40
const XRP = 'XRP'
-const LP_TOKEN_IDENTIFIER = '03'
+// https://xrpl.org/docs/concepts/tokens/decentralized-exchange/automated-market-makers#lp-token-currency-codes
+export const LP_TOKEN_IDENTIFIER = '03'
export interface Props {
issuer?: string
diff --git a/src/containers/shared/components/DomainLink.tsx b/src/containers/shared/components/DomainLink.tsx
index 4afe8e831..a79d25d30 100644
--- a/src/containers/shared/components/DomainLink.tsx
+++ b/src/containers/shared/components/DomainLink.tsx
@@ -5,6 +5,7 @@ export interface Props {
className?: string
decode?: boolean
domain: string
+ displayDomain?: string
keepProtocol?: boolean
}
@@ -13,7 +14,13 @@ const PROTOCOL_REGEX = /^([a-z][a-z0-9+\-.]*):\/\//
const PROTOCOL_REMOVAL_REGEX = /^(https?:\/\/)?(.*?)(\/)?$/
const DomainLink = (props: Props) => {
- const { className, decode = false, domain, keepProtocol = true } = props
+ const {
+ className,
+ decode = false,
+ domain,
+ displayDomain = '',
+ keepProtocol = true,
+ } = props
// If decode is true, decode the domain
const decodedDomain = decode ? decodeHex(domain) : domain
@@ -40,7 +47,7 @@ const DomainLink = (props: Props) => {
href={href}
onClick={(event) => event.stopPropagation()}
>
- {domainText}
+ {displayDomain || domainText}
)
}
diff --git a/src/containers/shared/components/NFTokenLink.tsx b/src/containers/shared/components/NFTokenLink.tsx
index e22bdcdbc..336be0601 100644
--- a/src/containers/shared/components/NFTokenLink.tsx
+++ b/src/containers/shared/components/NFTokenLink.tsx
@@ -3,10 +3,11 @@ import { NFT_ROUTE } from '../../App/routes'
export interface NFTokenLinkProps {
tokenID: string
+ shortTokenID?: string
}
-export const NFTokenLink = ({ tokenID }: NFTokenLinkProps) => (
+export const NFTokenLink = ({ tokenID, shortTokenID }: NFTokenLinkProps) => (
- {tokenID}
+ {shortTokenID || tokenID}
)
diff --git a/src/containers/shared/components/PaginatedTable/index.tsx b/src/containers/shared/components/PaginatedTable/index.tsx
new file mode 100644
index 000000000..99311af03
--- /dev/null
+++ b/src/containers/shared/components/PaginatedTable/index.tsx
@@ -0,0 +1,140 @@
+import './styles.scss'
+import { useState, useMemo, ReactNode } from 'react'
+
+function getPageItems(current: number, total: number) {
+ const pages: (number | 'dots')[] = []
+ const push = (v: number | 'dots') => pages.push(v)
+ const addRange = (s: number, e: number) => {
+ for (let i = s; i <= e; i += 1) {
+ push(i)
+ }
+ }
+
+ if (total <= 7) {
+ addRange(1, total)
+ return pages
+ }
+
+ if (current <= 4) {
+ addRange(1, 5)
+ push('dots')
+ push(total)
+ return pages
+ }
+ if (current >= total - 3) {
+ push(1)
+ push('dots')
+ addRange(total - 4, total)
+ return pages
+ }
+
+ push(1)
+ push('dots')
+ addRange(current - 2, current + 2)
+ push('dots')
+ push(total)
+ return pages
+}
+
+export interface PaginatedTableProps {
+ data: any[]
+ pageSize?: number
+ tableStructure: (paginatedData: any[]) => ReactNode
+}
+
+export function PaginatedTable({
+ data,
+ pageSize = 10,
+ tableStructure,
+}: PaginatedTableProps) {
+ const [currentPage, setCurrentPage] = useState(1)
+
+ const totalPages = Math.max(1, Math.ceil(data.length / pageSize))
+
+ // Calculate paginated data
+ const paginatedData = useMemo(() => {
+ const start = (currentPage - 1) * pageSize
+ return data.slice(start, start + pageSize)
+ }, [currentPage, data, pageSize])
+
+ const items = getPageItems(currentPage, totalPages)
+ const prevDisabled = currentPage === 1
+ const nextDisabled = currentPage === totalPages
+
+ const pagination =
+ totalPages > 1 ? (
+
+ setCurrentPage(1)}
+ >
+ «
+
+
+ setCurrentPage(Math.max(1, currentPage - 1))}
+ >
+ ‹
+
+
+ {items.map((it, index) =>
+ it === 'dots' ? (
+
+ …
+
+ ) : (
+ setCurrentPage(it as number)}
+ >
+ {it}
+
+ ),
+ )}
+
+ setCurrentPage(Math.min(totalPages, currentPage + 1))}
+ >
+ ›
+
+
+ setCurrentPage(totalPages)}
+ >
+ »
+
+
+ ) : null
+
+ return (
+
+ {tableStructure(paginatedData)}
+ {pagination}
+
+ )
+}
diff --git a/src/containers/shared/components/PaginatedTable/styles.scss b/src/containers/shared/components/PaginatedTable/styles.scss
new file mode 100644
index 000000000..f7f39c048
--- /dev/null
+++ b/src/containers/shared/components/PaginatedTable/styles.scss
@@ -0,0 +1,58 @@
+@use '../../css/variables' as *;
+
+.table-pagination {
+ position: relative;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 12px 0 0;
+ gap: 3px;
+}
+
+/* Page numbers + chevrons */
+.table-pg-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ color: $black-50;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.table-pg-btn:hover {
+ background: rgb(255 255 255 / 3%);
+ color: $black-0;
+}
+
+.table-pg-btn.is-active {
+ background: rgb(22 163 74 / 6%);
+ color: $green-30;
+}
+
+/* Larger green chevrons for first/prev/next/last */
+.table-pg-btn[aria-label="First page"],
+.table-pg-btn[aria-label="Previous page"],
+.table-pg-btn[aria-label="Next page"],
+.table-pg-btn[aria-label="Last page"] {
+ padding: 0 5px;
+ color: $green-30;
+ font-size: 25px;
+}
+
+/* Disabled state */
+.table-pg-btn:disabled {
+ background: transparent;
+ color: $black-50;
+ cursor: not-allowed;
+ opacity: 0.35;
+}
+
+/* Ellipsis */
+.table-pg-ellipsis {
+ padding: 0 6px;
+ color: $black-50;
+ font-size: 15px;
+}
diff --git a/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx b/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx
new file mode 100644
index 000000000..b75a102be
--- /dev/null
+++ b/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx
@@ -0,0 +1,325 @@
+import { render, screen, fireEvent } from '@testing-library/react'
+import { PaginatedTable } from '../index'
+
+describe('PaginatedTable', () => {
+ const mockTableStructure = (data: any[]) => (
+
+
+ {data.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+ )
+
+ const generateMockData = (count: number) =>
+ Array.from({ length: count }, (_, i) => ({ name: `Item ${i + 1}` }))
+
+ describe('Rendering', () => {
+ it('renders without crashing with empty data', () => {
+ render( )
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
+ })
+
+ it('renders table with data', () => {
+ const data = generateMockData(5)
+ render( )
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.getByText('Item 5')).toBeInTheDocument()
+ })
+
+ it('does not render pagination when data fits in one page', () => {
+ const data = generateMockData(5)
+ render(
+ ,
+ )
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
+ })
+
+ it('renders pagination when data exceeds page size', () => {
+ const data = generateMockData(15)
+ render(
+ ,
+ )
+ expect(
+ screen.getByRole('navigation', { name: 'Table pagination' }),
+ ).toBeInTheDocument()
+ })
+ })
+
+ describe('Pagination controls', () => {
+ it('renders all pagination buttons', () => {
+ const data = generateMockData(15)
+ render(
+ ,
+ )
+ expect(screen.getByLabelText('First page')).toBeInTheDocument()
+ expect(screen.getByLabelText('Previous page')).toBeInTheDocument()
+ expect(screen.getByLabelText('Next page')).toBeInTheDocument()
+ expect(screen.getByLabelText('Last page')).toBeInTheDocument()
+ })
+
+ it('disables prev/first buttons on first page', () => {
+ const data = generateMockData(15)
+ render(
+ ,
+ )
+ expect(screen.getByLabelText('First page')).toBeDisabled()
+ expect(screen.getByLabelText('Previous page')).toBeDisabled()
+ expect(screen.getByLabelText('Next page')).not.toBeDisabled()
+ expect(screen.getByLabelText('Last page')).not.toBeDisabled()
+ })
+
+ it('disables next/last buttons on last page', () => {
+ const data = generateMockData(15)
+ render(
+ ,
+ )
+ const lastPageButton = screen.getByLabelText('Last page')
+ fireEvent.click(lastPageButton)
+
+ expect(screen.getByLabelText('First page')).not.toBeDisabled()
+ expect(screen.getByLabelText('Previous page')).not.toBeDisabled()
+ expect(screen.getByLabelText('Next page')).toBeDisabled()
+ expect(screen.getByLabelText('Last page')).toBeDisabled()
+ })
+
+ it('marks current page as active', () => {
+ const data = generateMockData(15)
+ render(
+ ,
+ )
+ const page1Button = screen.getByRole('button', {
+ name: '1',
+ current: 'page',
+ })
+ expect(page1Button).toHaveClass('is-active')
+ })
+ })
+
+ describe('Navigation', () => {
+ it('navigates to next page', () => {
+ const data = generateMockData(25)
+ render(
+ ,
+ )
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.queryByText('Item 11')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Next page'))
+
+ expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
+ expect(screen.getByText('Item 11')).toBeInTheDocument()
+ })
+
+ it('navigates to previous page', () => {
+ const data = generateMockData(25)
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByLabelText('Next page'))
+ fireEvent.click(screen.getByLabelText('Previous page'))
+
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.queryByText('Item 11')).not.toBeInTheDocument()
+ })
+
+ it('navigates to first page', () => {
+ const data = generateMockData(25)
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByLabelText('Last page'))
+ fireEvent.click(screen.getByLabelText('First page'))
+
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.queryByText('Item 21')).not.toBeInTheDocument()
+ })
+
+ it('navigates to last page', () => {
+ const data = generateMockData(25)
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByLabelText('Last page'))
+
+ expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
+ expect(screen.getByText('Item 21')).toBeInTheDocument()
+ expect(screen.getByText('Item 25')).toBeInTheDocument()
+ })
+
+ it('navigates to specific page by clicking page number', () => {
+ const data = generateMockData(30)
+ render(
+ ,
+ )
+ const page2Button = screen.getByRole('button', { name: '2' })
+ fireEvent.click(page2Button)
+
+ expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
+ expect(screen.getByText('Item 11')).toBeInTheDocument()
+ expect(screen.getByText('Item 20')).toBeInTheDocument()
+ })
+ })
+
+ describe('Page items display', () => {
+ it('shows all pages when total pages <= 7', () => {
+ const data = generateMockData(70)
+ render(
+ ,
+ )
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '7' })).toBeInTheDocument()
+ expect(screen.queryByText('…')).not.toBeInTheDocument()
+ })
+
+ it('shows ellipsis at end when current page <= 4', () => {
+ const data = generateMockData(100)
+ render(
+ ,
+ )
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument()
+ expect(screen.getAllByText('…')).toHaveLength(1)
+ })
+
+ it('shows ellipsis at start when current page >= total - 3', () => {
+ const data = generateMockData(100)
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByLabelText('Last page'))
+
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '6' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument()
+ expect(screen.getAllByText('…')).toHaveLength(1)
+ })
+
+ it('shows ellipsis at both ends when in middle pages', () => {
+ const data = generateMockData(100)
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByRole('button', { name: '5' }))
+
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '7' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument()
+ expect(screen.getAllByText('…')).toHaveLength(2)
+ })
+ })
+
+ describe('Data pagination', () => {
+ it('displays correct data for each page', () => {
+ const data = generateMockData(25)
+ render(
+ ,
+ )
+
+ // Page 1: Items 1-10
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.getByText('Item 10')).toBeInTheDocument()
+ expect(screen.queryByText('Item 11')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Next page'))
+
+ // Page 2: Items 11-20
+ expect(screen.queryByText('Item 10')).not.toBeInTheDocument()
+ expect(screen.getByText('Item 11')).toBeInTheDocument()
+ expect(screen.getByText('Item 20')).toBeInTheDocument()
+ expect(screen.queryByText('Item 21')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Next page'))
+
+ // Page 3: Items 21-25
+ expect(screen.queryByText('Item 20')).not.toBeInTheDocument()
+ expect(screen.getByText('Item 21')).toBeInTheDocument()
+ expect(screen.getByText('Item 25')).toBeInTheDocument()
+ })
+
+ it('uses custom page size', () => {
+ const data = generateMockData(15)
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
+ expect(screen.getByText('Item 5')).toBeInTheDocument()
+ expect(screen.queryByText('Item 6')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/containers/shared/components/test/Account.test.tsx b/src/containers/shared/components/test/Account.test.tsx
index 958463089..2b7dc1d35 100644
--- a/src/containers/shared/components/test/Account.test.tsx
+++ b/src/containers/shared/components/test/Account.test.tsx
@@ -72,4 +72,29 @@ describe('Account', () => {
expect(element).toHaveAttribute('title', ACCOUNT)
expect(screen.queryByTestId('dt')).toHaveTextContent(':123')
})
+
+ it('should render with displayText', () => {
+ renderComponent(
+ ,
+ )
+ const element = screen.getByTestId('account')
+ expect(element).toHaveClass('account')
+ expect(element).toHaveTextContent('Custom Display Name')
+ expect(element).toHaveAttribute('href', `/accounts/${ACCOUNT}`)
+ expect(element).toHaveAttribute('title', ACCOUNT)
+ })
+
+ it('should render with displayText and no link', () => {
+ renderComponent(
+ ,
+ )
+ const element = screen.getByTestId('account')
+ expect(element).toHaveTextContent('Custom Display Name')
+ expect(element).not.toHaveAttribute('href')
+ expect(element).toHaveAttribute('title', ACCOUNT)
+ })
})
diff --git a/src/containers/shared/components/test/DomainLink.test.tsx b/src/containers/shared/components/test/DomainLink.test.tsx
index 57e83ad77..2f2993fb1 100644
--- a/src/containers/shared/components/test/DomainLink.test.tsx
+++ b/src/containers/shared/components/test/DomainLink.test.tsx
@@ -1,72 +1,82 @@
-import { mount } from 'enzyme'
+import { render, screen } from '@testing-library/react'
import DomainLink from '../DomainLink'
describe('DomainLink', () => {
it('handles domain link with only domain parameter', () => {
- const wrapper = mount( )
- expect(wrapper.find('a').props().className).toEqual('domain')
- expect(wrapper.find('a').text()).toEqual('bithomp.com')
- expect(wrapper.find('a').props().href).toEqual('https://bithomp.com')
- wrapper.unmount()
+ render( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain')
+ expect(link).toHaveTextContent('bithomp.com')
+ expect(link).toHaveAttribute('href', 'https://bithomp.com')
})
it('handles domain link with decoded domain parameter', () => {
- const wrapper = mount(
- ,
- )
- expect(wrapper.find('a').props().className).toEqual('domain')
- expect(wrapper.find('a').text()).toEqual('sologenic.com')
- expect(wrapper.find('a').props().href).toEqual('https://sologenic.com')
- wrapper.unmount()
+ render( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain')
+ expect(link).toHaveTextContent('sologenic.com')
+ expect(link).toHaveAttribute('href', 'https://sologenic.com')
})
it('handles domain link with domain parameter and classname', () => {
- const wrapper = mount( )
- expect(wrapper.find('a').props().className).toEqual('domain test')
- expect(wrapper.find('a').text()).toEqual('bithomp.com')
- expect(wrapper.find('a').props().href).toEqual('https://bithomp.com')
- wrapper.unmount()
+ render( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain test')
+ expect(link).toHaveTextContent('bithomp.com')
+ expect(link).toHaveAttribute('href', 'https://bithomp.com')
})
it('handles domain link with decoded domain parameter and classname', () => {
- const wrapper = mount(
+ render(
,
)
- expect(wrapper.find('a').props().className).toEqual('domain test')
- expect(wrapper.find('a').text()).toEqual('sologenic.com')
- expect(wrapper.find('a').props().href).toEqual('https://sologenic.com')
- wrapper.unmount()
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain test')
+ expect(link).toHaveTextContent('sologenic.com')
+ expect(link).toHaveAttribute('href', 'https://sologenic.com')
})
it('handles domain link with domain provided in HEX-encoded format', () => {
const url = 'https://example.com'
const urlInHex = '68747470733A2F2F6578616D706C652E636F6D'
- const wrapper = mount( )
- expect(wrapper.find('a').props().className).toEqual('domain')
- expect(wrapper.find('a').text()).toEqual(url)
- expect(wrapper.find('a').props().href).toEqual(url)
- wrapper.unmount()
+ render( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain')
+ expect(link).toHaveTextContent(url)
+ expect(link).toHaveAttribute('href', url)
})
it('handles IPFS domain link', () => {
- const wrapper = mount( )
- expect(wrapper.find('a').props().className).toEqual('domain')
- expect(wrapper.find('a').text()).toEqual('ipfs://random/metadata.json')
- expect(wrapper.find('a').props().href).toEqual(
+ render( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain')
+ expect(link).toHaveTextContent('ipfs://random/metadata.json')
+ expect(link).toHaveAttribute(
+ 'href',
'https://ipfs.io/ipfs/random/metadata.json',
)
- wrapper.unmount()
})
it('handles domain link with protocol removal', () => {
const url = 'https://example.com/'
- const wrapper = mount( )
- expect(wrapper.find('a').props().className).toEqual('domain')
- expect(wrapper.find('a').text()).toEqual('example.com')
- expect(wrapper.find('a').props().href).toEqual('https://example.com/')
+ render( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain')
+ expect(link).toHaveTextContent('example.com')
+ expect(link).toHaveAttribute('href', 'https://example.com/')
+ })
+
+ it('handles domain link with displayDomain', () => {
+ render(
+ ,
+ )
+ const link = screen.getByRole('link')
+ expect(link).toHaveClass('domain')
+ expect(link).toHaveTextContent('Custom Display Text')
+ expect(link).toHaveAttribute('href', 'https://bithomp.com')
})
})
diff --git a/src/containers/shared/components/test/NFTokenLink.test.tsx b/src/containers/shared/components/test/NFTokenLink.test.tsx
new file mode 100644
index 000000000..aa9985318
--- /dev/null
+++ b/src/containers/shared/components/test/NFTokenLink.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { NFTokenLink } from '../NFTokenLink'
+
+const renderComponent = (component: JSX.Element) =>
+ render({component} )
+
+const TOKEN_ID =
+ '000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4B11F5E1D5E8A3F3D8E9A'
+
+describe('NFTokenLink', () => {
+ it('should render with full tokenID', () => {
+ renderComponent( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveTextContent(TOKEN_ID)
+ expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`)
+ expect(link).toHaveAttribute('title', TOKEN_ID)
+ })
+
+ it('should render with shortTokenID when provided', () => {
+ const shortID = '000827...3D8E9A'
+ renderComponent( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveTextContent(shortID)
+ expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`)
+ expect(link).toHaveAttribute('title', TOKEN_ID)
+ })
+
+ it('should use full tokenID when shortTokenID is empty string', () => {
+ renderComponent( )
+ const link = screen.getByRole('link')
+ expect(link).toHaveTextContent(TOKEN_ID)
+ expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`)
+ expect(link).toHaveAttribute('title', TOKEN_ID)
+ })
+})
diff --git a/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx b/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx
new file mode 100644
index 000000000..dd7f4719d
--- /dev/null
+++ b/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx
@@ -0,0 +1,73 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from 'react-query'
+import { useXRPToUSDRate } from '../useXRPToUSDRate'
+import SocketContext from '../../SocketContext'
+import * as rippled from '../../../../rippled/lib/rippled'
+import Log from '../../log'
+
+jest.mock('../../../../rippled/lib/rippled')
+jest.mock('../../log')
+
+const mockedGetAccountLines = rippled.getAccountLines as jest.Mock
+
+const createWrapper =
+ (socket: any = {}, queryClient = new QueryClient()) =>
+ ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+
+describe('useXRPToUSDRate', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ process.env.VITE_ENVIRONMENT = 'mainnet'
+ })
+
+ it('returns 0.0 if no lines available', async () => {
+ mockedGetAccountLines.mockResolvedValue({ lines: [] })
+ const { result } = renderHook(() => useXRPToUSDRate(), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => expect(result.current).toBe(0.0))
+ })
+
+ it('returns 1.5 if not mainnet', () => {
+ process.env.VITE_ENVIRONMENT = 'dev'
+ const { result } = renderHook(() => useXRPToUSDRate(), {
+ wrapper: createWrapper(),
+ })
+ expect(result.current).toBe(1.5)
+ })
+
+ it('fetches XRP/USD rate if mainnet', async () => {
+ mockedGetAccountLines.mockResolvedValue({ lines: [{ limit: 2.3 }] })
+ const { result } = renderHook(() => useXRPToUSDRate(), {
+ wrapper: createWrapper({}),
+ })
+ await waitFor(() => expect(result.current).toBe(2.3))
+ })
+
+ it('falls back to last successful rate on error', async () => {
+ mockedGetAccountLines
+ .mockResolvedValueOnce({ lines: [{ limit: 2.1 }] })
+ .mockRejectedValueOnce(new Error('network error'))
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: 0 } },
+ })
+ const { result } = renderHook(() => useXRPToUSDRate(), {
+ wrapper: createWrapper({}, queryClient),
+ })
+
+ // First fetch succeeds
+ await waitFor(() => expect(result.current).toBe(2.1))
+
+ // Force useQuery to refetch
+ queryClient.invalidateQueries('XRPToUSDRate')
+
+ await waitFor(() => expect(result.current).toBe(2.1))
+ expect(Log.error).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/containers/shared/hooks/useXRPToUSDRate.ts b/src/containers/shared/hooks/useXRPToUSDRate.ts
new file mode 100644
index 000000000..0edfe3117
--- /dev/null
+++ b/src/containers/shared/hooks/useXRPToUSDRate.ts
@@ -0,0 +1,46 @@
+import { useContext, useState } from 'react'
+import { useQuery } from 'react-query'
+import SocketContext from '../SocketContext'
+import { getAccountLines } from '../../../rippled/lib/rippled'
+import Log from '../log'
+
+const FETCH_INTERVAL_MILLIS = 5 * 1000 // 1 minute
+const XRP_USD_ORACLE_ACCOUNT = 'rXUMMaPpZqPutoRszR29jtC8amWq3APkx'
+
+const fetchXRPToUSDRate = async (rippledSocket: any) => {
+ const accountLines = await getAccountLines(
+ rippledSocket,
+ XRP_USD_ORACLE_ACCOUNT,
+ 1,
+ )
+
+ return accountLines.lines[0]?.limit ?? 0.0
+}
+
+/**
+ * Returns the current exchange rate for XRP to USD.
+ * Retries {@link FETCH_RETRY_COUNT} times on failure and falls back to the last successful value if fetching fails.
+ */
+export function useXRPToUSDRate(): number {
+ const isMainnet = process.env.VITE_ENVIRONMENT === 'mainnet'
+
+ const rippledSocket = useContext(SocketContext)
+ const [lastRate, setLastRate] = useState(0.0)
+
+ const { data } = useQuery(
+ ['XRPToUSDRate'],
+ () => fetchXRPToUSDRate(rippledSocket),
+ {
+ enabled: isMainnet,
+ refetchInterval: FETCH_INTERVAL_MILLIS,
+ onSuccess: (rate: number) => setLastRate(rate), // store the last successfully fetched rate
+ onError: (error) => Log.error(error), // do nothing, last rate stays
+ },
+ )
+
+ if (!isMainnet) {
+ return 1.5 // This is chosen randomly for non-mainnet environments
+ }
+
+ return data ?? lastRate
+}
diff --git a/src/containers/shared/images/clock-icon.svg b/src/containers/shared/images/clock-icon.svg
new file mode 100644
index 000000000..2ca85f9a0
--- /dev/null
+++ b/src/containers/shared/images/clock-icon.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/containers/shared/images/default_token_icon.svg b/src/containers/shared/images/default_token_icon.svg
new file mode 100644
index 000000000..2008239c3
--- /dev/null
+++ b/src/containers/shared/images/default_token_icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/containers/shared/images/info-duotone.svg b/src/containers/shared/images/info-duotone.svg
new file mode 100644
index 000000000..bee2e99b7
--- /dev/null
+++ b/src/containers/shared/images/info-duotone.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/containers/shared/images/usd_icon.svg b/src/containers/shared/images/usd_icon.svg
new file mode 100644
index 000000000..45aa5e424
--- /dev/null
+++ b/src/containers/shared/images/usd_icon.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/containers/shared/images/xrp_balance_icon.svg b/src/containers/shared/images/xrp_balance_icon.svg
new file mode 100644
index 000000000..8743414d5
--- /dev/null
+++ b/src/containers/shared/images/xrp_balance_icon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/containers/shared/images/xrp_reserve_balance_icon.svg b/src/containers/shared/images/xrp_reserve_balance_icon.svg
new file mode 100644
index 000000000..435082dba
--- /dev/null
+++ b/src/containers/shared/images/xrp_reserve_balance_icon.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/containers/shared/test/NumberFormattingUtils.test.ts b/src/containers/shared/test/NumberFormattingUtils.test.ts
new file mode 100644
index 000000000..d282e8532
--- /dev/null
+++ b/src/containers/shared/test/NumberFormattingUtils.test.ts
@@ -0,0 +1,178 @@
+import {
+ formatUsdValue,
+ formatTokenBalance,
+ calculateFormattedUsdBalance,
+} from '../NumberFormattingUtils'
+
+describe('NumberFormattingUtils', () => {
+ const lang = 'en-US'
+
+ describe('formatUsdValue', () => {
+ it('returns "--" for zero value', () => {
+ expect(formatUsdValue(0, lang)).toBe('--')
+ })
+
+ it('formats regular USD value (>= $1) with 2 decimals', () => {
+ expect(formatUsdValue(1, lang)).toBe('$1.00')
+ expect(formatUsdValue(100, lang)).toBe('$100.00')
+ expect(formatUsdValue(23.565, lang)).toBe('$23.57')
+ expect(formatUsdValue(5678.9, lang)).toBe('$5,678.90')
+ })
+
+ it('formats small USD value (>= $0.0001, < $1) with 4 decimals', () => {
+ expect(formatUsdValue(0.0001, lang)).toBe('$0.0001')
+ expect(formatUsdValue(0.00014, lang)).toBe('$0.0001')
+ expect(formatUsdValue(0.00015, lang)).toBe('$0.0002')
+ expect(formatUsdValue(0.25, lang)).toBe('$0.25')
+ expect(formatUsdValue(0.5, lang)).toBe('$0.50')
+ expect(formatUsdValue(0.9999, lang)).toBe('$0.9999')
+ expect(formatUsdValue(0.99995, lang)).toBe('$1.00')
+ })
+
+ it('formats extra small USD value (< $0.0001) with up to 10 decimals', () => {
+ expect(formatUsdValue(0.00000000051, lang)).toBe('$0.0000000005')
+ expect(formatUsdValue(0.000001, lang)).toBe('$0.000001')
+ expect(formatUsdValue(0.0000051, lang)).toBe('$0.0000051')
+ expect(formatUsdValue(0.000014, lang)).toBe('$0.000014')
+ expect(formatUsdValue(0.00005, lang)).toBe('$0.00005')
+ })
+
+ it('handles negative values', () => {
+ expect(formatUsdValue(-100, lang)).toBe('-$100.00')
+ expect(formatUsdValue(-0.0001, lang)).toBe('-$0.0001')
+ expect(formatUsdValue(-200, lang)).toBe('-$200.00')
+ expect(formatUsdValue(-0.0005, lang)).toBe('-$0.0005')
+ })
+ })
+
+ describe('formatTokenBalance', () => {
+ it('formats large token balance (> 999) with 2 decimals', () => {
+ expect(formatTokenBalance(1000, lang)).toBe('1,000')
+ expect(formatTokenBalance(5000, lang)).toBe('5,000')
+ expect(formatTokenBalance(1234567.891, lang)).toBe('1,234,567.89')
+ })
+
+ it('formats small token balance (<= 999) with 4 decimals', () => {
+ expect(formatTokenBalance(0, lang)).toBe('0')
+ expect(formatTokenBalance(1, lang)).toBe('1')
+ expect(formatTokenBalance(100, lang)).toBe('100')
+ expect(formatTokenBalance(999, lang)).toBe('999')
+ expect(formatTokenBalance(0.1234, lang)).toBe('0.1234')
+ expect(formatTokenBalance(500.56784, lang)).toBe('500.5678')
+ expect(formatTokenBalance(500.56785, lang)).toBe('500.5679')
+ })
+
+ it('handles negative token balances', () => {
+ expect(formatTokenBalance(-1000, lang)).toBe('-1,000')
+ expect(formatTokenBalance(-100, lang)).toBe('-100')
+ })
+ })
+
+ describe('calculateFormattedUsdBalance', () => {
+ it('returns all "--" when price is zero', () => {
+ const result = calculateFormattedUsdBalance(1000, 0, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '--',
+ formattedBalance: '1,000', // Balance is formatted even when price is zero
+ formattedBalanceUsd: '--',
+ })
+ })
+
+ it('calculates formatted USD balance correctly for regular values', () => {
+ const result = calculateFormattedUsdBalance(100, 50, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$50.00',
+ formattedBalance: '100',
+ formattedBalanceUsd: '$5,000.00',
+ })
+ })
+
+ it('calculates formatted USD balance correctly for small token balance', () => {
+ const result = calculateFormattedUsdBalance(0.5, 100, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$100.00',
+ formattedBalance: '0.5',
+ formattedBalanceUsd: '$50.00',
+ })
+ })
+
+ it('calculates formatted USD balance correctly for small price', () => {
+ const result = calculateFormattedUsdBalance(1000, 0.0005, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$0.0005',
+ formattedBalance: '1,000',
+ formattedBalanceUsd: '$0.50',
+ })
+ })
+
+ it('calculates formatted USD balance correctly for extra small price', () => {
+ const result = calculateFormattedUsdBalance(10000, 0.00001, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$0.00001',
+ formattedBalance: '10,000',
+ formattedBalanceUsd: '$0.10',
+ })
+ })
+
+ it('handles calculations resulting in small USD balances', () => {
+ const result = calculateFormattedUsdBalance(10, 0.05, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$0.05',
+ formattedBalance: '10',
+ formattedBalanceUsd: '$0.50',
+ })
+ })
+
+ it('handles calculations with decimal token balances', () => {
+ const result = calculateFormattedUsdBalance(123.4561, 2.5, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$2.50',
+ formattedBalance: '123.4561',
+ formattedBalanceUsd: '$308.64',
+ })
+ })
+
+ it('handles large token balances with regular prices', () => {
+ const result = calculateFormattedUsdBalance(5000, 1.25, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$1.25',
+ formattedBalance: '5,000',
+ formattedBalanceUsd: '$6,250.00',
+ })
+ })
+
+ it('handles calculations that result in very small USD balances', () => {
+ const result = calculateFormattedUsdBalance(0.1, 0.0001, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$0.0001',
+ formattedBalance: '0.1',
+ formattedBalanceUsd: '$0.00001',
+ })
+ })
+
+ it('handles zero token balance', () => {
+ const result = calculateFormattedUsdBalance(0, 100, lang)
+ expect(result).toEqual({
+ formattedUsdPrice: '$100.00',
+ formattedBalance: '0',
+ formattedBalanceUsd: '--',
+ })
+ })
+
+ it('correctly removes commas and dollar signs in calculation', () => {
+ // Large values that will have commas
+ const result = calculateFormattedUsdBalance(1234, 5.671, lang)
+ expect(result.formattedUsdPrice).toBe('$5.67')
+ expect(result.formattedBalance).toBe('1,234')
+ // 1234 * 5.67 = 6,996.78
+ expect(result.formattedBalanceUsd).toBe('$6,996.78')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles very large numbers', () => {
+ expect(formatUsdValue(1000000, lang)).toBe('$1,000,000.00')
+ expect(formatTokenBalance(999999999, lang)).toBe('999,999,999')
+ })
+ })
+})
diff --git a/src/containers/shared/test/utils.test.ts b/src/containers/shared/test/utils.test.ts
index bc4994268..234cdfd50 100644
--- a/src/containers/shared/test/utils.test.ts
+++ b/src/containers/shared/test/utils.test.ts
@@ -7,6 +7,10 @@ import {
localizeDate,
durationToHuman,
formatAsset,
+ shortenAccount,
+ shortenDomain,
+ shortenNFTTokenID,
+ shortenMPTID,
} from '../utils'
describe('utils', () => {
@@ -174,3 +178,55 @@ describe('AMM utils format asset', () => {
expect(formatted).toEqual({ currency: 'USD', issuer: 'your mom' })
})
})
+
+describe('Shorten utils', () => {
+ describe('shortenAccount', () => {
+ it('shortens long account addresses', () => {
+ const longAccount = 'rN7n7otQDd6FczFgLdlqtyMVrn5f4W01dn'
+ expect(shortenAccount(longAccount)).toBe('rN7n7ot...W01dn')
+ })
+
+ it('returns short account addresses unchanged', () => {
+ const shortAccount = 'rShortAddr'
+ expect(shortenAccount(shortAccount)).toBe(shortAccount)
+ })
+ })
+
+ describe('shortenDomain', () => {
+ it('shortens long domain names', () => {
+ const longDomain = 'verylongdomainnamethatexceedslimit.com'
+ expect(shortenDomain(longDomain)).toBe('verylongdomainn...dslimit.com')
+ })
+
+ it('returns short domain names unchanged', () => {
+ const shortDomain = 'example.com'
+ expect(shortenDomain(shortDomain)).toBe(shortDomain)
+ })
+ })
+
+ describe('shortenNFTTokenID', () => {
+ it('shortens long NFT token IDs', () => {
+ const longTokenID =
+ '000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4B11F5E1D5E8A3F3D8E9A'
+ expect(shortenNFTTokenID(longTokenID)).toBe('000827103B...8A3F3D8E9A')
+ })
+
+ it('returns short NFT token IDs unchanged', () => {
+ const shortTokenID = '000827103B94ECBB7BF0'
+ expect(shortenNFTTokenID(shortTokenID)).toBe(shortTokenID)
+ })
+ })
+
+ describe('shortenMPTID', () => {
+ it('shortens long MPT token IDs', () => {
+ const longMPTID =
+ '00000000A8B71A79C3CE4E8A3F3D8E9A5BEB9D7C6F4B11F5E1D5E8A3F3D8E9A'
+ expect(shortenMPTID(longMPTID)).toBe('00000000A8...8A3F3D8E9A')
+ })
+
+ it('returns short MPT token IDs unchanged', () => {
+ const shortMPTID = '00000000A8B71A79C3CE'
+ expect(shortenMPTID(shortMPTID)).toBe(shortMPTID)
+ })
+ })
+})
diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js
index 3d8365cbc..770cc6c82 100644
--- a/src/containers/shared/utils.js
+++ b/src/containers/shared/utils.js
@@ -401,3 +401,19 @@ export function convertScaledPrice(assetPrice, scale) {
? `${integerPart}.${fractionalPart.toString().padStart(scale, '0')}`
: `${integerPart}`
}
+
+export const shortenAccount = (addr = '') =>
+ addr.length > 12 ? `${addr.slice(0, 7)}...${addr.slice(-5)}` : addr
+
+export const shortenDomain = (domain = '') =>
+ domain.length > 26 ? `${domain.slice(0, 15)}...${domain.slice(-11)}` : domain
+
+export const shortenNFTTokenID = (nftTokenID = '') =>
+ nftTokenID.length > 20
+ ? `${nftTokenID.slice(0, 10)}...${nftTokenID.slice(-10)}`
+ : nftTokenID
+
+export const shortenMPTID = (mptTokenID = '') =>
+ mptTokenID.length > 20
+ ? `${mptTokenID.slice(0, 10)}...${mptTokenID.slice(-10)}`
+ : mptTokenID
diff --git a/src/rippled/accountState.ts b/src/rippled/accountState.ts
index 122cb8fdc..3e0a6658a 100644
--- a/src/rippled/accountState.ts
+++ b/src/rippled/accountState.ts
@@ -1,9 +1,3 @@
-/**
- * page path: /accounts/:id?
- *
- * part 1 of 2
- */
-
import {
isValidClassicAddress,
isValidXAddress,
@@ -11,16 +5,12 @@ import {
} from 'ripple-address-codec'
import {
getAccountInfo,
- getAccountEscrows,
getAccountPaychannels,
- getBalances,
getServerInfo,
getAccountTransactions,
- getAccountBridges,
} from './lib/rippled'
import logger from './lib/logger'
import { formatAccountInfo, formatSignerList } from './lib/utils'
-import type { ExplorerAmount } from '../containers/shared/types'
import type { ExplorerXrplClient } from '../containers/shared/SocketContext'
interface XAddress {
@@ -31,18 +21,11 @@ interface XAddress {
export interface AccountState {
account: string
- balances: {
- XRP: number
- }
paychannels?: {
// eslint-disable-next-line camelcase
total_available: number
channels: any[]
}
- escrows?: {
- totalIn: number
- totalOut: number
- }
signerList?: {
signers: {
account: string
@@ -52,6 +35,7 @@ export interface AccountState {
maxSigners: number
}
info: {
+ accountTransactionID?: string
reserve: number
sequence?: number
ticketCount: number
@@ -66,53 +50,16 @@ export interface AccountState {
test: boolean
}
deleted: boolean
- hasBridge: boolean
}
-const log = logger({ name: 'account balances' })
-
-const formatBalances = (info, data) => {
- const balances = { XRP: Number(info.Balance) / 1000000 }
- const { assets = {}, obligations = {} } = data
- const tokens: ExplorerAmount[] = []
-
- Object.keys(obligations).forEach((currency) => {
- if (!balances[currency]) {
- balances[currency] = 0
- }
-
- balances[currency] += Number(obligations[currency])
- })
-
- Object.keys(assets).forEach((issuer) => {
- assets[issuer].forEach((d) => {
- if (!balances[d.currency]) {
- balances[d.currency] = 0
- }
+const log = logger({ name: 'GetAccountState' })
- balances[d.currency] += Number(d.value)
- tokens.push({
- amount: Number(d.value),
- currency: d.currency,
- issuer,
- })
- })
- })
-
- return {
- balances,
- tokens,
- }
-}
async function getAccountState(
account: string,
rippledSocket: ExplorerXrplClient,
): Promise {
- // TODO: Retrieve balances for untagged X-address only? or display notice/warning
-
let classicAddress: string
let decomposedAddress: XAddress | null = null
-
try {
if (!isValidClassicAddress(account) && !isValidXAddress(account)) {
throw new Error('Malformed address')
@@ -121,10 +68,7 @@ async function getAccountState(
if (isValidXAddress(account)) {
decomposedAddress = xAddressToClassicAddress(account)
;({ classicAddress } = decomposedAddress)
- // TODO: Display tag, if present
const isTestnet = decomposedAddress.test
-
- // TODO: Display tag, if present
if (
(isTestnet && process.env.VITE_ENVIRONMENT === 'mainnet') ||
(!isTestnet &&
@@ -140,36 +84,25 @@ async function getAccountState(
log.error(error.toString())
throw error
}
- log.info(`get balances: ${account} -> ${classicAddress}`)
+
return getAccountInfo(rippledSocket, classicAddress)
.then((info) =>
Promise.all([
- getBalances(rippledSocket, classicAddress).then((data) =>
- formatBalances(info, data),
- ),
- getAccountEscrows(rippledSocket, classicAddress, info.ledger_index),
getAccountPaychannels(rippledSocket, classicAddress, info.ledger_index),
getServerInfo(rippledSocket),
- getAccountBridges(rippledSocket, classicAddress),
]).then((data) => ({
account: info.Account as string,
- info: formatAccountInfo(info, data[3].info.validated_ledger),
- balances: data[0].balances,
- tokens: data[0].tokens,
+ info: formatAccountInfo(info, data[1].info.validated_ledger),
signerList: info.signer_lists?.[0]
? formatSignerList(info.signer_lists[0])
: undefined,
- escrows: data[1],
- paychannels: data[2],
+ paychannels: data[1],
xAddress: decomposedAddress || undefined,
deleted: false,
- hasBridge: data[4]?.length > 0,
})),
)
.catch((error) => {
- // X-address:
- // error.toString(): CustomError: account not found
- // error.code: 404
+ // Check if it's a deleted account
if (error.code === 404) {
return getAccountTransactions(rippledSocket, classicAddress, 1).then(
(data) => {
@@ -178,8 +111,6 @@ async function getAccountState(
account: classicAddress,
deleted: true,
xAddress: decomposedAddress || undefined,
- balances: { XRP: 0 },
- hasBridge: false,
info: { reserve: 0, ticketCount: 0, flags: [] },
}
}
diff --git a/src/rippled/index.js b/src/rippled/index.js
index 7928a12d7..874b45934 100644
--- a/src/rippled/index.js
+++ b/src/rippled/index.js
@@ -7,7 +7,7 @@ import getNegativeUNL from './nUNL'
import getToken from './token'
import getOffers from './offers'
-import { getAccountInfo, getAMMInfo } from './lib/rippled'
+import { getAccountInfo, getAMMInfoByAssets } from './lib/rippled'
export {
getAccountInfo,
@@ -19,5 +19,5 @@ export {
getNegativeUNL,
getToken,
getOffers,
- getAMMInfo,
+ getAMMInfoByAssets,
}
diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js
index 6720ee845..fca22a980 100644
--- a/src/rippled/lib/rippled.js
+++ b/src/rippled/lib/rippled.js
@@ -178,14 +178,13 @@ const getTransaction = (rippledSocket, txId) => {
})
}
-// get account info
-const getAccountInfo = (rippledSocket, account) =>
+const getAccountInfo = (rippledSocket, account, includeSignerLists = true) =>
query(rippledSocket, {
command: 'account_info',
api_version: 1,
account,
ledger_index: 'validated',
- signer_lists: true,
+ signer_lists: includeSignerLists,
}).then((resp) => {
if (resp.error === 'actNotFound') {
throw new Error('account not found', 404)
@@ -265,6 +264,7 @@ const getAccountPaychannels = async (
throw new Error(resp.error_message, 500)
}
+ // This isn't working, resp.marker isn't empty, but we don't enter this if block
if (!resp.account_objects.length) {
return undefined
}
@@ -278,10 +278,12 @@ const getAccountPaychannels = async (
})
await getChannels()
+
const channels = list.map((c) => {
remaining += c.Amount - c.Balance
return formatPaychannel(c)
})
+
return channels.length
? {
channels,
@@ -347,11 +349,9 @@ const getBalances = (rippledSocket, account, ledgerIndex = 'validated') =>
if (resp.error_message) {
throw new Error(resp.error_message, 500)
}
-
return resp
})
-// get account transactions
const getAccountTransactions = (
rippledSocket,
account,
@@ -395,7 +395,40 @@ const getAccountNFTs = (rippledSocket, account, marker = '', limit = 20) =>
command: 'account_nfts',
account,
marker: marker || undefined,
+ limit, // Not `limit` of NFTs, but `limit` pages of NFTs
+ }).then((resp) => {
+ if (resp.error === 'actNotFound') {
+ throw new Error('account not found', 404)
+ }
+
+ if (resp.error_message) {
+ throw new Error(resp.error_message, 500)
+ }
+
+ return resp
+ })
+
+const getNFTsIssuedByAccount = (
+ rippledSocket,
+ issuer,
+ marker = '',
+ limit = 20,
+) =>
+ query(rippledSocket, {
+ command: 'nfts_by_issuer',
+ issuer,
+ marker: marker || undefined,
limit,
+ }).then((resp) => {
+ if (resp.error === 'actNotFound') {
+ throw new Error('account not found', 404)
+ }
+
+ if (resp.error_message) {
+ throw new Error(resp.error_message, 500)
+ }
+
+ return resp
})
const getNFTInfo = (rippledSocket, tokenId) =>
@@ -413,27 +446,40 @@ const getNFTInfo = (rippledSocket, tokenId) =>
return resp
})
-const getNFToffers = (
+const getNFToffers = async (
offerCmd,
rippledSocket,
tokenId,
limit = 50,
marker = '',
-) =>
- query(rippledSocket, {
- command: offerCmd,
- nft_id: tokenId,
- limit,
- marker: marker !== '' ? marker : undefined,
- }).then((resp) => {
+) => {
+ const allOffers = []
+ let currentMarker = marker
+
+ do {
+ // eslint-disable-next-line no-await-in-loop
+ const resp = await query(rippledSocket, {
+ command: offerCmd,
+ nft_id: tokenId,
+ limit,
+ marker: currentMarker !== '' ? currentMarker : undefined,
+ })
+
+ // The NFT does not have any offers (note that object refers to the offer rather than the NFT itself).
if (resp.error === 'objectNotFound') {
- throw new Error('NFT not found', 404)
+ throw new Error(resp.error_message, 404)
}
+
if (resp.error_message) {
throw new Error(resp.error_message, 500)
}
- return resp
- })
+
+ allOffers.push(...(resp.offers || []))
+ currentMarker = resp.marker
+ } while (currentMarker)
+
+ return { offers: allOffers }
+}
const getBuyNFToffers = (rippledSocket, tokenId, limit = 50, marker = '') =>
getNFToffers('nft_buy_offers', rippledSocket, tokenId, limit, marker)
@@ -551,25 +597,34 @@ const getOffers = (
return resp
})
-// AMM Additions
-
-const getAMMInfo = (rippledSocket, asset, asset2) => {
+const getAMMInfo = (rippledSocket, params) => {
const request = {
command: 'amm_info',
- asset,
- asset2,
ledger_index: 'validated',
+ ...params,
}
return query(rippledSocket, request).then((resp) => {
- if (resp.error_message || !resp.validated) {
- throw new Error('Failed to get amm info', 404)
+ if (resp.error_message) {
+ throw new Error(resp.error_message)
+ }
+
+ if (!resp.validated) {
+ throw new Error(
+ 'Ledger is not validated. The response data is pending and might change',
+ )
}
return resp
})
}
+const getAMMInfoByAssets = (rippledSocket, asset, asset2) =>
+ getAMMInfo(rippledSocket, { asset, asset2 })
+
+const getAMMInfoByAMMAccount = (rippledSocket, ammAccount) =>
+ getAMMInfo(rippledSocket, { amm_account: ammAccount })
+
// get feature
const getFeature = (rippledSocket, amendmentId) => {
const request = {
@@ -597,12 +652,13 @@ const getMPTIssuance = (rippledSocket, tokenId) =>
resp.error === 'lgrNotFound' ||
resp.error === 'objectNotFound'
) {
- throw new Error('MPT not found', 404)
+ throw new Error('MPT Issuance not found', 404)
}
if (resp.error_message) {
throw new Error(resp.error_message, 500)
}
+
return resp
})
@@ -611,6 +667,7 @@ const getAccountMPTs = (
account,
marker = '',
ledgerIndex = 'validated',
+ limit = 400,
) =>
query(rippledSocket, {
command: 'account_objects',
@@ -618,13 +675,47 @@ const getAccountMPTs = (
ledger_index: ledgerIndex,
type: 'mptoken',
marker: marker || undefined,
- limit: 400,
+ limit,
}).then((resp) => {
if (resp.error === 'actNotFound') {
throw new Error('account not found', 404)
}
+
if (resp.error === 'invalidParams') {
- return undefined
+ // For example, "error_message": "Required field 'account' missing"
+ throw new Error(resp.error_message, 400)
+ }
+
+ if (resp.error_message) {
+ throw new Error(resp.error_message, 500)
+ }
+
+ return resp
+ })
+
+const getAccountObjects = (
+ rippledSocket,
+ account,
+ objectType,
+ marker = '',
+ ledgerIndex = 'validated',
+ limit = 400,
+) =>
+ query(rippledSocket, {
+ command: 'account_objects',
+ account,
+ ledger_index: ledgerIndex,
+ type: objectType,
+ marker: marker || undefined,
+ limit,
+ }).then((resp) => {
+ if (resp.error === 'actNotFound') {
+ throw new Error('account not found', 404)
+ }
+
+ if (resp.error === 'invalidParams') {
+ // For example, "error_message": "Required field 'account' missing"
+ throw new Error(resp.error_message, 400)
}
if (resp.error_message) {
@@ -634,17 +725,19 @@ const getAccountMPTs = (
return resp
})
-const getAccountLines = (rippledSocket, account, limit) =>
+const getAccountLines = (rippledSocket, account, limit, marker = '') =>
query(rippledSocket, {
command: 'account_lines',
account,
limit,
+ marker: marker || undefined,
}).then((resp) => {
if (resp.error === 'actNotFound') {
throw new Error('account not found', 404)
}
if (resp.error === 'invalidParams') {
- return undefined
+ // For example, "error_message": "Required field 'account' missing"
+ throw new Error(resp.error_message, 400)
}
if (resp.error_message) {
@@ -663,6 +756,8 @@ export {
getAccountPaychannels,
getAccountBridges,
getAccountNFTs,
+ getAccountObjects,
+ getNFTsIssuedByAccount,
getBalances,
getAccountTransactions,
getNegativeUNL,
@@ -673,7 +768,8 @@ export {
getBuyNFToffers,
getSellNFToffers,
getNFTTransactions,
- getAMMInfo,
+ getAMMInfoByAssets,
+ getAMMInfoByAMMAccount,
getFeature,
getMPTIssuance,
getAccountMPTs,
diff --git a/src/rippled/lib/test/utils.test.ts b/src/rippled/lib/test/utils.test.ts
index 1c7f9d0dc..134348d93 100644
--- a/src/rippled/lib/test/utils.test.ts
+++ b/src/rippled/lib/test/utils.test.ts
@@ -1,4 +1,4 @@
-import { formatAccountInfo, formatNFTInfo } from '../utils'
+import { formatAccountInfo, formatNFTInfo, formatTransferFee } from '../utils'
describe('rippled utils:', () => {
describe('formatNFTInfo', () => {
@@ -89,4 +89,85 @@ describe('rippled utils:', () => {
)
})
})
+
+ describe('formatTransferFee', () => {
+ describe('when no transfer fee is provided', () => {
+ it('should return "0" for null', () => {
+ expect(formatTransferFee(null)).toBe('0')
+ })
+
+ it('should return "0" for undefined', () => {
+ expect(formatTransferFee(undefined)).toBe('0')
+ })
+
+ it('should return "0" for 0', () => {
+ expect(formatTransferFee(0)).toBe('0')
+ })
+ })
+
+ describe('when tokenType is "IOU"', () => {
+ it('should calculate percentage for IOU tokens using billion-based formula', () => {
+ // 0.5% transfer fee: 1000000000 + (1000000000 * 0.005) = 1005000000
+ expect(formatTransferFee(1005000000, 'IOU')).toBe('0.5')
+
+ // 10% transfer fee: 1000000000 + (1000000000 * 0.1) = 1100000000
+ expect(formatTransferFee(1100000000, 'IOU')).toBe('10')
+ })
+
+ it('should handle high precision values for IOU tokens', () => {
+ // Test with a value that needs rounding to 7 decimal places
+ const transferFee = 1000000000 + 12345678 // should result in 1.2345678%
+ expect(formatTransferFee(transferFee, 'IOU')).toBe('1.2345678')
+ })
+
+ it('should handle very small transfer fees for IOU tokens', () => {
+ const billion = 1000000000
+ const transferFee = billion + 1 // 0.0000001%
+ expect(formatTransferFee(transferFee, 'IOU')).toBe('1e-7') // JavaScript converts very small numbers to scientific notation
+ })
+ })
+
+ describe('when tokenType is "MPT" or "NFT"', () => {
+ it('should calculate percentage for MPT tokens using thousand-based formula', () => {
+ // 0.5% transfer fee
+ expect(formatTransferFee(5, 'MPT')).toBe('0.005')
+
+ // 10% transfer fee
+ expect(formatTransferFee(100, 'MPT')).toBe('0.1')
+ })
+
+ it('should calculate percentage for NFT tokens using thousand-based formula', () => {
+ expect(formatTransferFee(5, 'NFT')).toBe('0.005')
+ expect(formatTransferFee(100, 'NFT')).toBe('0.1')
+ })
+
+ it('should handle high precision values for MPT/NFT tokens', () => {
+ // Test with a value that needs rounding to 3 decimal places
+ expect(formatTransferFee(1234, 'MPT')).toBe('1.234')
+ expect(formatTransferFee(1235, 'NFT')).toBe('1.235') // Test rounding
+ })
+
+ it('should handle very small transfer fees for MPT/NFT tokens', () => {
+ expect(formatTransferFee(1, 'MPT')).toBe('0.001')
+ expect(formatTransferFee(0.5, 'NFT')).toBe('0.001') // Should round to 3 decimal places
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should throw error for unsupported token types', () => {
+ expect(() => formatTransferFee(100, 'XRP')).toThrow(
+ 'Unsupported Token type: XRP',
+ )
+ expect(() => formatTransferFee(100, 'INVALID')).toThrow(
+ 'Unsupported Token type: INVALID',
+ )
+ expect(() => formatTransferFee(100, '')).toThrow(
+ 'Unsupported Token type: ',
+ )
+ expect(() => formatTransferFee(100, null)).toThrow(
+ 'Unsupported Token type: null',
+ )
+ })
+ })
+ })
})
diff --git a/src/rippled/lib/utils.js b/src/rippled/lib/utils.js
index e08f4862f..407f39c7b 100644
--- a/src/rippled/lib/utils.js
+++ b/src/rippled/lib/utils.js
@@ -5,6 +5,7 @@ import { formatSignerList } from './formatSignerList'
import { decodeHex } from '../../containers/shared/transactionUtils'
const XRP_BASE = 1000000
+const THOUSAND = 1000
const BILLION = 1000000000
export const ACCOUNT_FLAGS = {
@@ -57,7 +58,7 @@ const zeroPad = (num, size, back = false) => {
return s
}
-const buildFlags = (flags, flagMap) => {
+export const buildFlags = (flags, flagMap) => {
const bits = zeroPad((flags || 0).toString(2), 32).split('')
return bits
@@ -69,16 +70,38 @@ const buildFlags = (flags, flagMap) => {
.filter((d) => Boolean(d))
}
+const formatTransferFee = (transferFee, tokenType) => {
+ if (!transferFee) {
+ return '0'
+ }
+
+ // https://xrpl.org/docs/concepts/tokens/fungible-tokens/transfer-fees#technical-details
+ if (tokenType === 'IOU') {
+ const transferFeePercentage = (100 * (transferFee - BILLION)) / BILLION
+ return parseFloat(transferFeePercentage.toFixed(7)).toString()
+ }
+
+ // MTP: https://xrpl.org/docs/references/protocol/data-types/nftoken#transferfee
+ // NFT: https://xrpl.org/docs/concepts/tokens/fungible-tokens/multi-purpose-tokens#transfer-fees
+ if (tokenType === 'NFT' || tokenType === 'MPT') {
+ const transferFeePercentage = transferFee / THOUSAND
+ return parseFloat(transferFeePercentage.toFixed(3)).toString()
+ }
+
+ throw new Error(`Unsupported Token type: ${tokenType}`)
+}
+
const formatAccountInfo = (info, serverInfoValidated) => ({
+ accountTransactionID: info.AccountTxnID,
sequence: info.Sequence,
- ticketCount: info.TicketCount,
+ ticketCount: info.TicketCount ?? 0,
ownerCount: info.OwnerCount,
reserve: serverInfoValidated.reserve_base_xrp
? serverInfoValidated.reserve_base_xrp +
info.OwnerCount * serverInfoValidated.reserve_inc_xrp
: undefined,
tick: info.TickSize,
- rate: info.TransferRate ? (info.TransferRate - BILLION) / BILLION : undefined,
+ rate: formatTransferFee(info.TransferRate, 'IOU'),
domain: info.Domain ? hexToString(info.Domain) : undefined,
emailHash: info.EmailHash,
flags: buildFlags(info.Flags, ACCOUNT_FLAGS),
@@ -135,24 +158,24 @@ 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)
+const formatMPTIssuance = (info) => ({
+ issuer: info.Issuer,
+ assetScale: info.AssetScale,
+ maxAmt: info.MaximumAmount
+ ? BigInt(info.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)
+ outstandingAmt: info.OutstandingAmount
+ ? BigInt(info.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),
+ transferFee: info.TransferFee,
+ sequence: info.Sequence,
+ metadata: info.MPTokenMetadata
+ ? decodeHex(info.MPTokenMetadata)
+ : info.MPTokenMetadata,
+ flags: buildFlags(info.Flags, MPT_ISSUANCE_FLAGS),
})
-const formatMPTokenInfo = (info) => ({
+const formatMPToken = (info) => ({
account: info.Account,
flags: buildFlags(info.Flags, MPTOKEN_FLAGS),
mptIssuanceID: info.MPTokenIssuanceID,
@@ -171,6 +194,7 @@ export {
formatAccountInfo,
convertHexToString,
formatNFTInfo,
- formatMPTIssuanceInfo,
- formatMPTokenInfo,
+ formatMPTIssuance,
+ formatMPToken,
+ formatTransferFee,
}
diff --git a/src/rippled/token.ts b/src/rippled/token.ts
index ab1adebd9..d0c04671d 100644
--- a/src/rippled/token.ts
+++ b/src/rippled/token.ts
@@ -9,7 +9,7 @@ export interface TokenData {
balance: string
reserve: number
sequence: number
- rate?: number
+ rate?: string
obligations?: string
domain?: string
emailHash?: string