website_next: part 4

This commit is contained in:
nym21
2026-07-06 13:17:11 +02:00
parent 3f9edb211e
commit 3f2a48f248
16 changed files with 1175 additions and 929 deletions
+54
View File
@@ -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,
});
}
+19 -38
View File
@@ -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();
});
+22 -40
View File
@@ -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 });
+145
View File
@@ -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,
});
}
+69 -340
View File
@@ -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();
}
+175
View File
@@ -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,
});
}
+86
View File
@@ -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,
});
}
+77
View File
@@ -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,
});
}
+43
View File
@@ -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];
}
+8 -344
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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;
+8 -39
View File
@@ -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);
});
}
+32
View File
@@ -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,
});
}
+102
View File
@@ -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,
});
}