website_next: part 5

This commit is contained in:
nym21
2026-07-06 15:54:25 +02:00
parent 3f2a48f248
commit 2207ec1b7e
25 changed files with 1083 additions and 130 deletions
+27 -19
View File
@@ -2,12 +2,15 @@ import {
scanBranch,
GAP_LIMIT,
} from "./branch.js";
import { mapConcurrent } from "../concurrent.js";
import {
getOutputDescriptorBranchIds,
isOutputDescriptor,
} from "../derive/index.js";
import { isUsedAddress } from "./activity.js";
const BRANCH_SCAN_CONCURRENCY = 2;
const keyBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [0] },
{ id: "change", label: "Change", path: [1] },
@@ -85,9 +88,10 @@ function addBranch(address, branch) {
/**
* @param {string} source
* @returns {WalletBranch[]}
*/
function getWalletBranches(source) {
if (!isOutputDescriptor(source)) return keyBranches;
if (!isOutputDescriptor(source)) return [...keyBranches];
const branchIds = new Set(getOutputDescriptorBranchIds(source));
const branches = descriptorBranches.filter((branch) => {
@@ -110,23 +114,29 @@ export async function scanBranches(client, source, options) {
branches.find((branch) => branch.id === "receive") ?? branches[0];
/** @type {ScannedAddress | undefined} */
let receiveAddress;
let maxed = false;
const scans = await mapConcurrent(
branches,
BRANCH_SCAN_CONCURRENCY,
async (branch) => {
const scan = await scanBranch(client, source, {
script: options.script,
path: branch.path,
branchId: branch.id,
onProgress(progress) {
options.onProgress?.({
branchId: branch.id,
branchLabel: branch.label,
scannedCount: progress.scannedCount,
unusedInRow: progress.unusedInRow,
});
},
});
for (const branch of branches) {
const scan = await scanBranch(client, source, {
script: options.script,
path: branch.path,
branchId: branch.id,
onProgress(progress) {
options.onProgress?.({
branchId: branch.id,
branchLabel: branch.label,
scannedCount: progress.scannedCount,
unusedInRow: progress.unusedInRow,
});
},
});
return { branch, scan };
},
);
for (const { branch, scan } of scans) {
for (const address of scan.addresses) {
const branchedAddress = addBranch(address, branch);
@@ -139,14 +149,12 @@ export async function scanBranches(client, source, options) {
addresses.push(branchedAddress);
}
maxed = maxed || scan.maxed;
}
return {
addresses: addresses.sort(compareWalletAddresses),
receiveAddress,
gapLimit: GAP_LIMIT,
maxed,
maxed: scans.some(({ scan }) => scan.maxed),
};
}
+10 -7
View File
@@ -1,8 +1,11 @@
import { scanBranches } from "./branches.js";
import { mapConcurrent } from "../concurrent.js";
import { isOutputDescriptor } from "../derive/index.js";
import { parseOutputDescriptor } from "../derive/descriptor.js";
import { addressScripts } from "../derive/script.js";
const SCRIPT_SCAN_CONCURRENCY = 2;
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../lookup/index.js").AddressClient} AddressClient
@@ -117,10 +120,10 @@ export async function scanWalletAddresses({
source,
onProgress,
}) {
const scans = /** @type {ScriptScan[]} */ ([]);
for (const script of getSourceScripts(source)) {
scans.push(await scanBranches(client, source, {
const scans = await mapConcurrent(
getSourceScripts(source),
SCRIPT_SCAN_CONCURRENCY,
(script) => scanBranches(client, source, {
script: script.id,
onProgress(progress) {
onProgress?.({
@@ -128,12 +131,12 @@ export async function scanWalletAddresses({
branchLabel: `${script.label} ${progress.branchLabel}`,
});
},
}));
}
}),
);
const addresses = scans.flatMap((scan) => scan.addresses)
.sort(compareWalletAddresses);
const btcUsdPrice = await client.getLivePrice({ cache: false });
const btcUsdPrice = await client.getLivePrice();
return {
addresses,
+48 -34
View File
@@ -3,6 +3,7 @@ import { createBucketKey } from "../bucket-key.js";
const HISTORY_CONCURRENCY = 4;
const MAX_SELECTED_ADDRESS_TXS = 100;
const CACHE_KEY_SEPARATOR = "\n\n";
const historyByBucketKey =
/** @type {Map<string, Promise<Map<string, ApiTransaction[]>>>} */ (new Map());
@@ -17,65 +18,78 @@ const historyByBucketKey =
* @property {(address: string, options?: { cache?: boolean }) => Promise<ApiTransaction[]>} getAddressTxs
*/
/**
* @typedef {Object} AddressHistory
* @property {ApiTransaction[]} transactions
*/
/**
* @param {AddressHistoryClient} client
* @param {readonly string[]} addresses
* @param {readonly string[]} bucketAddresses
* @param {ReadonlySet<string>} selectedAddresses
* @returns {Promise<Map<string, ApiTransaction[]>>}
*/
async function fetchBucketHistory(client, addresses) {
const entries = await mapConcurrent(
addresses,
async function fetchBucketHistory(client, bucketAddresses, selectedAddresses) {
const history = /** @type {Map<string, ApiTransaction[]>} */ (new Map());
await mapConcurrent(
bucketAddresses,
HISTORY_CONCURRENCY,
async (address) => {
const transactions = await client.getAddressTxs(address, { cache: false });
return /** @type {const} */ ([address, transactions]);
if (selectedAddresses.has(address)) {
history.set(address, transactions);
}
},
);
return new Map(entries);
return history;
}
/** @param {WalletAddress} address */
function canLoadHistory(address) {
return (
address.txCount <= MAX_SELECTED_ADDRESS_TXS &&
address.historyAddresses.length > 0
);
}
/** @param {readonly WalletAddress[]} addresses */
function createHistoryCacheKey(addresses) {
return [
createBucketKey(addresses[0].historyAddresses),
createBucketKey(addresses.map((address) => address.address)),
].join(CACHE_KEY_SEPARATOR);
}
/**
* @param {AddressHistoryClient} client
* @param {WalletAddress} address
* @returns {Promise<AddressHistory>}
* @param {readonly WalletAddress[]} addresses
* @returns {Promise<Map<string, ApiTransaction[]>>}
*/
async function load(client, address) {
if (
address.txCount > MAX_SELECTED_ADDRESS_TXS ||
address.historyAddresses.length === 0
) {
return {
transactions: [],
};
}
async function loadBucket(client, addresses) {
const selectedAddresses = addresses.filter(canLoadHistory);
if (!selectedAddresses.length) return new Map();
const key = createBucketKey(address.historyAddresses);
const key = createHistoryCacheKey(selectedAddresses);
let history = historyByBucketKey.get(key);
if (!history) {
history = fetchBucketHistory(client, address.historyAddresses).catch(
(error) => {
historyByBucketKey.delete(key);
throw error;
},
const bucketAddresses = selectedAddresses[0].historyAddresses;
const selectedAddressSet = new Set(
selectedAddresses.map((address) => address.address),
);
history = fetchBucketHistory(
client,
bucketAddresses,
selectedAddressSet,
).catch((error) => {
historyByBucketKey.delete(key);
throw error;
});
historyByBucketKey.set(key, history);
}
const bucketHistory = await history;
return {
transactions: bucketHistory.get(address.address) ?? [],
};
return history;
}
export const addressHistory = /** @type {const} */ ({
load,
loadBucket,
});
+37 -7
View File
@@ -1,6 +1,10 @@
import { addressHistory } from "./address.js";
import { mapConcurrent } from "../../concurrent.js";
import { createBucketKey } from "../bucket-key.js";
import { readWalletTransaction } from "./transaction.js";
const HISTORY_BUCKET_CONCURRENCY = 2;
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
* @typedef {import("./transaction.js").ApiTransaction} ApiTransaction
@@ -19,6 +23,24 @@ function isUsedAddress(address) {
return address.txCount > 0;
}
/**
* @param {readonly WalletAddress[]} addresses
* @returns {WalletAddress[][]}
*/
function groupAddressesByBucket(addresses) {
const groups = /** @type {Map<string, WalletAddress[]>} */ (new Map());
for (const address of addresses) {
const key = createBucketKey(address.historyAddresses);
const group = groups.get(key) ?? [];
group.push(address);
groups.set(key, group);
}
return [...groups.values()];
}
/**
* @param {WalletTransaction} a
* @param {WalletTransaction} b
@@ -44,15 +66,23 @@ async function load(client, addresses) {
new Map()
);
const usedAddresses = addresses.filter(isUsedAddress);
const histories = await mapConcurrent(
groupAddressesByBucket(usedAddresses),
HISTORY_BUCKET_CONCURRENCY,
(group) => addressHistory.loadBucket(client, group),
);
for (const address of usedAddresses) {
const history = await addressHistory.load(client, address);
for (const history of histories) {
for (const transactions of history.values()) {
for (const transaction of transactions) {
const walletTransaction = readWalletTransaction(
transaction,
usedAddresses,
);
for (const transaction of history.transactions) {
const walletTransaction = readWalletTransaction(transaction, usedAddresses);
if (walletTransaction.txid) {
transactionsById.set(walletTransaction.txid, walletTransaction);
if (walletTransaction.txid) {
transactionsById.set(walletTransaction.txid, walletTransaction);
}
}
}
}
+24 -35
View File
@@ -2,25 +2,11 @@ import { createQrDataUrl } from "../../../qr/index.js";
import { openDialog } from "../../../dialog/index.js";
import { createGroupedAddress } from "../address/index.js";
import { createWalletPart } from "../../dom.js";
import { formatNumber } from "../../format.js";
/**
* @typedef {import("../../scan/index.js").WalletAddress} ReceiveAddress
*/
/**
* @param {ReceiveAddress} receiveAddress
*/
function createReceiveTitle(receiveAddress) {
const title = document.createElement("h2");
title.append(
`${receiveAddress.branchLabel.toLowerCase()} #${formatNumber(receiveAddress.index)}`,
);
return title;
}
/**
* @param {ReceiveAddress} receiveAddress
*/
@@ -29,7 +15,7 @@ function createReceiveQr(receiveAddress) {
const uri = `bitcoin:${receiveAddress.address}`;
image.alt = `QR code for ${receiveAddress.address}`;
image.src = createQrDataUrl(uri, { scale: 8 });
image.src = createQrDataUrl(uri, { scale: 6 });
return image;
}
@@ -47,11 +33,13 @@ function createReceiveAddress(receiveAddress) {
/**
* @param {ReceiveAddress} receiveAddress
* @param {HTMLButtonElement} copy
* @param {HTMLElement} content
* @param {() => void} onCopied
*/
async function copyReceiveAddress(receiveAddress, copy) {
async function copyReceiveAddress(receiveAddress, content, onCopied) {
await navigator.clipboard.writeText(receiveAddress.address);
copy.textContent = "Copied";
content.dataset.copied = "";
onCopied();
}
/**
@@ -61,30 +49,31 @@ async function copyReceiveAddress(receiveAddress, copy) {
function openReceiveDialog(host, receiveAddress) {
const dialog = createWalletPart("dialog", "receive");
const content = document.createElement("article");
const actions = document.createElement("footer");
const copy = document.createElement("button");
const closeForm = document.createElement("form");
const close = document.createElement("button");
let copiedTimeout = 0;
copy.type = "button";
copy.append("Copy");
closeForm.method = "dialog";
close.type = "submit";
close.append("Close");
closeForm.append(close);
actions.append(copy, closeForm);
content.role = "button";
content.tabIndex = 0;
content.append(
createReceiveTitle(receiveAddress),
createReceiveQr(receiveAddress),
createReceiveAddress(receiveAddress),
actions,
);
dialog.append(content);
copy.addEventListener("click", () => {
void copyReceiveAddress(receiveAddress, copy).catch(() => {
copy.textContent = "Copy failed";
});
function copy() {
void copyReceiveAddress(receiveAddress, content, () => {
window.clearTimeout(copiedTimeout);
copiedTimeout = window.setTimeout(() => {
delete content.dataset.copied;
}, 1_000);
}).catch(() => {});
}
content.addEventListener("click", copy);
content.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
});
openDialog(dialog, host);
}
+55 -19
View File
@@ -1,40 +1,76 @@
main[data-page="wallets"] {
dialog[data-wallet="receive"] {
width: min(100% - 2rem, 32rem);
--dialog-space: 1rem;
width: min(100% - 2rem, 10rem);
border-radius: 0.125rem;
> article {
position: relative;
display: grid;
gap: 1rem;
gap: 0.375rem;
cursor: pointer;
> h2 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-lg);
&:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 0.25rem;
}
&::after {
content: "Copied";
position: absolute;
top: 50%;
left: 50%;
color: var(--black);
font-size: var(--font-size-sm);
opacity: 0;
pointer-events: none;
translate: -50% calc(-50% + 0.25rem);
transition:
opacity 150ms ease,
translate 150ms ease;
text-transform: uppercase;
}
&[data-copied]::after {
opacity: 1;
translate: -50% -50%;
}
&[data-copied] > * {
opacity: 0.1;
}
> img {
justify-self: center;
width: min(100%, 18rem);
width: 100%;
aspect-ratio: 1;
padding: 1rem;
background: var(--white);
image-rendering: pixelated;
}
> img,
> p {
margin: 0;
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
transition: opacity 150ms ease;
}
> footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: end;
> form {
margin: 0;
> p {
margin: 0;
font-size: var(--font-size-xs);
font-style: italic;
line-height: var(--line-height-xs);
[data-wallet="address"] {
max-width: 100%;
gap: 0 0.25rem;
> span {
color: var(--black);
> var {
color: color-mix(in oklch, var(--black) 75%, var(--gray));
}
}
}
}
}