Merge pull request #4747 from mempool/natsoni/more-fiat-currencies

Use fx rates to add more currencies support and improve fiat prices
This commit is contained in:
softsimon 2024-03-16 10:31:47 +07:00 committed by GitHub
commit 81c8c8dafb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 981 additions and 86 deletions

View File

@ -155,5 +155,9 @@
"MEMPOOL_SERVICES": {
"API": "https://mempool.space/api",
"ACCELERATIONS": false
},
"FIAT_PRICE": {
"ENABLED": true,
"API_KEY": "your-api-key-from-freecurrencyapi.com"
}
}

View File

@ -147,5 +147,9 @@
"ENABLED": false,
"UNIX_SOCKET_PATH": "/tmp/redis.sock",
"BATCH_QUERY_BASE_SIZE": 5000
},
"FIAT_PRICE": {
"ENABLED": true,
"API_KEY": "__MEMPOOL_CURRENCY_API_KEY__"
}
}

View File

@ -152,6 +152,11 @@ describe('Mempool Backend Config', () => {
UNIX_SOCKET_PATH: '',
BATCH_QUERY_BASE_SIZE: 5000,
});
expect(config.FIAT_PRICE).toStrictEqual({
ENABLED: true,
API_KEY: '',
});
});
});

View File

@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 74;
private static currentVersion = 75;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@ -608,7 +608,7 @@ class DatabaseMigration {
}
if (databaseSchemaVersion < 72 && isBitcoin === true) {
// reindex Goggles flags for mined block templates above height 833000
// reindex Goggles flags for mined block templates above height 832000
await this.$executeQuery('UPDATE blocks_summaries SET version = 0 WHERE height >= 832000;');
await this.updateToSchemaVersion(72);
}
@ -624,6 +624,36 @@ class DatabaseMigration {
await this.$executeQuery(`INSERT INTO state(name, number) VALUE ('last_acceleration_block', 0);`);
await this.updateToSchemaVersion(74);
}
if (databaseSchemaVersion < 75) {
await this.$executeQuery('ALTER TABLE `prices` ADD `BGN` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `BRL` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `CNY` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `CZK` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `DKK` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `HKD` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `HRK` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `HUF` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `IDR` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `ILS` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `INR` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `ISK` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `KRW` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `MXN` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `MYR` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `NOK` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `NZD` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `PHP` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `PLN` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `RON` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `RUB` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `SEK` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `SGD` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `THB` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `TRY` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `ZAR` float DEFAULT "-1"');
await this.updateToSchemaVersion(75);
}
}
/**

View File

@ -51,13 +51,20 @@ class MiningRoutes {
res.status(400).send('Prices are not available on testnets.');
return;
}
if (req.query.timestamp) {
res.status(200).send(await PricesRepository.$getNearestHistoricalPrice(
parseInt(<string>req.query.timestamp ?? 0, 10)
));
const timestamp = parseInt(req.query.timestamp as string, 10) || 0;
const currency = req.query.currency as string;
let response;
if (timestamp && currency) {
response = await PricesRepository.$getNearestHistoricalPrice(timestamp, currency);
} else if (timestamp) {
response = await PricesRepository.$getNearestHistoricalPrice(timestamp);
} else if (currency) {
response = await PricesRepository.$getHistoricalPrices(currency);
} else {
res.status(200).send(await PricesRepository.$getHistoricalPrices());
response = await PricesRepository.$getHistoricalPrices();
}
res.status(200).send(response);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}

View File

@ -158,6 +158,10 @@ interface IConfig {
UNIX_SOCKET_PATH: string;
BATCH_QUERY_BASE_SIZE: number;
},
FIAT_PRICE: {
ENABLED: boolean;
API_KEY: string;
},
}
const defaults: IConfig = {
@ -316,6 +320,10 @@ const defaults: IConfig = {
'UNIX_SOCKET_PATH': '',
'BATCH_QUERY_BASE_SIZE': 5000,
},
'FIAT_PRICE': {
'ENABLED': true,
'API_KEY': '',
},
};
class Config implements IConfig {
@ -337,6 +345,7 @@ class Config implements IConfig {
REPLICATION: IConfig['REPLICATION'];
MEMPOOL_SERVICES: IConfig['MEMPOOL_SERVICES'];
REDIS: IConfig['REDIS'];
FIAT_PRICE: IConfig['FIAT_PRICE'];
constructor() {
const configs = this.merge(configFromFile, defaults);
@ -358,6 +367,7 @@ class Config implements IConfig {
this.REPLICATION = configs.REPLICATION;
this.MEMPOOL_SERVICES = configs.MEMPOOL_SERVICES;
this.REDIS = configs.REDIS;
this.FIAT_PRICE = configs.FIAT_PRICE;
}
merge = (...objects: object[]): IConfig => {

View File

@ -131,7 +131,7 @@ class Server {
.use(express.text({ type: ['text/plain', 'application/base64'] }))
;
if (config.DATABASE.ENABLED) {
if (config.DATABASE.ENABLED && config.FIAT_PRICE.ENABLED) {
await priceUpdater.$initializeLatestPriceWithDb();
}
@ -168,7 +168,9 @@ class Server {
setInterval(refreshIcons, 3600_000);
}
if (config.FIAT_PRICE.ENABLED) {
priceUpdater.$run();
}
await chainTips.updateOrphanedBlocks();
this.setUpHttpApiRoutes();
@ -220,7 +222,9 @@ class Server {
await memPool.$updateMempool(newMempool, newAccelerations, pollRate);
}
indexer.$run();
if (config.FIAT_PRICE.ENABLED) {
priceUpdater.$run();
}
// rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS
const elapsed = Date.now() - start;
@ -284,7 +288,9 @@ class Server {
memPool.setAsyncMempoolChangedCallback(websocketHandler.$handleMempoolChange.bind(websocketHandler));
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
}
if (config.FIAT_PRICE.ENABLED) {
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
}
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
}

View File

@ -117,7 +117,7 @@ class Indexer {
switch (task) {
case 'blocksPrices': {
if (!['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
if (!['testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && config.FIAT_PRICE.ENABLED) {
let lastestPriceId;
try {
lastestPriceId = await PricesRepository.$getLatestPriceId();
@ -149,11 +149,13 @@ class Indexer {
return;
}
if (config.FIAT_PRICE.ENABLED) {
try {
await priceUpdater.$run();
} catch (e) {
logger.err(`Running priceUpdater failed. Reason: ` + (e instanceof Error ? e.message : e));
}
}
// Do not attempt to index anything unless Bitcoin Core is fully synced
const blockchainInfo = await bitcoinClient.getBlockchainInfo();

View File

@ -1,5 +1,6 @@
import DB from '../database';
import logger from '../logger';
import config from '../config';
import priceUpdater from '../tasks/price-updater';
export interface ApiPrice {
@ -11,8 +12,72 @@ export interface ApiPrice {
CHF: number,
AUD: number,
JPY: number,
BGN: number,
BRL: number,
CNY: number,
CZK: number,
DKK: number,
HKD: number,
HRK: number,
HUF: number,
IDR: number,
ILS: number,
INR: number,
ISK: number,
KRW: number,
MXN: number,
MYR: number,
NOK: number,
NZD: number,
PHP: number,
PLN: number,
RON: number,
RUB: number,
SEK: number,
SGD: number,
THB: number,
TRY: number,
ZAR: number,
}
const ApiPriceFields = `
const ApiPriceFields = config.FIAT_PRICE.API_KEY ?
`
UNIX_TIMESTAMP(time) as time,
USD,
EUR,
GBP,
CAD,
CHF,
AUD,
JPY,
BGN,
BRL,
CNY,
CZK,
DKK,
HKD,
HRK,
HUF,
IDR,
ILS,
INR,
ISK,
KRW,
MXN,
MYR,
NOK,
NZD,
PHP,
PLN,
RON,
RUB,
SEK,
SGD,
THB,
TRY,
ZAR
`:
`
UNIX_TIMESTAMP(time) as time,
USD,
EUR,
@ -21,7 +86,7 @@ const ApiPriceFields = `
CHF,
AUD,
JPY
`;
`;
export interface ExchangeRates {
USDEUR: number,
@ -30,6 +95,32 @@ export interface ExchangeRates {
USDCHF: number,
USDAUD: number,
USDJPY: number,
USDBGN?: number,
USDBRL?: number,
USDCNY?: number,
USDCZK?: number,
USDDKK?: number,
USDHKD?: number,
USDHRK?: number,
USDHUF?: number,
USDIDR?: number,
USDILS?: number,
USDINR?: number,
USDISK?: number,
USDKRW?: number,
USDMXN?: number,
USDMYR?: number,
USDNOK?: number,
USDNZD?: number,
USDPHP?: number,
USDPLN?: number,
USDRON?: number,
USDRUB?: number,
USDSEK?: number,
USDSGD?: number,
USDTHB?: number,
USDTRY?: number,
USDZAR?: number,
}
export interface Conversion {
@ -45,6 +136,32 @@ export const MAX_PRICES = {
CHF: 100000000,
AUD: 100000000,
JPY: 10000000000,
BGN: 1000000000,
BRL: 1000000000,
CNY: 1000000000,
CZK: 10000000000,
DKK: 1000000000,
HKD: 1000000000,
HRK: 1000000000,
HUF: 10000000000,
IDR: 100000000000,
ILS: 1000000000,
INR: 10000000000,
ISK: 10000000000,
KRW: 100000000000,
MXN: 1000000000,
MYR: 1000000000,
NOK: 1000000000,
NZD: 1000000000,
PHP: 10000000000,
PLN: 1000000000,
RON: 1000000000,
RUB: 10000000000,
SEK: 1000000000,
SGD: 100000000,
THB: 10000000000,
TRY: 10000000000,
ZAR: 10000000000,
};
class PricesRepository {
@ -64,17 +181,49 @@ class PricesRepository {
}
try {
if (!config.FIAT_PRICE.API_KEY) { // Store only the 7 main currencies
await DB.query(`
INSERT INTO prices(time, USD, EUR, GBP, CAD, CHF, AUD, JPY)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ? )`,
[time, prices.USD, prices.EUR, prices.GBP, prices.CAD, prices.CHF, prices.AUD, prices.JPY]
);
} else { // Store all 7 main currencies + all the currencies obtained with the external API
await DB.query(`
INSERT INTO prices(time, USD, EUR, GBP, CAD, CHF, AUD, JPY, BGN, BRL, CNY, CZK, DKK, HKD, HRK, HUF, IDR, ILS, INR, ISK, KRW, MXN, MYR, NOK, NZD, PHP, PLN, RON, RUB, SEK, SGD, THB, TRY, ZAR)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ? , ?, ?, ?, ?, ?, ?, ?, ? , ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? , ? )`,
[time, prices.USD, prices.EUR, prices.GBP, prices.CAD, prices.CHF, prices.AUD, prices.JPY, prices.BGN, prices.BRL, prices.CNY, prices.CZK, prices.DKK,
prices.HKD, prices.HRK, prices.HUF, prices.IDR, prices.ILS, prices.INR, prices.ISK, prices.KRW, prices.MXN, prices.MYR, prices.NOK, prices.NZD,
prices.PHP, prices.PLN, prices.RON, prices.RUB, prices.SEK, prices.SGD, prices.THB, prices.TRY, prices.ZAR]
);
}
} catch (e) {
logger.err(`Cannot save exchange rate into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $saveAdditionalCurrencyPrices(time: number, prices: ApiPrice, legacyCurrencies: string[]): Promise<void> {
try {
await DB.query(`
UPDATE prices
SET BGN = ?, BRL = ?, CNY = ?, CZK = ?, DKK = ?, HKD = ?, HRK = ?, HUF = ?, IDR = ?, ILS = ?, INR = ?, ISK = ?, KRW = ?, MXN = ?, MYR = ?, NOK = ?, NZD = ?, PHP = ?, PLN = ?, RON = ?, RUB = ?, SEK = ?, SGD = ?, THB = ?, TRY = ?, ZAR = ?
WHERE UNIX_TIMESTAMP(time) = ?`,
[prices.BGN, prices.BRL, prices.CNY, prices.CZK, prices.DKK, prices.HKD, prices.HRK, prices.HUF, prices.IDR, prices.ILS, prices.INR, prices.ISK, prices.KRW, prices.MXN, prices.MYR, prices.NOK, prices.NZD, prices.PHP, prices.PLN, prices.RON, prices.RUB, prices.SEK, prices.SGD, prices.THB, prices.TRY, prices.ZAR, time]
);
for (const currency of legacyCurrencies) {
await DB.query(`
UPDATE prices
SET ${currency} = ?
WHERE UNIX_TIMESTAMP(time) = ?`,
[prices[currency], time]
);
}
} catch (e) {
logger.err(`Cannot update exchange rate into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getOldestPriceTime(): Promise<number> {
const [oldestRow] = await DB.query(`
SELECT UNIX_TIMESTAMP(time) AS time
@ -118,6 +267,28 @@ class PricesRepository {
return times.map(time => time.time);
}
public async $getPricesTimesWithMissingFields(): Promise<{time: number, USD: number, eur_missing: boolean, gbp_missing: boolean, cad_missing: boolean, chf_missing: boolean, aud_missing: boolean, jpy_missing: boolean}[]> {
const [times] = await DB.query(`
SELECT UNIX_TIMESTAMP(time) AS time,
USD,
CASE WHEN EUR = -1 THEN TRUE ELSE FALSE END AS eur_missing,
CASE WHEN GBP = -1 THEN TRUE ELSE FALSE END AS gbp_missing,
CASE WHEN CAD = -1 THEN TRUE ELSE FALSE END AS cad_missing,
CASE WHEN CHF = -1 THEN TRUE ELSE FALSE END AS chf_missing,
CASE WHEN AUD = -1 THEN TRUE ELSE FALSE END AS aud_missing,
CASE WHEN JPY = -1 THEN TRUE ELSE FALSE END AS jpy_missing
FROM prices
WHERE USD != -1
AND -1 IN (EUR, GBP, CAD, CHF, AUD, JPY, BGN, BRL, CNY, CZK, DKK, HKD, HRK, HUF, IDR, ILS, INR, ISK, KRW,
MXN, MYR, NOK, NZD, PHP, PLN, RON, RUB, SEK, SGD, THB, TRY, ZAR)
ORDER BY time DESC
`);
if (!Array.isArray(times)) {
return [];
}
return times as {time: number, USD: number, eur_missing: boolean, gbp_missing: boolean, cad_missing: boolean, chf_missing: boolean, aud_missing: boolean, jpy_missing: boolean}[];
}
public async $getPricesTimesAndId(): Promise<{time: number, id: number, USD: number}[]> {
const [times] = await DB.query(`
SELECT
@ -144,7 +315,7 @@ class PricesRepository {
return rates[0] as ApiPrice;
}
public async $getNearestHistoricalPrice(timestamp: number | undefined): Promise<Conversion | null> {
public async $getNearestHistoricalPrice(timestamp: number | undefined, currency?: string): Promise<Conversion | null> {
try {
const [rates] = await DB.query(`
SELECT ${ApiPriceFields}
@ -158,16 +329,59 @@ class PricesRepository {
throw Error(`Cannot get single historical price from the database`);
}
const [latestPrices] = await DB.query(`
SELECT ${ApiPriceFields}
FROM prices
ORDER BY time DESC
LIMIT 1
`);
if (!Array.isArray(latestPrices)) {
throw Error(`Cannot get single historical price from the database`);
}
// Compute fiat exchange rates
let latestPrice = rates[0] as ApiPrice;
let latestPrice = latestPrices[0] as ApiPrice;
if (!latestPrice || latestPrice.USD === -1) {
latestPrice = priceUpdater.getEmptyPricesObj();
}
const computeFx = (usd: number, other: number): number =>
Math.round(Math.max(other, 0) / Math.max(usd, 1) * 100) / 100;
const computeFx = (usd: number, other: number): number => usd <= 0.05 ? 0 : Math.round(Math.max(other, 0) / usd * 100) / 100;
const exchangeRates: ExchangeRates = {
const exchangeRates: ExchangeRates = config.FIAT_PRICE.API_KEY ?
{
USDEUR: computeFx(latestPrice.USD, latestPrice.EUR),
USDGBP: computeFx(latestPrice.USD, latestPrice.GBP),
USDCAD: computeFx(latestPrice.USD, latestPrice.CAD),
USDCHF: computeFx(latestPrice.USD, latestPrice.CHF),
USDAUD: computeFx(latestPrice.USD, latestPrice.AUD),
USDJPY: computeFx(latestPrice.USD, latestPrice.JPY),
USDBGN: computeFx(latestPrice.USD, latestPrice.BGN),
USDBRL: computeFx(latestPrice.USD, latestPrice.BRL),
USDCNY: computeFx(latestPrice.USD, latestPrice.CNY),
USDCZK: computeFx(latestPrice.USD, latestPrice.CZK),
USDDKK: computeFx(latestPrice.USD, latestPrice.DKK),
USDHKD: computeFx(latestPrice.USD, latestPrice.HKD),
USDHRK: computeFx(latestPrice.USD, latestPrice.HRK),
USDHUF: computeFx(latestPrice.USD, latestPrice.HUF),
USDIDR: computeFx(latestPrice.USD, latestPrice.IDR),
USDILS: computeFx(latestPrice.USD, latestPrice.ILS),
USDINR: computeFx(latestPrice.USD, latestPrice.INR),
USDISK: computeFx(latestPrice.USD, latestPrice.ISK),
USDKRW: computeFx(latestPrice.USD, latestPrice.KRW),
USDMXN: computeFx(latestPrice.USD, latestPrice.MXN),
USDMYR: computeFx(latestPrice.USD, latestPrice.MYR),
USDNOK: computeFx(latestPrice.USD, latestPrice.NOK),
USDNZD: computeFx(latestPrice.USD, latestPrice.NZD),
USDPHP: computeFx(latestPrice.USD, latestPrice.PHP),
USDPLN: computeFx(latestPrice.USD, latestPrice.PLN),
USDRON: computeFx(latestPrice.USD, latestPrice.RON),
USDRUB: computeFx(latestPrice.USD, latestPrice.RUB),
USDSEK: computeFx(latestPrice.USD, latestPrice.SEK),
USDSGD: computeFx(latestPrice.USD, latestPrice.SGD),
USDTHB: computeFx(latestPrice.USD, latestPrice.THB),
USDTRY: computeFx(latestPrice.USD, latestPrice.TRY),
USDZAR: computeFx(latestPrice.USD, latestPrice.ZAR),
} : {
USDEUR: computeFx(latestPrice.USD, latestPrice.EUR),
USDGBP: computeFx(latestPrice.USD, latestPrice.GBP),
USDCAD: computeFx(latestPrice.USD, latestPrice.CAD),
@ -176,6 +390,30 @@ class PricesRepository {
USDJPY: computeFx(latestPrice.USD, latestPrice.JPY),
};
if (currency) {
if (!latestPrice[currency]) {
return null;
}
const filteredRates = rates.map((rate: any) => {
return {
time: rate.time,
[currency]: rate[currency],
['USD']: rate['USD']
};
});
if (filteredRates.length === 0) { // No price data before 2010-07-19: add a fake entry
filteredRates.push({
time: 1279497600,
[currency]: 0,
['USD']: 0
});
}
return {
prices: filteredRates as ApiPrice[],
exchangeRates: exchangeRates
};
}
return {
prices: rates as ApiPrice[],
exchangeRates: exchangeRates
@ -186,7 +424,7 @@ class PricesRepository {
}
}
public async $getHistoricalPrices(): Promise<Conversion | null> {
public async $getHistoricalPrices(currency?: string): Promise<Conversion | null> {
try {
const [rates] = await DB.query(`
SELECT ${ApiPriceFields}
@ -204,9 +442,43 @@ class PricesRepository {
}
const computeFx = (usd: number, other: number): number =>
Math.round(Math.max(other, 0) / Math.max(usd, 1) * 100) / 100;
usd <= 0 ? 0 : Math.round(Math.max(other, 0) / usd * 100) / 100;
const exchangeRates: ExchangeRates = {
const exchangeRates: ExchangeRates = config.FIAT_PRICE.API_KEY ?
{
USDEUR: computeFx(latestPrice.USD, latestPrice.EUR),
USDGBP: computeFx(latestPrice.USD, latestPrice.GBP),
USDCAD: computeFx(latestPrice.USD, latestPrice.CAD),
USDCHF: computeFx(latestPrice.USD, latestPrice.CHF),
USDAUD: computeFx(latestPrice.USD, latestPrice.AUD),
USDJPY: computeFx(latestPrice.USD, latestPrice.JPY),
USDBGN: computeFx(latestPrice.USD, latestPrice.BGN),
USDBRL: computeFx(latestPrice.USD, latestPrice.BRL),
USDCNY: computeFx(latestPrice.USD, latestPrice.CNY),
USDCZK: computeFx(latestPrice.USD, latestPrice.CZK),
USDDKK: computeFx(latestPrice.USD, latestPrice.DKK),
USDHKD: computeFx(latestPrice.USD, latestPrice.HKD),
USDHRK: computeFx(latestPrice.USD, latestPrice.HRK),
USDHUF: computeFx(latestPrice.USD, latestPrice.HUF),
USDIDR: computeFx(latestPrice.USD, latestPrice.IDR),
USDILS: computeFx(latestPrice.USD, latestPrice.ILS),
USDINR: computeFx(latestPrice.USD, latestPrice.INR),
USDISK: computeFx(latestPrice.USD, latestPrice.ISK),
USDKRW: computeFx(latestPrice.USD, latestPrice.KRW),
USDMXN: computeFx(latestPrice.USD, latestPrice.MXN),
USDMYR: computeFx(latestPrice.USD, latestPrice.MYR),
USDNOK: computeFx(latestPrice.USD, latestPrice.NOK),
USDNZD: computeFx(latestPrice.USD, latestPrice.NZD),
USDPHP: computeFx(latestPrice.USD, latestPrice.PHP),
USDPLN: computeFx(latestPrice.USD, latestPrice.PLN),
USDRON: computeFx(latestPrice.USD, latestPrice.RON),
USDRUB: computeFx(latestPrice.USD, latestPrice.RUB),
USDSEK: computeFx(latestPrice.USD, latestPrice.SEK),
USDSGD: computeFx(latestPrice.USD, latestPrice.SGD),
USDTHB: computeFx(latestPrice.USD, latestPrice.THB),
USDTRY: computeFx(latestPrice.USD, latestPrice.TRY),
USDZAR: computeFx(latestPrice.USD, latestPrice.ZAR),
} : {
USDEUR: computeFx(latestPrice.USD, latestPrice.EUR),
USDGBP: computeFx(latestPrice.USD, latestPrice.GBP),
USDCAD: computeFx(latestPrice.USD, latestPrice.CAD),
@ -215,6 +487,23 @@ class PricesRepository {
USDJPY: computeFx(latestPrice.USD, latestPrice.JPY),
};
if (currency) {
if (!latestPrice[currency]) {
return null;
}
const filteredRates = rates.map((rate: any) => {
return {
time: rate.time,
[currency]: rate[currency],
['USD']: rate['USD']
};
});
return {
prices: filteredRates as ApiPrice[],
exchangeRates: exchangeRates
};
}
return {
prices: rates as ApiPrice[],
exchangeRates: exchangeRates

View File

@ -0,0 +1,73 @@
import { query } from '../../utils/axios-query';
import { ConversionFeed, ConversionRates } from '../price-updater';
const emptyRates = {
AUD: -1,
BGN: -1,
BRL: -1,
CAD: -1,
CHF: -1,
CNY: -1,
CZK: -1,
DKK: -1,
EUR: -1,
GBP: -1,
HKD: -1,
HRK: -1,
HUF: -1,
IDR: -1,
ILS: -1,
INR: -1,
ISK: -1,
JPY: -1,
KRW: -1,
MXN: -1,
MYR: -1,
NOK: -1,
NZD: -1,
PHP: -1,
PLN: -1,
RON: -1,
RUB: -1,
SEK: -1,
SGD: -1,
THB: -1,
TRY: -1,
USD: -1,
ZAR: -1,
};
class FreeCurrencyApi implements ConversionFeed {
private API_KEY: string;
constructor(apiKey: string) {
this.API_KEY = apiKey;
}
public async $getQuota(): Promise<any> {
const response = await query(`https://api.freecurrencyapi.com/v1/status?apikey=${this.API_KEY}`);
if (response && response['quotas']) {
return response['quotas'];
}
return null;
}
public async $fetchLatestConversionRates(): Promise<ConversionRates> {
const response = await query(`https://api.freecurrencyapi.com/v1/latest?apikey=${this.API_KEY}`);
if (response && response['data']) {
return response['data'];
}
return emptyRates;
}
public async $fetchConversionRates(date: string): Promise<ConversionRates> {
const response = await query(`https://api.freecurrencyapi.com/v1/historical?date=${date}&apikey=${this.API_KEY}`);
if (response && response['data'] && response['data'][date]) {
return response['data'][date];
}
return emptyRates;
}
}
export default FreeCurrencyApi;

View File

@ -8,6 +8,7 @@ import BitflyerApi from './price-feeds/bitflyer-api';
import CoinbaseApi from './price-feeds/coinbase-api';
import GeminiApi from './price-feeds/gemini-api';
import KrakenApi from './price-feeds/kraken-api';
import FreeCurrencyApi from './price-feeds/free-currency-api';
export interface PriceFeed {
name: string;
@ -23,6 +24,16 @@ export interface PriceHistory {
[timestamp: number]: ApiPrice;
}
export interface ConversionFeed {
$getQuota(): Promise<any>;
$fetchLatestConversionRates(): Promise<ConversionRates>;
$fetchConversionRates(date: string): Promise<ConversionRates>;
}
export interface ConversionRates {
[currency: string]: number
}
function getMedian(arr: number[]): number {
const sortedArr = arr.slice().sort((a, b) => a - b);
const mid = Math.floor(sortedArr.length / 2);
@ -33,6 +44,9 @@ function getMedian(arr: number[]): number {
class PriceUpdater {
public historyInserted = false;
private additionalCurrenciesHistoryInserted = false;
private additionalCurrenciesHistoryRunning = false;
private lastFailedHistoricalRun = 0;
private timeBetweenUpdatesMs = 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR;
private cyclePosition = -1;
private firstRun = true;
@ -42,6 +56,10 @@ class PriceUpdater {
private feeds: PriceFeed[] = [];
private currencies: string[] = ['USD', 'EUR', 'GBP', 'CAD', 'CHF', 'AUD', 'JPY'];
private latestPrices: ApiPrice;
private currencyConversionFeed: ConversionFeed | undefined;
private newCurrencies: string[] = ['BGN', 'BRL', 'CNY', 'CZK', 'DKK', 'HKD', 'HRK', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR'];
private lastTimeConversionsRatesFetched: number = 0;
private latestConversionsRatesFromFeed: ConversionRates = {};
private ratesChangedCallback: ((rates: ApiPrice) => void) | undefined;
constructor() {
@ -53,6 +71,7 @@ class PriceUpdater {
this.feeds.push(new BitfinexApi());
this.feeds.push(new GeminiApi());
this.currencyConversionFeed = new FreeCurrencyApi(config.FIAT_PRICE.API_KEY);
this.setCyclePosition();
}
@ -70,6 +89,32 @@ class PriceUpdater {
CHF: -1,
AUD: -1,
JPY: -1,
BGN: -1,
BRL: -1,
CNY: -1,
CZK: -1,
DKK: -1,
HKD: -1,
HRK: -1,
HUF: -1,
IDR: -1,
ILS: -1,
INR: -1,
ISK: -1,
KRW: -1,
MXN: -1,
MYR: -1,
NOK: -1,
NZD: -1,
PHP: -1,
PLN: -1,
RON: -1,
RUB: -1,
SEK: -1,
SGD: -1,
THB: -1,
TRY: -1,
ZAR: -1,
};
}
@ -99,6 +144,23 @@ class PriceUpdater {
if ((Math.round(new Date().getTime() / 1000) - this.lastHistoricalRun) > 3600 * 24) {
// Once a day, look for missing prices (could happen due to network connectivity issues)
this.historyInserted = false;
this.additionalCurrenciesHistoryInserted = false;
}
if (this.lastFailedHistoricalRun > 0 && (Math.round(new Date().getTime() / 1000) - this.lastFailedHistoricalRun) > 60) {
// If the last attempt to insert missing prices failed, we try again after 60 seconds
this.additionalCurrenciesHistoryInserted = false;
}
if (config.FIAT_PRICE.API_KEY && this.currencyConversionFeed && (Math.round(new Date().getTime() / 1000) - this.lastTimeConversionsRatesFetched) > 3600 * 24) {
// Once a day, fetch conversion rates from api: we don't need more granularity for fiat currencies and have a limited number of requests
try {
this.latestConversionsRatesFromFeed = await this.currencyConversionFeed.$fetchLatestConversionRates();
this.lastTimeConversionsRatesFetched = Math.round(new Date().getTime() / 1000);
logger.debug(`Fetched currencies conversion rates from external API: ${JSON.stringify(this.latestConversionsRatesFromFeed)}`);
} catch (e) {
logger.err(`Cannot fetch conversion rates from the API. Reason: ${(e instanceof Error ? e.message : e)}`);
}
}
try {
@ -106,6 +168,10 @@ class PriceUpdater {
if (this.historyInserted === false && config.DATABASE.ENABLED === true) {
await this.$insertHistoricalPrices();
}
if (this.additionalCurrenciesHistoryInserted === false && config.DATABASE.ENABLED === true && config.FIAT_PRICE.API_KEY && !this.additionalCurrenciesHistoryRunning) {
await this.$insertMissingAdditionalPrices();
}
} catch (e: any) {
logger.err(`Cannot save BTC prices in db. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining);
}
@ -185,6 +251,14 @@ class PriceUpdater {
}
}
if (config.FIAT_PRICE.API_KEY && this.latestPrices.USD > 0 && Object.keys(this.latestConversionsRatesFromFeed).length > 0) {
for (const conversionCurrency of this.newCurrencies) {
if (this.latestConversionsRatesFromFeed[conversionCurrency] > 0 && this.latestPrices.USD * this.latestConversionsRatesFromFeed[conversionCurrency] < MAX_PRICES[conversionCurrency]) {
this.latestPrices[conversionCurrency] = Math.round(this.latestPrices.USD * this.latestConversionsRatesFromFeed[conversionCurrency]);
}
}
}
if (config.DATABASE.ENABLED === true && this.cyclePosition === 0) {
// Save everything in db
try {
@ -253,7 +327,7 @@ class PriceUpdater {
await this.$insertMissingRecentPrices('hour');
this.historyInserted = true;
this.lastHistoricalRun = new Date().getTime();
this.lastHistoricalRun = Math.round(new Date().getTime() / 1000);
}
/**
@ -320,6 +394,83 @@ class PriceUpdater {
logger.debug(`Inserted ${totalInserted} ${type === 'day' ? 'dai' : 'hour'}ly historical prices into the db`, logger.tags.mining);
}
}
/**
* Find missing prices for additional currencies and insert them in the database
* We calculate the additional prices from the USD price and the conversion rates
*/
private async $insertMissingAdditionalPrices(): Promise<void> {
this.lastFailedHistoricalRun = 0;
const priceTimesToFill = await PricesRepository.$getPricesTimesWithMissingFields();
if (priceTimesToFill.length === 0) {
return;
}
try {
const remainingQuota = await this.currencyConversionFeed?.$getQuota();
if (remainingQuota['month']['remaining'] < 500) { // We need some calls left for the daily updates
logger.debug(`Not enough currency API credit to insert missing prices in ${priceTimesToFill.length} rows (${remainingQuota['month']['remaining']} calls left).`, logger.tags.mining);
this.additionalCurrenciesHistoryInserted = true; // Do not try again until next day
return;
}
} catch (e) {
logger.err(`Cannot fetch currency API credit, insertion of missing prices aborted. Reason: ${(e instanceof Error ? e.message : e)}`);
return;
}
this.additionalCurrenciesHistoryRunning = true;
logger.debug(`Fetching missing conversion rates from external API to fill ${priceTimesToFill.length} rows`, logger.tags.mining);
let conversionRates: { [timestamp: number]: ConversionRates } = {};
let totalInserted = 0;
for (let i = 0; i < priceTimesToFill.length; i++) {
const priceTime = priceTimesToFill[i];
const missingLegacyCurrencies = this.getMissingLegacyCurrencies(priceTime); // In the case a legacy currency (EUR, GBP, CAD, CHF, AUD, JPY)
const year = new Date(priceTime.time * 1000).getFullYear(); // is missing, we use the same process as for the new currencies
const month = new Date(priceTime.time * 1000).getMonth();
const yearMonthTimestamp = new Date(year, month, 1).getTime() / 1000;
if (conversionRates[yearMonthTimestamp] === undefined) {
conversionRates[yearMonthTimestamp] = await this.currencyConversionFeed?.$fetchConversionRates(`${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01`) || { USD: -1 };
if (conversionRates[yearMonthTimestamp]['USD'] < 0) {
logger.err(`Cannot fetch conversion rates from the API for ${year}-${month + 1 < 10 ? `0${month + 1}` : `${month + 1}`}-01. Aborting insertion of missing prices.`, logger.tags.mining);
this.lastFailedHistoricalRun = Math.round(new Date().getTime() / 1000);
break;
}
}
const prices: ApiPrice = this.getEmptyPricesObj();
let willInsert = false;
for (const conversionCurrency of this.newCurrencies.concat(missingLegacyCurrencies)) {
if (conversionRates[yearMonthTimestamp][conversionCurrency] > 0 && priceTime.USD * conversionRates[yearMonthTimestamp][conversionCurrency] < MAX_PRICES[conversionCurrency]) {
prices[conversionCurrency] = year >= 2013 ? Math.round(priceTime.USD * conversionRates[yearMonthTimestamp][conversionCurrency]) : Math.round(priceTime.USD * conversionRates[yearMonthTimestamp][conversionCurrency] * 100) / 100;
willInsert = true;
} else {
prices[conversionCurrency] = 0;
}
}
if (willInsert) {
await PricesRepository.$saveAdditionalCurrencyPrices(priceTime.time, prices, missingLegacyCurrencies);
++totalInserted;
}
}
logger.debug(`Inserted ${totalInserted} missing additional currency prices into the db`, logger.tags.mining);
this.additionalCurrenciesHistoryInserted = true;
this.additionalCurrenciesHistoryRunning = false;
}
// Helper function to get legacy missing currencies in a row (EUR, GBP, CAD, CHF, AUD, JPY)
private getMissingLegacyCurrencies(priceTime: any): string[] {
const missingCurrencies: string[] = [];
['eur', 'gbp', 'cad', 'chf', 'aud', 'jpy'].forEach(currency => {
if (priceTime[`${currency}_missing`]) {
missingCurrencies.push(currency.toUpperCase());
}
});
return missingCurrencies;
}
}
export default new PriceUpdater();

View File

@ -153,5 +153,9 @@
"ENABLED": __REDIS_ENABLED__,
"UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__",
"BATCH_QUERY_BASE_SIZE": __REDIS_BATCH_QUERY_BASE_SIZE__
},
"FIAT_PRICE": {
"ENABLED": __FIAT_PRICE_ENABLED__,
"API_KEY": "__FIAT_PRICE_API_KEY__"
}
}

View File

@ -155,6 +155,10 @@ __REDIS_ENABLED__=${REDIS_ENABLED:=false}
__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=true}
__REDIS_BATCH_QUERY_BASE_SIZE__=${REDIS_BATCH_QUERY_BASE_SIZE:=5000}
# FIAT_PRICE
__FIAT_PRICE_ENABLED__=${FIAT_PRICE_ENABLED:=true}
__FIAT_PRICE_API_KEY__=${FIAT_PRICE_API_KEY:=""}
mkdir -p "${__MEMPOOL_CACHE_DIR__}"
sed -i "s!__MEMPOOL_NETWORK__!${__MEMPOOL_NETWORK__}!g" mempool-config.json
@ -301,4 +305,8 @@ sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json
sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json
sed -i "s!__REDIS_BATCH_QUERY_BASE_SIZE__!${__REDIS_BATCH_QUERY_BASE_SIZE__}!g" mempool-config.json
# FIAT_PRICE
sed -i "s!__FIAT_PRICE_ENABLED__!${__FIAT_PRICE_ENABLED__}!g" mempool-config.json
sed -i "s!__FIAT_PRICE_API_KEY__!${__FIAT_PRICE_API_KEY__}!g" mempool-config.json
node /backend/package/index.js

View File

@ -268,4 +268,134 @@ export const fiatCurrencies = {
code: 'USD',
indexed: true,
},
BGN: {
name: 'Bulgarian Lev',
code: 'BGN',
indexed: true,
},
BRL: {
name: 'Brazilian Real',
code: 'BRL',
indexed: true,
},
CNY: {
name: 'Chinese Yuan',
code: 'CNY',
indexed: true,
},
CZK: {
name: 'Czech Koruna',
code: 'CZK',
indexed: true,
},
DKK: {
name: 'Danish Krone',
code: 'DKK',
indexed: true,
},
HKD: {
name: 'Hong Kong Dollar',
code: 'HKD',
indexed: true,
},
HRK: {
name: 'Croatian Kuna',
code: 'HRK',
indexed: true,
},
HUF: {
name: 'Hungarian Forint',
code: 'HUF',
indexed: true,
},
IDR: {
name: 'Indonesian Rupiah',
code: 'IDR',
indexed: true,
},
ILS: {
name: 'Israeli Shekel',
code: 'ILS',
indexed: true,
},
INR: {
name: 'Indian Rupee',
code: 'INR',
indexed: true,
},
ISK: {
name: 'Icelandic Krona',
code: 'ISK',
indexed: true,
},
KRW: {
name: 'South Korean Won',
code: 'KRW',
indexed: true,
},
MXN: {
name: 'Mexican Peso',
code: 'MXN',
indexed: true,
},
MYR: {
name: 'Malaysian Ringgit',
code: 'MYR',
indexed: true,
},
NOK: {
name: 'Norwegian Krone',
code: 'NOK',
indexed: true,
},
NZD: {
name: 'New Zealand Dollar',
code: 'NZD',
indexed: true,
},
PHP: {
name: 'Philippine Peso',
code: 'PHP',
indexed: true,
},
PLN: {
name: 'Polish Zloty',
code: 'PLN',
indexed: true,
},
RON: {
name: 'Romanian Leu',
code: 'RON',
indexed: true,
},
RUB: {
name: 'Russian Ruble',
code: 'RUB',
indexed: true,
},
SEK: {
name: 'Swedish Krona',
code: 'SEK',
indexed: true,
},
SGD: {
name: 'Singapore Dollar',
code: 'SGD',
indexed: true,
},
THB: {
name: 'Thai Baht',
code: 'THB',
indexed: true,
},
TRY: {
name: 'Turkish Lira',
code: 'TRY',
indexed: true,
},
ZAR: {
name: 'South African Rand',
code: 'ZAR',
indexed: true,
},
};

View File

@ -335,7 +335,7 @@
</div>
<div class="clearfix"></div>
<app-transactions-list [transactions]="transactions" [paginated]="true"></app-transactions-list>
<app-transactions-list [transactions]="transactions" [paginated]="true" [blockTime]="block.timestamp"></app-transactions-list>
<ng-template [ngIf]="transactionsError">
<div class="text-center">

View File

@ -533,9 +533,9 @@ export class BlockComponent implements OnInit, OnDestroy {
if (this.priceSubscription) {
this.priceSubscription.unsubscribe();
}
this.priceSubscription = block$.pipe(
switchMap((block) => {
return this.priceService.getBlockPrice$(block.timestamp).pipe(
this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, block$]).pipe(
switchMap(([currency, block]) => {
return this.priceService.getBlockPrice$(block.timestamp, true, currency).pipe(
tap((price) => {
this.blockConversion = price;
})

View File

@ -35,6 +35,11 @@ export class FiatSelectorComponent implements OnInit {
this.stateService.fiatCurrency$.subscribe((fiat) => {
this.fiatForm.get('fiat')?.setValue(fiat);
});
if (!this.stateService.env.ADDITIONAL_CURRENCIES) {
this.currencies = this.currencies.filter((currency: any) => {
return ['AUD', 'CAD', 'EUR', 'JPY', 'GBP', 'CHF', 'USD'].includes(currency[0]);
});
}
}
changeFiat() {

View File

@ -533,7 +533,7 @@
<tr *ngIf="isMobile && (network === 'liquid' || network === 'liquidtestnet' || !featuresEnabled || network === '')"></tr>
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [blockConversion]="blockConversion" [value]="tx.fee"></app-fiat></span></td>
<td>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee"></app-fiat></span></td>
</tr>
<tr>
<td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>

View File

@ -76,6 +76,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
mempoolBlocksSubscription: Subscription;
blocksSubscription: Subscription;
miningSubscription: Subscription;
currencyChangeSubscription: Subscription;
fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction;
replaced: boolean = false;
@ -108,7 +109,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
hideFlow: boolean = this.stateService.hideFlow.value;
overrideFlowPreference: boolean = null;
flowEnabled: boolean;
blockConversion: Price;
tooltipPosition: { x: number, y: number };
isMobile: boolean;
@ -493,10 +493,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
this.fetchRbfHistory$.next(this.tx.txid);
this.priceService.getBlockPrice$(tx.status?.block_time, true).pipe(
tap((price) => {
this.blockConversion = price;
this.currencyChangeSubscription?.unsubscribe();
this.currencyChangeSubscription = this.stateService.fiatCurrency$.pipe(
switchMap((currency) => {
return tx.status.block_time ? this.priceService.getBlockPrice$(tx.status.block_time, true, currency).pipe(
tap((price) => tx['price'] = price),
) : of(undefined);
})
).subscribe();
@ -810,6 +812,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.mempoolBlocksSubscription.unsubscribe();
this.blocksSubscription.unsubscribe();
this.miningSubscription?.unsubscribe();
this.currencyChangeSubscription?.unsubscribe();
this.leaveTransaction();
}
}

View File

@ -32,11 +32,14 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Input() outputIndex: number;
@Input() address: string = '';
@Input() rowLimit = 12;
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
@Output() loadMore = new EventEmitter();
latestBlock$: Observable<BlockExtended>;
outspendsSubscription: Subscription;
currencyChangeSubscription: Subscription;
currency: string;
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
showDetails$ = new BehaviorSubject<boolean>(false);
@ -125,6 +128,35 @@ export class TransactionsListComponent implements OnInit, OnChanges {
)
,
).subscribe(() => this.ref.markForCheck());
this.currencyChangeSubscription = this.stateService.fiatCurrency$
.subscribe(currency => {
this.currency = currency;
this.refreshPrice();
});
}
refreshPrice(): void {
// Loop over all transactions
if (!this.transactions || !this.transactions.length || !this.currency) {
return;
}
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
if (!this.blockTime) {
this.transactions.forEach((tx) => {
if (!this.blockTime) {
if (tx.status.block_time) {
this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 10, this.currency).pipe(
tap((price) => tx['price'] = price),
).subscribe();
}
}
});
} else {
this.priceService.getBlockPrice$(this.blockTime, true, this.currency).pipe(
tap((price) => this.transactions.forEach((tx) => tx['price'] = price)),
).subscribe();
}
}
ngOnChanges(changes): void {
@ -148,6 +180,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.transactionsLength = this.transactions.length;
this.cacheService.setTxCache(this.transactions);
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
this.transactions.forEach((tx) => {
tx['@voutLimit'] = true;
tx['@vinLimit'] = true;
@ -197,10 +230,18 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
this.priceService.getBlockPrice$(tx.status.block_time).pipe(
tap((price) => tx['price'] = price)
if (!this.blockTime && tx.status.block_time && this.currency) {
this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 10, this.currency).pipe(
tap((price) => tx['price'] = price),
).subscribe();
}
});
if (this.blockTime && this.transactions?.length && this.currency) {
this.priceService.getBlockPrice$(this.blockTime, true, this.currency).pipe(
tap((price) => this.transactions.forEach((tx) => tx['price'] = price)),
).subscribe();
}
const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid);
if (txIds.length && !this.cached) {
this.refreshOutspends$.next(txIds);
@ -308,5 +349,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
ngOnDestroy(): void {
this.outspendsSubscription.unsubscribe();
this.currencyChangeSubscription?.unsubscribe();
}
}

View File

@ -1,5 +1,5 @@
import { Component, ElementRef, ViewChild, Input, OnChanges, OnInit } from '@angular/core';
import { tap } from 'rxjs';
import { Subscription, of, switchMap, tap } from 'rxjs';
import { Price, PriceService } from '../../services/price.service';
import { StateService } from '../../services/state.service';
import { environment } from '../../../environments/environment';
@ -35,6 +35,7 @@ export class TxBowtieGraphTooltipComponent implements OnChanges {
tooltipPosition = { x: 0, y: 0 };
blockConversion: Price;
currencyChangeSubscription: Subscription;
nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId;
@ -47,9 +48,12 @@ export class TxBowtieGraphTooltipComponent implements OnChanges {
ngOnChanges(changes): void {
if (changes.line?.currentValue) {
this.priceService.getBlockPrice$(changes.line?.currentValue.timestamp, true).pipe(
tap((price) => {
this.blockConversion = price;
this.currencyChangeSubscription?.unsubscribe();
this.currencyChangeSubscription = this.stateService.fiatCurrency$.pipe(
switchMap((currency) => {
return changes.line?.currentValue.timestamp ? this.priceService.getBlockPrice$(changes.line?.currentValue.timestamp, true, currency).pipe(
tap((price) => this.blockConversion = price),
) : of(undefined);
})
).subscribe();
}

View File

@ -405,7 +405,7 @@ export class ApiService {
);
}
getHistoricalPrice$(timestamp: number | undefined): Observable<Conversion> {
getHistoricalPrice$(timestamp: number | undefined, currency?: string): Observable<Conversion> {
if (this.stateService.isAnyTestnet()) {
return of({
prices: [],
@ -416,12 +416,47 @@ export class ApiService {
USDCHF: 0,
USDAUD: 0,
USDJPY: 0,
USDBGN: 0,
USDBRL: 0,
USDCNY: 0,
USDCZK: 0,
USDDKK: 0,
USDHKD: 0,
USDHRK: 0,
USDHUF: 0,
USDIDR: 0,
USDILS: 0,
USDINR: 0,
USDISK: 0,
USDKRW: 0,
USDMXN: 0,
USDMYR: 0,
USDNOK: 0,
USDNZD: 0,
USDPHP: 0,
USDPLN: 0,
USDRON: 0,
USDRUB: 0,
USDSEK: 0,
USDSGD: 0,
USDTHB: 0,
USDTRY: 0,
USDZAR: 0,
}
});
}
const queryParams = [];
if (timestamp) {
queryParams.push(`timestamp=${timestamp}`);
}
if (currency) {
queryParams.push(`currency=${currency}`);
}
return this.httpClient.get<Conversion>(
this.apiBaseUrl + this.apiBasePath + '/api/v1/historical-price' +
(timestamp ? `?timestamp=${timestamp}` : '')
`${this.apiBaseUrl}${this.apiBasePath}/api/v1/historical-price` +
(queryParams.length > 0 ? `?${queryParams.join('&')}` : '')
);
}

View File

@ -13,6 +13,32 @@ export interface ApiPrice {
CHF: number,
AUD: number,
JPY: number,
BGN?: number,
BRL?: number,
CNY?: number,
CZK?: number,
DKK?: number,
HKD?: number,
HRK?: number,
HUF?: number,
IDR?: number,
ILS?: number,
INR?: number,
ISK?: number,
KRW?: number,
MXN?: number,
MYR?: number,
NOK?: number,
NZD?: number,
PHP?: number,
PLN?: number,
RON?: number,
RUB?: number,
SEK?: number,
SGD?: number,
THB?: number,
TRY?: number,
ZAR?: number,
}
export interface ExchangeRates {
USDEUR: number,
@ -21,6 +47,32 @@ export interface ExchangeRates {
USDCHF: number,
USDAUD: number,
USDJPY: number,
USDBGN?: number,
USDBRL?: number,
USDCNY?: number,
USDCZK?: number,
USDDKK?: number,
USDHKD?: number,
USDHRK?: number,
USDHUF?: number,
USDIDR?: number,
USDILS?: number,
USDINR?: number,
USDISK?: number,
USDKRW?: number,
USDMXN?: number,
USDMYR?: number,
USDNOK?: number,
USDNZD?: number,
USDPHP?: number,
USDPLN?: number,
USDRON?: number,
USDRUB?: number,
USDSEK?: number,
USDSGD?: number,
USDTHB?: number,
USDTRY?: number,
USDZAR?: number,
}
export interface Conversion {
prices: ApiPrice[],
@ -46,6 +98,8 @@ export class PriceService {
lastQueriedTimestamp: number;
lastPriceHistoryUpdate: number;
lastQueriedCurrency: string;
lastQueriedHistoricalCurrency: string;
historicalPrice: ConversionDict = {
prices: null,
@ -60,16 +114,25 @@ export class PriceService {
getEmptyPrice(): Price {
return {
price: {
price: this.stateService.env.ADDITIONAL_CURRENCIES ? {
USD: 0, EUR: 0, GBP: 0, CAD: 0, CHF: 0, AUD: 0, JPY: 0, BGN: 0, BRL: 0, CNY: 0, CZK: 0, DKK: 0, HKD: 0, HRK: 0, HUF: 0, IDR: 0,
ILS: 0, INR: 0, ISK: 0, KRW: 0, MXN: 0, MYR: 0, NOK: 0, NZD: 0, PHP: 0, PLN: 0, RON: 0, RUB: 0, SEK: 0, SGD: 0, THB: 0, TRY: 0,
ZAR: 0
} :
{
USD: 0, EUR: 0, GBP: 0, CAD: 0, CHF: 0, AUD: 0, JPY: 0,
},
exchangeRates: {
exchangeRates: this.stateService.env.ADDITIONAL_CURRENCIES ? {
USDEUR: 0, USDGBP: 0, USDCAD: 0, USDCHF: 0, USDAUD: 0, USDJPY: 0, USDBGN: 0, USDBRL: 0, USDCNY: 0, USDCZK: 0, USDDKK: 0, USDHKD: 0,
USDHRK: 0, USDHUF: 0, USDIDR: 0, USDILS: 0, USDINR: 0, USDISK: 0, USDKRW: 0, USDMXN: 0, USDMYR: 0, USDNOK: 0, USDNZD: 0, USDPHP: 0,
USDPLN: 0, USDRON: 0, USDRUB: 0, USDSEK: 0, USDSGD: 0, USDTHB: 0, USDTRY: 0, USDZAR: 0
} : {
USDEUR: 0, USDGBP: 0, USDCAD: 0, USDCHF: 0, USDAUD: 0, USDJPY: 0,
},
};
}
getBlockPrice$(blockTimestamp: number, singlePrice = false): Observable<Price | undefined> {
getBlockPrice$(blockTimestamp: number, singlePrice = false, currency: string): Observable<Price | undefined> {
if (this.stateService.env.BASE_MODULE !== 'mempool' || !this.stateService.env.HISTORICAL_PRICE) {
return of(undefined);
}
@ -81,9 +144,10 @@ export class PriceService {
* query a different timestamp than the last one
*/
if (singlePrice) {
if (!this.singlePriceObservable$ || (this.singlePriceObservable$ && blockTimestamp !== this.lastQueriedTimestamp)) {
this.singlePriceObservable$ = this.apiService.getHistoricalPrice$(blockTimestamp).pipe(shareReplay());
if (!this.singlePriceObservable$ || (this.singlePriceObservable$ && (blockTimestamp !== this.lastQueriedTimestamp || currency !== this.lastQueriedCurrency))) {
this.singlePriceObservable$ = this.apiService.getHistoricalPrice$(blockTimestamp, currency).pipe(shareReplay());
this.lastQueriedTimestamp = blockTimestamp;
this.lastQueriedCurrency = currency;
}
return this.singlePriceObservable$.pipe(
@ -92,7 +156,17 @@ export class PriceService {
return undefined;
}
return {
price: {
price: this.stateService.env.ADDITIONAL_CURRENCIES ? {
USD: conversion.prices[0].USD, EUR: conversion.prices[0].EUR, GBP: conversion.prices[0].GBP, CAD: conversion.prices[0].CAD,
CHF: conversion.prices[0].CHF, AUD: conversion.prices[0].AUD, JPY: conversion.prices[0].JPY, BGN: conversion.prices[0].BGN,
BRL: conversion.prices[0].BRL, CNY: conversion.prices[0].CNY, CZK: conversion.prices[0].CZK, DKK: conversion.prices[0].DKK,
HKD: conversion.prices[0].HKD, HRK: conversion.prices[0].HRK, HUF: conversion.prices[0].HUF, IDR: conversion.prices[0].IDR,
ILS: conversion.prices[0].ILS, INR: conversion.prices[0].INR, ISK: conversion.prices[0].ISK, KRW: conversion.prices[0].KRW,
MXN: conversion.prices[0].MXN, MYR: conversion.prices[0].MYR, NOK: conversion.prices[0].NOK, NZD: conversion.prices[0].NZD,
PHP: conversion.prices[0].PHP, PLN: conversion.prices[0].PLN, RON: conversion.prices[0].RON, RUB: conversion.prices[0].RUB,
SEK: conversion.prices[0].SEK, SGD: conversion.prices[0].SGD, THB: conversion.prices[0].THB, TRY: conversion.prices[0].TRY,
ZAR: conversion.prices[0].ZAR
} : {
USD: conversion.prices[0].USD, EUR: conversion.prices[0].EUR, GBP: conversion.prices[0].GBP, CAD: conversion.prices[0].CAD,
CHF: conversion.prices[0].CHF, AUD: conversion.prices[0].AUD, JPY: conversion.prices[0].JPY
},
@ -106,9 +180,10 @@ export class PriceService {
* Query all price history only once. The observable is invalidated after 1 hour
*/
else {
if (!this.priceObservable$ || (this.priceObservable$ && (now - this.lastPriceHistoryUpdate > 3600))) {
this.priceObservable$ = this.apiService.getHistoricalPrice$(undefined).pipe(shareReplay());
if (!this.priceObservable$ || (this.priceObservable$ && (now - this.lastPriceHistoryUpdate > 3600 || currency !== this.lastQueriedHistoricalCurrency))) {
this.priceObservable$ = this.apiService.getHistoricalPrice$(undefined, currency).pipe(shareReplay());
this.lastPriceHistoryUpdate = new Date().getTime() / 1000;
this.lastQueriedHistoricalCurrency = currency;
}
return this.priceObservable$.pipe(
@ -122,9 +197,15 @@ export class PriceService {
exchangeRates: conversion.exchangeRates,
};
for (const price of conversion.prices) {
historicalPrice.prices[price.time] = {
USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD,
CHF: price.CHF, AUD: price.AUD, JPY: price.JPY
historicalPrice.prices[price.time] = this.stateService.env.ADDITIONAL_CURRENCIES ? {
USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD, CHF: price.CHF, AUD: price.AUD,
JPY: price.JPY, BGN: price.BGN, BRL: price.BRL, CNY: price.CNY, CZK: price.CZK, DKK: price.DKK,
HKD: price.HKD, HRK: price.HRK, HUF: price.HUF, IDR: price.IDR, ILS: price.ILS, INR: price.INR,
ISK: price.ISK, KRW: price.KRW, MXN: price.MXN, MYR: price.MYR, NOK: price.NOK, NZD: price.NZD,
PHP: price.PHP, PLN: price.PLN, RON: price.RON, RUB: price.RUB, SEK: price.SEK, SGD: price.SGD,
THB: price.THB, TRY: price.TRY, ZAR: price.ZAR
} : {
USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD, CHF: price.CHF, AUD: price.AUD, JPY: price.JPY
};
}

View File

@ -49,6 +49,7 @@ export interface Env {
SIGNET_BLOCK_AUDIT_START_HEIGHT: number;
HISTORICAL_PRICE: boolean;
ACCELERATOR: boolean;
ADDITIONAL_CURRENCIES: boolean;
GIT_COMMIT_HASH_MEMPOOL_SPACE?: string;
PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string;
}
@ -82,6 +83,7 @@ const defaultEnv: Env = {
'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0,
'HISTORICAL_PRICE': true,
'ACCELERATOR': false,
'ADDITIONAL_CURRENCIES': false,
};
@Injectable({