Merge pull request #5375 from mempool/mononaut/wallet-dashboard-widgets
custom wallet dashboard widgets
@ -30,6 +30,7 @@ export interface AbstractBitcoinApi {
|
|||||||
$getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
$getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
||||||
$getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]>;
|
$getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]>;
|
||||||
$getCoinbaseTx(blockhash: string): Promise<IEsploraApi.Transaction>;
|
$getCoinbaseTx(blockhash: string): Promise<IEsploraApi.Transaction>;
|
||||||
|
$getAddressTransactionSummary(address: string): Promise<IEsploraApi.AddressTxSummary[]>;
|
||||||
|
|
||||||
startHealthChecks(): void;
|
startHealthChecks(): void;
|
||||||
getHealthStatus(): HealthCheckHost[];
|
getHealthStatus(): HealthCheckHost[];
|
||||||
|
@ -255,6 +255,10 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||||||
return this.$getRawTransaction(txids[0]);
|
return this.$getRawTransaction(txids[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async $getAddressTransactionSummary(address: string): Promise<IEsploraApi.AddressTxSummary[]> {
|
||||||
|
throw new Error('Method getAddressTransactionSummary not supported by the Bitcoin RPC API.');
|
||||||
|
}
|
||||||
|
|
||||||
$getEstimatedHashrate(blockHeight: number): Promise<number> {
|
$getEstimatedHashrate(blockHeight: number): Promise<number> {
|
||||||
// 120 is the default block span in Core
|
// 120 is the default block span in Core
|
||||||
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);
|
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);
|
||||||
|
@ -179,4 +179,11 @@ export namespace IEsploraApi {
|
|||||||
burn_count: number;
|
burn_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddressTxSummary {
|
||||||
|
txid: string;
|
||||||
|
value: number;
|
||||||
|
height: number;
|
||||||
|
time: number;
|
||||||
|
tx_position?: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -361,6 +361,10 @@ class ElectrsApi implements AbstractBitcoinApi {
|
|||||||
return this.failoverRouter.$get<IEsploraApi.Transaction>('/tx/' + txid);
|
return this.failoverRouter.$get<IEsploraApi.Transaction>('/tx/' + txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async $getAddressTransactionSummary(address: string): Promise<IEsploraApi.AddressTxSummary[]> {
|
||||||
|
return this.failoverRouter.$get<IEsploraApi.AddressTxSummary[]>('/address/' + address + '/txs/summary');
|
||||||
|
}
|
||||||
|
|
||||||
public startHealthChecks(): void {
|
public startHealthChecks(): void {
|
||||||
this.failoverRouter.startHealthChecks();
|
this.failoverRouter.startHealthChecks();
|
||||||
}
|
}
|
||||||
|
26
backend/src/api/services/services-routes.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Application, Request, Response } from 'express';
|
||||||
|
import config from '../../config';
|
||||||
|
import WalletApi from './wallets';
|
||||||
|
|
||||||
|
class ServicesRoutes {
|
||||||
|
public initRoutes(app: Application): void {
|
||||||
|
app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getWallet(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 5).toUTCString());
|
||||||
|
const walletId = req.params.walletId;
|
||||||
|
const wallet = await WalletApi.getWallet(walletId);
|
||||||
|
res.status(200).send(wallet);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ServicesRoutes();
|
128
backend/src/api/services/wallets.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import logger from '../../logger';
|
||||||
|
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
|
||||||
|
import bitcoinApi from '../bitcoin/bitcoin-api-factory';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { TransactionExtended } from '../../mempool.interfaces';
|
||||||
|
|
||||||
|
interface WalletAddress {
|
||||||
|
address: string;
|
||||||
|
active: boolean;
|
||||||
|
transactions?: IEsploraApi.AddressTxSummary[];
|
||||||
|
lastSync: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Wallet {
|
||||||
|
name: string;
|
||||||
|
addresses: Record<string, WalletAddress>;
|
||||||
|
lastPoll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
class WalletApi {
|
||||||
|
private wallets: Record<string, Wallet> = {};
|
||||||
|
private syncing = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.wallets = config.WALLETS.ENABLED ? (config.WALLETS.WALLETS as string[]).reduce((acc, wallet) => {
|
||||||
|
acc[wallet] = { name: wallet, addresses: {}, lastPoll: 0 };
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Wallet>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getWallet(wallet: string): Record<string, WalletAddress> {
|
||||||
|
return this.wallets?.[wallet]?.addresses || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// resync wallet addresses from the services backend
|
||||||
|
async $syncWallets(): Promise<void> {
|
||||||
|
if (!config.WALLETS.ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.syncing = true;
|
||||||
|
for (const walletKey of Object.keys(this.wallets)) {
|
||||||
|
const wallet = this.wallets[walletKey];
|
||||||
|
if (wallet.lastPoll < (Date.now() - POLL_FREQUENCY)) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/wallets/${wallet.name}`);
|
||||||
|
const addresses: Record<string, WalletAddress> = response.data;
|
||||||
|
const addressList: WalletAddress[] = Object.values(addresses);
|
||||||
|
// sync all current addresses
|
||||||
|
for (const address of addressList) {
|
||||||
|
await this.$syncWalletAddress(wallet, address);
|
||||||
|
}
|
||||||
|
// remove old addresses
|
||||||
|
for (const address of Object.keys(wallet.addresses)) {
|
||||||
|
if (!addresses[address]) {
|
||||||
|
delete wallet.addresses[address];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wallet.lastPoll = Date.now();
|
||||||
|
logger.debug(`Synced ${Object.keys(wallet.addresses).length} addresses for wallet ${wallet.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Error syncing wallet ${wallet.name}: ${(e instanceof Error ? e.message : e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// resync address transactions from esplora
|
||||||
|
async $syncWalletAddress(wallet: Wallet, address: WalletAddress): Promise<void> {
|
||||||
|
// fetch full transaction data if the address is new or still active and hasn't been synced in the last hour
|
||||||
|
const refreshTransactions = !wallet.addresses[address.address] || (address.active && (Date.now() - wallet.addresses[address.address].lastSync) > 60 * 60 * 1000);
|
||||||
|
if (refreshTransactions) {
|
||||||
|
try {
|
||||||
|
const walletAddress: WalletAddress = {
|
||||||
|
address: address.address,
|
||||||
|
active: address.active,
|
||||||
|
transactions: await bitcoinApi.$getAddressTransactionSummary(address.address),
|
||||||
|
lastSync: Date.now(),
|
||||||
|
};
|
||||||
|
wallet.addresses[address.address] = walletAddress;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Error syncing wallet address ${address.address}: ${(e instanceof Error ? e.message : e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check a new block for transactions that affect wallet address balances, and add relevant transactions to wallets
|
||||||
|
processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record<string, Record<string, IEsploraApi.AddressTxSummary[]>> {
|
||||||
|
const walletTransactions: Record<string, Record<string, IEsploraApi.AddressTxSummary[]>> = {};
|
||||||
|
for (const walletKey of Object.keys(this.wallets)) {
|
||||||
|
const wallet = this.wallets[walletKey];
|
||||||
|
walletTransactions[walletKey] = {};
|
||||||
|
for (const tx of blockTxs) {
|
||||||
|
const funded: Record<string, number> = {};
|
||||||
|
const spent: Record<string, number> = {};
|
||||||
|
for (const vin of tx.vin) {
|
||||||
|
const address = vin.prevout?.scriptpubkey_address;
|
||||||
|
if (address && wallet.addresses[address]) {
|
||||||
|
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vout of tx.vout) {
|
||||||
|
const address = vout.scriptpubkey_address;
|
||||||
|
if (address && wallet.addresses[address]) {
|
||||||
|
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const address of Object.keys({ ...funded, ...spent })) {
|
||||||
|
if (!walletTransactions[walletKey][address]) {
|
||||||
|
walletTransactions[walletKey][address] = [];
|
||||||
|
}
|
||||||
|
walletTransactions[walletKey][address].push({
|
||||||
|
txid: tx.txid,
|
||||||
|
value: (funded[address] ?? 0) - (spent[address] ?? 0),
|
||||||
|
height: block.height,
|
||||||
|
time: block.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return walletTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WalletApi();
|
@ -27,6 +27,7 @@ import mempool from './mempool';
|
|||||||
import statistics from './statistics/statistics';
|
import statistics from './statistics/statistics';
|
||||||
import accelerationRepository from '../repositories/AccelerationRepository';
|
import accelerationRepository from '../repositories/AccelerationRepository';
|
||||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||||
|
import walletApi from './services/wallets';
|
||||||
|
|
||||||
interface AddressTransactions {
|
interface AddressTransactions {
|
||||||
mempool: MempoolTransactionExtended[],
|
mempool: MempoolTransactionExtended[],
|
||||||
@ -307,6 +308,14 @@ class WebsocketHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedMessage && parsedMessage['track-wallet']) {
|
||||||
|
if (parsedMessage['track-wallet'] === 'stop') {
|
||||||
|
client['track-wallet'] = null;
|
||||||
|
} else {
|
||||||
|
client['track-wallet'] = parsedMessage['track-wallet'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (parsedMessage && parsedMessage['track-asset']) {
|
if (parsedMessage && parsedMessage['track-asset']) {
|
||||||
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-asset'])) {
|
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-asset'])) {
|
||||||
client['track-asset'] = parsedMessage['track-asset'];
|
client['track-asset'] = parsedMessage['track-asset'];
|
||||||
@ -1112,6 +1121,9 @@ class WebsocketHandler {
|
|||||||
replaced: replacedTransactions,
|
replaced: replacedTransactions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// check for wallet transactions
|
||||||
|
const walletTransactions = config.WALLETS.ENABLED ? walletApi.processBlock(block, transactions) : [];
|
||||||
|
|
||||||
const responseCache = { ...this.socketData };
|
const responseCache = { ...this.socketData };
|
||||||
function getCachedResponse(key, data): string {
|
function getCachedResponse(key, data): string {
|
||||||
if (!responseCache[key]) {
|
if (!responseCache[key]) {
|
||||||
@ -1316,6 +1328,11 @@ class WebsocketHandler {
|
|||||||
response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta);
|
response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client['track-wallet']) {
|
||||||
|
const trackedWallet = client['track-wallet'];
|
||||||
|
response['wallet-transactions'] = getCachedResponse(`wallet-transactions-${trackedWallet}`, walletTransactions[trackedWallet] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(response).length) {
|
if (Object.keys(response).length) {
|
||||||
client.send(this.serializeResponse(response));
|
client.send(this.serializeResponse(response));
|
||||||
}
|
}
|
||||||
|
@ -162,6 +162,10 @@ interface IConfig {
|
|||||||
PAID: boolean;
|
PAID: boolean;
|
||||||
API_KEY: string;
|
API_KEY: string;
|
||||||
},
|
},
|
||||||
|
WALLETS: {
|
||||||
|
ENABLED: boolean;
|
||||||
|
WALLETS: string[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: IConfig = {
|
const defaults: IConfig = {
|
||||||
@ -324,6 +328,10 @@ const defaults: IConfig = {
|
|||||||
'PAID': false,
|
'PAID': false,
|
||||||
'API_KEY': '',
|
'API_KEY': '',
|
||||||
},
|
},
|
||||||
|
'WALLETS': {
|
||||||
|
'ENABLED': false,
|
||||||
|
'WALLETS': [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
class Config implements IConfig {
|
class Config implements IConfig {
|
||||||
@ -345,6 +353,7 @@ class Config implements IConfig {
|
|||||||
MEMPOOL_SERVICES: IConfig['MEMPOOL_SERVICES'];
|
MEMPOOL_SERVICES: IConfig['MEMPOOL_SERVICES'];
|
||||||
REDIS: IConfig['REDIS'];
|
REDIS: IConfig['REDIS'];
|
||||||
FIAT_PRICE: IConfig['FIAT_PRICE'];
|
FIAT_PRICE: IConfig['FIAT_PRICE'];
|
||||||
|
WALLETS: IConfig['WALLETS'];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const configs = this.merge(configFromFile, defaults);
|
const configs = this.merge(configFromFile, defaults);
|
||||||
@ -366,6 +375,7 @@ class Config implements IConfig {
|
|||||||
this.MEMPOOL_SERVICES = configs.MEMPOOL_SERVICES;
|
this.MEMPOOL_SERVICES = configs.MEMPOOL_SERVICES;
|
||||||
this.REDIS = configs.REDIS;
|
this.REDIS = configs.REDIS;
|
||||||
this.FIAT_PRICE = configs.FIAT_PRICE;
|
this.FIAT_PRICE = configs.FIAT_PRICE;
|
||||||
|
this.WALLETS = configs.WALLETS;
|
||||||
}
|
}
|
||||||
|
|
||||||
merge = (...objects: object[]): IConfig => {
|
merge = (...objects: object[]): IConfig => {
|
||||||
|
@ -32,6 +32,7 @@ import pricesRoutes from './api/prices/prices.routes';
|
|||||||
import miningRoutes from './api/mining/mining-routes';
|
import miningRoutes from './api/mining/mining-routes';
|
||||||
import liquidRoutes from './api/liquid/liquid.routes';
|
import liquidRoutes from './api/liquid/liquid.routes';
|
||||||
import bitcoinRoutes from './api/bitcoin/bitcoin.routes';
|
import bitcoinRoutes from './api/bitcoin/bitcoin.routes';
|
||||||
|
import servicesRoutes from './api/services/services-routes';
|
||||||
import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher';
|
import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher';
|
||||||
import forensicsService from './tasks/lightning/forensics.service';
|
import forensicsService from './tasks/lightning/forensics.service';
|
||||||
import priceUpdater from './tasks/price-updater';
|
import priceUpdater from './tasks/price-updater';
|
||||||
@ -46,6 +47,7 @@ import bitcoinSecondClient from './api/bitcoin/bitcoin-second-client';
|
|||||||
import accelerationRoutes from './api/acceleration/acceleration.routes';
|
import accelerationRoutes from './api/acceleration/acceleration.routes';
|
||||||
import aboutRoutes from './api/about.routes';
|
import aboutRoutes from './api/about.routes';
|
||||||
import mempoolBlocks from './api/mempool-blocks';
|
import mempoolBlocks from './api/mempool-blocks';
|
||||||
|
import walletApi from './api/services/wallets';
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
private wss: WebSocket.Server | undefined;
|
private wss: WebSocket.Server | undefined;
|
||||||
@ -238,6 +240,10 @@ class Server {
|
|||||||
await memPool.$updateMempool(newMempool, newAccelerations, minFeeMempool, minFeeTip, pollRate);
|
await memPool.$updateMempool(newMempool, newAccelerations, minFeeMempool, minFeeTip, pollRate);
|
||||||
}
|
}
|
||||||
indexer.$run();
|
indexer.$run();
|
||||||
|
if (config.WALLETS.ENABLED) {
|
||||||
|
// might take a while, so run in the background
|
||||||
|
walletApi.$syncWallets();
|
||||||
|
}
|
||||||
if (config.FIAT_PRICE.ENABLED) {
|
if (config.FIAT_PRICE.ENABLED) {
|
||||||
priceUpdater.$run();
|
priceUpdater.$run();
|
||||||
}
|
}
|
||||||
@ -335,6 +341,9 @@ class Server {
|
|||||||
if (config.MEMPOOL_SERVICES.ACCELERATIONS) {
|
if (config.MEMPOOL_SERVICES.ACCELERATIONS) {
|
||||||
accelerationRoutes.initRoutes(this.app);
|
accelerationRoutes.initRoutes(this.app);
|
||||||
}
|
}
|
||||||
|
if (config.WALLETS.ENABLED) {
|
||||||
|
servicesRoutes.initRoutes(this.app);
|
||||||
|
}
|
||||||
if (!config.MEMPOOL.OFFICIAL) {
|
if (!config.MEMPOOL.OFFICIAL) {
|
||||||
aboutRoutes.initRoutes(this.app);
|
aboutRoutes.initRoutes(this.app);
|
||||||
}
|
}
|
||||||
|
48
frontend/custom-bitb-config.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"theme": "wiz",
|
||||||
|
"enterprise": "bitb",
|
||||||
|
"branding": {
|
||||||
|
"name": "bitb",
|
||||||
|
"title": "BITB",
|
||||||
|
"site_id": 20,
|
||||||
|
"header_img": "/resources/bitblogo.svg",
|
||||||
|
"footer_img": "/resources/bitblogo.svg"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"component": "fees",
|
||||||
|
"mobileOrder": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "walletBalance",
|
||||||
|
"mobileOrder": 1,
|
||||||
|
"props": {
|
||||||
|
"wallet": "BITB"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "goggles",
|
||||||
|
"mobileOrder": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "wallet",
|
||||||
|
"mobileOrder": 2,
|
||||||
|
"props": {
|
||||||
|
"wallet": "BITB",
|
||||||
|
"period": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "blocks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": "walletTransactions",
|
||||||
|
"mobileOrder": 3,
|
||||||
|
"props": {
|
||||||
|
"wallet": "BITB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@ -83,7 +83,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (!this.address || !this.stats) {
|
if (!this.addressSummary$ && (!this.address || !this.stats)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) {
|
if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) {
|
||||||
@ -144,15 +144,16 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepareChartOptions(summary: AddressTxSummary[]) {
|
prepareChartOptions(summary: AddressTxSummary[]) {
|
||||||
if (!summary || !this.stats) {
|
if (!summary) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum);
|
const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0);
|
||||||
|
let runningTotal = total;
|
||||||
const processData = summary.map(d => {
|
const processData = summary.map(d => {
|
||||||
const balance = total;
|
const balance = runningTotal;
|
||||||
const fiatBalance = total * d.price / 100_000_000;
|
const fiatBalance = runningTotal * d.price / 100_000_000;
|
||||||
total -= d.value;
|
runningTotal -= d.value;
|
||||||
return {
|
return {
|
||||||
time: d.time * 1000,
|
time: d.time * 1000,
|
||||||
balance,
|
balance,
|
||||||
@ -172,7 +173,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
|
|||||||
this.fiatData = this.fiatData.filter(d => d[0] >= startFiat);
|
this.fiatData = this.fiatData.filter(d => d[0] >= startFiat);
|
||||||
}
|
}
|
||||||
this.data.push(
|
this.data.push(
|
||||||
{value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }}
|
{value: [now, total], symbol: 'none', tooltip: { show: false }}
|
||||||
);
|
);
|
||||||
|
|
||||||
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);
|
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<app-truncate [text]="transaction.txid" [lastChars]="5"></app-truncate>
|
<app-truncate [text]="transaction.txid" [lastChars]="5"></app-truncate>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="table-cell-satoshis"><app-amount [satoshis]="transaction.value" digitsInfo="1.2-4" [noFiat]="true"></app-amount></td>
|
<td class="table-cell-satoshis"><app-amount [satoshis]="transaction.value" [digitsInfo]="getAmountDigits(transaction.value)" [noFiat]="true"></app-amount></td>
|
||||||
<td class="table-cell-fiat" ><app-fiat [value]="transaction.value" [blockConversion]="transaction.price" digitsInfo="1.0-0"></app-fiat></td>
|
<td class="table-cell-fiat" ><app-fiat [value]="transaction.value" [blockConversion]="transaction.price" digitsInfo="1.0-0"></app-fiat></td>
|
||||||
<td class="table-cell-date"><app-time kind="since" [time]="transaction.time" [fastRender]="true" [showTooltip]="true"></app-time></td>
|
<td class="table-cell-date"><app-time kind="since" [time]="transaction.time" [fastRender]="true" [showTooltip]="true"></app-time></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -43,7 +43,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On
|
|||||||
|
|
||||||
startAddressSubscription(): void {
|
startAddressSubscription(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (!this.address || !this.addressInfo) {
|
if (!this.addressSummary$ && (!this.address || !this.addressInfo)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.transactions$ = (this.addressSummary$ || (this.isPubkey
|
this.transactions$ = (this.addressSummary$ || (this.isPubkey
|
||||||
@ -55,7 +55,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On
|
|||||||
})
|
})
|
||||||
)).pipe(
|
)).pipe(
|
||||||
map(summary => {
|
map(summary => {
|
||||||
return summary?.slice(0, 6);
|
return summary?.filter(tx => Math.abs(tx.value) >= 1000000)?.slice(0, 6);
|
||||||
}),
|
}),
|
||||||
switchMap(txs => {
|
switchMap(txs => {
|
||||||
return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, txs.length < 3, this.currency).pipe(
|
return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, txs.length < 3, this.currency).pipe(
|
||||||
@ -68,6 +68,12 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On
|
|||||||
))));
|
))));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
getAmountDigits(value: number): string {
|
||||||
|
const decimals = Math.max(0, 4 - Math.ceil(Math.log10(Math.abs(value / 100_000_000))));
|
||||||
|
return `1.${decimals}-${decimals}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
@ -4,10 +4,10 @@
|
|||||||
<div class="item">
|
<div class="item">
|
||||||
<h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5>
|
<h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5>
|
||||||
<div class="card-text">
|
<div class="card-text">
|
||||||
{{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
|
{{ ((total) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="symbol">
|
<div class="symbol">
|
||||||
<app-fiat [value]="(addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum)"></app-fiat>
|
<app-fiat [value]="(total)"></app-fiat>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
@ -19,6 +19,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
|
|||||||
isLoading: boolean = true;
|
isLoading: boolean = true;
|
||||||
error: any;
|
error: any;
|
||||||
|
|
||||||
|
total: number = 0;
|
||||||
delta7d: number = 0;
|
delta7d: number = 0;
|
||||||
delta30d: number = 0;
|
delta30d: number = 0;
|
||||||
|
|
||||||
@ -34,7 +35,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
|
|||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (!this.address || !this.addressInfo) {
|
if (!this.addressSummary$ && (!this.address || !this.addressInfo)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(this.addressSummary$ || (this.isPubkey
|
(this.addressSummary$ || (this.isPubkey
|
||||||
@ -57,6 +58,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
|
|||||||
calculateStats(summary: AddressTxSummary[]): void {
|
calculateStats(summary: AddressTxSummary[]): void {
|
||||||
let weekTotal = 0;
|
let weekTotal = 0;
|
||||||
let monthTotal = 0;
|
let monthTotal = 0;
|
||||||
|
this.total = this.addressInfo ? this.addressInfo.chain_stats.funded_txo_sum - this.addressInfo.chain_stats.spent_txo_sum : summary.reduce((acc, tx) => acc + tx.value, 0);
|
||||||
|
|
||||||
const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
|
const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
|
||||||
const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
|
const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
|
||||||
|
@ -257,6 +257,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case ('walletBalance') {
|
||||||
|
<div class="col card-wrapper" [style.order]="isMobile && widget.mobileOrder || 8">
|
||||||
|
<div class="main-title" i18n="dashboard.treasury">Treasury</div>
|
||||||
|
<app-balance-widget [addressSummary$]="walletSummary$"></app-balance-widget>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('wallet') {
|
||||||
|
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
||||||
|
<div class="card graph-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="title-link">
|
||||||
|
<h5 class="card-title d-inline" i18n="dashboard.balance-history">Balance History</h5>
|
||||||
|
</span>
|
||||||
|
<app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight"></app-address-graph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('walletTransactions') {
|
||||||
|
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="title-link">
|
||||||
|
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
|
||||||
|
</span>
|
||||||
|
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@case ('twitter') {
|
@case ('twitter') {
|
||||||
<div class="col" style="min-height:410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
<div class="col" style="min-height:410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
||||||
<div class="card graph-card">
|
<div class="card graph-card">
|
||||||
|
@ -62,8 +62,10 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
|
|||||||
widgets;
|
widgets;
|
||||||
|
|
||||||
addressSubscription: Subscription;
|
addressSubscription: Subscription;
|
||||||
|
walletSubscription: Subscription;
|
||||||
blockTxSubscription: Subscription;
|
blockTxSubscription: Subscription;
|
||||||
addressSummary$: Observable<AddressTxSummary[]>;
|
addressSummary$: Observable<AddressTxSummary[]>;
|
||||||
|
walletSummary$: Observable<AddressTxSummary[]>;
|
||||||
address: Address;
|
address: Address;
|
||||||
|
|
||||||
goggleResolution = 82;
|
goggleResolution = 82;
|
||||||
@ -107,6 +109,10 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
|
|||||||
this.websocketService.stopTrackingAddress();
|
this.websocketService.stopTrackingAddress();
|
||||||
this.address = null;
|
this.address = null;
|
||||||
}
|
}
|
||||||
|
if (this.walletSubscription) {
|
||||||
|
this.walletSubscription.unsubscribe();
|
||||||
|
this.websocketService.stopTrackingWallet();
|
||||||
|
}
|
||||||
this.destroy$.next(1);
|
this.destroy$.next(1);
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
@ -260,6 +266,7 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.startAddressSubscription();
|
this.startAddressSubscription();
|
||||||
|
this.startWalletSubscription();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) {
|
handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) {
|
||||||
@ -358,6 +365,51 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startWalletSubscription(): void {
|
||||||
|
if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.wallet)) {
|
||||||
|
const walletName = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.wallet).props.wallet;
|
||||||
|
this.websocketService.startTrackingWallet(walletName);
|
||||||
|
|
||||||
|
this.walletSummary$ = this.apiService.getWallet$(walletName).pipe(
|
||||||
|
catchError(e => {
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
map((walletTransactions) => {
|
||||||
|
const transactions = Object.values(walletTransactions).flatMap(wallet => wallet.transactions);
|
||||||
|
return this.deduplicateWalletTransactions(transactions);
|
||||||
|
}),
|
||||||
|
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
||||||
|
startWith(null),
|
||||||
|
scan((summary, walletTransactions) => {
|
||||||
|
if (walletTransactions) {
|
||||||
|
const transactions: AddressTxSummary[] = [...summary, ...Object.values(walletTransactions).flat()];
|
||||||
|
return this.deduplicateWalletTransactions(transactions);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}, initial)
|
||||||
|
)),
|
||||||
|
share(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
|
||||||
|
const transactions = new Map<string, AddressTxSummary>();
|
||||||
|
for (const tx of walletTransactions) {
|
||||||
|
if (transactions.has(tx.txid)) {
|
||||||
|
transactions.get(tx.txid).value += tx.value;
|
||||||
|
} else {
|
||||||
|
transactions.set(tx.txid, tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(transactions.values()).sort((a, b) => {
|
||||||
|
if (a.height === b.height) {
|
||||||
|
return b.tx_position - a.tx_position;
|
||||||
|
}
|
||||||
|
return b.height - a.height;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
onResize(): void {
|
onResize(): void {
|
||||||
if (window.innerWidth >= 992) {
|
if (window.innerWidth >= 992) {
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<img [src]="enterpriseInfo.img" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
|
<img [src]="enterpriseInfo.img" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
|
||||||
}
|
}
|
||||||
@if (enterpriseInfo?.header_img) {
|
@if (enterpriseInfo?.header_img) {
|
||||||
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="60px" class="mr-3">
|
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="60px" class="mr-3">
|
||||||
} @else {
|
} @else {
|
||||||
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" width="500" height="126" class="mempool-logo" style="width: 200px; height: 50px"></app-svg-images>
|
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" width="500" height="126" class="mempool-logo" style="width: 200px; height: 50px"></app-svg-images>
|
||||||
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 200px; height: 50px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
|
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 200px; height: 50px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
<a class="navbar-brand d-none d-md-flex" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
|
<a class="navbar-brand d-none d-md-flex" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
|
||||||
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
|
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
|
||||||
@if (enterpriseInfo?.header_img) {
|
@if (enterpriseInfo?.header_img) {
|
||||||
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="48px" class="mr-3">
|
<img [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="48px" class="mr-3">
|
||||||
} @else {
|
} @else {
|
||||||
<ng-template [ngIf]="subdomain && enterpriseInfo">
|
<ng-template [ngIf]="subdomain && enterpriseInfo">
|
||||||
<div class="subdomain_container">
|
<div class="subdomain_container">
|
||||||
@ -39,7 +39,7 @@
|
|||||||
<!-- Mobile -->
|
<!-- Mobile -->
|
||||||
<a class="navbar-brand d-flex d-md-none justify-content-center" [ngClass]="{'dual-logos': subdomain, 'mr-0': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
|
<a class="navbar-brand d-flex d-md-none justify-content-center" [ngClass]="{'dual-logos': subdomain, 'mr-0': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
|
||||||
@if (enterpriseInfo?.header_img) {
|
@if (enterpriseInfo?.header_img) {
|
||||||
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="42px">
|
<img [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="42px">
|
||||||
} @else {
|
} @else {
|
||||||
<ng-template [ngIf]="subdomain && enterpriseInfo">
|
<ng-template [ngIf]="subdomain && enterpriseInfo">
|
||||||
<div class="subdomain_container">
|
<div class="subdomain_container">
|
||||||
@ -49,7 +49,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
|
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
|
||||||
@if (enterpriseInfo?.header_img) {
|
@if (enterpriseInfo?.header_img) {
|
||||||
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="36px">
|
<img [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="36px">
|
||||||
} @else {
|
} @else {
|
||||||
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
|
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
|
||||||
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
@if (enterpriseInfo?.header_img) {
|
@if (enterpriseInfo?.header_img) {
|
||||||
<a class="d-flex" [routerLink]="['/' | relativeUrl]">
|
<a class="d-flex" [routerLink]="['/' | relativeUrl]">
|
||||||
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="42px">
|
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="42px">
|
||||||
</a>
|
</a>
|
||||||
} @else if (enterpriseInfo?.img || enterpriseInfo?.imageMd5) {
|
} @else if (enterpriseInfo?.img || enterpriseInfo?.imageMd5) {
|
||||||
<a [routerLink]="['/' | relativeUrl]">
|
<a [routerLink]="['/' | relativeUrl]">
|
||||||
|
@ -164,6 +164,7 @@ export interface AddressTxSummary {
|
|||||||
height: number;
|
height: number;
|
||||||
time: number;
|
time: number;
|
||||||
price?: number;
|
price?: number;
|
||||||
|
tx_position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChainStats {
|
export interface ChainStats {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Block, Transaction } from "./electrs.interface";
|
import { AddressTxSummary, Block, Transaction } from "./electrs.interface";
|
||||||
|
|
||||||
export interface OptimizedMempoolStats {
|
export interface OptimizedMempoolStats {
|
||||||
added: number;
|
added: number;
|
||||||
@ -471,3 +471,8 @@ export interface TxResult {
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
export interface WalletAddress {
|
||||||
|
address: string;
|
||||||
|
active: boolean;
|
||||||
|
transactions?: AddressTxSummary[];
|
||||||
|
}
|
||||||
|
@ -36,6 +36,7 @@ export interface WebsocketResponse {
|
|||||||
'track-rbf'?: string;
|
'track-rbf'?: string;
|
||||||
'track-rbf-summary'?: boolean;
|
'track-rbf-summary'?: boolean;
|
||||||
'track-accelerations'?: boolean;
|
'track-accelerations'?: boolean;
|
||||||
|
'track-wallet'?: string;
|
||||||
'watch-mempool'?: boolean;
|
'watch-mempool'?: boolean;
|
||||||
'refresh-blocks'?: boolean;
|
'refresh-blocks'?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
||||||
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult,
|
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, SubmitPackageResult } from '../interfaces/node-api.interface';
|
||||||
SubmitPackageResult} from '../interfaces/node-api.interface';
|
|
||||||
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
||||||
import { StateService } from './state.service';
|
import { StateService } from './state.service';
|
||||||
import { Transaction } from '../interfaces/electrs.interface';
|
import { Transaction } from '../interfaces/electrs.interface';
|
||||||
@ -518,6 +517,12 @@ export class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWallet$(walletName: string): Observable<Record<string, WalletAddress>> {
|
||||||
|
return this.httpClient.get<Record<string, WalletAddress>>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/wallet/${walletName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getAccelerationsByPool$(slug: string): Observable<AccelerationInfo[]> {
|
getAccelerationsByPool$(slug: string): Observable<AccelerationInfo[]> {
|
||||||
return this.httpClient.get<AccelerationInfo[]>(
|
return this.httpClient.get<AccelerationInfo[]>(
|
||||||
this.apiBaseUrl + this.apiBasePath + `/api/v1/accelerations/pool/${slug}`
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/accelerations/pool/${slug}`
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
|
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
|
||||||
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
|
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
|
||||||
import { Transaction } from '../interfaces/electrs.interface';
|
import { AddressTxSummary, Transaction } from '../interfaces/electrs.interface';
|
||||||
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '../interfaces/websocket.interface';
|
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '../interfaces/websocket.interface';
|
||||||
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '../interfaces/node-api.interface';
|
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '../interfaces/node-api.interface';
|
||||||
import { Router, NavigationStart } from '@angular/router';
|
import { Router, NavigationStart } from '@angular/router';
|
||||||
@ -159,6 +159,7 @@ export class StateService {
|
|||||||
mempoolRemovedTransactions$ = new Subject<Transaction>();
|
mempoolRemovedTransactions$ = new Subject<Transaction>();
|
||||||
multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>();
|
multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>();
|
||||||
blockTransactions$ = new Subject<Transaction>();
|
blockTransactions$ = new Subject<Transaction>();
|
||||||
|
walletTransactions$ = new Subject<Record<string, AddressTxSummary[]>>();
|
||||||
isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
|
isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
|
||||||
isLoadingMempool$ = new BehaviorSubject<boolean>(true);
|
isLoadingMempool$ = new BehaviorSubject<boolean>(true);
|
||||||
vbytesPerSecond$ = new ReplaySubject<number>(1);
|
vbytesPerSecond$ = new ReplaySubject<number>(1);
|
||||||
|
@ -34,6 +34,8 @@ export class WebsocketService {
|
|||||||
private isTrackingAddress: string | false = false;
|
private isTrackingAddress: string | false = false;
|
||||||
private isTrackingAddresses: string[] | false = false;
|
private isTrackingAddresses: string[] | false = false;
|
||||||
private isTrackingAccelerations: boolean = false;
|
private isTrackingAccelerations: boolean = false;
|
||||||
|
private isTrackingWallet: boolean = false;
|
||||||
|
private trackingWalletName: string;
|
||||||
private trackingMempoolBlock: number;
|
private trackingMempoolBlock: number;
|
||||||
private stoppingTrackMempoolBlock: any | null = null;
|
private stoppingTrackMempoolBlock: any | null = null;
|
||||||
private latestGitCommit = '';
|
private latestGitCommit = '';
|
||||||
@ -137,6 +139,9 @@ export class WebsocketService {
|
|||||||
if (this.isTrackingAccelerations) {
|
if (this.isTrackingAccelerations) {
|
||||||
this.startTrackAccelerations();
|
this.startTrackAccelerations();
|
||||||
}
|
}
|
||||||
|
if (this.isTrackingWallet) {
|
||||||
|
this.startTrackingWallet(this.trackingWalletName);
|
||||||
|
}
|
||||||
this.stateService.connectionState$.next(2);
|
this.stateService.connectionState$.next(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +201,18 @@ export class WebsocketService {
|
|||||||
this.isTrackingAddresses = false;
|
this.isTrackingAddresses = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTrackingWallet(walletName: string) {
|
||||||
|
this.websocketSubject.next({ 'track-wallet': walletName });
|
||||||
|
this.isTrackingWallet = true;
|
||||||
|
this.trackingWalletName = walletName;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTrackingWallet() {
|
||||||
|
this.websocketSubject.next({ 'track-wallet': 'stop' });
|
||||||
|
this.isTrackingWallet = false;
|
||||||
|
this.trackingWalletName = '';
|
||||||
|
}
|
||||||
|
|
||||||
startTrackAsset(asset: string) {
|
startTrackAsset(asset: string) {
|
||||||
this.websocketSubject.next({ 'track-asset': asset });
|
this.websocketSubject.next({ 'track-asset': asset });
|
||||||
}
|
}
|
||||||
@ -452,6 +469,10 @@ export class WebsocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response['wallet-transactions']) {
|
||||||
|
this.stateService.walletTransactions$.next(response['wallet-transactions']);
|
||||||
|
}
|
||||||
|
|
||||||
if (response['accelerations']) {
|
if (response['accelerations']) {
|
||||||
if (response['accelerations'].accelerations) {
|
if (response['accelerations'].accelerations) {
|
||||||
this.stateService.accelerations$.next({
|
this.stateService.accelerations$.next({
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<div class="col-md-12 branding mt-2">
|
<div class="col-md-12 branding mt-2">
|
||||||
<div class="main-logo" [class]="{'services': isServicesPage}">
|
<div class="main-logo" [class]="{'services': isServicesPage}">
|
||||||
@if (enterpriseInfo?.footer_img) {
|
@if (enterpriseInfo?.footer_img) {
|
||||||
<img [src]="enterpriseInfo?.footer_img" alt="enterpriseInfo.title" height="60px" class="mr-3">
|
<img [src]="enterpriseInfo?.footer_img" [alt]="enterpriseInfo.title" height="60px" class="mr-3">
|
||||||
} @else {
|
} @else {
|
||||||
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
||||||
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
||||||
|
@ -23,7 +23,7 @@ export class FiatCurrencyPipe implements PipeTransform {
|
|||||||
const digits = args[0] || 1;
|
const digits = args[0] || 1;
|
||||||
const currency = args[1] || this.currency || 'USD';
|
const currency = args[1] || this.currency || 'USD';
|
||||||
|
|
||||||
if (num >= 1000) {
|
if (Math.abs(num) >= 1000) {
|
||||||
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num);
|
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num);
|
||||||
} else {
|
} else {
|
||||||
return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num);
|
return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num);
|
||||||
|
45
frontend/src/index.mempool.bitb.html
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>BITB | Bitwise Bitcoin ETF</title>
|
||||||
|
<script src="/resources/config.js"></script>
|
||||||
|
<script src="/resources/customize.js"></script>
|
||||||
|
<base href="/">
|
||||||
|
|
||||||
|
<meta name="description" content="BITB provides low-cost access to bitcoin through a professionally managed fund." />
|
||||||
|
<meta property="og:image" content="https://mempool.space/resources/bitb/bitb-preview.jpg" />
|
||||||
|
<meta property="og:image:type" content="image/jpeg" />
|
||||||
|
<meta property="og:image:width" content="2000" />
|
||||||
|
<meta property="og:image:height" content="1000" />
|
||||||
|
<meta property="og:description" content="BITB provides low-cost access to bitcoin through a professionally managed fund." />
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:site" content="@mempool">
|
||||||
|
<meta name="twitter:creator" content="@mempool">
|
||||||
|
<meta name="twitter:title" content="BITB | Bitwise Bitcoin ETF">
|
||||||
|
<meta name="twitter:description" content="BITB provides low-cost access to bitcoin through a professionally managed fund." />
|
||||||
|
<meta name="twitter:image" content="https://mempool.space/resources/bitb/bitb-preview.jpg" />
|
||||||
|
<meta name="twitter:domain" content="bitwise.mempool.space">
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/resources/bitb/favicons/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/resources/bitb/favicons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/resources/bitb/favicons/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/resources/bitb/favicons/site.webmanifest">
|
||||||
|
<link rel="shortcut icon" href="/resources/bitb/favicons/favicon.ico">
|
||||||
|
<link id="canonical" rel="canonical" href="https://bitwise.mempool.space">
|
||||||
|
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="msapplication-TileColor" content="#000000">
|
||||||
|
<meta name="msapplication-config" content="/resources/favicons/browserconfig.xml">
|
||||||
|
<meta name="theme-color" content="#1d1f31">
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
BIN
frontend/src/resources/bitb/bitb-preview.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
frontend/src/resources/bitb/favicons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
frontend/src/resources/bitb/favicons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
frontend/src/resources/bitb/favicons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 8.8 KiB |
BIN
frontend/src/resources/bitb/favicons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 485 B |
BIN
frontend/src/resources/bitb/favicons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
frontend/src/resources/bitb/favicons/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
1
frontend/src/resources/bitb/favicons/site.webmanifest
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
5
frontend/src/resources/bitblogo.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 92 42" xmlns="http://www.w3.org/2000/svg" fill="white" class="c-cLYGRd c-cLYGRd-cpLjVx-size-md c-cLYGRd-ljqKRC-margin-0">
|
||||||
|
<g transform="translate(0, 10)">
|
||||||
|
<path d="M6.65783 17.9122V10.1221H8.34548C11.4689 10.1221 13.6855 11.2274 13.6855 14.9202C13.6855 18.0471 12.3505 19.5296 9.37819 19.5296C7.94245 19.5296 6.65783 19.4757 6.65783 17.9122ZM6.65783 9.36955V2.87905C6.65783 1.89411 6.82154 1.84361 8.10399 1.84361C11.7058 1.84361 13.3157 2.6265 13.3157 5.32877C13.3157 7.7785 11.9514 9.31905 9.16818 9.36955H6.65783ZM2.38354 3.99388V17.003C2.38354 19.3146 2.15037 19.5296 0 19.5565V20.2822H9.35278C13.1354 20.2822 17.3843 19.6102 17.3843 15.0947C17.3843 11.8155 14.379 10.4178 11.7363 9.88026V9.8265C14.0421 9.39645 16.2961 7.99877 16.2961 5.28405C16.2961 1.30605 11.4255 0.714722 8.26467 0.714722H0V1.44044C2.15037 1.46732 2.38354 1.68235 2.38354 3.99388ZM21.1495 10.5461C21.1495 10.2306 20.8501 9.91512 19.4346 9.83625V9.20525C21.0406 8.91605 22.6194 8.41651 24.1982 7.86439V18.3022C24.1982 19.1172 24.3615 19.2749 25.7226 19.2749V19.9059H19.5163V19.2749C20.9862 19.2749 21.1495 19.1172 21.1495 18.3022V10.5461ZM24.5985 2.699C24.5985 3.83288 23.6375 4.68329 22.6481 4.68329C21.6588 4.68329 20.6977 3.83288 20.6977 2.699C20.6977 1.56513 21.6588 0.714722 22.6481 0.714722C23.6375 0.714722 24.5985 1.56513 24.5985 2.699ZM34.5731 9.2977C34.6288 9.3695 34.6787 9.45159 34.7217 9.54548L39.4097 20.2198H40.1249L43.9919 10.7549L47.9384 20.2198H48.733L52.3881 11.1493C52.8119 10.0713 53.2621 9.01964 54.507 8.80931V8.17832H50.5075V8.80931L50.5671 8.81915C51.3375 8.94629 52.0173 9.05846 52.0173 9.54548C52.0173 9.75581 51.8584 10.1502 51.6464 10.676L49.5276 16.4602L47.0643 10.3868C47.0268 10.2844 46.9693 10.149 46.9108 10.011C46.804 9.75929 46.6935 9.49892 46.6935 9.41401C46.6935 9.07223 47.3557 8.88818 48.3621 8.80931V8.17832H41.4227V8.80931C42.9589 8.94077 43.4357 9.44031 43.4357 9.88726C43.4357 10.1539 43.2843 10.5145 43.0935 10.969C43.0586 11.0522 43.0224 11.1386 42.9854 11.2281L40.9195 16.355L38.4298 10.5709C38.3733 10.4267 38.3144 10.2923 38.2597 10.1677C38.1349 9.88315 38.0325 9.64944 38.0325 9.4666C38.0325 9.12481 38.7211 8.80931 39.8336 8.80931V8.17832H33.4239V8.18367L30.9649 8.18787V3.72512L26.7653 8.47835V9.11211H28.1484V17.8264C28.1484 19.2524 29.3303 20.2822 31.1158 20.2822C32.2224 20.2822 33.9323 19.5164 34.5611 18.5921V17.6415C33.9826 18.196 33.2785 18.8562 32.4486 18.8562C31.0907 18.8562 30.9649 17.932 30.9649 17.2982V9.11211L33.8612 9.10635C33.8612 9.10635 34.4489 9.15481 34.5731 9.2977ZM55.616 9.83625C57.0314 9.91512 57.3308 10.2306 57.3308 10.5461V18.3022C57.3308 19.1172 57.1675 19.2749 55.6976 19.2749V19.9059H61.9039V19.2749C60.5429 19.2749 60.3795 19.1172 60.3795 18.3022V7.86439C58.8007 8.41651 57.2219 8.91605 55.616 9.20525V9.83625ZM58.8582 4.68329C59.8476 4.68329 60.8087 3.83288 60.8087 2.699C60.8087 1.56513 59.8476 0.714722 58.8582 0.714722C57.8689 0.714722 56.9078 1.56513 56.9078 2.699C56.9078 3.83288 57.8689 4.68329 58.8582 4.68329ZM72.8554 8.83782C71.3383 8.28533 69.9567 7.86439 68.5752 7.86439C66.4622 7.86439 63.7532 8.94305 63.7532 11.3635C63.7532 13.2577 65.487 14.2312 67.2207 15.0204C68.9544 15.836 70.6882 16.4938 70.6882 17.7566C70.6882 18.8089 70.038 19.6508 68.9003 19.6508C66.2726 19.6508 64.9723 17.5198 64.4847 15.2835L63.8345 15.3888L64.0783 19.3614C65.1619 19.8349 67.1123 20.2822 68.2772 20.2822C70.8778 20.2822 73.3701 19.4666 73.3701 16.5463C73.3701 14.5206 71.5551 13.626 69.8755 12.8104C67.8167 11.8108 66.2726 11.1267 66.2726 10.2322C66.2726 9.23245 67.1936 8.4958 68.1418 8.4958C70.3632 8.4958 71.528 10.5479 72.2053 12.3632L72.8554 12.3106V8.83782ZM86.2423 15.7045L86.6189 16.0991C85.5429 18.6773 83.7136 20.2822 80.7277 20.2822C76.8809 20.2822 74.7827 17.9144 74.7827 14.2837C74.7827 10.6794 77.419 7.86439 81.1043 7.86439C84.0095 7.86439 86.565 9.99541 86.565 12.942H77.7956C77.6611 15.4677 79.2751 17.9144 82.0727 17.9144C83.9289 17.9144 85.1394 17.1251 86.2423 15.7045ZM77.8084 12.3799H82.7024C83.1995 12.3799 83.3566 12.3041 83.3566 11.7734C83.3566 10.2064 82.1528 8.99326 80.7396 8.99326C78.5412 8.99326 77.913 10.8382 77.8084 12.3799Z"></path><path d="M88.1542 9.64194C89.3296 9.64194 90.2731 8.67285 90.2731 7.47069C90.2731 6.26853 89.3296 5.29944 88.1542 5.29944C86.9827 5.29944 86.0352 6.26853 86.0352 7.47069C86.0352 8.67285 86.9827 9.64194 88.1542 9.64194ZM88.1542 9.08584C87.2746 9.08584 86.5669 8.36618 86.5669 7.47069C86.5669 6.57111 87.2746 5.85554 88.1542 5.85554C89.0378 5.85554 89.7414 6.57111 89.7414 7.47069C89.7414 8.36618 89.0378 9.08584 88.1542 9.08584ZM87.8183 8.43569V7.76101H88.1182L88.562 8.43569H89.1577L88.6499 7.68741C88.9098 7.60972 89.0617 7.38482 89.0617 7.08223C89.0617 6.70196 88.7699 6.44435 88.3421 6.44435H87.3185V8.43569H87.8183ZM87.8183 6.86961H88.2221C88.422 6.86961 88.526 6.95548 88.526 7.11904C88.526 7.28669 88.422 7.36438 88.2142 7.36438H87.8183V6.86961Z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.8 KiB |