mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-09 18:18:14 -07:00
website_next: part 4
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { VIEWBOX_WIDTH } from "./viewbox.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChartPointerPosition
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {SVGSVGElement} svg
|
||||
* @param {() => number | undefined} getHeight
|
||||
* @param {(position: ChartPointerPosition) => void} onMove
|
||||
*/
|
||||
export function createChartPointer(svg, getHeight, onMove) {
|
||||
let rect = svg.getBoundingClientRect();
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
let frame = 0;
|
||||
|
||||
function measure() {
|
||||
rect = svg.getBoundingClientRect();
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function update(event) {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
if (frame) return;
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
const height = getHeight();
|
||||
if (height === undefined) return;
|
||||
|
||||
onMove({
|
||||
x: ((clientX - rect.left) / rect.width) * VIEWBOX_WIDTH,
|
||||
y: ((clientY - rect.top) / rect.height) * height,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
cancel,
|
||||
measure,
|
||||
update,
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
getChartPointRadius,
|
||||
layoutChartMarker,
|
||||
} from "../marker.js";
|
||||
import { createChartPointer } from "../pointer.js";
|
||||
import { createSvgElement } from "../svg.js";
|
||||
import { getPlotBottom, VIEWBOX_WIDTH } from "../viewbox.js";
|
||||
import { getPlotBottom } from "../viewbox.js";
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
@@ -77,10 +78,16 @@ export function createScrubber(svg, readout, highlight, format) {
|
||||
let currentStep = -1;
|
||||
/** @type {ChartPoint[]} */
|
||||
let currentPoints = [];
|
||||
let rect = svg.getBoundingClientRect();
|
||||
let pointerX = 0;
|
||||
let pointerY = 0;
|
||||
let pointerFrame = 0;
|
||||
const pointer = createChartPointer(
|
||||
svg,
|
||||
() => frame?.height,
|
||||
({ x, y }) => {
|
||||
const currentFrame = frame;
|
||||
if (!currentFrame) return;
|
||||
|
||||
update((x - currentFrame.left) / currentFrame.plotWidth, x, y);
|
||||
},
|
||||
);
|
||||
|
||||
group.dataset.scrubber = "root";
|
||||
shade.dataset.scrubber = "shade";
|
||||
@@ -90,10 +97,6 @@ export function createScrubber(svg, readout, highlight, format) {
|
||||
svg.append(group);
|
||||
plot.append(dateMarker);
|
||||
|
||||
function measure() {
|
||||
rect = svg.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/** @param {number} step */
|
||||
function getPointsAtStep(step) {
|
||||
return series.map((item) => getPointAtStep(item, step));
|
||||
@@ -160,13 +163,8 @@ export function createScrubber(svg, readout, highlight, format) {
|
||||
update(1, undefined, undefined, false);
|
||||
}
|
||||
|
||||
function cancelPointerUpdate() {
|
||||
if (pointerFrame) cancelAnimationFrame(pointerFrame);
|
||||
pointerFrame = 0;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cancelPointerUpdate();
|
||||
pointer.cancel();
|
||||
series = [];
|
||||
markers = [];
|
||||
currentStep = -1;
|
||||
@@ -188,9 +186,9 @@ export function createScrubber(svg, readout, highlight, format) {
|
||||
frame = nextFrame;
|
||||
currentStep = -1;
|
||||
stepCount = Math.max(...series.map(({ points }) => points.length - 1));
|
||||
measure();
|
||||
const { width } = pointer.measure();
|
||||
dateMarker.style.display = "";
|
||||
const radius = getChartPointRadius(rect.width);
|
||||
const radius = getChartPointRadius(width);
|
||||
markers = series.map(({ color }, index) => {
|
||||
const marker = createSvgElement("circle");
|
||||
|
||||
@@ -207,33 +205,16 @@ export function createScrubber(svg, readout, highlight, format) {
|
||||
update(1, undefined, undefined, false);
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function updateFromPointer(event) {
|
||||
pointerX = event.clientX;
|
||||
pointerY = event.clientY;
|
||||
if (pointerFrame) return;
|
||||
|
||||
pointerFrame = requestAnimationFrame(() => {
|
||||
pointerFrame = 0;
|
||||
if (!frame) return;
|
||||
|
||||
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
|
||||
const y = ((pointerY - rect.top) / rect.height) * frame.height;
|
||||
|
||||
update((x - frame.left) / frame.plotWidth, x, y);
|
||||
});
|
||||
}
|
||||
|
||||
svg.addEventListener("pointerenter", measure);
|
||||
svg.addEventListener("pointermove", updateFromPointer);
|
||||
svg.addEventListener("pointerenter", pointer.measure);
|
||||
svg.addEventListener("pointermove", pointer.update);
|
||||
svg.addEventListener("pointerleave", () => {
|
||||
cancelPointerUpdate();
|
||||
pointer.cancel();
|
||||
highlight.clearPreview();
|
||||
hide();
|
||||
});
|
||||
svg.addEventListener("focus", () => update(1));
|
||||
svg.addEventListener("blur", () => {
|
||||
cancelPointerUpdate();
|
||||
pointer.cancel();
|
||||
highlight.clearPreview();
|
||||
hide();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSeriesHighlight } from "../highlight.js";
|
||||
import { createLegend } from "../legend/index.js";
|
||||
import { getChartPointRadius, layoutChartMarker } from "../marker.js";
|
||||
import { createLinePathData } from "../path.js";
|
||||
import { createChartPointer } from "../pointer.js";
|
||||
import { createSvgElement } from "../svg.js";
|
||||
import { createChartFrame, VIEWBOX_WIDTH } from "../viewbox.js";
|
||||
|
||||
@@ -45,10 +46,23 @@ export function createXyChart({
|
||||
let currentSeries = [];
|
||||
/** @type {ChartFrame | undefined} */
|
||||
let currentFrame;
|
||||
let rect = svg.getBoundingClientRect();
|
||||
let pointerX = 0;
|
||||
let pointerY = 0;
|
||||
let pointerFrame = 0;
|
||||
const pointer = createChartPointer(
|
||||
svg,
|
||||
() => currentFrame?.height,
|
||||
({ x, y }) => {
|
||||
const frame = currentFrame;
|
||||
if (!frame) return;
|
||||
|
||||
const closest = findClosestPoint(series, currentSeries, x, y);
|
||||
|
||||
if (!closest) {
|
||||
hideMarker();
|
||||
return;
|
||||
}
|
||||
|
||||
showMarker(closest, frame);
|
||||
},
|
||||
);
|
||||
|
||||
figure.dataset.chart = "xy";
|
||||
figure.dataset.chartLegend = "";
|
||||
@@ -62,10 +76,6 @@ export function createXyChart({
|
||||
plotElement.append(svg, markerElement);
|
||||
figure.append(legend, plotElement);
|
||||
|
||||
function measure() {
|
||||
rect = svg.getBoundingClientRect();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const frame = createChartFrame(svg, fallbackHeight, frameOptions);
|
||||
const plottedSeries = plot(frame);
|
||||
@@ -90,29 +100,6 @@ export function createXyChart({
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} event */
|
||||
function updateFromPointer(event) {
|
||||
pointerX = event.clientX;
|
||||
pointerY = event.clientY;
|
||||
if (pointerFrame) return;
|
||||
|
||||
pointerFrame = requestAnimationFrame(() => {
|
||||
pointerFrame = 0;
|
||||
if (!currentFrame) return;
|
||||
|
||||
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
|
||||
const y = ((pointerY - rect.top) / rect.height) * currentFrame.height;
|
||||
const closest = findClosestPoint(series, currentSeries, x, y);
|
||||
|
||||
if (!closest) {
|
||||
hideMarker();
|
||||
return;
|
||||
}
|
||||
|
||||
showMarker(closest, currentFrame);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ index: number, point: ChartPoint }} closest
|
||||
* @param {ChartFrame} frame
|
||||
@@ -145,23 +132,18 @@ export function createXyChart({
|
||||
highlight.clearPreview();
|
||||
}
|
||||
|
||||
function cancelPointerFrame() {
|
||||
if (pointerFrame) cancelAnimationFrame(pointerFrame);
|
||||
pointerFrame = 0;
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
cancelPointerFrame();
|
||||
pointer.cancel();
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
render();
|
||||
requestAnimationFrame(render);
|
||||
resizeObserver.observe(svg);
|
||||
svg.addEventListener("pointerenter", measure);
|
||||
svg.addEventListener("pointermove", updateFromPointer);
|
||||
svg.addEventListener("pointerenter", pointer.measure);
|
||||
svg.addEventListener("pointermove", pointer.update);
|
||||
svg.addEventListener("pointerleave", () => {
|
||||
cancelPointerFrame();
|
||||
pointer.cancel();
|
||||
hideMarker();
|
||||
});
|
||||
figure.addEventListener("chart:destroy", disconnect, { once: true });
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createEnteringConfirmedCube, setConfirmedInterval } from "./block-cube.js";
|
||||
import { scrollToElement } from "./scroll.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLElement} args.blocksElement
|
||||
* @param {() => Element | null} args.firstProjectedElement
|
||||
* @param {(block: Block) => void} args.onSelect
|
||||
* @param {() => void} args.onScrollSelect
|
||||
*/
|
||||
export function createConfirmedBlocks({
|
||||
blocksElement,
|
||||
firstProjectedElement,
|
||||
onSelect,
|
||||
onScrollSelect,
|
||||
}) {
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let selectedCube = null;
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let tipCube = null;
|
||||
/** @type {Map<string, Block>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
function clear() {
|
||||
selectedCube = null;
|
||||
tipCube = null;
|
||||
blocksByHash.clear();
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function cache(block) {
|
||||
blocksByHash.set(block.id, block);
|
||||
}
|
||||
|
||||
/** @param {string} hash */
|
||||
function get(hash) {
|
||||
return blocksByHash.get(hash);
|
||||
}
|
||||
|
||||
/** @param {string | number} hashOrHeight */
|
||||
function find(hashOrHeight) {
|
||||
const attribute = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
function newest() {
|
||||
const firstProjected = firstProjectedElement();
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
firstProjected
|
||||
? firstProjected.previousElementSibling
|
||||
: blocksElement.lastElementChild
|
||||
);
|
||||
}
|
||||
|
||||
function markTip() {
|
||||
tipCube?.removeAttribute("data-tip");
|
||||
tipCube = newest();
|
||||
tipCube?.setAttribute("data-tip", "");
|
||||
}
|
||||
|
||||
function deselect() {
|
||||
if (selectedCube) delete selectedCube.dataset.selected;
|
||||
selectedCube = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} cube
|
||||
* @param {{ scroll?: "smooth" | "instant" }} [options]
|
||||
*/
|
||||
function select(cube, { scroll } = {}) {
|
||||
if (cube !== selectedCube) {
|
||||
deselect();
|
||||
selectedCube = cube;
|
||||
cube.dataset.selected = "";
|
||||
}
|
||||
|
||||
const hash = cube.dataset.hash;
|
||||
const block = hash ? get(hash) : undefined;
|
||||
if (block) onSelect(block);
|
||||
|
||||
if (scroll) {
|
||||
scrollToElement(cube, scroll);
|
||||
onScrollSelect();
|
||||
}
|
||||
}
|
||||
|
||||
function markSkeletons() {
|
||||
for (const cube of blocksElement.children) {
|
||||
if (!cube.hasAttribute("data-projected")) {
|
||||
cube.setAttribute("data-skeleton", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function create(block) {
|
||||
cache(block);
|
||||
|
||||
return createEnteringConfirmedCube(block, select);
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function prepend(block) {
|
||||
const cube = create(block);
|
||||
const oldFirst = /** @type {HTMLElement | null} */ (
|
||||
blocksElement.firstElementChild
|
||||
);
|
||||
|
||||
blocksElement.insertBefore(cube, oldFirst);
|
||||
if (oldFirst) setConfirmedInterval(oldFirst);
|
||||
|
||||
return cube;
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function append(block) {
|
||||
const cube = create(block);
|
||||
|
||||
blocksElement.insertBefore(cube, firstProjectedElement());
|
||||
setConfirmedInterval(cube);
|
||||
|
||||
return cube;
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
append,
|
||||
cache,
|
||||
clear,
|
||||
create,
|
||||
find,
|
||||
get,
|
||||
markSkeletons,
|
||||
markTip,
|
||||
newest,
|
||||
select,
|
||||
tipCube: () => tipCube,
|
||||
prepend,
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,24 @@
|
||||
import { brk } from "../../utils/client.js";
|
||||
import {
|
||||
createEnteringConfirmedCube,
|
||||
createPlaceholderCube,
|
||||
createProjectedCube,
|
||||
setConfirmedInterval,
|
||||
updateProjectedCube,
|
||||
updateProjectedTime,
|
||||
} from "./block-cube.js";
|
||||
import { createConfirmedBlocks } from "./confirmed.js";
|
||||
import { createEdgeButton } from "./edge.js";
|
||||
import {
|
||||
distanceFromViewport,
|
||||
findVisibleConfirmedHeight,
|
||||
isHorizontalLayout,
|
||||
olderRemaining,
|
||||
olderRunway,
|
||||
olderWheelDelta,
|
||||
preserveScrollPosition,
|
||||
scrollToElement,
|
||||
} from "./scroll.js";
|
||||
import { createJumpController } from "./jump.js";
|
||||
import { createOlderBlocks } from "./older.js";
|
||||
import { createProjectedBlocks } from "./projected.js";
|
||||
import { createTipVisibility } from "./tip.js";
|
||||
|
||||
const BLOCK_BATCH_SIZE = 15;
|
||||
const EDGE_LOAD_DISTANCE = 50;
|
||||
const OLDER_RESERVE_VIEWPORTS = 6;
|
||||
const POLL_INTERVAL = 1_000;
|
||||
const PROJECTED_LIMIT = 8;
|
||||
const TARGET_BLOCK_SECONDS = 600;
|
||||
const TIP_BLOCK_THRESHOLD = 10;
|
||||
|
||||
/** @typedef {Awaited<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
|
||||
/** @typedef {Awaited<ReturnType<typeof brk.getMempoolBlocks>>[number]} MempoolBlock */
|
||||
/** @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch */
|
||||
|
||||
/** @param {string | number | null | undefined} hashOrHeight */
|
||||
function normalizeTarget(hashOrHeight) {
|
||||
@@ -53,48 +41,64 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
jumpToTip();
|
||||
});
|
||||
const jump = createJumpController(element, () => {
|
||||
if (tipCube) selectCube(tipCube, { scroll: "instant" });
|
||||
const tipCube = confirmed.tipCube();
|
||||
if (tipCube) confirmed.select(tipCube, { scroll: "instant" });
|
||||
});
|
||||
|
||||
element.id = "chain";
|
||||
setTipVisible(false);
|
||||
scrollElement.dataset.chainScroll = "";
|
||||
blocksElement.dataset.chainBlocks = "";
|
||||
scrollElement.append(blocksElement);
|
||||
element.append(tipButton, scrollElement);
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let selectedCube = null;
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let tipCube = null;
|
||||
|
||||
/** @type {Map<string, Block>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
/** @type {ReturnType<typeof createProjectedCube>[]} */
|
||||
const projectedCubes = [];
|
||||
const projected = createProjectedBlocks({
|
||||
blocksElement,
|
||||
isLayoutFrozen: () => tipButton.hasAttribute("data-visible"),
|
||||
isElementVisible,
|
||||
});
|
||||
const confirmed = createConfirmedBlocks({
|
||||
blocksElement,
|
||||
firstProjectedElement: projected.firstElement,
|
||||
onSelect,
|
||||
onScrollSelect: () => tip.schedule(),
|
||||
});
|
||||
const tip = createTipVisibility({
|
||||
button: tipButton,
|
||||
reachedTip: () => reachedTip,
|
||||
newestHeight: () => newestHeight,
|
||||
tipCube: confirmed.tipCube,
|
||||
visibleConfirmedHeight: () =>
|
||||
findVisibleConfirmedHeight(scrollElement, blocksElement),
|
||||
hasVisibleProjected: () => projected.hasVisibleElement(),
|
||||
isElementVisible,
|
||||
});
|
||||
|
||||
let active = false;
|
||||
let newestHeight = -1;
|
||||
let oldestHeight = Infinity;
|
||||
let oldestReservedHeight = -1;
|
||||
let newestTimestamp = 0;
|
||||
let hydratingOlder = false;
|
||||
let loadingNewer = false;
|
||||
let polling = false;
|
||||
let reachedTip = false;
|
||||
let olderGeneration = 0;
|
||||
|
||||
/** @type {OlderBatch[]} */
|
||||
const olderBatches = [];
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let pollId;
|
||||
let tipSyncFrame = 0;
|
||||
|
||||
/** @type {AbortController} */
|
||||
let controller = new AbortController();
|
||||
|
||||
const older = createOlderBlocks({
|
||||
scrollElement,
|
||||
blocksElement,
|
||||
batchSize: BLOCK_BATCH_SIZE,
|
||||
isActive: () => active,
|
||||
isHorizontal,
|
||||
fetchBlocks: (startHeight) =>
|
||||
brk.getBlocksV1FromHeight(startHeight, { signal: controller.signal }),
|
||||
createCube: confirmed.create,
|
||||
isAborted: () => controller.signal.aborted,
|
||||
onError: (error) => logChainError("explore older:", error),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {unknown} error
|
||||
@@ -106,180 +110,57 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
/** @param {string | number | null | undefined} hashOrHeight */
|
||||
function findCube(hashOrHeight) {
|
||||
if (hashOrHeight == null) {
|
||||
return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null;
|
||||
return reachedTip && newestHeight >= 0 ? confirmed.newest() : null;
|
||||
}
|
||||
|
||||
const attribute = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
function firstProjectedCube() {
|
||||
return projectedCubes[0]?.element ?? null;
|
||||
}
|
||||
|
||||
function newestConfirmedCube() {
|
||||
const firstProjected = firstProjectedCube();
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
firstProjected
|
||||
? firstProjected.previousElementSibling
|
||||
: blocksElement.lastElementChild
|
||||
);
|
||||
}
|
||||
|
||||
function deselectCube() {
|
||||
if (selectedCube) delete selectedCube.dataset.selected;
|
||||
selectedCube = null;
|
||||
}
|
||||
|
||||
function updateTipCube() {
|
||||
tipCube?.removeAttribute("data-tip");
|
||||
tipCube = newestConfirmedCube();
|
||||
tipCube?.setAttribute("data-tip", "");
|
||||
return confirmed.find(hashOrHeight);
|
||||
}
|
||||
|
||||
function jumpToTip() {
|
||||
if (tipCube) jump.jump();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} cube
|
||||
* @param {{ scroll?: "smooth" | "instant" }} [options]
|
||||
*/
|
||||
function selectCube(cube, { scroll } = {}) {
|
||||
if (cube !== selectedCube) {
|
||||
deselectCube();
|
||||
selectedCube = cube;
|
||||
cube.dataset.selected = "";
|
||||
}
|
||||
|
||||
const hash = cube.dataset.hash;
|
||||
const block = hash ? blocksByHash.get(hash) : undefined;
|
||||
if (block) onSelect(block);
|
||||
|
||||
if (scroll) {
|
||||
scrollToElement(cube, scroll);
|
||||
scheduleTipVisibilitySync();
|
||||
}
|
||||
}
|
||||
|
||||
function markConfirmedSkeletons() {
|
||||
for (const cube of blocksElement.children) {
|
||||
if (!cube.hasAttribute("data-projected")) {
|
||||
cube.setAttribute("data-skeleton", "");
|
||||
}
|
||||
}
|
||||
if (confirmed.tipCube()) jump.jump();
|
||||
}
|
||||
|
||||
function isHorizontal() {
|
||||
return isHorizontalLayout(blocksElement);
|
||||
}
|
||||
|
||||
/** @param {number} [delta] */
|
||||
function reserveOlderRunway(delta = 0) {
|
||||
if (!active || oldestReservedHeight <= 0) return;
|
||||
|
||||
const horizontal = isHorizontal();
|
||||
const runway =
|
||||
olderRunway(scrollElement, horizontal, OLDER_RESERVE_VIEWPORTS) + delta;
|
||||
let remaining = olderRemaining(scrollElement, horizontal);
|
||||
|
||||
while (remaining < runway) {
|
||||
if (!reserveOlderBatch()) return;
|
||||
remaining = olderRemaining(scrollElement, horizontal);
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
newestHeight = -1;
|
||||
oldestHeight = Infinity;
|
||||
oldestReservedHeight = -1;
|
||||
newestTimestamp = 0;
|
||||
hydratingOlder = false;
|
||||
loadingNewer = false;
|
||||
reachedTip = false;
|
||||
olderGeneration++;
|
||||
selectedCube = null;
|
||||
tipCube = null;
|
||||
blocksByHash.clear();
|
||||
confirmed.clear();
|
||||
blocksElement.textContent = "";
|
||||
projectedCubes.length = 0;
|
||||
olderBatches.length = 0;
|
||||
setTipVisible(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element | null} anchor
|
||||
* @param {number} count
|
||||
*/
|
||||
function prependOlderPlaceholders(anchor, count) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const placeholders = /** @type {HTMLElement[]} */ ([]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cube = createPlaceholderCube();
|
||||
|
||||
placeholders.push(cube);
|
||||
fragment.append(cube);
|
||||
}
|
||||
|
||||
blocksElement.insertBefore(fragment, anchor);
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
function reserveOlderBatch() {
|
||||
if (!active || oldestReservedHeight <= 0) return false;
|
||||
|
||||
const anchor = blocksElement.firstElementChild;
|
||||
const count = Math.min(BLOCK_BATCH_SIZE, oldestReservedHeight);
|
||||
const startHeight = oldestReservedHeight - 1;
|
||||
const placeholders = prependOlderPlaceholders(anchor, count);
|
||||
|
||||
if (!placeholders.length) return false;
|
||||
|
||||
oldestReservedHeight -= placeholders.length;
|
||||
olderBatches.push({ generation: olderGeneration, startHeight, placeholders });
|
||||
void hydrateOlderBatches();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function createKnownEnteringConfirmedCube(block) {
|
||||
blocksByHash.set(block.id, block);
|
||||
|
||||
return createEnteringConfirmedCube(block, selectCube);
|
||||
projected.clear();
|
||||
older.reset();
|
||||
tip.setVisible(false);
|
||||
}
|
||||
|
||||
/** @param {Block[]} blocks */
|
||||
function appendNewerBlocks(blocks) {
|
||||
if (!blocks.length) return false;
|
||||
|
||||
const anchor = newestConfirmedCube();
|
||||
const anchor = confirmed.newest();
|
||||
const anchorRect = anchor?.getBoundingClientRect();
|
||||
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const block = blocks[i];
|
||||
|
||||
if (block.height > newestHeight) {
|
||||
appendConfirmed(createKnownEnteringConfirmedCube(block));
|
||||
confirmed.append(block);
|
||||
} else {
|
||||
blocksByHash.set(block.id, block);
|
||||
confirmed.cache(block);
|
||||
}
|
||||
}
|
||||
|
||||
newestHeight = Math.max(newestHeight, blocks[0].height);
|
||||
newestTimestamp = blocks[0].timestamp;
|
||||
updateTipCube();
|
||||
confirmed.markTip();
|
||||
refreshProjected();
|
||||
|
||||
preserveScrollPosition(scrollElement, anchor, anchorRect);
|
||||
|
||||
syncTipVisibility();
|
||||
tip.sync();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -294,16 +175,15 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
clear();
|
||||
|
||||
for (const block of blocks) {
|
||||
prependConfirmed(createKnownEnteringConfirmedCube(block));
|
||||
confirmed.prepend(block);
|
||||
}
|
||||
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
oldestReservedHeight = oldestHeight;
|
||||
older.setOldestHeight(blocks[blocks.length - 1].height);
|
||||
newestTimestamp = blocks[0].timestamp;
|
||||
reachedTip = height == null;
|
||||
updateTipCube();
|
||||
reserveOlderRunway();
|
||||
confirmed.markTip();
|
||||
older.reserve();
|
||||
|
||||
if (reachedTip) await pollProjected();
|
||||
else await loadNewer();
|
||||
@@ -316,13 +196,13 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
if (typeof hashOrHeight === "number") return hashOrHeight;
|
||||
|
||||
if (typeof hashOrHeight === "string") {
|
||||
const cached = blocksByHash.get(hashOrHeight);
|
||||
const cached = confirmed.get(hashOrHeight);
|
||||
if (cached) return cached.height;
|
||||
|
||||
const block = await brk.getBlockV1(hashOrHeight, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
blocksByHash.set(hashOrHeight, block);
|
||||
confirmed.cache(block);
|
||||
|
||||
return block.height;
|
||||
}
|
||||
@@ -338,18 +218,18 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
|
||||
const existing = findCube(hashOrHeight);
|
||||
if (existing) {
|
||||
selectCube(existing, { scroll: "smooth" });
|
||||
confirmed.select(existing, { scroll: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
markConfirmedSkeletons();
|
||||
confirmed.markSkeletons();
|
||||
element.dataset.loading = "";
|
||||
|
||||
try {
|
||||
const height = await resolveHeight(hashOrHeight);
|
||||
const startHash = await loadInitial(height);
|
||||
const cube = findCube(startHash);
|
||||
if (cube) selectCube(cube, { scroll: "instant" });
|
||||
if (cube) confirmed.select(cube, { scroll: "instant" });
|
||||
} catch (error) {
|
||||
logChainError("explore chain load:", error);
|
||||
} finally {
|
||||
@@ -382,68 +262,6 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateOlderBatches() {
|
||||
if (hydratingOlder) return;
|
||||
|
||||
const generation = olderGeneration;
|
||||
|
||||
hydratingOlder = true;
|
||||
|
||||
try {
|
||||
while (
|
||||
active &&
|
||||
generation === olderGeneration &&
|
||||
olderBatches[0]?.generation === generation
|
||||
) {
|
||||
await hydrateOlderBatch(olderBatches[0]);
|
||||
if (olderBatches[0]?.generation === generation) olderBatches.shift();
|
||||
}
|
||||
} finally {
|
||||
if (generation === olderGeneration) hydratingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {OlderBatch} batch */
|
||||
async function hydrateOlderBatch(batch) {
|
||||
try {
|
||||
const blocks = await brk.getBlocksV1FromHeight(batch.startHeight, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!batch.placeholders.some((placeholder) => placeholder.isConnected)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cubes = [...blocks].reverse().map(createKnownEnteringConfirmedCube);
|
||||
|
||||
for (let i = 0; i < batch.placeholders.length; i++) {
|
||||
const cube = cubes[i];
|
||||
|
||||
if (cube) batch.placeholders[i].replaceWith(cube);
|
||||
else batch.placeholders[i].remove();
|
||||
}
|
||||
|
||||
for (const cube of cubes) setConfirmedInterval(cube);
|
||||
|
||||
const next = cubes.at(-1)?.nextElementSibling;
|
||||
if (next instanceof HTMLElement) setConfirmedInterval(next);
|
||||
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
} else {
|
||||
oldestReservedHeight = oldestHeight;
|
||||
}
|
||||
|
||||
reserveOlderRunway();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
for (const placeholder of batch.placeholders) placeholder.remove();
|
||||
oldestReservedHeight = oldestHeight;
|
||||
logChainError("explore older:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNewer() {
|
||||
if (!active || loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||
|
||||
@@ -467,104 +285,15 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {HTMLButtonElement} cube */
|
||||
function prependConfirmed(cube) {
|
||||
const oldFirst = /** @type {HTMLElement | null} */ (
|
||||
blocksElement.firstElementChild
|
||||
);
|
||||
|
||||
blocksElement.insertBefore(cube, oldFirst);
|
||||
if (oldFirst) setConfirmedInterval(oldFirst);
|
||||
}
|
||||
|
||||
/** @param {HTMLButtonElement} cube */
|
||||
function appendConfirmed(cube) {
|
||||
blocksElement.insertBefore(cube, firstProjectedCube());
|
||||
setConfirmedInterval(cube);
|
||||
}
|
||||
|
||||
/** @param {MempoolBlock[]} blocks */
|
||||
function renderProjected(blocks) {
|
||||
const want = Math.min(blocks.length, PROJECTED_LIMIT);
|
||||
|
||||
while (projectedCubes.length > want) {
|
||||
projectedCubes.pop()?.element.remove();
|
||||
}
|
||||
|
||||
while (projectedCubes.length < want) {
|
||||
const cube = createProjectedCube();
|
||||
projectedCubes.push(cube);
|
||||
blocksElement.append(cube.element);
|
||||
}
|
||||
|
||||
for (let i = 0; i < want; i++) {
|
||||
updateProjectedCube(projectedCubes[i], blocks[i]);
|
||||
}
|
||||
|
||||
updateTipCube();
|
||||
projected.render(blocks);
|
||||
confirmed.markTip();
|
||||
refreshProjected();
|
||||
}
|
||||
|
||||
function refreshProjected() {
|
||||
if (!projectedCubes.length || !newestTimestamp) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const elapsed = Math.max(0, now - newestTimestamp);
|
||||
const updateLayout = !tipButton.hasAttribute("data-visible");
|
||||
|
||||
for (let i = 0; i < projectedCubes.length; i++) {
|
||||
const cube = projectedCubes[i];
|
||||
const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
|
||||
const timestamp = now + i * TARGET_BLOCK_SECONDS;
|
||||
|
||||
if (updateLayout) {
|
||||
cube.element.style.setProperty("--block-interval", String(interval));
|
||||
}
|
||||
|
||||
updateProjectedTime(cube, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTipVisibilitySync() {
|
||||
if (tipSyncFrame) return;
|
||||
|
||||
tipSyncFrame = window.requestAnimationFrame(() => {
|
||||
tipSyncFrame = 0;
|
||||
syncTipVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
function cancelTipVisibilitySync() {
|
||||
if (!tipSyncFrame) return;
|
||||
|
||||
window.cancelAnimationFrame(tipSyncFrame);
|
||||
tipSyncFrame = 0;
|
||||
}
|
||||
|
||||
/** @param {boolean} visible */
|
||||
function setTipVisible(visible) {
|
||||
tipButton.toggleAttribute("data-visible", visible);
|
||||
tipButton.setAttribute("aria-hidden", String(!visible));
|
||||
tipButton.tabIndex = visible ? 0 : -1;
|
||||
}
|
||||
|
||||
function syncTipVisibility() {
|
||||
if (!reachedTip || newestHeight < 0 || !tipCube) {
|
||||
setTipVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeight = findVisibleConfirmedHeight(scrollElement, blocksElement);
|
||||
if (projectedCubes.some(({ element }) => isElementVisible(element))) {
|
||||
setTipVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTipVisible(
|
||||
visibleHeight != null
|
||||
? newestHeight - visibleHeight > TIP_BLOCK_THRESHOLD
|
||||
: !isElementVisible(tipCube),
|
||||
);
|
||||
projected.refresh(newestTimestamp);
|
||||
}
|
||||
|
||||
/** @param {Element} element */
|
||||
@@ -578,7 +307,7 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
}
|
||||
|
||||
function shouldLoadNewer() {
|
||||
const cube = newestConfirmedCube();
|
||||
const cube = confirmed.newest();
|
||||
|
||||
return cube != null && cubeDistanceFromViewport(cube) <= EDGE_LOAD_DISTANCE;
|
||||
}
|
||||
@@ -586,7 +315,7 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
scrollElement.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
reserveOlderRunway(olderWheelDelta(event, isHorizontal()));
|
||||
older.reserve(olderWheelDelta(event, isHorizontal()));
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -594,8 +323,8 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
scrollElement.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
scheduleTipVisibilitySync();
|
||||
reserveOlderRunway();
|
||||
tip.schedule();
|
||||
older.reserve();
|
||||
|
||||
if (reachedTip || loadingNewer) return;
|
||||
if (shouldLoadNewer()) void loadNewer();
|
||||
@@ -624,7 +353,7 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
pollId = undefined;
|
||||
}
|
||||
|
||||
cancelTipVisibilitySync();
|
||||
tip.cancel();
|
||||
jump.cancel();
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createPlaceholderCube, setConfirmedInterval } from "./block-cube.js";
|
||||
import { olderRemaining, olderRunway } from "./scroll.js";
|
||||
|
||||
const OLDER_RESERVE_VIEWPORTS = 6;
|
||||
|
||||
/**
|
||||
* @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block
|
||||
* @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLElement} args.scrollElement
|
||||
* @param {HTMLElement} args.blocksElement
|
||||
* @param {number} args.batchSize
|
||||
* @param {() => boolean} args.isActive
|
||||
* @param {() => boolean} args.isHorizontal
|
||||
* @param {(startHeight: number) => Promise<Block[]>} args.fetchBlocks
|
||||
* @param {(block: Block) => HTMLButtonElement} args.createCube
|
||||
* @param {() => boolean} args.isAborted
|
||||
* @param {(error: unknown) => void} args.onError
|
||||
*/
|
||||
export function createOlderBlocks({
|
||||
scrollElement,
|
||||
blocksElement,
|
||||
batchSize,
|
||||
isActive,
|
||||
isHorizontal,
|
||||
fetchBlocks,
|
||||
createCube,
|
||||
isAborted,
|
||||
onError,
|
||||
}) {
|
||||
let oldestHeight = Infinity;
|
||||
let oldestReservedHeight = -1;
|
||||
let hydrating = false;
|
||||
let generation = 0;
|
||||
/** @type {OlderBatch[]} */
|
||||
const batches = [];
|
||||
|
||||
function reset() {
|
||||
oldestHeight = Infinity;
|
||||
oldestReservedHeight = -1;
|
||||
hydrating = false;
|
||||
generation++;
|
||||
batches.length = 0;
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
function setOldestHeight(height) {
|
||||
oldestHeight = height;
|
||||
oldestReservedHeight = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element | null} anchor
|
||||
* @param {number} count
|
||||
*/
|
||||
function prependPlaceholders(anchor, count) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const placeholders = /** @type {HTMLElement[]} */ ([]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cube = createPlaceholderCube();
|
||||
|
||||
placeholders.push(cube);
|
||||
fragment.append(cube);
|
||||
}
|
||||
|
||||
blocksElement.insertBefore(fragment, anchor);
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
function reserveBatch() {
|
||||
if (!isActive() || oldestReservedHeight <= 0) return false;
|
||||
|
||||
const anchor = blocksElement.firstElementChild;
|
||||
const count = Math.min(batchSize, oldestReservedHeight);
|
||||
const startHeight = oldestReservedHeight - 1;
|
||||
const placeholders = prependPlaceholders(anchor, count);
|
||||
|
||||
if (!placeholders.length) return false;
|
||||
|
||||
oldestReservedHeight -= placeholders.length;
|
||||
batches.push({ generation, startHeight, placeholders });
|
||||
void hydrateBatches();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param {number} [delta] */
|
||||
function reserve(delta = 0) {
|
||||
if (!isActive() || oldestReservedHeight <= 0) return;
|
||||
|
||||
const horizontal = isHorizontal();
|
||||
const runway =
|
||||
olderRunway(scrollElement, horizontal, OLDER_RESERVE_VIEWPORTS) + delta;
|
||||
let remaining = olderRemaining(scrollElement, horizontal);
|
||||
|
||||
while (remaining < runway) {
|
||||
if (!reserveBatch()) return;
|
||||
remaining = olderRemaining(scrollElement, horizontal);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateBatches() {
|
||||
if (hydrating) return;
|
||||
|
||||
const currentGeneration = generation;
|
||||
|
||||
hydrating = true;
|
||||
|
||||
try {
|
||||
while (
|
||||
isActive() &&
|
||||
currentGeneration === generation &&
|
||||
batches[0]?.generation === currentGeneration
|
||||
) {
|
||||
await hydrateBatch(batches[0]);
|
||||
if (batches[0]?.generation === currentGeneration) batches.shift();
|
||||
}
|
||||
} finally {
|
||||
if (currentGeneration === generation) hydrating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {OlderBatch} batch */
|
||||
async function hydrateBatch(batch) {
|
||||
try {
|
||||
const blocks = await fetchBlocks(batch.startHeight);
|
||||
|
||||
if (
|
||||
batch.generation !== generation ||
|
||||
!batch.placeholders.some((placeholder) => placeholder.isConnected)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cubes = [...blocks].reverse().map(createCube);
|
||||
|
||||
for (let i = 0; i < batch.placeholders.length; i++) {
|
||||
const cube = cubes[i];
|
||||
|
||||
if (cube) batch.placeholders[i].replaceWith(cube);
|
||||
else batch.placeholders[i].remove();
|
||||
}
|
||||
|
||||
for (const cube of cubes) setConfirmedInterval(cube);
|
||||
|
||||
const next = cubes.at(-1)?.nextElementSibling;
|
||||
if (next instanceof HTMLElement) setConfirmedInterval(next);
|
||||
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
} else {
|
||||
oldestReservedHeight = oldestHeight;
|
||||
}
|
||||
|
||||
reserve();
|
||||
} catch (error) {
|
||||
if (isAborted() || batch.generation !== generation) return;
|
||||
|
||||
for (const placeholder of batch.placeholders) placeholder.remove();
|
||||
oldestReservedHeight = oldestHeight;
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
reserve,
|
||||
reset,
|
||||
setOldestHeight,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
createProjectedCube,
|
||||
updateProjectedCube,
|
||||
updateProjectedTime,
|
||||
} from "./block-cube.js";
|
||||
|
||||
const PROJECTED_LIMIT = 8;
|
||||
const TARGET_BLOCK_SECONDS = 600;
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").MempoolBlock} MempoolBlock */
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLElement} args.blocksElement
|
||||
* @param {() => boolean} args.isLayoutFrozen
|
||||
* @param {(element: Element) => boolean} args.isElementVisible
|
||||
*/
|
||||
export function createProjectedBlocks({
|
||||
blocksElement,
|
||||
isLayoutFrozen,
|
||||
isElementVisible,
|
||||
}) {
|
||||
/** @type {ReturnType<typeof createProjectedCube>[]} */
|
||||
const cubes = [];
|
||||
|
||||
function firstElement() {
|
||||
return cubes[0]?.element ?? null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cubes.length = 0;
|
||||
}
|
||||
|
||||
/** @param {MempoolBlock[]} blocks */
|
||||
function render(blocks) {
|
||||
const want = Math.min(blocks.length, PROJECTED_LIMIT);
|
||||
|
||||
while (cubes.length > want) {
|
||||
cubes.pop()?.element.remove();
|
||||
}
|
||||
|
||||
while (cubes.length < want) {
|
||||
const cube = createProjectedCube();
|
||||
|
||||
cubes.push(cube);
|
||||
blocksElement.append(cube.element);
|
||||
}
|
||||
|
||||
for (let i = 0; i < want; i++) {
|
||||
updateProjectedCube(cubes[i], blocks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} newestTimestamp */
|
||||
function refresh(newestTimestamp) {
|
||||
if (!cubes.length || !newestTimestamp) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const elapsed = Math.max(0, now - newestTimestamp);
|
||||
const updateLayout = !isLayoutFrozen();
|
||||
|
||||
for (let i = 0; i < cubes.length; i++) {
|
||||
const cube = cubes[i];
|
||||
const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
|
||||
const timestamp = now + i * TARGET_BLOCK_SECONDS;
|
||||
|
||||
if (updateLayout) {
|
||||
cube.element.style.setProperty("--block-interval", String(interval));
|
||||
}
|
||||
|
||||
updateProjectedTime(cube, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function hasVisibleElement() {
|
||||
return cubes.some(({ element }) => isElementVisible(element));
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
clear,
|
||||
firstElement,
|
||||
hasVisibleElement,
|
||||
refresh,
|
||||
render,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const TIP_BLOCK_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLButtonElement} args.button
|
||||
* @param {() => boolean} args.reachedTip
|
||||
* @param {() => number} args.newestHeight
|
||||
* @param {() => HTMLButtonElement | null} args.tipCube
|
||||
* @param {() => number | null} args.visibleConfirmedHeight
|
||||
* @param {() => boolean} args.hasVisibleProjected
|
||||
* @param {(element: Element) => boolean} args.isElementVisible
|
||||
*/
|
||||
export function createTipVisibility({
|
||||
button,
|
||||
reachedTip,
|
||||
newestHeight,
|
||||
tipCube,
|
||||
visibleConfirmedHeight,
|
||||
hasVisibleProjected,
|
||||
isElementVisible,
|
||||
}) {
|
||||
let frame = 0;
|
||||
|
||||
/** @param {boolean} visible */
|
||||
function setVisible(visible) {
|
||||
button.toggleAttribute("data-visible", visible);
|
||||
button.setAttribute("aria-hidden", String(!visible));
|
||||
button.tabIndex = visible ? 0 : -1;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const cube = tipCube();
|
||||
const height = newestHeight();
|
||||
|
||||
if (!reachedTip() || height < 0 || !cube) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeight = visibleConfirmedHeight();
|
||||
if (hasVisibleProjected()) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(
|
||||
visibleHeight != null
|
||||
? height - visibleHeight > TIP_BLOCK_THRESHOLD
|
||||
: !isElementVisible(cube),
|
||||
);
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (frame) return;
|
||||
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!frame) return;
|
||||
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
|
||||
return /** @type {const} */ ({
|
||||
cancel,
|
||||
schedule,
|
||||
setVisible,
|
||||
sync,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { withBusy } from "../dom.js";
|
||||
import { generateAddressesFromWalletSource } from "../derive/index.js";
|
||||
import { readWalletSourceText } from "./source.js";
|
||||
|
||||
/** @typedef {import("./index.js").AddWalletFormSubmit} AddWalletFormSubmit */
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {{ addWallet(input: { name: string, source: string }): Promise<void> }} options.vault
|
||||
* @param {HTMLDialogElement} options.dialog
|
||||
* @param {() => void} options.onAdded
|
||||
*/
|
||||
export function createAddSubmit({ vault, dialog, onAdded }) {
|
||||
/** @param {AddWalletFormSubmit} formData */
|
||||
return async function submitWallet({
|
||||
name,
|
||||
source,
|
||||
submit: button,
|
||||
form,
|
||||
}) {
|
||||
await withBusy(button, "Add", "Adding", async () => {
|
||||
source.removeAttribute("aria-invalid");
|
||||
|
||||
try {
|
||||
const value = readWalletSourceText(source.value);
|
||||
|
||||
await generateAddressesFromWalletSource(value, { count: 1 });
|
||||
|
||||
await vault.addWallet({
|
||||
name: name.value,
|
||||
source: value,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
dialog.close();
|
||||
onAdded();
|
||||
} catch {
|
||||
source.setAttribute("aria-invalid", "true");
|
||||
source.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
const CHECKSUM_SEPARATOR = "#";
|
||||
const WSH_SORTEDMULTI_PREFIX = "wsh(sortedmulti(";
|
||||
const WSH_SORTEDMULTI_SUFFIX = "))";
|
||||
const MAX_WSH_MULTISIG_KEYS = 20;
|
||||
|
||||
/**
|
||||
* @typedef {Object} DescriptorKey
|
||||
* @property {string} xpub
|
||||
* @property {number[]} path
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SortedMultisigDescriptor
|
||||
* @property {"v0_p2wsh_sortedmulti"} script
|
||||
* @property {number} threshold
|
||||
* @property {DescriptorKey[]} keys
|
||||
*/
|
||||
|
||||
/** @param {string} text */
|
||||
function compactText(text) {
|
||||
return text.trim().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function stripDescriptorChecksum(text) {
|
||||
const value = compactText(text);
|
||||
const checksumIndex = value.indexOf(CHECKSUM_SEPARATOR);
|
||||
|
||||
return checksumIndex === -1 ? value : value.slice(0, checksumIndex);
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function isSupportedDescriptor(text) {
|
||||
return (
|
||||
text.startsWith(WSH_SORTEDMULTI_PREFIX) &&
|
||||
text.endsWith(WSH_SORTEDMULTI_SUFFIX)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function extractOutputDescriptors(text) {
|
||||
const value = compactText(text);
|
||||
const descriptors = /** @type {string[]} */ ([]);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < value.length) {
|
||||
const start = value.indexOf(WSH_SORTEDMULTI_PREFIX, offset);
|
||||
|
||||
if (start === -1) break;
|
||||
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
let seenOpen = false;
|
||||
|
||||
for (let index = start; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
|
||||
if (character === "(") {
|
||||
depth += 1;
|
||||
seenOpen = true;
|
||||
}
|
||||
|
||||
if (character === ")") {
|
||||
depth -= 1;
|
||||
}
|
||||
|
||||
if (seenOpen && depth === 0) {
|
||||
end = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (end === -1) break;
|
||||
|
||||
const descriptor = stripDescriptorChecksum(value.slice(start, end));
|
||||
|
||||
if (isSupportedDescriptor(descriptor)) {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
offset = end;
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
export function isOutputDescriptor(text) {
|
||||
return extractOutputDescriptors(text).length > 0;
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function readFirstOutputDescriptor(text) {
|
||||
const descriptor = extractOutputDescriptors(text)[0];
|
||||
|
||||
if (!descriptor) {
|
||||
throw new Error("Unsupported output descriptor");
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function splitDescriptorArguments(text) {
|
||||
const values = /** @type {string[]} */ ([]);
|
||||
let bracketDepth = 0;
|
||||
let groupDepth = 0;
|
||||
let start = 0;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
|
||||
if (character === "[") bracketDepth += 1;
|
||||
if (character === "]") bracketDepth -= 1;
|
||||
if (character === "(") groupDepth += 1;
|
||||
if (character === ")") groupDepth -= 1;
|
||||
|
||||
if (character === "," && bracketDepth === 0 && groupDepth === 0) {
|
||||
values.push(text.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
values.push(text.slice(start));
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function readThreshold(value) {
|
||||
const threshold = Number(value);
|
||||
|
||||
if (!Number.isSafeInteger(threshold) || threshold < 1) {
|
||||
throw new Error("Invalid multisig threshold");
|
||||
}
|
||||
|
||||
return threshold;
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function readNonHardenedIndex(value) {
|
||||
if (value.endsWith("'") || value.endsWith("h")) {
|
||||
throw new Error("Descriptor xpub derivation cannot be hardened");
|
||||
}
|
||||
|
||||
const index = Number(value);
|
||||
|
||||
if (!Number.isSafeInteger(index) || index < 0) {
|
||||
throw new Error("Invalid descriptor derivation path");
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function readDescriptorKeyPath(text) {
|
||||
if (!text.startsWith("/")) {
|
||||
throw new Error("Expected a ranged descriptor key path");
|
||||
}
|
||||
|
||||
const segments = text.slice(1).split("/");
|
||||
|
||||
if (segments[segments.length - 1] !== "*") {
|
||||
throw new Error("Expected a descriptor wildcard path");
|
||||
}
|
||||
|
||||
return segments.slice(0, -1).map(readNonHardenedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {DescriptorKey}
|
||||
*/
|
||||
function readDescriptorKey(text) {
|
||||
let value = text;
|
||||
|
||||
if (value.startsWith("[")) {
|
||||
const end = value.indexOf("]");
|
||||
|
||||
if (end === -1) {
|
||||
throw new Error("Invalid descriptor key origin");
|
||||
}
|
||||
|
||||
value = value.slice(end + 1);
|
||||
}
|
||||
|
||||
const pathIndex = value.indexOf("/");
|
||||
|
||||
if (pathIndex === -1) {
|
||||
throw new Error("Expected descriptor key derivation");
|
||||
}
|
||||
|
||||
return {
|
||||
xpub: value.slice(0, pathIndex),
|
||||
path: readDescriptorKeyPath(value.slice(pathIndex)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {SortedMultisigDescriptor}
|
||||
*/
|
||||
export function parseOutputDescriptor(text) {
|
||||
const value = readFirstOutputDescriptor(text);
|
||||
const body = value.slice(
|
||||
WSH_SORTEDMULTI_PREFIX.length,
|
||||
-WSH_SORTEDMULTI_SUFFIX.length,
|
||||
);
|
||||
const [thresholdText, ...keyTexts] = splitDescriptorArguments(body);
|
||||
const threshold = readThreshold(thresholdText);
|
||||
const keys = keyTexts.map(readDescriptorKey);
|
||||
|
||||
if (
|
||||
threshold > keys.length ||
|
||||
keys.length < 1 ||
|
||||
keys.length > MAX_WSH_MULTISIG_KEYS
|
||||
) {
|
||||
throw new Error("Invalid multisig key count");
|
||||
}
|
||||
|
||||
return {
|
||||
script: "v0_p2wsh_sortedmulti",
|
||||
threshold,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {string} descriptorText */
|
||||
function inferDescriptorBranchId(descriptorText) {
|
||||
const descriptor = parseOutputDescriptor(descriptorText);
|
||||
const branchIds = descriptor.keys.map((key) => {
|
||||
return key.path[key.path.length - 1];
|
||||
});
|
||||
const sameBranch = branchIds.every((branchId) => {
|
||||
return branchId === branchIds[0];
|
||||
});
|
||||
|
||||
if (!sameBranch) return undefined;
|
||||
if (branchIds[0] === 0) return "receive";
|
||||
if (branchIds[0] === 1) return "change";
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
export function getOutputDescriptorBranchIds(text) {
|
||||
const branchIds = /** @type {string[]} */ ([]);
|
||||
|
||||
for (const descriptor of extractOutputDescriptors(text)) {
|
||||
const branchId = inferDescriptorBranchId(descriptor);
|
||||
|
||||
if (branchId && !branchIds.includes(branchId)) {
|
||||
branchIds.push(branchId);
|
||||
}
|
||||
}
|
||||
|
||||
return branchIds.length ? branchIds : ["receive"];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {string} [branchId]
|
||||
*/
|
||||
export function selectOutputDescriptor(source, branchId = "receive") {
|
||||
const descriptors = extractOutputDescriptors(source);
|
||||
|
||||
if (descriptors.length === 0) {
|
||||
throw new Error("Unsupported output descriptor");
|
||||
}
|
||||
|
||||
return descriptors.find((descriptor) => {
|
||||
return inferDescriptorBranchId(descriptor) === branchId;
|
||||
}) ?? descriptors[0];
|
||||
}
|
||||
@@ -1,355 +1,19 @@
|
||||
import { concatBytes } from "./bytes.js";
|
||||
import { derivePublicKeys, parseXpub } from "./bip32.js";
|
||||
import { encodeP2wshAddressData } from "./address.js";
|
||||
import { parseOutputDescriptor } from "./descriptor-parser.js";
|
||||
import { encodeSortedMultisigScript } from "./multisig.js";
|
||||
|
||||
const CHECKSUM_SEPARATOR = "#";
|
||||
const WSH_SORTEDMULTI_PREFIX = "wsh(sortedmulti(";
|
||||
const WSH_SORTEDMULTI_SUFFIX = "))";
|
||||
const OP_CHECKMULTISIG = 0xae;
|
||||
const COMPRESSED_PUBLIC_KEY_BYTES = 33;
|
||||
const MAX_WSH_MULTISIG_KEYS = 20;
|
||||
export {
|
||||
getOutputDescriptorBranchIds,
|
||||
isOutputDescriptor,
|
||||
parseOutputDescriptor,
|
||||
selectOutputDescriptor,
|
||||
} from "./descriptor-parser.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
|
||||
* @typedef {import("./index.js").GeneratedAddress} GeneratedAddress
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DescriptorKey
|
||||
* @property {string} xpub
|
||||
* @property {number[]} path
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SortedMultisigDescriptor
|
||||
* @property {"v0_p2wsh_sortedmulti"} script
|
||||
* @property {number} threshold
|
||||
* @property {DescriptorKey[]} keys
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function compactText(text) {
|
||||
return text.trim().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function stripDescriptorChecksum(text) {
|
||||
const value = compactText(text);
|
||||
const checksumIndex = value.indexOf(CHECKSUM_SEPARATOR);
|
||||
|
||||
return checksumIndex === -1 ? value : value.slice(0, checksumIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function isSupportedDescriptor(text) {
|
||||
return (
|
||||
text.startsWith(WSH_SORTEDMULTI_PREFIX) &&
|
||||
text.endsWith(WSH_SORTEDMULTI_SUFFIX)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function extractOutputDescriptors(text) {
|
||||
const value = compactText(text);
|
||||
const descriptors = /** @type {string[]} */ ([]);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < value.length) {
|
||||
const start = value.indexOf(WSH_SORTEDMULTI_PREFIX, offset);
|
||||
|
||||
if (start === -1) break;
|
||||
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
let seenOpen = false;
|
||||
|
||||
for (let index = start; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
|
||||
if (character === "(") {
|
||||
depth += 1;
|
||||
seenOpen = true;
|
||||
}
|
||||
|
||||
if (character === ")") {
|
||||
depth -= 1;
|
||||
}
|
||||
|
||||
if (seenOpen && depth === 0) {
|
||||
end = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (end === -1) break;
|
||||
|
||||
const descriptor = stripDescriptorChecksum(value.slice(start, end));
|
||||
|
||||
if (isSupportedDescriptor(descriptor)) {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
offset = end;
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
export function isOutputDescriptor(text) {
|
||||
return extractOutputDescriptors(text).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function readFirstOutputDescriptor(text) {
|
||||
const descriptor = extractOutputDescriptors(text)[0];
|
||||
|
||||
if (!descriptor) {
|
||||
throw new Error("Unsupported output descriptor");
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function splitDescriptorArguments(text) {
|
||||
const values = /** @type {string[]} */ ([]);
|
||||
let bracketDepth = 0;
|
||||
let groupDepth = 0;
|
||||
let start = 0;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
|
||||
if (character === "[") bracketDepth += 1;
|
||||
if (character === "]") bracketDepth -= 1;
|
||||
if (character === "(") groupDepth += 1;
|
||||
if (character === ")") groupDepth -= 1;
|
||||
|
||||
if (character === "," && bracketDepth === 0 && groupDepth === 0) {
|
||||
values.push(text.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
values.push(text.slice(start));
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
function readThreshold(value) {
|
||||
const threshold = Number(value);
|
||||
|
||||
if (!Number.isSafeInteger(threshold) || threshold < 1) {
|
||||
throw new Error("Invalid multisig threshold");
|
||||
}
|
||||
|
||||
return threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
function readNonHardenedIndex(value) {
|
||||
if (value.endsWith("'") || value.endsWith("h")) {
|
||||
throw new Error("Descriptor xpub derivation cannot be hardened");
|
||||
}
|
||||
|
||||
const index = Number(value);
|
||||
|
||||
if (!Number.isSafeInteger(index) || index < 0) {
|
||||
throw new Error("Invalid descriptor derivation path");
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function readDescriptorKeyPath(text) {
|
||||
if (!text.startsWith("/")) {
|
||||
throw new Error("Expected a ranged descriptor key path");
|
||||
}
|
||||
|
||||
const segments = text.slice(1).split("/");
|
||||
|
||||
if (segments[segments.length - 1] !== "*") {
|
||||
throw new Error("Expected a descriptor wildcard path");
|
||||
}
|
||||
|
||||
return segments.slice(0, -1).map(readNonHardenedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {DescriptorKey}
|
||||
*/
|
||||
function readDescriptorKey(text) {
|
||||
let value = text;
|
||||
|
||||
if (value.startsWith("[")) {
|
||||
const end = value.indexOf("]");
|
||||
|
||||
if (end === -1) {
|
||||
throw new Error("Invalid descriptor key origin");
|
||||
}
|
||||
|
||||
value = value.slice(end + 1);
|
||||
}
|
||||
|
||||
const pathIndex = value.indexOf("/");
|
||||
|
||||
if (pathIndex === -1) {
|
||||
throw new Error("Expected descriptor key derivation");
|
||||
}
|
||||
|
||||
return {
|
||||
xpub: value.slice(0, pathIndex),
|
||||
path: readDescriptorKeyPath(value.slice(pathIndex)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {SortedMultisigDescriptor}
|
||||
*/
|
||||
export function parseOutputDescriptor(text) {
|
||||
const value = readFirstOutputDescriptor(text);
|
||||
|
||||
const body = value.slice(
|
||||
WSH_SORTEDMULTI_PREFIX.length,
|
||||
-WSH_SORTEDMULTI_SUFFIX.length,
|
||||
);
|
||||
const [thresholdText, ...keyTexts] = splitDescriptorArguments(body);
|
||||
const threshold = readThreshold(thresholdText);
|
||||
const keys = keyTexts.map(readDescriptorKey);
|
||||
|
||||
if (
|
||||
threshold > keys.length ||
|
||||
keys.length < 1 ||
|
||||
keys.length > MAX_WSH_MULTISIG_KEYS
|
||||
) {
|
||||
throw new Error("Invalid multisig key count");
|
||||
}
|
||||
|
||||
return {
|
||||
script: "v0_p2wsh_sortedmulti",
|
||||
threshold,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} descriptorText
|
||||
*/
|
||||
function inferDescriptorBranchId(descriptorText) {
|
||||
const descriptor = parseOutputDescriptor(descriptorText);
|
||||
const branchIds = descriptor.keys.map((key) => {
|
||||
return key.path[key.path.length - 1];
|
||||
});
|
||||
const sameBranch = branchIds.every((branchId) => {
|
||||
return branchId === branchIds[0];
|
||||
});
|
||||
|
||||
if (!sameBranch) return undefined;
|
||||
if (branchIds[0] === 0) return "receive";
|
||||
if (branchIds[0] === 1) return "change";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
export function getOutputDescriptorBranchIds(text) {
|
||||
const branchIds = /** @type {string[]} */ ([]);
|
||||
|
||||
for (const descriptor of extractOutputDescriptors(text)) {
|
||||
const branchId = inferDescriptorBranchId(descriptor);
|
||||
|
||||
if (branchId && !branchIds.includes(branchId)) {
|
||||
branchIds.push(branchId);
|
||||
}
|
||||
}
|
||||
|
||||
return branchIds.length ? branchIds : ["receive"];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {string} [branchId]
|
||||
*/
|
||||
export function selectOutputDescriptor(source, branchId = "receive") {
|
||||
const descriptors = extractOutputDescriptors(source);
|
||||
|
||||
if (descriptors.length === 0) {
|
||||
throw new Error("Unsupported output descriptor");
|
||||
}
|
||||
|
||||
return descriptors.find((descriptor) => {
|
||||
return inferDescriptorBranchId(descriptor) === branchId;
|
||||
}) ?? descriptors[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} left
|
||||
* @param {Uint8Array} right
|
||||
*/
|
||||
function compareBytes(left, right) {
|
||||
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] - right[index];
|
||||
}
|
||||
|
||||
return left.length - right.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
function encodeScriptNumber(value) {
|
||||
if (value <= 16) return Uint8Array.of(0x50 + value);
|
||||
|
||||
return Uint8Array.of(0x01, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly Uint8Array[]} publicKeys
|
||||
* @param {number} threshold
|
||||
*/
|
||||
function encodeSortedMultisigScript(publicKeys, threshold) {
|
||||
const sortedKeys = [...publicKeys].sort(compareBytes);
|
||||
const pushes = sortedKeys.map((publicKey) => {
|
||||
if (publicKey.length !== COMPRESSED_PUBLIC_KEY_BYTES) {
|
||||
throw new Error("Expected compressed multisig public keys");
|
||||
}
|
||||
|
||||
return concatBytes([Uint8Array.of(COMPRESSED_PUBLIC_KEY_BYTES), publicKey]);
|
||||
});
|
||||
|
||||
return concatBytes([
|
||||
encodeScriptNumber(threshold),
|
||||
...pushes,
|
||||
encodeScriptNumber(sortedKeys.length),
|
||||
Uint8Array.of(OP_CHECKMULTISIG),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} descriptorText
|
||||
* @param {Object} options
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { concatBytes } from "./bytes.js";
|
||||
|
||||
const OP_CHECKMULTISIG = 0xae;
|
||||
const COMPRESSED_PUBLIC_KEY_BYTES = 33;
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} left
|
||||
* @param {Uint8Array} right
|
||||
*/
|
||||
function compareBytes(left, right) {
|
||||
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] - right[index];
|
||||
}
|
||||
|
||||
return left.length - right.length;
|
||||
}
|
||||
|
||||
/** @param {number} value */
|
||||
function encodeScriptNumber(value) {
|
||||
if (value <= 16) return Uint8Array.of(0x50 + value);
|
||||
|
||||
return Uint8Array.of(0x01, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly Uint8Array[]} publicKeys
|
||||
* @param {number} threshold
|
||||
*/
|
||||
export function encodeSortedMultisigScript(publicKeys, threshold) {
|
||||
const sortedKeys = [...publicKeys].sort(compareBytes);
|
||||
const pushes = sortedKeys.map((publicKey) => {
|
||||
if (publicKey.length !== COMPRESSED_PUBLIC_KEY_BYTES) {
|
||||
throw new Error("Expected compressed multisig public keys");
|
||||
}
|
||||
|
||||
return concatBytes([Uint8Array.of(COMPRESSED_PUBLIC_KEY_BYTES), publicKey]);
|
||||
});
|
||||
|
||||
return concatBytes([
|
||||
encodeScriptNumber(threshold),
|
||||
...pushes,
|
||||
encodeScriptNumber(sortedKeys.length),
|
||||
Uint8Array.of(OP_CHECKMULTISIG),
|
||||
]);
|
||||
}
|
||||
+18
-128
@@ -1,23 +1,19 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import {
|
||||
setStatus,
|
||||
withBusy,
|
||||
} from "./dom.js";
|
||||
import { setStatus } from "./dom.js";
|
||||
import { createEmpty } from "./empty/index.js";
|
||||
import { getErrorMessage } from "./errors.js";
|
||||
import { createAddForm } from "./add/index.js";
|
||||
import { createAddSubmit } from "./add/submit.js";
|
||||
import { createLayout } from "./layout/index.js";
|
||||
import { redaction } from "./redaction/index.js";
|
||||
import { readWalletSourceText } from "./add/source.js";
|
||||
import { scanStatus } from "./wallet/status.js";
|
||||
import { createSelector } from "./selector/index.js";
|
||||
import { createStart } from "./start/index.js";
|
||||
import { createWalletSession } from "./start/session.js";
|
||||
import {
|
||||
createWalletPanel,
|
||||
renderWalletPanel,
|
||||
} from "./wallet/index.js";
|
||||
import { createVault } from "./vault/index.js";
|
||||
import { generateAddressesFromWalletSource } from "./derive/index.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("./scan/index.js").WalletScan} WalletScan
|
||||
@@ -37,6 +33,16 @@ export function createWalletsPage() {
|
||||
addDialog,
|
||||
} = createLayout();
|
||||
const vault = createVault();
|
||||
const session = createWalletSession({
|
||||
vault,
|
||||
content,
|
||||
onChange: render,
|
||||
});
|
||||
const submitAdd = createAddSubmit({
|
||||
vault,
|
||||
dialog: addDialog,
|
||||
onAdded: render,
|
||||
});
|
||||
const selector = createSelector(walletList, {
|
||||
getSelectedId() {
|
||||
return vault.selectedId;
|
||||
@@ -60,26 +66,6 @@ export function createWalletsPage() {
|
||||
render();
|
||||
}
|
||||
|
||||
function lock() {
|
||||
vault.lock();
|
||||
render();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
vault.reset();
|
||||
render();
|
||||
}
|
||||
|
||||
function startEphemeral() {
|
||||
vault.startEphemeral();
|
||||
render();
|
||||
}
|
||||
|
||||
function clearEphemeral() {
|
||||
vault.clearEphemeral();
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} walletId
|
||||
*/
|
||||
@@ -109,31 +95,13 @@ export function createWalletsPage() {
|
||||
|
||||
sessionButton.addEventListener("click", () => {
|
||||
if (vault.isEphemeral()) {
|
||||
clearEphemeral();
|
||||
session.clearEphemeral();
|
||||
return;
|
||||
}
|
||||
|
||||
lock();
|
||||
session.lock();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {"create" | "unlock"} mode
|
||||
*/
|
||||
function renderStart(mode) {
|
||||
content.replaceChildren(createStart({
|
||||
mode,
|
||||
onPassword(password, button, status) {
|
||||
return mode === "unlock"
|
||||
? unlock(password, button, status)
|
||||
: setup(password, button, status);
|
||||
},
|
||||
onEphemeral() {
|
||||
startEphemeral();
|
||||
},
|
||||
onReset: mode === "unlock" ? reset : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StoredWallet} wallet
|
||||
* @param {WalletRuntime} runtime
|
||||
@@ -189,48 +157,6 @@ export function createWalletsPage() {
|
||||
renderWalletPanel(scan, panel, brk);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} password
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {HTMLElement} status
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function unlock(password, button, status) {
|
||||
let unlocked = false;
|
||||
|
||||
await withBusy(button, "Unlock", "Unlocking", async () => {
|
||||
setStatus(status, "");
|
||||
|
||||
try {
|
||||
await vault.unlock(password);
|
||||
unlocked = true;
|
||||
render();
|
||||
} catch {
|
||||
unlocked = false;
|
||||
}
|
||||
});
|
||||
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} password
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {HTMLElement} status
|
||||
*/
|
||||
async function setup(password, button, status) {
|
||||
await withBusy(button, "Create", "Creating", async () => {
|
||||
setStatus(status, "");
|
||||
|
||||
try {
|
||||
await vault.setup(password);
|
||||
render();
|
||||
} catch (error) {
|
||||
setStatus(status, getErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const needsSetup = vault.needsSetup();
|
||||
const locked = vault.isLocked();
|
||||
@@ -244,12 +170,12 @@ export function createWalletsPage() {
|
||||
sessionButton.textContent = ephemeral ? "Clear" : "Lock";
|
||||
|
||||
if (needsSetup) {
|
||||
renderStart("create");
|
||||
session.renderStart("create");
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
renderStart("unlock");
|
||||
session.renderStart("unlock");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,7 +184,7 @@ export function createWalletsPage() {
|
||||
onAdd() {
|
||||
openAdd();
|
||||
},
|
||||
onClear: ephemeral ? clearEphemeral : undefined,
|
||||
onClear: ephemeral ? session.clearEphemeral : undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -275,42 +201,6 @@ export function createWalletsPage() {
|
||||
renderContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {HTMLInputElement} options.name
|
||||
* @param {HTMLTextAreaElement} options.source
|
||||
* @param {HTMLButtonElement} options.submit
|
||||
* @param {HTMLFormElement} options.form
|
||||
*/
|
||||
async function submitAdd({
|
||||
name,
|
||||
source,
|
||||
submit,
|
||||
form,
|
||||
}) {
|
||||
await withBusy(submit, "Add", "Adding", async () => {
|
||||
source.removeAttribute("aria-invalid");
|
||||
|
||||
try {
|
||||
const value = readWalletSourceText(source.value);
|
||||
|
||||
await generateAddressesFromWalletSource(value, { count: 1 });
|
||||
|
||||
await vault.addWallet({
|
||||
name: name.value,
|
||||
source: value,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
addDialog.close();
|
||||
render();
|
||||
} catch {
|
||||
source.setAttribute("aria-invalid", "true");
|
||||
source.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return main;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "./stats.js";
|
||||
import { findUsablePrefixBucket } from "./bucket.js";
|
||||
import { isLocalClient } from "./local.js";
|
||||
import { createAddressMetadata } from "./metadata.js";
|
||||
|
||||
const LOOKUP_CONCURRENCY = 8;
|
||||
|
||||
@@ -106,33 +107,6 @@ function isNotFound(error) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AddressClient} client
|
||||
* @param {string} address
|
||||
* @param {Map<string, Promise<AddressStats>>} cache
|
||||
*/
|
||||
function getBucketMetadata(client, address, cache) {
|
||||
let metadata = cache.get(address);
|
||||
|
||||
if (!metadata) {
|
||||
metadata = client.getAddress(address, { cache: false });
|
||||
cache.set(address, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AddressClient} client
|
||||
* @param {readonly string[]} addresses
|
||||
* @param {Map<string, Promise<AddressStats>>} cache
|
||||
*/
|
||||
async function fetchBucketMetadata(client, addresses, cache) {
|
||||
await Promise.all(
|
||||
addresses.map((address) => getBucketMetadata(client, address, cache)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AddressClient} client
|
||||
* @param {GeneratedAddress} generated
|
||||
@@ -157,28 +131,24 @@ async function fetchDirectWalletAddress(client, generated) {
|
||||
/**
|
||||
* @param {AddressClient} client
|
||||
* @param {GeneratedAddress} generated
|
||||
* @param {Map<string, Promise<AddressStats>>} metadataCache
|
||||
* @param {ReturnType<typeof createAddressMetadata>} metadata
|
||||
* @returns {Promise<WalletAddress>}
|
||||
*/
|
||||
async function fetchWalletAddress(client, generated, metadataCache) {
|
||||
async function fetchWalletAddress(client, generated, metadata) {
|
||||
const matches = await findUsablePrefixBucket(client, generated);
|
||||
|
||||
if (!matches.addresses.includes(generated.address)) {
|
||||
return createEmptyWalletAddress(generated, matches.addresses.length);
|
||||
}
|
||||
|
||||
await fetchBucketMetadata(client, matches.addresses, metadataCache);
|
||||
await metadata.fetchAll(matches.addresses);
|
||||
|
||||
const stats = await getBucketMetadata(
|
||||
client,
|
||||
generated.address,
|
||||
metadataCache,
|
||||
);
|
||||
const stats = await metadata.get(generated.address);
|
||||
|
||||
const historyAddresses = [];
|
||||
|
||||
for (const address of matches.addresses) {
|
||||
const bucketStats = await getBucketMetadata(client, address, metadataCache);
|
||||
const bucketStats = await metadata.get(address);
|
||||
|
||||
if (getAddressTxCount(bucketStats) > 0) {
|
||||
historyAddresses.push(address);
|
||||
@@ -205,10 +175,9 @@ export async function fetchWalletAddresses(client, generated) {
|
||||
});
|
||||
}
|
||||
|
||||
const metadataCache =
|
||||
/** @type {Map<string, Promise<AddressStats>>} */ (new Map());
|
||||
const metadata = createAddressMetadata(client);
|
||||
|
||||
return mapConcurrent(generated, LOOKUP_CONCURRENCY, (address) => {
|
||||
return fetchWalletAddress(client, address, metadataCache);
|
||||
return fetchWalletAddress(client, address, metadata);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @typedef {import("./stats.js").AddressStats} AddressStats
|
||||
* @typedef {import("./index.js").AddressClient} AddressClient
|
||||
*/
|
||||
|
||||
/** @param {AddressClient} client */
|
||||
export function createAddressMetadata(client) {
|
||||
/** @type {Map<string, Promise<AddressStats>>} */
|
||||
const cache = new Map();
|
||||
|
||||
/** @param {string} address */
|
||||
function get(address) {
|
||||
let metadata = cache.get(address);
|
||||
|
||||
if (!metadata) {
|
||||
metadata = client.getAddress(address, { cache: false });
|
||||
cache.set(address, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** @param {readonly string[]} addresses */
|
||||
async function fetchAll(addresses) {
|
||||
await Promise.all(addresses.map(get));
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
fetchAll,
|
||||
get,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
setStatus,
|
||||
withBusy,
|
||||
} from "../dom.js";
|
||||
import { getErrorMessage } from "../errors.js";
|
||||
import { createStart } from "./index.js";
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {Object} options.vault
|
||||
* @param {() => void} options.vault.lock
|
||||
* @param {() => void} options.vault.reset
|
||||
* @param {() => void} options.vault.startEphemeral
|
||||
* @param {() => void} options.vault.clearEphemeral
|
||||
* @param {(password: string) => Promise<void>} options.vault.setup
|
||||
* @param {(password: string) => Promise<void>} options.vault.unlock
|
||||
* @param {HTMLElement} options.content
|
||||
* @param {() => void} options.onChange
|
||||
*/
|
||||
export function createWalletSession({ vault, content, onChange }) {
|
||||
function lock() {
|
||||
vault.lock();
|
||||
onChange();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
vault.reset();
|
||||
onChange();
|
||||
}
|
||||
|
||||
function startEphemeral() {
|
||||
vault.startEphemeral();
|
||||
onChange();
|
||||
}
|
||||
|
||||
function clearEphemeral() {
|
||||
vault.clearEphemeral();
|
||||
onChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} password
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {HTMLElement} status
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function unlock(password, button, status) {
|
||||
let unlocked = false;
|
||||
|
||||
await withBusy(button, "Unlock", "Unlocking", async () => {
|
||||
setStatus(status, "");
|
||||
|
||||
try {
|
||||
await vault.unlock(password);
|
||||
unlocked = true;
|
||||
onChange();
|
||||
} catch {
|
||||
unlocked = false;
|
||||
}
|
||||
});
|
||||
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} password
|
||||
* @param {HTMLButtonElement} button
|
||||
* @param {HTMLElement} status
|
||||
*/
|
||||
async function setup(password, button, status) {
|
||||
await withBusy(button, "Create", "Creating", async () => {
|
||||
setStatus(status, "");
|
||||
|
||||
try {
|
||||
await vault.setup(password);
|
||||
onChange();
|
||||
} catch (error) {
|
||||
setStatus(status, getErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {"create" | "unlock"} mode */
|
||||
function renderStart(mode) {
|
||||
content.replaceChildren(createStart({
|
||||
mode,
|
||||
onPassword(password, button, status) {
|
||||
return mode === "unlock"
|
||||
? unlock(password, button, status)
|
||||
: setup(password, button, status);
|
||||
},
|
||||
onEphemeral: startEphemeral,
|
||||
onReset: mode === "unlock" ? reset : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
clearEphemeral,
|
||||
lock,
|
||||
renderStart,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user