diff --git a/website_next/chart/pointer.js b/website_next/chart/pointer.js new file mode 100644 index 000000000..6572f23e6 --- /dev/null +++ b/website_next/chart/pointer.js @@ -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, + }); +} diff --git a/website_next/chart/scrubber/index.js b/website_next/chart/scrubber/index.js index 0397a332b..39569ce5e 100644 --- a/website_next/chart/scrubber/index.js +++ b/website_next/chart/scrubber/index.js @@ -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(); }); diff --git a/website_next/chart/xy/index.js b/website_next/chart/xy/index.js index 0a717d117..f74fc7613 100644 --- a/website_next/chart/xy/index.js +++ b/website_next/chart/xy/index.js @@ -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 }); diff --git a/website_next/explore/chain/confirmed.js b/website_next/explore/chain/confirmed.js new file mode 100644 index 000000000..dbcb6c725 --- /dev/null +++ b/website_next/explore/chain/confirmed.js @@ -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} */ + 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, + }); +} diff --git a/website_next/explore/chain/index.js b/website_next/explore/chain/index.js index 9bbb08b8c..a136af7eb 100644 --- a/website_next/explore/chain/index.js +++ b/website_next/explore/chain/index.js @@ -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>[number]} Block */ /** @typedef {Awaited>[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} */ - const blocksByHash = new Map(); - - /** @type {ReturnType[]} */ - 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(); } diff --git a/website_next/explore/chain/older.js b/website_next/explore/chain/older.js new file mode 100644 index 000000000..b2573d5f1 --- /dev/null +++ b/website_next/explore/chain/older.js @@ -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} 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, + }); +} diff --git a/website_next/explore/chain/projected.js b/website_next/explore/chain/projected.js new file mode 100644 index 000000000..d4bf823ef --- /dev/null +++ b/website_next/explore/chain/projected.js @@ -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[]} */ + 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, + }); +} diff --git a/website_next/explore/chain/tip.js b/website_next/explore/chain/tip.js new file mode 100644 index 000000000..f514c0df2 --- /dev/null +++ b/website_next/explore/chain/tip.js @@ -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, + }); +} diff --git a/website_next/wallets/add/submit.js b/website_next/wallets/add/submit.js new file mode 100644 index 000000000..70c719875 --- /dev/null +++ b/website_next/wallets/add/submit.js @@ -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 }} 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(); + } + }); + }; +} diff --git a/website_next/wallets/derive/descriptor-parser.js b/website_next/wallets/derive/descriptor-parser.js new file mode 100644 index 000000000..be7ef7cf6 --- /dev/null +++ b/website_next/wallets/derive/descriptor-parser.js @@ -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]; +} diff --git a/website_next/wallets/derive/descriptor.js b/website_next/wallets/derive/descriptor.js index 6e8300372..e51c74adc 100644 --- a/website_next/wallets/derive/descriptor.js +++ b/website_next/wallets/derive/descriptor.js @@ -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 diff --git a/website_next/wallets/derive/multisig.js b/website_next/wallets/derive/multisig.js new file mode 100644 index 000000000..2f51b7209 --- /dev/null +++ b/website_next/wallets/derive/multisig.js @@ -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), + ]); +} diff --git a/website_next/wallets/index.js b/website_next/wallets/index.js index 30afbc419..2e1a5d72f 100644 --- a/website_next/wallets/index.js +++ b/website_next/wallets/index.js @@ -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} - */ - 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; diff --git a/website_next/wallets/lookup/index.js b/website_next/wallets/lookup/index.js index df7567312..9dee1bf17 100644 --- a/website_next/wallets/lookup/index.js +++ b/website_next/wallets/lookup/index.js @@ -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>} 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>} 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>} metadataCache + * @param {ReturnType} metadata * @returns {Promise} */ -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>} */ (new Map()); + const metadata = createAddressMetadata(client); return mapConcurrent(generated, LOOKUP_CONCURRENCY, (address) => { - return fetchWalletAddress(client, address, metadataCache); + return fetchWalletAddress(client, address, metadata); }); } diff --git a/website_next/wallets/lookup/metadata.js b/website_next/wallets/lookup/metadata.js new file mode 100644 index 000000000..980276bc3 --- /dev/null +++ b/website_next/wallets/lookup/metadata.js @@ -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>} */ + 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, + }); +} diff --git a/website_next/wallets/start/session.js b/website_next/wallets/start/session.js new file mode 100644 index 000000000..314b954ba --- /dev/null +++ b/website_next/wallets/start/session.js @@ -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} options.vault.setup + * @param {(password: string) => Promise} 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} + */ + 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, + }); +}