diff --git a/src/containers/Vault/VaultHeader/index.tsx b/src/containers/Vault/VaultHeader/index.tsx index 0b20da374..dd62dae39 100644 --- a/src/containers/Vault/VaultHeader/index.tsx +++ b/src/containers/Vault/VaultHeader/index.tsx @@ -15,6 +15,7 @@ import { shortenAccount, getCurrencySymbol, isCurrencyExoticSymbol, + convertScaledPrice, } from '../../shared/utils' import './styles.scss' import { useAnalytics } from '../../shared/analytics' @@ -47,8 +48,11 @@ interface Props { data: VaultData vaultId: string displayCurrency: string + assetScale?: number } +const DEFAULT_EMPTY_VALUE = '--' + // Vault flags from XLS-65d spec const VAULT_FLAGS = { lsfVaultPrivate: 0x00010000, @@ -59,7 +63,12 @@ const WITHDRAWAL_POLICIES: { [key: number]: string } = { 1: 'first_come_first_served', } -export const VaultHeader = ({ data, vaultId, displayCurrency }: Props) => { +export const VaultHeader = ({ + data, + vaultId, + displayCurrency, + assetScale, +}: Props) => { const { t } = useTranslation() const { trackException } = useAnalytics() const rippledSocket = useContext(SocketContext) @@ -86,10 +95,19 @@ export const VaultHeader = ({ data, vaultId, displayCurrency }: Props) => { const convertToDisplayCurrency = ( amount: string | undefined, ): string | undefined => { - if (!amount || displayCurrency !== 'USD') return amount + if (!amount) return amount + + let normalized = amount + if (asset?.currency === 'XRP') { + normalized = convertScaledPrice(BigInt(amount), 6) + } else if (asset?.mpt_issuance_id) { + normalized = convertScaledPrice(BigInt(amount), assetScale ?? 0) + } + + if (displayCurrency !== 'USD') return normalized - const numAmount = Number(amount) - if (Number.isNaN(numAmount)) return amount + const numAmount = Number(normalized) + if (Number.isNaN(numAmount)) return normalized return tokenToUsdRate > 0 ? String(numAmount * tokenToUsdRate) : undefined } @@ -298,16 +316,16 @@ export const VaultHeader = ({ data, vaultId, displayCurrency }: Props) => { convertedAmount === undefined && displayCurrency === 'USD' ) { - return '--' + return DEFAULT_EMPTY_VALUE } - const amount = convertedAmount ?? assetsTotal - if (amount === undefined) return '--' + const amount = convertedAmount ?? '0' + if (amount === undefined) return DEFAULT_EMPTY_VALUE if ( ['0', '0.00', '0.0000'].includes( parseAmount(amount ?? '0', 2), ) ) - return '--' + return DEFAULT_EMPTY_VALUE // Note: As per the NumberFormat policy, prices in the range of [10_000, 1M] do not display decimal values // Very large prices (greater than 1M must have two decimal places) const displayedCurrency: string = getDisplayCurrencyLabel() @@ -334,8 +352,17 @@ export const VaultHeader = ({ data, vaultId, displayCurrency }: Props) => { value={(() => { if (assetsMaximum === undefined) return t('no_limit') - const parsedAmt = parseAmount(assetsMaximum, 2) - if (['0', '0.00', '0.0000'].includes(parsedAmt)) return '--' + const convertedAmount = + convertToDisplayCurrency(assetsMaximum) + if ( + convertedAmount === undefined && + displayCurrency === 'USD' + ) { + return DEFAULT_EMPTY_VALUE + } + const parsedAmt = parseAmount(convertedAmount ?? '0', 2) + if (['0', '0.00', '0.0000'].includes(parsedAmt)) + return DEFAULT_EMPTY_VALUE const displayedCurrency: string = getDisplayCurrencyLabel() if ( @@ -353,8 +380,17 @@ export const VaultHeader = ({ data, vaultId, displayCurrency }: Props) => { { - const parsedAmt = parseAmount(assetsAvailable ?? '0', 2) - if (['0', '0.00', '0.0000'].includes(parsedAmt)) return '--' + const convertedAmount = + convertToDisplayCurrency(assetsAvailable) + if ( + convertedAmount === undefined && + displayCurrency === 'USD' + ) { + return DEFAULT_EMPTY_VALUE + } + const parsedAmt = parseAmount(convertedAmount ?? '0', 2) + if (['0', '0.00', '0.0000'].includes(parsedAmt)) + return DEFAULT_EMPTY_VALUE const displayedCurrency: string = getDisplayCurrencyLabel() if ( @@ -368,8 +404,17 @@ export const VaultHeader = ({ data, vaultId, displayCurrency }: Props) => { { - const parsedAmt = parseAmount(lossUnrealized ?? '0', 2) - if (['0', '0.00', '0.0000'].includes(parsedAmt)) return '--' + const convertedAmount = + convertToDisplayCurrency(lossUnrealized) + if ( + convertedAmount === undefined && + displayCurrency === 'USD' + ) { + return DEFAULT_EMPTY_VALUE + } + const parsedAmt = parseAmount(convertedAmount ?? '0', 2) + if (['0', '0.00', '0.0000'].includes(parsedAmt)) + return DEFAULT_EMPTY_VALUE const displayedCurrency: string = getDisplayCurrencyLabel() if ( diff --git a/src/containers/Vault/VaultHeader/test/VaultHeader.test.tsx b/src/containers/Vault/VaultHeader/test/VaultHeader.test.tsx index 5b4b76144..f7570dd69 100644 --- a/src/containers/Vault/VaultHeader/test/VaultHeader.test.tsx +++ b/src/containers/Vault/VaultHeader/test/VaultHeader.test.tsx @@ -443,8 +443,9 @@ describe('VaultHeader Component', () => { const vaultData = { Owner: 'rTestOwner', Asset: { currency: 'XRP' }, - AssetsTotal: '12500000', // 12.5 million - AssetsAvailable: '5000000', // 5 million + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsTotal: '12500000000000', // 12.5M XRP + AssetsAvailable: '5000000000000', // 5M XRP } render( @@ -458,7 +459,6 @@ describe('VaultHeader Component', () => { ) // Numbers >= 1,000,000 should display with M suffix - // Verify exact formatted values: 12,500,000 -> "12.5M XRP", 5,000,000 -> "5M XRP" expect(screen.getByText('\uE900 12.50M')).toBeInTheDocument() expect(screen.getByText('\uE900 5.00M')).toBeInTheDocument() }) @@ -467,8 +467,9 @@ describe('VaultHeader Component', () => { const vaultData = { Owner: 'rTestOwner', Asset: { currency: 'XRP' }, - AssetsAvailable: '250000', // 250 thousand - LossUnrealized: '75000', // 75 thousand + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsAvailable: '250000000000', // 250K XRP + LossUnrealized: '75000000000', // 75K XRP } render( @@ -482,7 +483,6 @@ describe('VaultHeader Component', () => { ) // Numbers >= 1,000 but < 1,000,000 should display with K suffix - // Verify exact formatted values: 250,000 -> "250K XRP", 75,000 -> "75K XRP" expect(screen.getByText('\uE900 250.00K')).toBeInTheDocument() expect(screen.getByText('\uE900 75.00K')).toBeInTheDocument() }) @@ -491,7 +491,8 @@ describe('VaultHeader Component', () => { const vaultData = { Owner: 'rTestOwner', Asset: { currency: 'XRP' }, - AssetsAvailable: '500', // Less than 1000 + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsAvailable: '500000000', // 500 XRP } render( @@ -505,7 +506,6 @@ describe('VaultHeader Component', () => { ) // Numbers < 1,000 should display as-is without K/M suffix - // Verify exact formatted value: 500 -> "500 XRP" expect(screen.getByText('\uE900 500.00')).toBeInTheDocument() }) @@ -1050,6 +1050,43 @@ describe('VaultHeader Component', () => { expect(screen.getByText('5,000.00 VTKN')).toBeInTheDocument() }) }) + + it('scales raw MPT amounts by 10^AssetScale before display', async () => { + const mptId = '00001234ABCD5678EF90ABCDEF1234567890ABCDEF' + const mptMetadata = { + ticker: 'VTKN', + name: 'Vault Token', + } + const mptMetadataHex = Buffer.from(JSON.stringify(mptMetadata)) + .toString('hex') + .toUpperCase() + + mockedGetMPTIssuance.mockResolvedValue({ + node: { MPTokenMetadata: mptMetadataHex, AssetScale: 6 }, + }) + + const vaultData = { + Owner: 'rTestOwner', + Asset: { mpt_issuance_id: mptId }, + // Raw value 5,000,000 with AssetScale 6 → 5 VTKN (not 5,000,000 VTKN) + AssetsAvailable: '5000000', + } + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('5.00 VTKN')).toBeInTheDocument() + }) + }) }) /** @@ -1119,7 +1156,8 @@ describe('VaultHeader Component', () => { const vaultData = { Owner: 'rTestOwner', Asset: { currency: 'XRP' }, - AssetsMaximum: '10000000', // 10 million + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsMaximum: '10000000000000', // 10M XRP } render( @@ -1170,7 +1208,8 @@ describe('VaultHeader Component', () => { const vaultData = { Owner: 'rTestOwner', Asset: { currency: 'XRP' }, - AssetsTotal: '5000000', + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsTotal: '5000000000000', // 5M XRP } render( @@ -1303,7 +1342,8 @@ describe('VaultHeader Component', () => { const vaultData = { Owner: 'rTestOwner', Asset: { currency: 'XRP' }, - AssetsTotal: '1000000', // 1 million XRP + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsTotal: '1000000000000', // 1M XRP } render( @@ -1317,7 +1357,6 @@ describe('VaultHeader Component', () => { ) // 1,000,000 XRP * 2.5 = 2,500,000 USD = "2.50M USD" - // formatAmount joins [prefix, formattedNum, currency] with spaces expect(screen.getByText('$2.50M USD')).toBeInTheDocument() }) @@ -1368,5 +1407,33 @@ describe('VaultHeader Component', () => { const tvlRow = screen.getByText('Total Value Locked (TVL)').closest('tr') expect(tvlRow).toHaveTextContent('Total Value Locked (TVL)$2.00M USD') }) + + it('converts Available to Borrow to USD using exchange rate', () => { + mockXRPToUSDRate.mockReturnValue(2.5) + mockTokenToUSDRate.mockImplementation((token: any) => { + if (token?.currency === 'XRP') return 2.5 + return 0 + }) + + const vaultData = { + Owner: 'rTestOwner', + Asset: { currency: 'XRP' }, + // XRP amounts on the Vault ledger entry are in drops (1 XRP = 1,000,000 drops) + AssetsAvailable: '2000000000000', // 2M XRP * 2.5 USD = $5M + } + + render( + + + , + ) + + const availableRow = screen.getByText('Available to Borrow').closest('tr') + expect(availableRow).toHaveTextContent('Available to Borrow$5.00M USD') + }) }) }) diff --git a/src/containers/Vault/VaultLoans/BrokerDetails.tsx b/src/containers/Vault/VaultLoans/BrokerDetails.tsx index 41adf0f8d..a11072077 100644 --- a/src/containers/Vault/VaultLoans/BrokerDetails.tsx +++ b/src/containers/Vault/VaultLoans/BrokerDetails.tsx @@ -9,6 +9,7 @@ import { shortenMPTID, getCurrencySymbol, isCurrencyExoticSymbol, + convertScaledPrice, } from '../../shared/utils' // TODO: Use types from xrpl.js instead of hand-writing it. @@ -36,6 +37,7 @@ interface Props { asset?: AssetInfo loans?: any[] mptTicker?: string + assetScale?: number } export const BrokerDetails = ({ @@ -44,6 +46,7 @@ export const BrokerDetails = ({ asset, loans, mptTicker, + assetScale, }: Props) => { const { t } = useTranslation() const { rate: tokenToUsdRate } = useTokenToUSDRate(asset) @@ -52,9 +55,18 @@ export const BrokerDetails = ({ const convertToDisplayCurrency = ( amount: string | undefined, ): string | undefined => { - if (!amount || displayCurrency !== 'USD') return amount - const numAmount = Number(amount) - if (Number.isNaN(numAmount)) return amount + if (!amount) return amount + + let normalized = amount + if (asset?.currency === 'XRP') { + normalized = convertScaledPrice(BigInt(amount), 6) + } else if (asset?.mpt_issuance_id) { + normalized = convertScaledPrice(BigInt(amount), assetScale ?? 0) + } + if (displayCurrency !== 'USD') return normalized + + const numAmount = Number(normalized) + if (Number.isNaN(numAmount)) return normalized return tokenToUsdRate > 0 ? String(numAmount * tokenToUsdRate) : undefined } @@ -162,6 +174,7 @@ export const BrokerDetails = ({ } displayCurrency={displayCurrency} asset={asset} + assetScale={assetScale} isCurrencySpecialSymbol={ asset?.currency !== undefined && isCurrencyExoticSymbol(asset?.currency) diff --git a/src/containers/Vault/VaultLoans/BrokerLoansTable.tsx b/src/containers/Vault/VaultLoans/BrokerLoansTable.tsx index 9fae9a0c1..88735def8 100644 --- a/src/containers/Vault/VaultLoans/BrokerLoansTable.tsx +++ b/src/containers/Vault/VaultLoans/BrokerLoansTable.tsx @@ -20,6 +20,7 @@ interface Props { currency: string displayCurrency: string asset?: AssetInfo + assetScale?: number isCurrencySpecialSymbol?: boolean } @@ -28,6 +29,7 @@ export const BrokerLoansTable = ({ currency, displayCurrency, asset, + assetScale, isCurrencySpecialSymbol = false, }: Props) => { const { t } = useTranslation() @@ -126,6 +128,7 @@ export const BrokerLoansTable = ({ currency={currency} displayCurrency={effectiveDisplayCurrency} asset={asset} + assetScale={assetScale} isCurrencySpecialSymbol={isCurrencySpecialSymbol} /> ))} diff --git a/src/containers/Vault/VaultLoans/LoanRow.tsx b/src/containers/Vault/VaultLoans/LoanRow.tsx index b84ad809e..a2f087e69 100644 --- a/src/containers/Vault/VaultLoans/LoanRow.tsx +++ b/src/containers/Vault/VaultLoans/LoanRow.tsx @@ -15,7 +15,7 @@ import { truncateId, } from './utils' import ExpandIcon from '../../shared/images/down_arrow.svg' -import { getCurrencySymbol } from '../../shared/utils' +import { getCurrencySymbol, convertScaledPrice } from '../../shared/utils' export interface LoanData { index: string @@ -52,6 +52,7 @@ interface Props { currency: string displayCurrency: string asset?: AssetInfo + assetScale?: number isCurrencySpecialSymbol?: boolean } @@ -60,6 +61,7 @@ export const LoanRow = ({ currency, displayCurrency, asset, + assetScale, isCurrencySpecialSymbol = false, }: Props) => { const { t } = useTranslation() @@ -112,7 +114,14 @@ export const LoanRow = ({ ) const formatAmount = (amount: string | number): string => { - const num = typeof amount === 'string' ? Number(amount) : amount + const raw = String(amount) + let normalized = raw + if (asset?.currency === 'XRP') { + normalized = convertScaledPrice(BigInt(raw), 6) + } else if (asset?.mpt_issuance_id) { + normalized = convertScaledPrice(BigInt(raw), assetScale ?? 0) + } + const num = Number(normalized) if (Number.isNaN(num)) return String(amount) // Convert to USD if needed @@ -132,7 +141,14 @@ export const LoanRow = ({ const formatFee = (fee: string | number): string => { // this method is used with fields which have a soeDEFAULT configuration. If they are not specified, display 0. if (!fee) return '0' - const num = typeof fee === 'string' ? Number(fee) : fee + const raw = String(fee) + let normalized = raw + if (asset?.currency === 'XRP') { + normalized = convertScaledPrice(BigInt(raw), 6) + } else if (asset?.mpt_issuance_id) { + normalized = convertScaledPrice(BigInt(raw), assetScale ?? 0) + } + const num = Number(normalized) if (Number.isNaN(num)) return String(fee) if (num === 0) return '0' diff --git a/src/containers/Vault/VaultLoans/index.tsx b/src/containers/Vault/VaultLoans/index.tsx index 247702ee5..9ed7d2427 100644 --- a/src/containers/Vault/VaultLoans/index.tsx +++ b/src/containers/Vault/VaultLoans/index.tsx @@ -37,6 +37,7 @@ interface Props { displayCurrency: string asset?: AssetInfo mptTicker?: string + assetScale?: number } export const VaultLoans = ({ @@ -45,6 +46,7 @@ export const VaultLoans = ({ displayCurrency, asset, mptTicker, + assetScale, }: Props) => { const { t } = useTranslation() const { trackException } = useAnalytics() @@ -183,6 +185,7 @@ export const VaultLoans = ({ asset={asset} loans={brokerLoansMap[selectedBroker.index]} mptTicker={mptTicker} + assetScale={assetScale} /> )} diff --git a/src/containers/Vault/VaultLoans/test/BrokerLoansTable.test.tsx b/src/containers/Vault/VaultLoans/test/BrokerLoansTable.test.tsx index 89f1ee05b..0093d3ff6 100644 --- a/src/containers/Vault/VaultLoans/test/BrokerLoansTable.test.tsx +++ b/src/containers/Vault/VaultLoans/test/BrokerLoansTable.test.tsx @@ -1041,7 +1041,13 @@ describe('BrokerLoansTable Component', () => { }) it('display a XRP-denominated Loan in XRP currency', () => { - const loans = [createMockLoan()] + // XRP-denominated loan amounts on the ledger are in drops (1 XRP = 1,000,000 drops) + const loans = [ + createMockLoan({ + PrincipalOutstanding: '10000000000', // 10K XRP + TotalValueOutstanding: '10500000000', // 10.5K XRP + }), + ] // This should not throw - currency defaults to XRP because XRP is the asset of the Vault. const { container } = render( @@ -1067,7 +1073,13 @@ describe('BrokerLoansTable Component', () => { }) it(`display a XRP-denominated Loan in USD currency`, () => { - const loans = [createMockLoan()] + // XRP-denominated loan amounts on the ledger are in drops (1 XRP = 1,000,000 drops) + const loans = [ + createMockLoan({ + PrincipalOutstanding: '10000000000', // 10K XRP * 1.5 USD = $15K + TotalValueOutstanding: '10500000000', // 10.5K XRP * 1.5 USD = $15.75K + }), + ] // This should not throw - currency defaults to XRP because XRP is the asset of the Vault. const { container } = render( diff --git a/src/containers/Vault/VaultLoans/test/VaultLoans.test.tsx b/src/containers/Vault/VaultLoans/test/VaultLoans.test.tsx index f65e58f78..ef2eb411a 100644 --- a/src/containers/Vault/VaultLoans/test/VaultLoans.test.tsx +++ b/src/containers/Vault/VaultLoans/test/VaultLoans.test.tsx @@ -758,11 +758,12 @@ describe('VaultLoans Component', () => { }) it('displays total debt and maximum debt metrics', async () => { + // XRP debt amounts on the ledger are in drops (1 XRP = 1,000,000 drops) const broker = createMockBroker({ index: 'BROKER_123', VaultID: 'TEST_VAULT_ID', - DebtTotal: '50000', - DebtMaximum: '1000000', + DebtTotal: '50000000000', // 50K XRP + DebtMaximum: '1000000000000', // 1M XRP }) mockedGetAccountObjects @@ -799,11 +800,12 @@ describe('VaultLoans Component', () => { }) it(`display BrokerDetails card: XRP-denominated Loan in USD Currency`, async () => { + // XRP debt amounts on the ledger are in drops (1 XRP = 1,000,000 drops) const broker = createMockBroker({ index: 'BROKER_123', VaultID: 'TEST_VAULT_ID', - DebtTotal: '50000', - DebtMaximum: '1000000', + DebtTotal: '50000000000', // 50K XRP * 1.5 USD = $75K + DebtMaximum: '1000000000000', // 1M XRP * 1.5 USD = $1.5M }) mockedGetAccountObjects diff --git a/src/containers/Vault/index.tsx b/src/containers/Vault/index.tsx index dc5d49b7c..4e6ab5d3d 100644 --- a/src/containers/Vault/index.tsx +++ b/src/containers/Vault/index.tsx @@ -106,6 +106,8 @@ export const Vault = () => { const mptTicker = parseMPTokenMetadata(assetMptIssuanceData?.MPTokenMetadata) ?.ticker as string | undefined + const assetScale = assetMptIssuanceData?.AssetScale as number | undefined + // Compute native currency label from vault asset const nativeCurrency = getCurrencySymbol(vaultData?.Asset?.currency) ?? @@ -180,6 +182,7 @@ export const Vault = () => { data={vaultData} vaultId={vaultId} displayCurrency={displayCurrency || nativeCurrency} + assetScale={assetScale} /> {transactionAccountId && ( { displayCurrency={displayCurrency || nativeCurrency} asset={vaultData.Asset} mptTicker={mptTicker} + assetScale={assetScale} /> )} {/* TODO: Include the VaultDepositors component here once Clio APIs are available */}