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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ transactions also refresh on new blocks rather than on a timer. A newly observed
tip resets pending block template polling and delays the next request by 15
seconds so the electrs cache can refresh.

While an unconfirmed transaction page is focused, mempool summary and fee
estimates refresh on the standard cadence to keep its ETA, mempool depth, and
fee analysis current. The transaction confirmation status does not use another
fixed-rate poll: each newly observed tip requests `/tx/:txid/status`. Once the
transaction is confirmed, those unconfirmed-transaction refreshes stop and the
containing block metadata is loaded for the block details shown below the
transaction.

The dashboard requests both `/mempool/recent` and `/mempool` because they serve
different UI contracts. `/mempool/recent` supplies the recent transaction list;
`/mempool` supplies aggregate backlog fields such as transaction count, virtual
Expand Down
16 changes: 15 additions & 1 deletion client/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,14 @@ export default function main(
).startWith(null).scan((S, mod) => mod(S))

// Single TX
, tx$ = reply('tx').merge(goTx$.mapTo(null)).startWith(null)
, tx$ = O.merge(
reply('tx').map(tx => _ => tx),
reply('tx-status', true).map(r => tx =>
tx && tx.txid == r.request.txid
? { ...tx, status: r.body }
: tx),
goTx$.mapTo(_ => null)
).startWith(_ => null).scan((tx, update) => update(tx), null)
, txBlock$ = reply('tx-block').merge(goTx$.mapTo(null)).startWith(null)

// Predecessor metadata for confirmed block interval calculations
Expand Down Expand Up @@ -665,6 +672,13 @@ export default function main(
// fetch single tx (including confirmation status)
, goTx$.map(txid => ({ category: 'tx', method: 'GET', path: `/tx/${txid}` }))

// A transaction's confirmation can only change when the chain tip changes.
// Reuse the existing tip poll instead of running another fixed-rate poll.
, !pollingEnabled ? O.empty() : subsequentTipHeight$
.withLatestFrom(view$, tx$, (_, view, tx) => ({ view, tx }))
.filter(({ view, tx }) => view == 'tx' && tx && tx.status && !tx.status.confirmed && hasFocus())
.map(({ tx }) => ({ category: 'tx-status', method: 'GET', path: `/tx/${tx.txid}/status`, txid: tx.txid, bg: true }))

// fetch the block containing a confirmed tx
, tx$.filter(tx => tx && tx.status && tx.status.confirmed && tx.status.block_hash)
.map(tx => ({ category: 'tx-block', method: 'GET', path: `/block/${tx.status.block_hash}` }))
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/status-badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const StatusBadge = (
</span>
);

export const StatusDot = () => (
<span className="confirmation-status-dot" aria-hidden="true">
export const StatusDot = ({ key } = {}) => (
<span key={key} className="confirmation-status-dot" aria-hidden="true">
<span className="confirmation-status-dot-back"></span>
<span className="confirmation-status-dot-middle"></span>
<span className="confirmation-status-dot-front"></span>
Expand Down
19 changes: 12 additions & 7 deletions client/src/views/tx.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default ({
return layout(
[
<div className="tx-page">
{txHeader(tx, { t, tipHeight, ...S })}
{txHeader(tx, { ...S, block, t, tipHeight })}
{unblinded && unblinded.error && (
<div className="transaction-warning text-danger mt-3">
{t`Warning:`} {unblinded.error.toString()}
Expand Down Expand Up @@ -231,6 +231,7 @@ const btnDetailsContent = (isOpen, t) => (
const txHeader = (
tx,
{
block,
tipHeight,
feeEst,
t,
Expand All @@ -251,10 +252,12 @@ const txHeader = (
: confEstimate == -1
? t`Unknown`
: `~${Math.ceil(confEstimate * targetBlockIntervalSeconds / 60)} min`;
const confirmationTime =
isConfirmed && Number.isFinite(tx.status.block_time)
? formatTime(tx.status.block_time)
: "N/A";
const blockTime = isConfirmed && Number.isFinite(tx.status.block_time)
? tx.status.block_time
: block && block.timestamp;
const confirmationTime = Number.isFinite(blockTime)
? formatTime(blockTime)
: "N/A";
const segwitSavings = segwitGainsView(segwitGains, t);

return (
Expand All @@ -279,9 +282,11 @@ const txHeader = (
</button>
<StatusBadge variant={isConfirmed ? "success" : "warning"}>
{!isConfirmed ? (
<StatusDot />
<StatusDot key="transaction-confirmation-dot" />
) : null}
<span>{confirmationText(tx.status, tipHeight, t)}</span>
<span key="transaction-confirmation-label">
{confirmationText(tx.status, tipHeight, t)}
</span>
</StatusBadge>
</div>
<div className="info-stats-row">
Expand Down
163 changes: 163 additions & 0 deletions test/app.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const render = require("snabbdom-to-html");
const { Subject } = require("../client/node_modules/rxjs/Subject");
const {
TestScheduler,
Expand All @@ -23,6 +24,7 @@ const {
schedulePollsWhileActive,
tickWhileFocused,
} = require("../client/src/util");
const { formatTime } = require("../client/src/views/util");
const {
default: main,
dashboardNewBlocks,
Expand Down Expand Up @@ -69,6 +71,24 @@ const makeBlockRoute = (hash) => {
return route;
};

const makeTxRoute = (txid) => {
const location = {
hash: "",
key: "tx",
params: { txid },
pathname: `/tx/${txid}`,
query: {},
};
const location$ = O.of(location);
const route = (pattern) =>
pattern === undefined || pattern === "/tx/:txid"
? location$
: empty$;

route.all$ = location$;
return route;
};

const makeApiRoute = () => {
const location = {
hash: "",
Expand Down Expand Up @@ -129,6 +149,23 @@ const requestFrames = (requests, category) => requests
.filter((request) => request.category === category)
.map((request) => request.frame);

const findVNode = (vnode, predicate) => {
if (!vnode) return null;
if (predicate(vnode)) return vnode;

for (const child of vnode.children || []) {
const match = findVNode(child, predicate);
if (match) return match;
}

return null;
};

const transactionStatusBadge = (vnode) => findVNode(
vnode,
(child) => child.data && child.data.class && child.data.class["status-badge"],
);

const highValueAssetRequestCount = (requests, frame) => requests.filter(
(request) =>
request.frame === frame &&
Expand Down Expand Up @@ -442,6 +479,132 @@ test("refreshes the dashboard block list when the tip height changes", () => {
);
});

test("refreshes an unconfirmed transaction on new tips and loads its confirming block", () => {
const scheduler = new TestScheduler((actual, expected) =>
assert.deepEqual(actual, expected));
const txid = "a".repeat(64);
const blockHash = "b".repeat(64);
const previousBlockHash = "c".repeat(64);
const txResponses = new Subject();
const txStatusResponses = new Subject();
const txBlockResponses = new Subject();
const tipHeightResponses = new Subject();
const requests = [];
const vnodes = [];
let focused = false;
const sources = makeSources({
responseStreams: {
tx: txResponses,
"tx-status": txStatusResponses,
"tx-block": txBlockResponses,
"tip-height": tipHeightResponses,
},
route: makeTxRoute(txid),
});
const sinks = main(sources, {
...pollingOptions(scheduler),
hasFocus: () => focused,
});

sinks.HTTP.subscribe((request) => requests.push(request));
sinks.DOM.subscribe((vnode) => vnodes.push(vnode));
txResponses.next(O.of({
body: {
txid,
version: 2,
locktime: 0,
size: 100,
weight: 400,
fee: 100,
vin: [{ is_coinbase: true, sequence: 0xffffffff }],
vout: [{
asset: "asset-id",
scriptpubkey: "",
scriptpubkey_asm: "",
scriptpubkey_type: "fee",
value: 100,
}],
status: { confirmed: false },
},
}));

const unconfirmedVNode = vnodes[vnodes.length - 1];
assert.match(render(unconfirmedVNode), /Unconfirmed/);
assert.doesNotMatch(render(unconfirmedVNode), /Transaction Block/);
assert.deepEqual(
transactionStatusBadge(unconfirmedVNode).children
.filter(Boolean)
.map((child) => child.key),
["transaction-confirmation-dot", "transaction-confirmation-label"],
);

tipHeightResponses.next(O.of({ text: "100" }));
tipHeightResponses.next(O.of({ text: "100" }));
tipHeightResponses.next(O.of({ text: "101" }));
assert.equal(requestFrames(requests, "tx-status").length, 0);

focused = true;
tipHeightResponses.next(O.of({ text: "102" }));
const statusRequest = requests.find((request) => request.category === "tx-status");
assert.deepEqual(statusRequest, {
bg: true,
category: "tx-status",
method: "GET",
txid,
url: `/api/tx/${txid}/status`,
});

txStatusResponses.next(O.of({
body: {
confirmed: true,
block_hash: blockHash,
block_height: 101,
},
request: statusRequest,
}));

assert.ok(requests.some((request) =>
request.category === "tx-block" &&
request.url === `/api/block/${blockHash}`
));
assert.match(render(vnodes[vnodes.length - 1]), /Transaction Block/);
assert.match(render(vnodes[vnodes.length - 1]), /Loading block/);
assert.deepEqual(
transactionStatusBadge(vnodes[vnodes.length - 1]).children
.filter(Boolean)
.map((child) => child.key),
["transaction-confirmation-label"],
);

txBlockResponses.next(O.of({
body: {
id: blockHash,
height: 101,
previousblockhash: previousBlockHash,
timestamp: 1_700_000_000,
tx_count: 1,
size: 1_000,
weight: 4_000,
version: 1,
nonce: 2,
merkle_root: "d".repeat(64),
},
}));

const confirmedHtml = render(vnodes[vnodes.length - 1]);
assert.match(confirmedHtml, /Confirmed/);
assert.match(confirmedHtml, /Transaction Block/);
assert.match(confirmedHtml, /#101/);
assert.ok(confirmedHtml.split(formatTime(1_700_000_000)).length > 2);
assert.ok(
confirmedHtml.indexOf("Transaction Block") >
confirmedHtml.indexOf('id="transaction-box"'),
);

tipHeightResponses.next(O.of({ text: "103" }));
assert.equal(requestFrames(requests, "tx-status").length, 1);
});

test("treats the first block response of each dashboard visit as a baseline", () => {
const page$ = new Subject();
const latestBlock$ = new Subject();
Expand Down
Loading