Merge pull request #6370 from mempool/knorrium/fix_fiat

Fix fiat precision across the app
This commit is contained in:
mononaut 2026-03-10 19:17:08 +09:00 committed by GitHub
commit 5ec48f7f68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 339 additions and 25 deletions

View file

@ -83,22 +83,28 @@ describe('Calculator', () => {
});
describe('bitcoin input updates fiat and sats', () => {
it('updates fiat and sats when entering 0.5 BTC', () => {
const expectedFiat = Math.round(MOCK_BTC_PRICE_USD * 0.5 * 100) / 100;
cy.get('input[formControlName="bitcoin"]').clear().type('0.5');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '50000000');
it('updates fiat and sats when entering 0.33 BTC', () => {
const expectedFiat = Math.round(MOCK_BTC_PRICE_USD * 0.33 * 100) / 100; // 40740.48 USD
cy.get('input[formControlName="bitcoin"]').clear().type('0.33');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '33000000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
cy.get('.fiat-text').invoke('text').then((text) => {
expect(text.trim()).to.equal('$40,740.48');
});
});
it('updates fiat and sats when entering 1 sat (0.00000001 BTC)', () => {
const expectedFiat = (MOCK_BTC_PRICE_USD / 100_000_000 * 100 / 100).toFixed(8);
const expectedFiat = (MOCK_BTC_PRICE_USD / 100_000_000 * 100 / 100);
cy.get('input[formControlName="bitcoin"]').clear().type('0.00000001');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '1');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
expect(String(fiatVal)).to.equal(expectedFiat);
expect(String(fiatVal)).to.equal(String(expectedFiat));
});
cy.get('.fiat-text').invoke('text').then((text) => {
expect(text.trim()).to.equal('$0.00');
});
});
});
@ -220,6 +226,9 @@ describe('Calculator', () => {
cy.get('.symbol').invoke('text').then((text) => {
expect(text.replace(/,/g, '')).to.include(String(MOCK_BTC_PRICE_JPY));
});
cy.get('.fiat-text').invoke('text').then((text) => {
expect(text.trim()).to.equal('¥11,057,757');
});
});
it('displays 1 BTC with correct sats and fiat in JPY', () => {
@ -234,13 +243,17 @@ describe('Calculator', () => {
});
it('updates fiat and sats when entering 0.5 BTC in JPY', () => {
const expectedFiat = Math.round(MOCK_BTC_PRICE_JPY * 0.5 * 100) / 100;
const expectedFiat = Math.round(MOCK_BTC_PRICE_JPY * 0.5)
cy.get('input[formControlName="bitcoin"]').clear().type('0.5');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '50000000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
cy.get('.fiat-text').invoke('text').then((text) => {
expect(text.trim()).to.equal('¥5,528,879');
});
});
it('updates BTC and sats when entering fiat value in JPY', () => {
@ -258,13 +271,16 @@ describe('Calculator', () => {
it('updates BTC and fiat when entering 10000 sats in JPY', () => {
const satsAmount = 10000;
const expectedFiat = Math.round((satsAmount / 100_000_000) * MOCK_BTC_PRICE_JPY * 100) / 100;
const expectedFiat = Math.round((satsAmount / 100_000_000) * MOCK_BTC_PRICE_JPY);
cy.get('input[formControlName="satoshis"]').clear().type(String(satsAmount));
cy.get('input[formControlName="bitcoin"]').invoke('val').should('equal', '0.00010000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
cy.get('.fiat-text').invoke('text').then((text) => {
expect(text.trim()).to.equal('¥1,106');
});
});
});

View file

@ -0,0 +1,275 @@
const baseModule = Cypress.env('BASE_MODULE');
// Helper to select "Fiat" from the BTC/sats/Fiat amount selector dropdown
const selectFiatMode = () => {
cy.get('app-amount-selector').first().scrollIntoView();
cy.get('app-amount-selector select').first().select('fiat');
};
// Helper to select currency from the fiat selector dropdown
const selectCurrency = (currency: 'USD' | 'JPY') => {
cy.get('app-fiat-selector').first().scrollIntoView();
cy.get('app-fiat-selector').first().click();
cy.get('app-fiat-selector select').first().select(currency);
};
describe('Fiat Currency Formatting', () => {
if (baseModule === 'mempool') {
describe('Dashboard', () => {
beforeEach(() => {
cy.visit('/');
cy.waitForSkeletonGone();
selectFiatMode();
cy.get('.latest-transactions', { timeout: 10000 }).should('exist');
cy.get('.table-cell-fiat', { timeout: 10000 }).should('have.length.at.least', 1);
});
describe('USD formatting', () => {
beforeEach(() => {
selectCurrency('USD');
});
it('displays USD values with correct currency symbol', () => {
cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => {
const trimmedText = text.trim();
expect(trimmedText).to.include('$');
});
});
it('displays USD values with proper decimal format', () => {
cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => {
const trimmedText = text.trim();
expect(trimmedText).to.match(/^\$[\d,]+(\.\d{2})?$/);
});
});
});
describe('JPY formatting', () => {
beforeEach(() => {
selectCurrency('JPY');
cy.get('.table-cell-fiat').eq(1).should(($el) => {
expect($el.text()).to.include('¥');
});
});
it('displays JPY values with yen symbol', () => {
cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => {
const trimmedText = text.trim();
expect(trimmedText).to.include('¥');
});
});
it('displays JPY values without decimal places', () => {
cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => {
const trimmedText = text.trim();
expect(trimmedText).to.not.match(/\.\d+$/);
});
});
it('formats all JPY values correctly without decimals', () => {
cy.get('.table-cell-fiat').each(($el) => {
const text = $el.text().trim();
if (text.includes('¥')) {
expect(text).to.not.match(/\.\d+/);
}
});
});
});
describe('currency switching', () => {
it('correctly formats when switching from USD to JPY', () => {
selectCurrency('USD');
cy.get('.table-cell-fiat').eq(1).invoke('text').then((usdText) => {
expect(usdText.trim()).to.include('$');
});
selectCurrency('JPY');
cy.get('.table-cell-fiat').eq(1).should(($el) => {
const text = $el.text().trim();
expect(text).to.include('¥');
expect(text).to.not.match(/\.\d+$/);
});
});
it('correctly formats when switching from JPY back to USD', () => {
selectCurrency('JPY');
cy.get('.table-cell-fiat').eq(1).should(($el) => {
expect($el.text()).to.include('¥');
});
selectCurrency('USD');
cy.get('.table-cell-fiat').eq(1).should(($el) => {
expect($el.text().trim()).to.include('$');
});
});
});
});
describe('Transaction Page', () => {
beforeEach(() => {
cy.visit('/tx/dd0faea1e9acd5bd812e5130c0912b62d6b63d04bac4558f2e07270ac613a8f2');
cy.waitForSkeletonGone();
});
it('displays USD fiat values with dollar symbol', () => {
selectCurrency('USD');
selectFiatMode();
cy.get('app-transaction-details .fiat').invoke('text').then((text) => {
expect(text.trim()).to.include('$');
});
cy.get('app-transactions-list .fiat', { timeout: 10000 }).first().invoke('text').then((text) => {
expect(text.trim()).to.include('$');
});
});
it('displays JPY fiat values without decimals', () => {
selectCurrency('JPY');
selectFiatMode();
cy.get('app-transaction-details .fiat').invoke('text').then((text) => {
const trimmedText = text.trim();
expect(trimmedText).to.include('¥');
expect(trimmedText).to.not.match(/\.\d+/);
});
cy.get('app-transactions-list .fiat').first().should(($el) => {
const text = $el.text().trim();
expect(text).to.include('¥');
expect(text).to.not.match(/\.\d+/);
});
});
});
describe('Block Page', () => {
beforeEach(() => {
cy.viewport(1080, 1920); // force taller screen to avoid scrollbars
cy.intercept('/api/v1/blocks/0').as('blocks-0');
cy.intercept('/api/v1/blocks/10').as('blocks-10');
cy.intercept('/api/v1/blocks/20').as('blocks-20');
cy.intercept('/api/txs/outspends*').as('outspends');
cy.visit('/block/100000');
});
it('displays USD fiat values in block reward with dollar symbol', () => {
cy.wait('@outspends').then(() => {
cy.waitUntil(() => cy.get('.fiat').each(($el) => $el.is(':visible') && $el.text().trim().includes('$')));
selectCurrency('USD');
selectFiatMode();
cy.get('.fiat').each(($el) => {
expect($el.text().trim()).to.include('$');
});
});
});
it('displays JPY fiat values without decimals', () => {
cy.wait('@outspends').then(() => {
cy.waitUntil(() => cy.get('.fiat').each(($el) => $el.is(':visible') && $el.text().trim().includes('$')));
selectCurrency('JPY');
selectFiatMode();
cy.get('.fiat').each(($el) => {
expect($el.text().trim()).to.include('¥');
expect($el.text().trim()).to.not.match(/\.\d+/);
});
});
})
});
describe('Address Page', () => {
beforeEach(() => {
cy.visit('/address/1wizaAB16Wrua9V58uNvqktyq2LBLtYso');
});
it('displays USD fiat values with dollar symbol', () => {
selectCurrency('USD');
selectFiatMode();
cy.get('app-amount .fiat', { timeout: 10000 }).each(($el) => {
expect($el.text().trim()).to.include('$');
});
});
it('displays JPY fiat values without decimals', () => {
selectCurrency('JPY');
selectFiatMode();
cy.get('app-amount .fiat').first().should(($el) => {
const text = $el.text().trim();
expect(text).to.include('¥');
expect(text).to.not.match(/\.\d+/);
});
});
});
describe('Calculator Page', () => {
beforeEach(() => {
cy.visit('/tools/calculator');
cy.waitForSkeletonGone();
cy.get('input[formControlName="bitcoin"]', { timeout: 10000 }).should('be.visible');
});
it('displays USD price with dollar symbol', () => {
selectCurrency('USD');
cy.get('.symbol').invoke('text').then((text) => {
expect(text.trim()).to.include('$');
});
});
it('displays JPY price without decimals after switching currency', () => {
selectCurrency('JPY');
cy.get('.symbol').should(($el) => {
const text = $el.text().trim();
expect(text).to.include('¥');
});
// Enter a value in BTC to see the fiat conversion
cy.get('input[formControlName="bitcoin"]').clear().type('1');
// Check that the fiat output doesn't have decimals for JPY
cy.get('input[formControlName="fiat"]').should(($el) => {
const value = $el.val() as string;
// JPY should not have decimal places in large values
expect(value).to.not.match(/\.\d+$/);
});
});
});
describe('Mempool Block Tooltip', () => {
beforeEach(() => {
cy.visit('/');
cy.waitForSkeletonGone();
selectFiatMode();
});
it('displays USD fiat values in mempool block', () => {
selectCurrency('USD');
cy.get('#mempool-block-0').scrollIntoView();
cy.get('#mempool-block-0').click();
cy.waitForSkeletonGone();
cy.get('app-mempool-block .fiat').each(($el) => {
expect($el.text().trim()).to.include('$');
});
});
it('displays JPY fiat values in mempool block', () => {
selectCurrency('JPY');
cy.get('#mempool-block-0').scrollIntoView();
cy.get('#mempool-block-0').click();
cy.waitForSkeletonGone();
cy.get('app-mempool-block .fiat').invoke('text').then((text) => {
expect(text.trim()).to.include('¥');
expect(text).to.not.match(/\.\d+$/);
});
});
});
} else {
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
}
});

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { combineLatest, Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
@ -20,12 +20,15 @@ export class CalculatorComponent implements OnInit {
form: FormGroup;
currentPrice = 0;
isMaxSupply = false;
currentCurrency = 'USD';
currencyDecimals = 2;
currency$ = this.stateService.fiatCurrency$;
price$: Observable<number>;
lastFiatPrice$: Observable<number>;
constructor(
@Inject(LOCALE_ID) private locale: string,
private stateService: StateService,
private formBuilder: FormBuilder,
private websocketService: WebsocketService,
@ -47,6 +50,8 @@ export class CalculatorComponent implements OnInit {
this.price$ = this.currency$.pipe(
switchMap((result) => {
currency = result;
this.currentCurrency = result;
this.updateCurrencyDecimals();
return this.stateService.conversions$.asObservable();
}),
map((conversions) => {
@ -132,8 +137,11 @@ export class CalculatorComponent implements OnInit {
if (name === 'bitcoin' && this.countDecimals(sanitizedValue) > 8) {
sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 8);
}
if (name === 'fiat' && this.countDecimals(sanitizedValue) > 2) {
sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 2);
if (name === 'fiat') {
const decimals = this.getCurrencyDecimals();
if (this.countDecimals(sanitizedValue) > decimals) {
sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, decimals);
}
}
if (sanitizedValue === '') {
sanitizedValue = '0';
@ -176,12 +184,20 @@ export class CalculatorComponent implements OnInit {
}
formatFiat(num: number): string | number {
// Get the number of decimal places for the current currency
const decimals = this.getCurrencyDecimals();
if (decimals === 0) {
return Math.round(num);
}
if (Math.abs(num) >= 1000) {
// For values >= 1000: show 2 decimals, or 0 if whole number
// For values >= 1000: show currency-specific decimals, or 0 if whole number
if (num % 1 === 0) {
return Math.round(num);
}
return (Math.round(num * 100) / 100).toFixed(2);
const factor = Math.pow(10, decimals);
return (Math.round(num * factor) / factor).toFixed(decimals);
}
if (num % 1 === 0) {
return Math.round(num);
@ -190,6 +206,23 @@ export class CalculatorComponent implements OnInit {
if (Math.abs(num) < 1 && num !== 0) {
return num.toFixed(8);
}
return (Math.round(num * 100) / 100).toFixed(2);
const factor = Math.pow(10, decimals);
return (Math.round(num * factor) / factor).toFixed(decimals);
}
private updateCurrencyDecimals(): void {
try {
const formatter = new Intl.NumberFormat(this.locale, {
style: 'currency',
currency: this.currentCurrency
});
this.currencyDecimals = formatter.resolvedOptions().maximumFractionDigits;
} catch {
this.currencyDecimals = 2; // Default to 2 decimal places
}
}
getCurrencyDecimals(): number {
return this.currencyDecimals;
}
}

View file

@ -21,17 +21,7 @@ export class FiatCurrencyPipe implements PipeTransform {
}
transform(num: number, ...args: any[]): unknown {
const digits = args[0] || 1;
const currency = args[1] || this.currency || 'USD';
if (Math.abs(num) >= 1000) {
// Check if decimals are exactly 0
if (num % 1 === 0) {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num);
}
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
} else {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num);
}
return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num);
}
}