Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions client/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Observable as O } from './rxjs'
import {setAdapt} from '@cycle/run/lib/adapt';

import { getMempoolDepth, getConfEstimate, calcSegwitFeeGains } from './lib/fees'
import { isBitcoinNetwork } from './lib/network'
import getPrivacyAnalysis from './lib/privacy-analysis'
import { nativeAssetId, blockTxsPerPage, blocksPerPage } from './const'
import {
Expand Down Expand Up @@ -32,6 +33,7 @@ const apiBase = (process.env.API_URL || '/api').replace(/\/+$/, '')

const reservedPaths = [ 'mempool', 'assets', 'search' ]
, NEW_TABLE_ENTRY_MS = 2000
, DIFFICULTY_PERIOD = 2016

// Make driver source observables rxjs5-compatible via rxjs-compat
setAdapt(stream => O.from(stream))
Expand Down Expand Up @@ -174,6 +176,24 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
.startWith([]).scan((S, mod) => mod(S))
.share()

, latestBlock$ = blocks$
.map(blocks => blocks && blocks[0])
.filter(Boolean)
.distinctUntilChanged((a, b) => a.height == b.height)

, dashboardEpochStartHeight$ = isBitcoinNetwork
? latestBlock$
.map(block => block.height - (block.height % DIFFICULTY_PERIOD))
.distinctUntilChanged()
: O.empty()

, dashboardPreviousDifficultyHeight$ = isBitcoinNetwork
? latestBlock$
.map(block => block.height - DIFFICULTY_PERIOD)
.filter(height => height >= 0)
.distinctUntilChanged()
: O.empty()

, newBlockEntries$ = trackNewEntries(
blocks$,
block => block.id,
Expand Down Expand Up @@ -241,6 +261,14 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
, dashboardState$ = O.combineLatest(blocks$, mempoolRecent$, (blks, txs) =>
({ dashblocks: blks.slice(0, 5), dashTxs: txs.slice(0, 5)}))

, dashboardEpochStartBlock$ = reply('dashboard-epoch-start-block', true)
.map(r => ({ ...r.body, requestedHeight: r.request.height }))
.startWith(null)

, dashboardPreviousDifficultyBlock$ = reply('dashboard-previous-difficulty-block', true)
.map(r => ({ ...r.body, requestedHeight: r.request.height }))
.startWith(null)

// Fee estimates
, feeEst$ = reply('fee-est').startWith(null)

Expand Down Expand Up @@ -314,6 +342,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
// App state
, state$ = combine({ t$, error$, tipHeight$, spends$
, goBlocks$, blocks$, nextBlocks$, prevBlocks$, dashboardState$
, dashboardEpochStartBlock$, dashboardPreviousDifficultyBlock$
, newBlockEntries$, newTxEntries$
, goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$
, mempool$, mempoolRecent$, feeEst$, bitcoinMarketChart$
Expand Down Expand Up @@ -367,6 +396,19 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
// fetch block by height
, goHeight$.map(n => ({ category: 'height', method: 'GET', path: `/block-height/${n}` }))

// fetch dashboard difficulty comparison blocks
, dashboardEpochStartHeight$
.map(height => ({ category: 'dashboard-epoch-start-height', method: 'GET', path: `/block-height/${height}`, height, bg: true }))

, reply('dashboard-epoch-start-height', true)
.map(r => ({ category: 'dashboard-epoch-start-block', method: 'GET', path: `/block/${r.text}`, height: r.request.height, bg: true }))

, dashboardPreviousDifficultyHeight$
.map(height => ({ category: 'dashboard-previous-difficulty-height', method: 'GET', path: `/block-height/${height}`, height, bg: true }))

, reply('dashboard-previous-difficulty-height', true)
.map(r => ({ category: 'dashboard-previous-difficulty-block', method: 'GET', path: `/block/${r.text}`, height: r.request.height, bg: true }))

// push tx
, pushtx$.map(rawtx => ({ category: 'pushtx', method: 'POST', path: `/tx`, send: rawtx, type: 'text/plain' }))

Expand Down
8 changes: 8 additions & 0 deletions client/src/components/icons.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions client/src/lib/network.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const activeNetwork = process.env.MENU_ACTIVE || "";

export const isBitcoinNetwork = activeNetwork
.toLowerCase()
.startsWith("bitcoin");
260 changes: 260 additions & 0 deletions client/src/views/difficulty-adjustment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { ArrowsInSimpleIcon } from "../components/icons";
import { InfoCard } from "../components/info-card";

const DIFFICULTY_PERIOD = 2016;
const TARGET_BLOCK_SECONDS = 10 * 60;
const HASHES_PER_DIFFICULTY = 2 ** 32;

const HASHRATE_UNITS = [
[1e24, "YH/s", "Yottahashes per second"],
[1e21, "ZH/s", "Zettahashes per second"],
[1e18, "EH/s", "Exahashes per second"],
[1e15, "PH/s", "Petahashes per second"],
[1e12, "TH/s", "Terahashes per second"],
[1e9, "GH/s", "Gigahashes per second"],
[1e6, "MH/s", "Megahashes per second"],
[1e3, "kH/s", "Kilohashes per second"],
[1, "H/s", "Hashes per second"],
];

const DIFFICULTY_UNITS = [
[1e15, "Q"],
[1e12, "T"],
[1e9, "B"],
[1e6, "M"],
[1e3, "K"],
[1, ""],
];

const formatAdjustment = (value) => {
if (!Number.isFinite(value)) return "N/A";
if (value === 0) return "0.00%";

return `${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
};

const adjustmentClass = (value) =>
value > 0 ? "success" : value < 0 ? "danger" : "";

const getEpochTiming = (latestBlock, epochStartBlock) => {
const epochStartHeight =
latestBlock && Number.isFinite(latestBlock.height)
? latestBlock.height - (latestBlock.height % DIFFICULTY_PERIOD)
: null;

if (
!latestBlock ||
!epochStartBlock ||
epochStartBlock.requestedHeight !== epochStartHeight ||
!Number.isFinite(epochStartBlock.height) ||
!Number.isFinite(latestBlock.timestamp) ||
!Number.isFinite(epochStartBlock.timestamp)
) {
return null;
}

const blocksMined = latestBlock.height - epochStartBlock.height;
const actualSeconds = latestBlock.timestamp - epochStartBlock.timestamp;

if (blocksMined < 0 || actualSeconds < 0) return null;

const averageBlockSeconds = blocksMined
? Math.max(actualSeconds, 1) / blocksMined
: TARGET_BLOCK_SECONDS;
const blocksUntilAdjustment =
DIFFICULTY_PERIOD - (latestBlock.height % DIFFICULTY_PERIOD);
const secondsUntilAdjustment =
blocksUntilAdjustment * averageBlockSeconds;

return {
averageBlockSeconds,
estimatedAdjustmentTimestamp:
latestBlock.timestamp + secondsUntilAdjustment,
};
};

const expectedAdjustment = (epochTiming) =>
epochTiming
? (TARGET_BLOCK_SECONDS / epochTiming.averageBlockSeconds - 1) * 100
: null;

const previousAdjustment = (latestBlock, previousBlock) => {
if (
!latestBlock ||
!previousBlock ||
previousBlock.requestedHeight !== latestBlock.height - DIFFICULTY_PERIOD ||
!Number.isFinite(latestBlock.difficulty) ||
!Number.isFinite(previousBlock.difficulty) ||
previousBlock.difficulty === 0
) {
return null;
}

return (latestBlock.difficulty / previousBlock.difficulty - 1) * 100;
};

const formatHashrate = (difficulty, averageBlockSeconds) => {
if (
!Number.isFinite(difficulty) ||
difficulty < 0 ||
!Number.isFinite(averageBlockSeconds) ||
averageBlockSeconds <= 0
) {
return { value: "N/A", footer: "Hashes per second" };
}

const hashrate = (difficulty * HASHES_PER_DIFFICULTY) / averageBlockSeconds;
const unit =
HASHRATE_UNITS.find(([threshold]) => hashrate >= threshold) ||
HASHRATE_UNITS[HASHRATE_UNITS.length - 1];
const [divisor, symbol, footer] = unit;
const value = (hashrate / divisor).toLocaleString("en-US", {
maximumSignificantDigits: 3,
});

return { value: `${value} ${symbol}`, footer };
};

const formatDifficulty = (difficulty) => {
if (!Number.isFinite(difficulty) || difficulty < 0) return "N/A";

const unit =
DIFFICULTY_UNITS.find(([threshold]) => difficulty >= threshold) ||
DIFFICULTY_UNITS[DIFFICULTY_UNITS.length - 1];
const [divisor, suffix] = unit;
const scaledDifficulty = difficulty / divisor;

if (scaledDifficulty < 0.01 && scaledDifficulty !== 0) {
return scaledDifficulty.toLocaleString("en-US", {
maximumSignificantDigits: 3,
});
}

return `${scaledDifficulty.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}${suffix}`;
};

const formatAdjustmentDate = (timestamp) => {
if (!Number.isFinite(timestamp)) return "N/A";

const date = new Date(timestamp * 1000);
const month = date.toLocaleString("en-US", { month: "long" });
const day = date.getDate();
const minute = String(date.getMinutes()).padStart(2, "0");
const period = date.getHours() >= 12 ? "pm" : "am";
const hour = String(date.getHours() % 12 || 12).padStart(2, "0");

return `${month} ${day} - ${hour}:${minute} ${period}`;
};

const formatTimeUntil = (timestamp) => {
if (!Number.isFinite(timestamp)) return "N/A";

const totalMinutes = Math.max(
0,
Math.floor((timestamp * 1000 - Date.now()) / (60 * 1000)),
);
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;

if (days >= 14) {
const weeks = Math.floor(days / 7);
const remainingDays = days % 7;
return remainingDays ? `${weeks}w ${remainingDays}d` : `${weeks}w`;
}

if (days) return `${days}d ${hours}h`;
if (hours) return `${hours}h ${minutes}m`;

return totalMinutes ? `${totalMinutes}m` : "< 1m";
};

const adjustmentStat = (title, value, className = "") => (
<div className="difficulty-adjustment-stat">
<p className="difficulty-adjustment-stat-title">{title}</p>
<p className={`difficulty-adjustment-stat-value ${className}`}>{value}</p>
</div>
);

const statDivider = () => (
<div className="difficulty-adjustment-stat-divider"></div>
);

export default ({
blocks,
dashboardEpochStartBlock,
dashboardPreviousDifficultyBlock,
}) => {
const latestBlock = blocks && blocks[0];
const epochTiming = getEpochTiming(latestBlock, dashboardEpochStartBlock);
const expected = expectedAdjustment(epochTiming);
const previous = previousAdjustment(
latestBlock,
dashboardPreviousDifficultyBlock,
);
const hashrate = formatHashrate(
latestBlock && latestBlock.difficulty,
epochTiming && epochTiming.averageBlockSeconds,
);
const estimatedAdjustmentTimestamp =
epochTiming && epochTiming.estimatedAdjustmentTimestamp;
const nextAdjustment = formatTimeUntil(estimatedAdjustmentTimestamp);
const nextAdjustmentFooter = Number.isFinite(estimatedAdjustmentTimestamp)
? `Next adj. in ${nextAdjustment}`
: "Next adjustment unavailable";

return (
<div className="difficulty-adjustment-section">
<div className="difficulty-adjustment-panel">
<div className="table-header">
<div className="table-header-icon-container">
<ArrowsInSimpleIcon />
</div>
<h1 className="table-header-title">Difficulty Adjustment</h1>
</div>
<div className="difficulty-adjustment-stats">
{adjustmentStat("AVERAGE BLOCK TIME", "~10 minutes")}

{statDivider()}
{adjustmentStat(
"EXPECTED",
formatAdjustment(expected),
adjustmentClass(expected),
)}

{statDivider()}
{adjustmentStat(
"PREVIOUS",
formatAdjustment(previous),
adjustmentClass(previous),
)}

{statDivider()}
{adjustmentStat(
"EXPECTED DATE",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This doesn't match the final design in Figma but I wanted to propose this alternative

formatAdjustmentDate(estimatedAdjustmentTimestamp),
)}
</div>
</div>

<div className="difficulty-adjustment-metrics">
<InfoCard
className="difficulty-adjustment-metric-card"
title="Hashrate"
value={hashrate.value}
footer={hashrate.footer}
/>

<InfoCard
className="difficulty-adjustment-metric-card"
title="Difficulty"
value={formatDifficulty(latestBlock && latestBlock.difficulty)}
footer={nextAdjustmentFooter}
/>
</div>
</div>
);
};
5 changes: 5 additions & 0 deletions client/src/views/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import layout from "./layout";
import { blks } from "./blocks";
import { transactions } from "./transactions";
import { overview } from "./overview";
import difficultyAdjustment from "./difficulty-adjustment";
import { isBitcoinNetwork } from "../lib/network";

const isTouch = process.browser && "ontouchstart" in window;

Expand All @@ -16,6 +18,9 @@ export const dashBoard = ({ t, blocks, dashboardState, loading, ...S }) => {
{overview({ blocks: dashblocks, ...S })}
{blks(dashblocks, true, { t, ...S })}
{transactions(dashTxs, true, { t, ...S })}
{isBitcoinNetwork
? difficultyAdjustment({ blocks: dashblocks, ...S })
: ""}
</div>,
{ ...S, t, activeTab: "dashBoard" },
);
Expand Down
Loading