Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Price formatter and Vis comp #465

Merged
merged 11 commits into from
Feb 10, 2025
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
"eslint.useFlatConfig": true,
"githubPullRequests.overrideDefaultBranch": "dev",
"githubIssues.issueBranchTitle": "${issueNumber}-${sanitizedIssueTitle}",
"cSpell.words": ["Cbor", "secp"]
"cSpell.words": ["Cbor", "secp"],
"[dotenv]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
}
}
7 changes: 5 additions & 2 deletions src/background/controller/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,7 +1174,10 @@ export class WalletController extends BaseController {

private async evmtokenPrice(tokeninfo, data) {
const token = tokeninfo.symbol.toLowerCase();
const price = await openapiService.getPricesByEvmaddress(tokeninfo.address, data);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this a bug?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that tokenInfo address was the cadence address and evmAddress was a property on TokenInfo. In effect it was a bug.

const price = await openapiService.getPricesByEvmaddress(
tokeninfo.evmAddress || tokeninfo.address,
data
);

if (token === 'flow') {
const flowPrice = price || data['FLOW'];
Expand Down Expand Up @@ -1460,7 +1463,7 @@ export class WalletController extends BaseController {

const mergedList = await mergeBalances(tokenList, allBalanceMap, flowBalance);

const data = await openapiService.getTokenPrices('evmPrice', true);
const data = await openapiService.getTokenPrices('pricesMap');
const prices = tokenList.map((token) => this.evmtokenPrice(token, data));
const allPrice = await Promise.all(prices);
const coins: CoinItem[] = mergedList.map((token, index) => {
Expand Down
20 changes: 10 additions & 10 deletions src/background/service/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ class OpenApiService {
}
};

getTokenPrices = async (storageKey: string, isEvm: boolean = false) => {
getTokenPrices = async (storageKey: string) => {
const cachedPrices = await storage.getExpiry(storageKey);
if (cachedPrices) {
return cachedPrices;
Expand All @@ -577,21 +577,20 @@ class OpenApiService {
const data = response?.data || [];

data.forEach((token) => {
if (isEvm && token.evmAddress) {
// EVM price
const { rateToUSD, evmAddress } = token;
if (token.evmAddress) {
const { rateToUSD, evmAddress, symbol } = token;
const key = evmAddress.toLowerCase();
pricesMap[key] = Number(rateToUSD).toFixed(8);
} else if (!isEvm && token.contractName && token.contractAddress) {
const symbolKey = symbol.toUpperCase();
if (symbolKey) {
pricesMap[symbolKey] = Number(rateToUSD).toFixed(8);
}
}
if (token.contractName && token.contractAddress) {
// Flow chain price
const { rateToUSD, contractName, contractAddress } = token;
const key = `${contractName.toLowerCase()}${contractAddress.toLowerCase()}`;
pricesMap[key] = Number(rateToUSD).toFixed(8);
} else if (isEvm && token.symbol) {
// Handle fallback for EVM tokens
const { rateToUSD, symbol } = token;
const key = symbol.toUpperCase();
pricesMap[key] = Number(rateToUSD).toFixed(8);
}
});
} catch (error) {
Expand Down Expand Up @@ -2202,6 +2201,7 @@ class OpenApiService {
return data;
};

// TODO: remove this function, need to verify, doesn't look to be used anywhere
getEvmFTPrice = async () => {
const gitPrice = await storage.getExpiry('EVMPrice');

Expand Down
94 changes: 94 additions & 0 deletions src/shared/utils/formatTokenValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';

import { formatPrice } from './formatTokenValue';

describe('formatPrice', () => {
it('should handle zero', () => {
const result = formatPrice(0, 4);
expect(result).toEqual({
price: 0,
formattedPrice: { leadingPart: '', zeroPart: null, endingPart: null },
});
});

it('should format numbers >= 1 with two decimal places', () => {
const testCases = [
{ input: 1.23456, expected: '1.23' },
{ input: 123.456789, expected: '123.46' },
{ input: 1000, expected: '1000.00' },
];

testCases.forEach(({ input, expected }) => {
const result = formatPrice(input, 4);
expect(result).toEqual({
price: input,
formattedPrice: {
leadingPart: expected,
zeroPart: null,
endingPart: null,
},
});
});
});

it('should format numbers < 1 based on threshold', () => {
const testCases = [
{
input: 0.123456,
threshold: 4,
expected: { leadingPart: '0.12', zeroPart: null, endingPart: null },
},
{
input: 0.0001234,
threshold: 4,
expected: { leadingPart: '0.00012', zeroPart: null, endingPart: null },
},
{
input: 0.00001234,
threshold: 4,
expected: { leadingPart: '0.0', zeroPart: 3, endingPart: '12' },
},
{
input: 0.00000001234,
threshold: 4,
expected: { leadingPart: '0.0', zeroPart: 6, endingPart: '12' },
},
];

testCases.forEach(({ input, threshold, expected }) => {
const result = formatPrice(input, threshold);
expect(result).toEqual({
price: input,
formattedPrice: expected,
});
});
});

it('should respect different threshold values', () => {
const testCases = [
{
input: 0.0001234,
threshold: 3,
expected: { leadingPart: '0.0', zeroPart: 2, endingPart: '12' },
},
{
input: 0.0001234,
threshold: 5,
expected: { leadingPart: '0.00012', zeroPart: null, endingPart: null },
},
{
input: 0.00000001234,
threshold: 10,
expected: { leadingPart: '0.000000012', zeroPart: null, endingPart: null },
},
];

testCases.forEach(({ input, threshold, expected }) => {
const result = formatPrice(input, threshold);
expect(result).toEqual({
price: input,
formattedPrice: expected,
});
});
});
});
85 changes: 85 additions & 0 deletions src/shared/utils/formatTokenValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
interface PriceParts {
leadingPart: string;
zeroPart: number | null;
endingPart: string | null;
}

interface FormattedPrice {
price: number;
formattedPrice: PriceParts;
}

/**
* Condenses the price to a more readable format.
* @param price - The price to format.
* @param zeroCondenseThreshold - The number of zeros to condense. example: 4 would condense 0.0000123 to 0.0(3)12.
* First zero after decimal point is maintained for readability.
* @returns The formatted price.
*/
export function formatPrice(price: number, zeroCondenseThreshold = 4): FormattedPrice {
if (price === 0 || price === null || price === undefined) {
return {
price,
formattedPrice: {
leadingPart: '',
zeroPart: null,
endingPart: null,
},
};
}

if (price >= 1) {
return {
price,
formattedPrice: {
leadingPart: price.toFixed(2),
zeroPart: null,
endingPart: null,
},
};
}

// Convert to non-scientific notation string
const priceStr = price.toFixed(20);
const parts = priceStr.split('.');
const decimal = parts.length > 1 ? parts[1] : '0';

// Count total zeros after the decimal point
let totalZeros = 0;
let firstNonZeroIndex = 0;

for (let i = 0; i < decimal.length; i++) {
if (decimal[i] === '0') {
totalZeros++;
} else {
firstNonZeroIndex = i;
break;
}
}

// If we don't have enough total zeros to meet threshold, format with 2 significant digits
if (totalZeros < zeroCondenseThreshold) {
const significantPart = decimal.slice(firstNonZeroIndex, firstNonZeroIndex + 2);
const formattedNumber = `0.${'0'.repeat(firstNonZeroIndex)}${significantPart}`;
return {
price,
formattedPrice: {
leadingPart: formattedNumber,
zeroPart: null,
endingPart: null,
},
};
}

// Break up the price into parts for condensed format
// zeroPart should be totalZeros - 1 since first zero is in leadingPart
const significantDigits = decimal.slice(firstNonZeroIndex, firstNonZeroIndex + 2);
return {
price,
formattedPrice: {
leadingPart: '0.0',
zeroPart: totalZeros - 1,
endingPart: significantDigits,
},
};
}
12 changes: 10 additions & 2 deletions src/ui/views/TokenDetail/TokenInfoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { addDotSeparators } from 'ui/utils/number';

import IconChevronRight from '../../../components/iconfont/IconChevronRight';

import { TokenPrice } from './TokenValue';

// import tips from 'ui/FRWAssets/svg/tips.svg';

const TokenInfoCard = ({
Expand Down Expand Up @@ -78,7 +80,7 @@ const TokenInfoCard = ({
}
})
.catch((err) => {
console.log('err ', err);
console.error('err ', err);
});
}
}, 400);
Expand Down Expand Up @@ -220,7 +222,13 @@ const TokenInfoCard = ({
</Typography>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '16px' }}>
${addDotSeparators(balance * price)} {chrome.i18n.getMessage('USD')}
<Box component="span" sx={{ marginRight: '0.25rem' }}>
<TokenPrice
value={balance * price}
prefix="$"
postFix={chrome.i18n.getMessage('USD')}
/>
</Box>
</Typography>
<Box sx={{ display: 'flex', gap: '12px', height: '36px', mt: '24px', width: '100%' }}>
{(!childType || childType === 'evm') && (
Expand Down
47 changes: 47 additions & 0 deletions src/ui/views/TokenDetail/TokenValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';

import { formatPrice } from '@/shared/utils/formatTokenValue';

interface TokenPriceProps {
value: number | string;
className?: string;
showPrefix?: boolean;
prefix?: string;
postFix?: string;
}

export const TokenPrice: React.FC<TokenPriceProps> = ({
value,
className = '',
prefix = '$',
postFix = '',
}) => {
if (value === 0 || value === null || value === undefined) {
return <span className={className}>{''}</span>;
}

// convert value to number if it's a string
const valueNumber = typeof value === 'string' ? parseFloat(value) : value;

const { formattedPrice } = formatPrice(valueNumber);
const { leadingPart, zeroPart, endingPart } = formattedPrice;

return (
<span className={className}>
{prefix}
<span style={leadingPart === '' ? { padding: '0 0.25rem' } : undefined}>{leadingPart}</span>
{zeroPart !== null && (
<sub
style={{
fontSize: '0.7em',
verticalAlign: '-0.25em',
}}
>
{zeroPart}
</sub>
)}
{endingPart !== null && endingPart}
{postFix && <span style={{ marginLeft: '0.25rem' }}>{postFix}</span>}
</span>
);
};
7 changes: 7 additions & 0 deletions src/ui/views/TokenDetail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ const TokenDetail = () => {
if (price) {
setPrice(price);
}
// TokenInfo does have evmAddress, sometimes, check first
const addressToCheck =
'evmAddress' in tokenResult! ? (tokenResult as any).evmAddress : tokenResult!.address;
const evmPrice = await usewallet.openapi.getPricesByEvmaddress(addressToCheck, data);
if (evmPrice) {
setPrice(evmPrice);
}
}
}, [usewallet, token]);

Expand Down
Loading