global: private xpub support part 2

This commit is contained in:
nym21
2026-06-17 11:25:42 +02:00
parent 0c7861071d
commit 43df9e098c
68 changed files with 1836 additions and 1653 deletions
+14 -8
View File
@@ -116,14 +116,20 @@
<link rel="stylesheet" href="/learn/contents/style.css" />
<link rel="stylesheet" href="/build/style.css" />
<link rel="stylesheet" href="/wallets/style.css" />
<link rel="stylesheet" href="/wallets/forms.css" />
<link rel="stylesheet" href="/wallets/settings.css" />
<link rel="stylesheet" href="/wallets/selector.css" />
<link rel="stylesheet" href="/wallets/summary.css" />
<link rel="stylesheet" href="/wallets/receive.css" />
<link rel="stylesheet" href="/wallets/table.css" />
<link rel="stylesheet" href="/wallets/address.css" />
<link rel="stylesheet" href="/wallets/history.css" />
<link rel="stylesheet" href="/wallets/layout/style.css" />
<link rel="stylesheet" href="/wallets/form/style.css" />
<link rel="stylesheet" href="/wallets/dialog/style.css" />
<link rel="stylesheet" href="/wallets/add/style.css" />
<link rel="stylesheet" href="/wallets/empty/style.css" />
<link rel="stylesheet" href="/wallets/lock/style.css" />
<link rel="stylesheet" href="/wallets/setup/style.css" />
<link rel="stylesheet" href="/wallets/wallet/settings/style.css" />
<link rel="stylesheet" href="/wallets/selector/style.css" />
<link rel="stylesheet" href="/wallets/wallet/summary/style.css" />
<link rel="stylesheet" href="/wallets/wallet/receive/style.css" />
<link rel="stylesheet" href="/wallets/wallet/table/style.css" />
<link rel="stylesheet" href="/wallets/wallet/address/style.css" />
<link rel="stylesheet" href="/wallets/wallet/history/style.css" />
<!-- /IMPORTMAP -->
<script>
@@ -1,13 +1,11 @@
import {
createElement,
createField,
} from "./dom.js";
import { arePrivateValuesHidden } from "./privacy-view.js";
import { createElement } from "../dom.js";
import { createField } from "../form/index.js";
import { redaction } from "../redaction/index.js";
/**
* @typedef {Object} AddWalletFormSubmit
* @property {HTMLInputElement} name
* @property {HTMLInputElement} xpub
* @property {HTMLInputElement} source
* @property {HTMLButtonElement} submit
* @property {HTMLElement} status
* @property {HTMLFormElement} form
@@ -19,11 +17,11 @@ import { arePrivateValuesHidden } from "./privacy-view.js";
* @property {(submit: AddWalletFormSubmit) => void | Promise<void>} onSubmit
*/
function createXpubInput() {
function createSourceInput() {
const input = document.createElement("input");
input.name = "xpub";
input.type = arePrivateValuesHidden() ? "password" : "text";
input.name = "source";
input.type = redaction.isHidden() ? "password" : "text";
input.setAttribute("data-wallets-private-input", "");
input.autocomplete = "off";
input.placeholder = "xpub or descriptor...";
@@ -36,18 +34,18 @@ function createXpubInput() {
/**
* @param {AddWalletFormOptions} options
*/
export function createAddWalletForm(options) {
export function createAddForm(options) {
const form = createElement("form", "wallets__dialog-form");
const title = document.createElement("h2");
const name = document.createElement("input");
const xpub = createXpubInput();
const source = createSourceInput();
const actions = createElement("div", "wallets__dialog-actions");
const cancel = document.createElement("button");
const submit = document.createElement("button");
const status = createElement("p", "wallets__status");
const fields = [
createField("name", name),
createField("xpub or descriptor", xpub),
createField("xpub or descriptor", source),
];
title.append("Watch wallet");
@@ -72,7 +70,7 @@ export function createAddWalletForm(options) {
event.preventDefault();
void options.onSubmit({
name,
xpub,
source,
submit,
status,
form,
+49
View File
@@ -0,0 +1,49 @@
import { fetchWalletAddresses } from "../lookup/index.js";
import {
generateAddressesFromKey,
isOutputDescriptor,
} from "../derive/index.js";
import { parseOutputDescriptor } from "../derive/descriptor.js";
import { addressScripts } from "../derive/script.js";
const RECEIVE_PATH = /** @type {const} */ ([0]);
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../scan/branch.js").AddressClient} AddressClient
* @typedef {import("../lookup/index.js").WalletAddress} WalletAddress
*/
/**
* @param {WalletAddress} address
*/
function hasHistory(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {AddressClient} client
* @param {string} source
* @returns {Promise<AddressScript>}
*/
export async function inferAddressScript(client, source) {
if (isOutputDescriptor(source)) {
return parseOutputDescriptor(source).script;
}
for (const { id } of addressScripts) {
const generated = await generateAddressesFromKey(source, {
start: 0,
count: 1,
script: id,
path: RECEIVE_PATH,
});
const [address] = await fetchWalletAddresses(client, generated);
if (address && hasHistory(address)) {
return id;
}
}
return addressScripts[0].id;
}
@@ -1,4 +1,4 @@
import { isOutputDescriptor } from "./xpub/index.js";
import { isOutputDescriptor } from "../derive/index.js";
const EXTENDED_PUBLIC_KEY_PATTERN =
/\b(?:xpub|ypub|zpub|tpub|upub|vpub)[1-9A-HJ-NP-Za-km-z]{20,}\b/;
+12
View File
@@ -0,0 +1,12 @@
main.wallets {
.wallets__dialog-form {
display: grid;
gap: 0.75rem;
}
.wallets__dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: end;
}
}
-20
View File
@@ -1,20 +0,0 @@
import { createAddressTable } from "./table-view.js";
/**
* @typedef {Parameters<typeof createAddressTable>[0][number] & {
* branchLabel?: string,
* }} WalletAddress
*/
/**
* @typedef {Parameters<typeof createAddressTable>[1]} AddressTableOptions
*/
/**
* @param {HTMLElement} results
* @param {WalletAddress[]} addresses
* @param {AddressTableOptions} tableOptions
*/
export function renderWalletAddresses(results, addresses, tableOptions) {
results.replaceChildren(createAddressTable(addresses, tableOptions));
}
-146
View File
@@ -1,146 +0,0 @@
import { createElement } from "./dom.js";
/**
* @typedef {Object} EmptyWalletViewOptions
* @property {() => void} onAdd
*/
/**
* @typedef {Object} LockedWalletViewOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onUnlock
* @property {() => void} onReset
*/
/**
* @typedef {Object} SetupWalletViewOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onCreate
*/
/**
* @typedef {Object} UnlockedWalletView
* @property {HTMLElement} settings
* @property {HTMLElement} summary
* @property {HTMLElement} status
* @property {HTMLElement} results
* @property {HTMLElement[]} nodes
*/
/**
* @param {EmptyWalletViewOptions} options
*/
export function createEmptyWalletView(options) {
const empty = createElement("section", "wallets__empty");
const text = document.createElement("p");
const button = document.createElement("button");
text.append("No watch-only wallets yet");
button.type = "button";
button.append("Add watch-only wallet");
button.addEventListener("click", options.onAdd);
empty.append(text, button);
return empty;
}
/**
* @param {SetupWalletViewOptions} options
*/
export function createSetupWalletView(options) {
const section = createElement("section", "wallets__setup");
const title = document.createElement("h1");
const description = createElement("div", "wallets__setup-description");
const form = createElement("form", "wallets__setup-form");
const password = document.createElement("input");
const button = document.createElement("button");
const status = createElement("p", "wallets__status");
title.append("Wallets");
description.append(
createDescriptionText("Import an extended public key, often called an xpub, or a watch-only descriptor to view a Bitcoin wallet without giving this site spending access."),
createDescriptionText("Your wallet sources stay in this browser and are encrypted before they are saved. Set a password for this local wallet vault."),
createDescriptionText("If you forget the password, you can reset the vault and import the xpubs or descriptors again."),
);
password.name = "password";
password.type = "password";
password.autocomplete = "new-password";
password.placeholder = "Set password";
password.required = true;
button.type = "submit";
button.append("Continue");
status.setAttribute("role", "status");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onCreate(password.value, button, status);
});
section.append(title, description, form, status);
return section;
}
/**
* @param {LockedWalletViewOptions} options
*/
export function createLockedWalletView(options) {
const section = createElement("section", "wallets__unlock");
const form = createElement("form", "wallets__unlock-form");
const password = document.createElement("input");
const button = document.createElement("button");
const reset = document.createElement("button");
const status = createElement("p", "wallets__status");
password.name = "password";
password.type = "password";
password.autocomplete = "current-password";
password.placeholder = "Password";
password.required = true;
button.type = "submit";
button.append("Unlock");
reset.type = "button";
reset.className = "wallets__reset";
reset.append("Reset vault");
status.setAttribute("role", "status");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onUnlock(password.value, button, status);
});
reset.addEventListener("click", options.onReset);
section.append(form, reset, status);
return section;
}
/**
* @param {string} text
*/
function createDescriptionText(text) {
const paragraph = document.createElement("p");
paragraph.append(text);
return paragraph;
}
/**
* @returns {UnlockedWalletView}
*/
export function createUnlockedWalletView() {
const settings = createElement("section", "wallets__settings");
const summary = createElement("section", "wallets__summary");
const status = createElement("p", "wallets__status");
const results = createElement("section", "wallets__results");
settings.setAttribute("aria-label", "Wallet settings");
status.setAttribute("role", "status");
summary.setAttribute("aria-label", "Wallets summary");
results.setAttribute("aria-label", "Wallets results");
return {
settings,
summary,
status,
results,
nodes: [settings, summary, status, results],
};
}
@@ -171,7 +171,7 @@ async function taggedHash(tag, bytes) {
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2pkhAddressData(publicKey, network) {
async function encodeP2pkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
return {
@@ -180,20 +180,12 @@ export async function encodeP2pkhAddressData(publicKey, network) {
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2pkhAddress(publicKey, network) {
return (await encodeP2pkhAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2shP2wpkhAddressData(publicKey, network) {
async function encodeP2shP2wpkhAddressData(publicKey, network) {
const publicKeyHash = await hash160(publicKey);
const redeemScript = concatBytes([Uint8Array.of(0x00, 0x14), publicKeyHash]);
const payload = await hash160(redeemScript);
@@ -204,20 +196,12 @@ export async function encodeP2shP2wpkhAddressData(publicKey, network) {
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2shP2wpkhAddress(publicKey, network) {
return (await encodeP2shP2wpkhAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2wpkhAddressData(publicKey, network) {
async function encodeP2wpkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
const values = [0, ...convertBits(payload, 8, 5)];
@@ -227,14 +211,6 @@ export async function encodeP2wpkhAddressData(publicKey, network) {
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2wpkhAddress(publicKey, network) {
return (await encodeP2wpkhAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} witnessScript
* @param {BitcoinNetwork} network
@@ -255,7 +231,7 @@ export async function encodeP2wshAddressData(witnessScript, network) {
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2trAddressData(publicKey, network) {
async function encodeP2trAddressData(publicKey, network) {
const internalKey = getXOnlyPublicKey(publicKey);
const tweak = await taggedHash("TapTweak", internalKey);
const payload = addXOnlyPublicKeyTweak(publicKey, tweak);
@@ -267,14 +243,6 @@ export async function encodeP2trAddressData(publicKey, network) {
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2trAddress(publicKey, network) {
return (await encodeP2trAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} publicKey
* @param {AddressScript} script
@@ -300,12 +268,3 @@ export async function encodePublicKeyAddressData(publicKey, script, network) {
throw new Error("Expected a single-key address script");
}
/**
* @param {Uint8Array} publicKey
* @param {AddressScript} script
* @param {BitcoinNetwork} network
*/
export async function encodePublicKeyAddress(publicKey, script, network) {
return (await encodePublicKeyAddressData(publicKey, script, network)).address;
}
@@ -46,7 +46,7 @@ function countLeadingZeroBytes(bytes) {
/**
* @param {string} text
*/
export function decodeBase58(text) {
function decodeBase58(text) {
let value = 0n;
for (const character of text) {
@@ -72,7 +72,7 @@ export function decodeBase58(text) {
/**
* @param {Uint8Array} bytes
*/
export function encodeBase58(bytes) {
function encodeBase58(bytes) {
let value = bytesToBigInt(bytes);
let text = "";
@@ -20,7 +20,7 @@ const HARDENED_INDEX = 0x80000000;
* @param {number} index
* @returns {Promise<ExtendedPublicKey>}
*/
export async function derivePublicChild(key, index) {
async function derivePublicChild(key, index) {
if (!Number.isSafeInteger(index) || index < 0 || index >= HARDENED_INDEX) {
throw new Error("Expected a non-hardened child index");
}
@@ -45,7 +45,7 @@ export async function derivePublicChild(key, index) {
* @param {ExtendedPublicKey} key
* @param {readonly number[]} path
*/
export async function derivePublicPath(key, path) {
async function derivePublicPath(key, path) {
let child = key;
for (const index of path) {
@@ -57,7 +57,7 @@ function isSupportedDescriptor(text) {
/**
* @param {string} text
*/
export function extractOutputDescriptors(text) {
function extractOutputDescriptors(text) {
const value = compactText(text);
const descriptors = /** @type {string[]} */ ([]);
let offset = 0;
@@ -1,4 +1,4 @@
import { concatBytes, createBytes } from "./bytes.js";
import { createBytes } from "./bytes.js";
const ripemdLeftIndexes = /** @type {const} */ ([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
@@ -71,7 +71,7 @@ export async function sha256(bytes) {
/**
* @param {Uint8Array} bytes
*/
export async function doubleSha256(bytes) {
async function doubleSha256(bytes) {
return sha256(await sha256(bytes));
}
@@ -172,7 +172,7 @@ function writeRipemdWord(target, offset, value) {
/**
* @param {Uint8Array} bytes
*/
export function ripemd160(bytes) {
function ripemd160(bytes) {
const blocks = createRipemdBlocks(bytes);
const digest = createBytes(20);
let h0 = 0x67452301;
@@ -263,13 +263,3 @@ export async function hash160(bytes) {
export async function checksum(bytes) {
return (await doubleSha256(bytes)).slice(0, 4);
}
/**
* @param {number} version
* @param {Uint8Array} payload
*/
export async function versionedChecksum(version, payload) {
const versioned = concatBytes([Uint8Array.of(version), payload]);
return checksum(versioned);
}
@@ -71,12 +71,12 @@ function readCount(value) {
}
/**
* @param {string} xpub
* @param {string} source
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromXpub(xpub, options = {}) {
const key = await parseXpub(xpub);
export async function generateAddressesFromKey(source, options = {}) {
const key = await parseXpub(source);
const start = readStart(options.start);
const count = readCount(options.count);
const script = options.script ?? key.version.script;
@@ -121,7 +121,7 @@ export async function generateAddressesFromWalletSource(source, options = {}) {
);
}
return generateAddressesFromXpub(source, {
return generateAddressesFromKey(source, {
...options,
start,
count,
@@ -5,7 +5,7 @@ const EXTENDED_PUBLIC_KEY_LENGTH = 78;
const PUBLIC_KEY_LENGTH = 33;
const CHAIN_CODE_LENGTH = 32;
export const extendedPublicKeyVersions = /** @type {const} */ ([
const extendedPublicKeyVersions = /** @type {const} */ ([
{
version: 0x0488b21e,
prefix: "xpub",
+6
View File
@@ -0,0 +1,6 @@
export const addressScripts = /** @type {const} */ ([
{ id: "v0_p2wpkh", label: "P2WPKH" },
{ id: "v1_p2tr", label: "P2TR" },
{ id: "p2sh_p2wpkh", label: "Nested P2WPKH" },
{ id: "p2pkh", label: "P2PKH" },
]);
@@ -179,7 +179,7 @@ function liftX(x, odd) {
/**
* @param {Uint8Array} publicKey
*/
export function parseCompressedPublicKey(publicKey) {
function parseCompressedPublicKey(publicKey) {
if (
publicKey.length !== 33 ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
@@ -193,7 +193,7 @@ export function parseCompressedPublicKey(publicKey) {
/**
* @param {Secp256k1Point} point
*/
export function compressPublicKey(point) {
function compressPublicKey(point) {
const publicKey = createBytes(33);
publicKey[0] = point.y & 1n ? 0x03 : 0x02;
+19
View File
@@ -0,0 +1,19 @@
main.wallets {
.wallets__dialog {
width: min(100% - 2rem, 30rem);
border: 1px solid color-mix(in oklch, var(--gray) 36%, transparent);
border-radius: 0.5rem;
padding: 1rem;
color: var(--white);
background: var(--black);
h2 {
margin: 0;
}
}
.wallets__dialog::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
}
}
+16 -14
View File
@@ -11,20 +11,6 @@ export function createElement(tag, className) {
return element;
}
/**
* @param {string} label
* @param {HTMLInputElement | HTMLSelectElement} control
*/
export function createField(label, control) {
const element = createElement("label", "wallets__field");
const text = createElement("span", "wallets__label");
text.append(label);
element.append(text, control);
return element;
}
/**
* @param {HTMLButtonElement} button
* @param {boolean} busy
@@ -37,6 +23,22 @@ export function setBusy(button, busy, idleLabel, busyLabel) {
button.textContent = busy ? busyLabel : idleLabel;
}
/**
* @param {HTMLButtonElement} button
* @param {string} idleLabel
* @param {string} busyLabel
* @param {() => Promise<void>} task
*/
export async function withBusy(button, idleLabel, busyLabel, task) {
setBusy(button, true, idleLabel, busyLabel);
try {
await task();
} finally {
setBusy(button, false, idleLabel, busyLabel);
}
}
/**
* @param {HTMLElement} status
* @param {string} text
+23
View File
@@ -0,0 +1,23 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} EmptyOptions
* @property {() => void} onAdd
*/
/**
* @param {EmptyOptions} options
*/
export function createEmpty(options) {
const empty = createElement("section", "wallets__empty");
const text = document.createElement("p");
const button = document.createElement("button");
text.append("No wallet imported yet");
button.type = "button";
button.append("Add wallet");
button.addEventListener("click", options.onAdd);
empty.append(text, button);
return empty;
}
+14
View File
@@ -0,0 +1,14 @@
main.wallets {
.wallets__empty {
display: grid;
gap: 1rem;
place-content: center;
min-height: 16rem;
text-align: center;
h2,
p {
margin: 0;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { createElement } from "../dom.js";
/**
* @param {string} label
* @param {HTMLInputElement | HTMLSelectElement} control
*/
export function createField(label, control) {
const element = createElement("label", "wallets__field");
const text = createElement("span", "wallets__label");
text.append(label);
element.append(text, control);
return element;
}
+15
View File
@@ -0,0 +1,15 @@
main.wallets {
.wallets__field {
display: grid;
gap: 0.375rem;
min-width: 0;
}
.wallets__label {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
}
-168
View File
@@ -1,168 +0,0 @@
main.wallets {
.wallets__empty,
.wallets__setup,
.wallets__unlock {
display: grid;
gap: 1rem;
place-content: center;
min-height: 16rem;
text-align: center;
h2,
p {
margin: 0;
}
}
.wallets__setup {
max-width: 36rem;
margin-inline: auto;
h1 {
margin: 0;
font-family: var(--font-serif);
font-size: clamp(3rem, 8vw, 5.5rem);
font-weight: 400;
line-height: 0.9;
}
}
.wallets__setup-description {
display: grid;
gap: 0.625rem;
color: var(--gray);
font-size: var(--font-size-md);
line-height: var(--line-height-md);
}
.wallets__unlock-form,
.wallets__setup-form,
.wallets__dialog-form {
display: grid;
gap: 0.75rem;
}
.wallets__unlock-form,
.wallets__setup-form {
grid-template-columns: minmax(12rem, 18rem) auto;
align-items: end;
justify-content: center;
}
.wallets__dialog {
width: min(100% - 2rem, 30rem);
border: 1px solid color-mix(in oklch, var(--gray) 36%, transparent);
border-radius: 0.5rem;
padding: 1rem;
color: var(--white);
background: var(--black);
h2 {
margin: 0;
}
}
.wallets__dialog::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
}
.wallets__dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: end;
}
.wallets__field {
display: grid;
gap: 0.375rem;
min-width: 0;
}
.wallets__label {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
.wallets__field :is(input, select),
.wallets__setup-form input,
.wallets__unlock-form input,
.wallets__actions button,
.wallets__empty button,
.wallets__setup-form button,
.wallets__unlock-form button,
.wallets__reset,
.wallets__dialog-form button {
min-width: 0;
height: var(--control-height);
border: 1px solid color-mix(in oklch, var(--gray) 45%, transparent);
border-radius: 0.375rem;
padding: 0 0.875rem;
color: var(--white);
background: color-mix(in oklch, var(--black) 72%, var(--white));
font: inherit;
line-height: 1;
}
.wallets__field :is(input, select):focus-visible,
.wallets__setup-form input:focus-visible,
.wallets__unlock-form input:focus-visible,
.wallets__actions button:focus-visible,
.wallets__empty button:focus-visible,
.wallets__setup-form button:focus-visible,
.wallets__unlock-form button:focus-visible,
.wallets__reset:focus-visible,
.wallets__dialog-form button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.wallets__field input::placeholder,
.wallets__setup-form input::placeholder {
color: color-mix(in oklch, var(--gray) 70%, transparent);
}
.wallets__actions button,
.wallets__empty button,
.wallets__setup-form button,
.wallets__unlock-form button,
.wallets__dialog-form button[type="submit"] {
border-color: var(--orange);
color: var(--black);
background: var(--orange);
cursor: pointer;
}
.wallets__dialog-form button[type="button"] {
cursor: pointer;
}
.wallets__reset {
justify-self: center;
color: var(--gray);
background: transparent;
cursor: pointer;
}
.wallets__actions button:disabled,
.wallets__empty button:disabled,
.wallets__setup-form button:disabled,
.wallets__unlock-form button:disabled,
.wallets__reset:disabled,
.wallets__dialog-form button:disabled {
border-color: var(--gray);
color: var(--black);
background: var(--gray);
cursor: progress;
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__unlock-form,
.wallets__setup-form {
grid-template-columns: 1fr;
}
}
}
-251
View File
@@ -1,251 +0,0 @@
import { createElement } from "./dom.js";
import { formatBtc } from "./format.js";
import {
createPrivateValue,
setPrivateTitle,
setPrivateValue,
} from "./privacy-view.js";
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @param {unknown} transaction
*/
function getTransactionId(transaction) {
if (
transaction &&
typeof transaction === "object" &&
"txid" in transaction &&
typeof transaction.txid === "string"
) {
return transaction.txid;
}
return "";
}
/**
* @param {string} txid
*/
function formatTxid(txid) {
return txid.length > 16 ? `${txid.slice(0, 8)}...${txid.slice(-8)}` : txid;
}
/**
* @param {unknown} value
*/
function readSats(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
/**
* @param {unknown} output
* @param {string} address
*/
function isAddressOutput(output, address) {
return (
output &&
typeof output === "object" &&
"scriptpubkeyAddress" in output &&
output.scriptpubkeyAddress === address
);
}
/**
* @param {unknown} output
*/
function getOutputValue(output) {
if (
output &&
typeof output === "object" &&
"value" in output
) {
return readSats(output.value);
}
return 0;
}
/**
* @param {unknown} transaction
* @param {string} address
*/
function getTransactionReceived(transaction, address) {
if (
!transaction ||
typeof transaction !== "object" ||
!("vout" in transaction) ||
!Array.isArray(transaction.vout)
) {
return 0;
}
return transaction.vout.reduce((total, output) => {
return (
total + (isAddressOutput(output, address) ? getOutputValue(output) : 0)
);
}, 0);
}
/**
* @param {unknown} transaction
* @param {string} address
*/
function getTransactionSent(transaction, address) {
if (
!transaction ||
typeof transaction !== "object" ||
!("vin" in transaction) ||
!Array.isArray(transaction.vin)
) {
return 0;
}
return transaction.vin.reduce((total, input) => {
if (
!input ||
typeof input !== "object" ||
!("prevout" in input)
) {
return total;
}
const prevout = input.prevout;
return (
total + (isAddressOutput(prevout, address) ? getOutputValue(prevout) : 0)
);
}, 0);
}
/**
* @param {unknown} transaction
*/
function getTransactionFee(transaction) {
if (
transaction &&
typeof transaction === "object" &&
"fee" in transaction
) {
return readSats(transaction.fee);
}
return 0;
}
/**
* @param {number} net
*/
function getTransactionDirection(net) {
if (net > 0) return "received";
if (net < 0) return "sent";
return "moved";
}
/**
* @param {unknown} transaction
*/
function getTransactionTime(transaction) {
if (
transaction &&
typeof transaction === "object" &&
"status" in transaction &&
transaction.status &&
typeof transaction.status === "object" &&
"blockTime" in transaction.status &&
typeof transaction.status.blockTime === "number"
) {
return new Date(transaction.status.blockTime * 1_000).toLocaleDateString(
"en-US",
);
}
return "mempool";
}
/**
* @param {AddressHistory} history
* @param {string} address
*/
export function createHistoryContent(history, address) {
const element = createElement("div", "wallets__history");
const list = createElement("ol", "wallets__history-list");
for (const transaction of history.transactions) {
const item = document.createElement("li");
const txid = document.createElement("code");
const date = document.createElement("span");
const direction = document.createElement("span");
const amount = document.createElement("strong");
const fee = document.createElement("span");
const received = getTransactionReceived(transaction, address);
const sent = getTransactionSent(transaction, address);
const net = received - sent;
const id = getTransactionId(transaction);
setPrivateTitle(txid, id);
setPrivateValue(txid, formatTxid(id));
date.append(getTransactionTime(transaction));
direction.append(getTransactionDirection(net));
setPrivateValue(amount, formatBtc(Math.abs(net)), "fixed");
fee.append(
"fee ",
createPrivateValue("span", formatBtc(getTransactionFee(transaction)), "fixed"),
);
item.append(date, direction, amount, fee, txid);
list.append(item);
}
element.append(list);
return element;
}
/**
* @param {string} text
*/
export function createHistoryMessage(text) {
const element = createElement("p", "wallets__history-message");
element.append(text);
return element;
}
/**
* @param {Node} content
* @param {number} columnCount
*/
function createHistoryCell(content, columnCount) {
const cell = document.createElement("td");
cell.colSpan = columnCount;
cell.append(content);
return cell;
}
/**
* @param {Node} content
* @param {number} columnCount
*/
export function createHistoryRow(content, columnCount) {
const row = document.createElement("tr");
row.append(createHistoryCell(content, columnCount));
return row;
}
/**
* @param {HTMLTableRowElement} row
* @param {Node} content
* @param {number} columnCount
*/
export function replaceHistoryRowContent(row, content, columnCount) {
row.replaceChildren(createHistoryCell(content, columnCount));
}
+178 -274
View File
@@ -1,254 +1,200 @@
import { brk } from "../utils/client.js";
import { createGroupedAddress } from "./address-view.js";
import { renderWalletAddresses } from "./addresses-view.js";
import { createGroupedAddress } from "./wallet/address/index.js";
import {
createEmptyWalletView,
createLockedWalletView,
createSetupWalletView,
createUnlockedWalletView,
} from "./content-view.js";
import {
createElement,
setBusy,
setStatus,
withBusy,
} from "./dom.js";
import { createEmpty } from "./empty/index.js";
import { getErrorMessage } from "./errors.js";
import { createAddForm } from "./add/index.js";
import { createLayout } from "./layout/index.js";
import { createLock } from "./lock/index.js";
import { redaction } from "./redaction/index.js";
import { historyCache } from "./wallet/history/cache.js";
import { inferAddressScript } from "./add/inference.js";
import { readWalletSourceText } from "./add/source.js";
import { scanStatus } from "./wallet/status.js";
import { createSelector } from "./selector/index.js";
import { renderSettings } from "./wallet/settings/index.js";
import { createSetup } from "./setup/index.js";
import {
createAddWalletForm,
} from "./import-view.js";
import {
syncPrivacyButton,
togglePrivateValues,
} from "./privacy-view.js";
import { fetchAddressHistory } from "./privacy/address-history.js";
import { renderReceiveButton } from "./receive-view.js";
import { inferAddressScript } from "./script-inference.js";
import { readWalletSourceText } from "./wallet-source.js";
import {
addWallet,
createWalletVault,
hasStoredWallets,
loadWallets,
resetWalletVault,
updateWalletScript,
} from "./storage/wallets.js";
import {
createScanPendingMessage,
scanWalletAddresses,
} from "./scan.js";
import {
initWalletSelector,
renderWalletSelector,
} from "./selector-view.js";
import { renderWalletSettings } from "./settings-view.js";
import { renderWalletSummary } from "./summary-view.js";
createWalletPanel,
renderWalletPanel,
} from "./wallet/index.js";
import { createVault } from "./vault/index.js";
/**
* @typedef {import("./xpub/address.js").AddressScript} AddressScript
* @typedef {import("./scan.js").WalletAddress} WalletAddress
* @typedef {import("./scan.js").WalletScan} WalletScan
* @typedef {import("./storage/wallets.js").StoredWallet} StoredWallet
* @typedef {import("./derive/address.js").AddressScript} AddressScript
* @typedef {import("./scan/index.js").WalletScan} WalletScan
* @typedef {import("./vault/index.js").StoredWallet} StoredWallet
* @typedef {import("./vault/index.js").WalletRuntime} WalletRuntime
*/
/**
* @param {WalletScan} scan
* @param {HTMLElement} summary
* @param {HTMLElement} settings
* @param {HTMLElement} results
*/
function renderWalletScan(scan, summary, settings, results) {
renderWalletSummary(summary, scan.addresses, scan.btcUsdPrice);
renderReceiveButton(settings, scan.receiveAddress);
renderWalletAddresses(results, scan.addresses, {
fetchHistory(address) {
return fetchAddressHistory(brk, address);
},
getErrorMessage,
});
}
export function createWalletsPage() {
const main = createElement("main", "wallets");
const header = createElement("header", "wallets__header");
const actions = createElement("div", "wallets__actions");
const privacyButton = document.createElement("button");
const lockButton = document.createElement("button");
const selector = createElement("section", "wallets__selector");
const walletList = createElement("div", "wallets__wallet-list");
const addButton = document.createElement("button");
const content = createElement("section", "wallets__content");
const addDialog = createElement("dialog", "wallets__dialog");
/** @type {StoredWallet[]} */
let wallets = [];
let selectedWalletId = "";
let pageLocked = hasStoredWallets();
let pagePassword = "";
/** @type {Map<string, { xpub: string, scan?: WalletScan, scanPromise?: Promise<WalletScan | undefined> }>} */
const walletStates = new Map();
privacyButton.type = "button";
syncPrivacyButton(privacyButton);
lockButton.type = "button";
lockButton.append("Lock");
addButton.type = "button";
addButton.append("Add watch-only wallet");
content.setAttribute("aria-live", "polite");
walletList.setAttribute("tabindex", "0");
walletList.setAttribute("aria-label", "Wallets");
header.append(selector, actions);
actions.append(addButton, privacyButton, lockButton);
selector.append(walletList);
initWalletSelector(walletList, {
getSelectedWalletId() {
return selectedWalletId;
const {
main,
header,
addButton,
privacyButton,
lockButton,
selector: selectorElement,
walletList,
content,
addDialog,
} = createLayout();
const vault = createVault();
const selector = createSelector(walletList, {
getSelectedId() {
return vault.selectedId;
},
onSelect: selectWallet,
onSelect: select,
});
/**
* @returns {StoredWallet | undefined}
*/
function getSelectedWallet() {
return wallets.find((wallet) => wallet.id === selectedWalletId);
}
redaction.syncButton(privacyButton);
/**
* @param {string} walletId
*/
function selectWallet(walletId) {
selectedWalletId = walletId;
function select(walletId) {
vault.select(walletId);
render();
}
function lockPage() {
wallets = [];
selectedWalletId = "";
walletStates.clear();
pagePassword = "";
pageLocked = hasStoredWallets();
function lock() {
vault.lock();
render();
}
function resetWallets() {
resetWalletVault();
wallets = [];
selectedWalletId = "";
walletStates.clear();
pagePassword = "";
pageLocked = false;
function reset() {
vault.reset();
render();
}
function openAddDialog() {
addDialog.replaceChildren(createAddWalletForm({
function openAdd() {
addDialog.replaceChildren(createAddForm({
onCancel() {
addDialog.close();
},
onSubmit(submit) {
return addStoredWallet(submit);
return submitAdd(submit);
},
}));
addDialog.showModal();
}
privacyButton.addEventListener("click", () => {
togglePrivateValues(main, privacyButton, createGroupedAddress);
redaction.toggle(main, privacyButton, createGroupedAddress);
});
lockButton.addEventListener("click", () => {
lockPage();
lock();
});
addButton.addEventListener("click", () => {
openAddDialog();
openAdd();
});
function syncSelectedWallet() {
selectedWalletId = wallets.some((wallet) => wallet.id === selectedWalletId)
? selectedWalletId
: wallets[0]?.id ?? "";
}
function renderLockedWallet() {
content.replaceChildren(createLockedWalletView({
function renderLocked() {
content.replaceChildren(createLock({
onUnlock(password, button, status) {
return unlockWallet(password, button, status);
return unlock(password, button, status);
},
onReset() {
resetWallets();
reset();
},
}));
}
function renderSetupWallet() {
content.replaceChildren(createSetupWalletView({
function renderSetup() {
content.replaceChildren(createSetup({
onCreate(password, button, status) {
return setupWallet(password, button, status);
return setup(password, button, status);
},
}));
}
/**
* @param {StoredWallet} wallet
* @param {{ xpub: string, scan?: WalletScan, scanPromise?: Promise<WalletScan | undefined> }} state
* @param {WalletRuntime} runtime
*/
function renderUnlockedWallet(wallet, state) {
const view = createUnlockedWalletView();
function renderUnlocked(wallet, runtime) {
const panel = createWalletPanel();
content.replaceChildren(...view.nodes);
renderWalletSettings(view.settings, wallet, {
content.replaceChildren(...panel.nodes);
renderSettings(panel.settings, wallet, {
onScriptChange(script, select, status) {
return updateSelectedWalletScript(wallet, state, script, select, status);
return updateScript(wallet, script, select, status);
},
});
if (state.scan) {
renderWalletScan(state.scan, view.summary, view.settings, view.results);
setStatus(view.status, "Ready");
if (runtime.scan) {
renderWalletData(runtime.scan, panel);
setStatus(panel.status, "Ready");
return;
}
if (!state.scanPromise) {
state.scanPromise = scanWalletAddresses({
client: brk,
xpub: state.xpub,
start: 0,
script: wallet.script,
status: view.status,
});
} else {
setStatus(view.status, createScanPendingMessage());
}
scanStatus.setPending(panel.status);
void runtime.load({
client: brk,
script: wallet.script,
onProgress(progress) {
scanStatus.setProgress(panel.status, progress);
},
}).then((scan) => {
if (!isCurrentPanel(wallet, runtime, panel)) return;
void state.scanPromise.then((scan) => {
if (!scan || walletStates.get(wallet.id) !== state) return;
state.scan = scan;
if (pageLocked || selectedWalletId !== wallet.id || !view.results.isConnected) {
return;
renderWalletData(scan, panel);
setStatus(panel.status, "Ready");
}, (error) => {
if (isCurrentPanel(wallet, runtime, panel)) {
setStatus(panel.status, getErrorMessage(error));
}
});
}
renderWalletScan(scan, view.summary, view.settings, view.results);
setStatus(view.status, "Ready");
/**
* @param {StoredWallet} wallet
* @param {WalletRuntime} runtime
* @param {ReturnType<typeof createWalletPanel>} panel
*/
function isCurrentPanel(wallet, runtime, panel) {
return (
vault.isCurrent(wallet, runtime) &&
!vault.isLocked() &&
vault.selectedId === wallet.id &&
panel.results.isConnected
);
}
/**
* @param {WalletScan} scan
* @param {ReturnType<typeof createWalletPanel>} panel
*/
function renderWalletData(scan, panel) {
renderWalletPanel(scan, panel, {
fetchHistory(address) {
return historyCache.load(brk, address);
},
getErrorMessage,
});
}
/**
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
*/
async function unlockPageWallets(password) {
wallets = await loadWallets(password);
selectedWalletId = wallets.some((wallet) => wallet.id === selectedWalletId)
? selectedWalletId
: wallets[0]?.id ?? "";
async function unlock(password, button, status) {
await withBusy(button, "Unlock", "Unlocking", async () => {
setStatus(status, "");
walletStates.clear();
pagePassword = password;
for (const wallet of wallets) {
walletStates.set(wallet.id, { xpub: wallet.xpub });
}
try {
await vault.unlock(password);
render();
} catch {
setStatus(status, "Invalid password");
}
});
}
/**
@@ -256,64 +202,38 @@ export function createWalletsPage() {
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
*/
async function unlockWallet(password, button, status) {
setBusy(button, true, "Unlock", "Unlocking");
setStatus(status, "");
async function setup(password, button, status) {
await withBusy(button, "Continue", "Creating", async () => {
setStatus(status, "");
try {
await unlockPageWallets(password);
pageLocked = false;
render();
} catch {
setStatus(status, "Invalid password");
} finally {
setBusy(button, false, "Unlock", "Unlocking");
}
}
/**
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
*/
async function setupWallet(password, button, status) {
setBusy(button, true, "Continue", "Creating");
setStatus(status, "");
try {
await createWalletVault(password);
wallets = [];
selectedWalletId = "";
walletStates.clear();
pagePassword = password;
pageLocked = false;
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
} finally {
setBusy(button, false, "Continue", "Creating");
}
try {
await vault.setup(password);
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
}
});
}
/**
* @param {StoredWallet} wallet
* @param {{ xpub: string, scan?: WalletScan, scanPromise?: Promise<WalletScan | undefined> }} state
* @param {AddressScript} script
* @param {HTMLSelectElement} select
* @param {HTMLElement} status
*/
async function updateSelectedWalletScript(wallet, state, script, select, status) {
async function updateScript(
wallet,
script,
select,
status,
) {
if (script === wallet.script) return;
select.disabled = true;
setStatus(status, "Saving");
try {
wallets = await updateWalletScript(wallets, {
walletId: wallet.id,
script,
}, pagePassword);
walletStates.set(wallet.id, { xpub: state.xpub });
await vault.updateWalletScript(wallet, script);
render();
} catch (error) {
select.value = wallet.script;
@@ -323,104 +243,88 @@ export function createWalletsPage() {
}
}
function renderSelectedWallet() {
const hasVault = hasStoredWallets();
const setup = !hasVault && !pagePassword;
const locked = pageLocked && hasVault;
const wallet = getSelectedWallet();
const state = wallet ? walletStates.get(wallet.id) : undefined;
function renderContent() {
const needsSetup = vault.needsSetup();
const locked = vault.isLocked();
const current = vault.current();
const empty = !needsSetup && !locked && !current;
main.toggleAttribute("data-wallets-page-locked", locked || setup);
header.hidden = locked || setup;
selector.hidden = locked || setup;
lockButton.hidden = locked || setup || !pagePassword;
main.toggleAttribute("data-wallets-page-locked", locked || needsSetup);
main.toggleAttribute("data-wallets-page-empty", empty);
header.hidden = locked || needsSetup || empty;
selectorElement.hidden = locked || needsSetup || empty;
lockButton.hidden = locked || needsSetup || !vault.hasPassword;
if (setup) {
renderSetupWallet();
if (needsSetup) {
renderSetup();
return;
}
if (locked) {
renderLockedWallet();
renderLocked();
return;
}
if (!wallet) {
content.replaceChildren(createEmptyWalletView({
if (!current) {
content.replaceChildren(createEmpty({
onAdd() {
openAddDialog();
openAdd();
},
}));
return;
}
if (state) {
renderUnlockedWallet(wallet, state);
return;
}
renderLockedWallet();
renderUnlocked(current.wallet, current.runtime);
}
function render() {
syncSelectedWallet();
if (pageLocked && hasStoredWallets()) {
walletList.replaceChildren();
if (vault.isLocked()) {
selector.clear();
} else {
renderWalletSelector(walletList, {
wallets,
selectedWalletId,
onSelect: selectWallet,
});
selector.render(vault.wallets);
}
renderSelectedWallet();
renderContent();
}
/**
* @param {Object} options
* @param {HTMLInputElement} options.name
* @param {HTMLInputElement} options.xpub
* @param {HTMLInputElement} options.source
* @param {HTMLButtonElement} options.submit
* @param {HTMLElement} options.status
* @param {HTMLFormElement} options.form
*/
async function addStoredWallet({
async function submitAdd({
name,
xpub,
source,
submit,
status,
form,
}) {
setBusy(submit, true, "Add", "Adding");
setStatus(status, "Checking address type");
await withBusy(submit, "Add", "Adding", async () => {
setStatus(status, "Checking address type");
try {
const value = readWalletSourceText(xpub.value);
const script = await inferAddressScript(brk, value);
try {
const value = readWalletSourceText(source.value);
const script = await inferAddressScript(brk, value);
setStatus(status, "Saving");
setStatus(status, "Saving");
const added = await addWallet(wallets, {
name: name.value,
xpub: value,
script,
}, pagePassword);
await vault.addWallet({
name: name.value,
source: value,
script,
});
form.reset();
addDialog.close();
wallets = added.wallets;
selectedWalletId = added.wallet.id;
pageLocked = false;
walletStates.set(added.wallet.id, { xpub: added.wallet.xpub });
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
} finally {
setBusy(submit, false, "Add", "Adding");
}
form.reset();
addDialog.close();
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
}
});
}
main.append(header, selector, content, addDialog);
render();
return main;
+55
View File
@@ -0,0 +1,55 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} WalletsLayout
* @property {HTMLElement} main
* @property {HTMLElement} header
* @property {HTMLButtonElement} addButton
* @property {HTMLButtonElement} privacyButton
* @property {HTMLButtonElement} lockButton
* @property {HTMLElement} selector
* @property {HTMLElement} walletList
* @property {HTMLElement} content
* @property {HTMLDialogElement} addDialog
*/
/**
* @returns {WalletsLayout}
*/
export function createLayout() {
const main = createElement("main", "wallets");
const header = createElement("header", "wallets__header");
const actions = createElement("div", "wallets__actions");
const addButton = document.createElement("button");
const privacyButton = document.createElement("button");
const lockButton = document.createElement("button");
const selector = createElement("section", "wallets__selector");
const walletList = createElement("div", "wallets__wallet-list");
const content = createElement("section", "wallets__content");
const addDialog = createElement("dialog", "wallets__dialog");
addButton.type = "button";
addButton.append("Add watch-only wallet");
privacyButton.type = "button";
lockButton.type = "button";
lockButton.append("Lock");
content.setAttribute("aria-live", "polite");
walletList.setAttribute("tabindex", "0");
walletList.setAttribute("aria-label", "Wallets");
actions.append(addButton, privacyButton, lockButton);
header.append(actions);
selector.append(walletList);
main.append(header, selector, content, addDialog);
return {
main,
header,
addButton,
privacyButton,
lockButton,
selector,
walletList,
content,
addDialog,
};
}
+32
View File
@@ -0,0 +1,32 @@
main.wallets {
.wallets__header {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
.wallets__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: end;
}
.wallets__content {
display: grid;
gap: 1.5rem;
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__header {
justify-content: start;
}
.wallets__actions {
justify-content: start;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} LockOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onUnlock
* @property {() => void} onReset
*/
/**
* @param {LockOptions} options
*/
export function createLock(options) {
const section = createElement("section", "wallets__unlock");
const form = createElement("form", "wallets__unlock-form");
const password = document.createElement("input");
const button = document.createElement("button");
const reset = document.createElement("button");
const status = createElement("p", "wallets__status");
password.name = "password";
password.type = "password";
password.autocomplete = "current-password";
password.placeholder = "Password";
password.required = true;
button.type = "submit";
button.append("Unlock");
reset.type = "button";
reset.className = "wallets__reset";
reset.append("Reset vault");
status.setAttribute("role", "status");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onUnlock(password.value, button, status);
});
reset.addEventListener("click", options.onReset);
section.append(form, reset, status);
return section;
}
+36
View File
@@ -0,0 +1,36 @@
main.wallets {
.wallets__unlock {
display: grid;
gap: 1rem;
place-content: center;
min-height: 16rem;
text-align: center;
h2,
p {
margin: 0;
}
}
.wallets__unlock-form {
display: grid;
grid-template-columns: minmax(12rem, 18rem) auto;
gap: 0.75rem;
align-items: end;
justify-content: center;
}
.wallets__reset {
justify-self: center;
color: var(--gray);
background: transparent;
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__unlock-form {
grid-template-columns: 1fr;
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import { rapidHashV3Prefix } from "./hash.js";
const MIN_PREFIX_NIBBLES = 4;
const MAX_PREFIX_NIBBLES = 16;
/**
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("../derive/index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {Object} AddrHashPrefixMatches
* @property {AddressType} addrType
* @property {string} prefix
* @property {boolean} truncated
* @property {string[]} addresses
*/
/**
* @typedef {Object} AddressClient
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @param {number} nibbles
* @returns {Promise<AddrHashPrefixMatches>}
*/
async function fetchPrefixMatches(client, generated, nibbles) {
const prefix = rapidHashV3Prefix(generated.payload, nibbles);
return /** @type {AddrHashPrefixMatches} */ (
await client.getAddressHashPrefixMatches(generated.addrType, prefix, {
cache: false,
})
);
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @returns {Promise<AddrHashPrefixMatches>}
*/
export async function findUsablePrefixBucket(client, generated) {
for (
let nibbles = MIN_PREFIX_NIBBLES;
nibbles <= MAX_PREFIX_NIBBLES;
nibbles += 1
) {
const matches = await fetchPrefixMatches(client, generated, nibbles);
if (matches.truncated) continue;
return matches;
}
throw new Error("Address prefix bucket is too large");
}
@@ -65,7 +65,7 @@ function readU64(bytes, offset) {
/**
* @param {Uint8Array} bytes
*/
export function rapidHashV3(bytes) {
function rapidHashV3(bytes) {
const length = bytes.length;
if (length <= 16) {
@@ -101,7 +101,7 @@ export function rapidHashV3(bytes) {
/**
* @param {Uint8Array} bytes
*/
export function rapidHashV3Hex(bytes) {
function rapidHashV3Hex(bytes) {
return rapidHashV3(bytes).toString(16).padStart(16, "0");
}
@@ -1,58 +1,28 @@
import { mapConcurrent } from "../concurrent.js";
import { rapidHashV3Prefix } from "./rapidhash.js";
import {
getAddressReceived,
getAddressSent,
getAddressTxCount,
} from "./stats.js";
import { findUsablePrefixBucket } from "./bucket.js";
const MIN_PREFIX_NIBBLES = 4;
const MAX_PREFIX_NIBBLES = 16;
const LOOKUP_CONCURRENCY = 8;
/**
* @typedef {import("../xpub/index.js").AddressType} AddressType
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("../derive/index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {Object} GeneratedAddress
* @property {number} index
* @property {string} address
* @property {Uint8Array} payload
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
*/
/**
* @typedef {Object} AddressStatsPart
* @property {number} fundedTxoSum
* @property {number} spentTxoSum
* @property {number} txCount
*/
/**
* @typedef {AddressStatsPart & {
* typeIndex: number,
* }} AddressChainStats
*/
/**
* @typedef {Object} AddressStats
* @property {string} address
* @property {AddressChainStats} chainStats
* @property {AddressStatsPart} mempoolStats
*/
/**
* @typedef {Object} AddrHashPrefixMatches
* @property {AddressType} addrType
* @property {string} prefix
* @property {boolean} truncated
* @property {string[]} addresses
* @typedef {import("./stats.js").AddressStats} AddressStats
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} script
* @property {string} network
* @property {GeneratedAddress["script"]} script
* @property {GeneratedAddress["network"]} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
@@ -69,27 +39,6 @@ const LOOKUP_CONCURRENCY = 8;
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @param {AddressStats} stats
*/
function getReceived(stats) {
return stats.chainStats.fundedTxoSum + stats.mempoolStats.fundedTxoSum;
}
/**
* @param {AddressStats} stats
*/
function getSent(stats) {
return stats.chainStats.spentTxoSum + stats.mempoolStats.spentTxoSum;
}
/**
* @param {AddressStats} stats
*/
function getTxCount(stats) {
return stats.chainStats.txCount + stats.mempoolStats.txCount;
}
/**
* @param {GeneratedAddress} generated
* @param {number} historyBucketSize
@@ -125,8 +74,8 @@ function createWalletAddress(
historyAddresses,
historyBucketSize,
) {
const received = getReceived(stats);
const sent = getSent(stats);
const received = getAddressReceived(stats);
const sent = getAddressSent(stats);
return {
index: generated.index,
@@ -137,50 +86,13 @@ function createWalletAddress(
balance: received - sent,
received,
sent,
txCount: getTxCount(stats),
txCount: getAddressTxCount(stats),
typeIndex: stats.chainStats.typeIndex,
historyAddresses: [...historyAddresses],
historyBucketSize,
};
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @param {number} nibbles
* @returns {Promise<AddrHashPrefixMatches>}
*/
async function fetchPrefixMatches(client, generated, nibbles) {
const prefix = rapidHashV3Prefix(generated.payload, nibbles);
return /** @type {AddrHashPrefixMatches} */ (
await client.getAddressHashPrefixMatches(generated.addrType, prefix, {
cache: false,
})
);
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @returns {Promise<AddrHashPrefixMatches>}
*/
async function findUsableBucket(client, generated) {
for (
let nibbles = MIN_PREFIX_NIBBLES;
nibbles <= MAX_PREFIX_NIBBLES;
nibbles += 1
) {
const matches = await fetchPrefixMatches(client, generated, nibbles);
if (matches.truncated) continue;
return matches;
}
throw new Error("Address prefix bucket is too large");
}
/**
* @param {AddressClient} client
* @param {readonly string[]} addresses
@@ -208,7 +120,7 @@ async function fetchBucketMetadata(client, addresses, cache) {
* @returns {Promise<WalletAddress>}
*/
async function fetchWalletAddress(client, generated, metadataCache) {
const matches = await findUsableBucket(client, generated);
const matches = await findUsablePrefixBucket(client, generated);
if (!matches.addresses.includes(generated.address)) {
return createEmptyWalletAddress(generated, matches.addresses.length);
@@ -227,7 +139,7 @@ async function fetchWalletAddress(client, generated, metadataCache) {
for (const address of matches.addresses) {
const bucketStats = await metadataCache.get(address);
if (bucketStats && getTxCount(bucketStats) > 0) {
if (bucketStats && getAddressTxCount(bucketStats) > 0) {
historyAddresses.push(address);
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @typedef {Object} AddressStatsPart
* @property {number} fundedTxoSum
* @property {number} spentTxoSum
* @property {number} txCount
*/
/**
* @typedef {AddressStatsPart & {
* typeIndex: number,
* }} AddressChainStats
*/
/**
* @typedef {Object} AddressStats
* @property {string} address
* @property {AddressChainStats} chainStats
* @property {AddressStatsPart} mempoolStats
*/
/**
* @param {AddressStats} stats
*/
export function getAddressReceived(stats) {
return stats.chainStats.fundedTxoSum + stats.mempoolStats.fundedTxoSum;
}
/**
* @param {AddressStats} stats
*/
export function getAddressSent(stats) {
return stats.chainStats.spentTxoSum + stats.mempoolStats.spentTxoSum;
}
/**
* @param {AddressStats} stats
*/
export function getAddressTxCount(stats) {
return stats.chainStats.txCount + stats.mempoolStats.txCount;
}
@@ -1,15 +1,15 @@
const FIXED_PRIVATE_TEXT = "*****";
let privateValuesHidden = false;
let hidden = false;
export function arePrivateValuesHidden() {
return privateValuesHidden;
function isHidden() {
return hidden;
}
/**
* @param {string} value
*/
export function createPrivateText(value) {
function createText(value) {
return [...value].map((character) => {
return character === " " ? " " : "*";
}).join("");
@@ -19,8 +19,8 @@ export function createPrivateText(value) {
* @param {string} value
* @param {string | null} mode
*/
function maskPrivateText(value, mode) {
return mode === "fixed" ? FIXED_PRIVATE_TEXT : createPrivateText(value);
function mask(value, mode) {
return mode === "fixed" ? FIXED_PRIVATE_TEXT : createText(value);
}
/**
@@ -28,19 +28,21 @@ function maskPrivateText(value, mode) {
* @param {string} value
* @param {"exact" | "fixed"} [mode]
*/
export function setPrivateValue(element, value, mode = "exact") {
function setValue(element, value, mode = "exact") {
element.setAttribute("data-wallets-private-value", value);
element.setAttribute("data-wallets-private-mode", mode);
element.textContent = privateValuesHidden ? maskPrivateText(value, mode) : value;
element.textContent = hidden
? mask(value, mode)
: value;
}
/**
* @param {HTMLElement} element
* @param {string} value
*/
export function setPrivateTitle(element, value) {
function setTitle(element, value) {
element.setAttribute("data-wallets-private-title", value);
element.title = privateValuesHidden ? createPrivateText(value) : value;
element.title = hidden ? createText(value) : value;
}
/**
@@ -49,10 +51,10 @@ export function setPrivateTitle(element, value) {
* @param {string} value
* @param {"exact" | "fixed"} [mode]
*/
export function createPrivateValue(tag, value, mode = "exact") {
function createValue(tag, value, mode = "exact") {
const element = document.createElement(tag);
setPrivateValue(element, value, mode);
setValue(element, value, mode);
return element;
}
@@ -61,7 +63,7 @@ export function createPrivateValue(tag, value, mode = "exact") {
* @param {HTMLElement} root
* @param {(text: string) => HTMLElement} createAddress
*/
export function syncPrivateValues(root, createAddress) {
function sync(root, createAddress) {
const values = root.querySelectorAll("[data-wallets-private-value]");
const titles = root.querySelectorAll("[data-wallets-private-title]");
const addresses = root.querySelectorAll("[data-wallets-private-address]");
@@ -71,8 +73,8 @@ export function syncPrivateValues(root, createAddress) {
const text = value.getAttribute("data-wallets-private-value") ?? "";
const mode = value.getAttribute("data-wallets-private-mode");
value.textContent = privateValuesHidden
? maskPrivateText(text, mode)
value.textContent = hidden
? mask(text, mode)
: text;
}
@@ -80,21 +82,21 @@ export function syncPrivateValues(root, createAddress) {
const title = /** @type {HTMLElement} */ (element);
const text = title.getAttribute("data-wallets-private-title") ?? "";
title.title = privateValuesHidden
? createPrivateText(text)
title.title = hidden
? createText(text)
: text;
}
for (const address of addresses) {
const text = address.getAttribute("data-wallets-private-address") ?? "";
const next = privateValuesHidden ? createPrivateText(text) : text;
const next = hidden ? createText(text) : text;
address.replaceChildren(...createAddress(next).childNodes);
}
for (const input of inputs) {
if (input instanceof HTMLInputElement) {
input.type = privateValuesHidden ? "password" : "text";
input.type = hidden ? "password" : "text";
}
}
}
@@ -102,9 +104,9 @@ export function syncPrivateValues(root, createAddress) {
/**
* @param {HTMLButtonElement} button
*/
export function syncPrivacyButton(button) {
button.textContent = privateValuesHidden ? "Reveal" : "Privacy";
button.setAttribute("aria-pressed", privateValuesHidden ? "true" : "false");
function syncButton(button) {
button.textContent = hidden ? "Reveal" : "Privacy";
button.setAttribute("aria-pressed", hidden ? "true" : "false");
}
/**
@@ -112,8 +114,18 @@ export function syncPrivacyButton(button) {
* @param {HTMLButtonElement} button
* @param {(text: string) => HTMLElement} createAddress
*/
export function togglePrivateValues(root, button, createAddress) {
privateValuesHidden = !privateValuesHidden;
syncPrivateValues(root, createAddress);
syncPrivacyButton(button);
function toggle(root, button, createAddress) {
hidden = !hidden;
sync(root, createAddress);
syncButton(button);
}
export const redaction = /** @type {const} */ ({
isHidden,
createText,
setValue,
setTitle,
createValue,
syncButton,
toggle,
});
-89
View File
@@ -1,89 +0,0 @@
import {
setBusy,
setStatus,
} from "./dom.js";
import { getErrorMessage } from "./errors.js";
import { formatNumber } from "./format.js";
import {
scanXpubBranches,
} from "./privacy/xpub-wallet.js";
import { XPUB_GAP_LIMIT } from "./privacy/xpub-scan.js";
/**
* @typedef {import("./xpub/address.js").AddressScript} AddressScript
* @typedef {import("./xpub/index.js").AddressType} AddressType
* @typedef {Awaited<ReturnType<typeof scanXpubBranches>>["addresses"][number]} WalletAddress
*/
/**
* @typedef {Object} WalletScan
* @property {WalletAddress[]} addresses
* @property {WalletAddress | undefined} receiveAddress
* @property {number} btcUsdPrice
*/
/**
* @typedef {Object} WalletScanClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
* @property {(options?: { cache?: boolean }) => Promise<unknown>} getLivePrice
*/
export function createScanPendingMessage() {
return `Scanning until ${XPUB_GAP_LIMIT} unused addresses`;
}
/**
* @param {Object} options
* @param {WalletScanClient} options.client
* @param {string} options.xpub
* @param {number} options.start
* @param {AddressScript} options.script
* @param {HTMLButtonElement} [options.button]
* @param {HTMLElement} options.status
* @returns {Promise<WalletScan | undefined>}
*/
export async function scanWalletAddresses({
client,
xpub,
start,
script,
button,
status,
}) {
if (button) {
setBusy(button, true, "Scan", "Scanning");
}
setStatus(status, createScanPendingMessage());
try {
const scan = await scanXpubBranches(client, xpub, {
start,
script,
onProgress(progress) {
setStatus(
status,
`${progress.branchLabel}: scanned ${formatNumber(progress.scannedCount)} addresses, ${progress.unusedInRow}/${XPUB_GAP_LIMIT} unused`,
);
},
});
const addresses = /** @type {WalletAddress[]} */ (scan.addresses);
const btcUsdPrice = /** @type {number} */ (
await client.getLivePrice({ cache: false })
);
setStatus(status, "Ready");
return {
addresses,
receiveAddress: scan.receiveAddress,
btcUsdPrice,
};
} catch (error) {
setStatus(status, getErrorMessage(error));
} finally {
if (button) {
setBusy(button, false, "Scan", "Scanning");
}
}
}
@@ -1,14 +1,15 @@
import { fetchWalletAddresses } from "./address-lookup.js";
import { generateAddressesFromWalletSource } from "../xpub/index.js";
import { fetchWalletAddresses } from "../lookup/index.js";
import { generateAddressesFromWalletSource } from "../derive/index.js";
export const XPUB_GAP_LIMIT = 10;
export const GAP_LIMIT = 10;
const SCAN_BATCH_SIZE = XPUB_GAP_LIMIT;
const SCAN_BATCH_SIZE = GAP_LIMIT;
const MAX_SCANNED_ADDRESSES = 1_000;
/**
* @typedef {import("../xpub/address.js").AddressScript} AddressScript
* @typedef {import("../xpub/index.js").AddressType} AddressType
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("../lookup/index.js").WalletAddress} WalletAddress
*/
/**
@@ -17,22 +18,6 @@ const MAX_SCANNED_ADDRESSES = 1_000;
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
* @property {number} sent
* @property {number} txCount
* @property {number | undefined} typeIndex
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} ScanProgress
* @property {number} scannedCount
@@ -40,8 +25,7 @@ const MAX_SCANNED_ADDRESSES = 1_000;
*/
/**
* @typedef {Object} ScanXpubOptions
* @property {number} start
* @typedef {Object} ScanOptions
* @property {AddressScript} script
* @property {readonly number[]} path
* @property {string} [branchId]
@@ -49,7 +33,7 @@ const MAX_SCANNED_ADDRESSES = 1_000;
*/
/**
* @typedef {Object} ScanXpubResult
* @typedef {Object} ScanResult
* @property {WalletAddress[]} addresses
* @property {number} scannedCount
* @property {number} gapLimit
@@ -65,25 +49,25 @@ function isUsedAddress(address) {
/**
* @param {AddressClient} client
* @param {string} xpub
* @param {ScanXpubOptions} options
* @returns {Promise<ScanXpubResult>}
* @param {string} source
* @param {ScanOptions} options
* @returns {Promise<ScanResult>}
*/
export async function scanXpubWallet(client, xpub, options) {
export async function scanBranch(client, source, options) {
const addresses = /** @type {WalletAddress[]} */ ([]);
let unusedInRow = 0;
let nextStart = options.start;
let nextStart = 0;
while (
unusedInRow < XPUB_GAP_LIMIT &&
unusedInRow < GAP_LIMIT &&
addresses.length < MAX_SCANNED_ADDRESSES
) {
const count = Math.min(
SCAN_BATCH_SIZE,
XPUB_GAP_LIMIT - unusedInRow,
GAP_LIMIT - unusedInRow,
MAX_SCANNED_ADDRESSES - addresses.length,
);
const generated = await generateAddressesFromWalletSource(xpub, {
const generated = await generateAddressesFromWalletSource(source, {
start: nextStart,
count,
script: options.script,
@@ -98,7 +82,7 @@ export async function scanXpubWallet(client, xpub, options) {
addresses.push(address);
unusedInRow = isUsedAddress(address) ? 0 : unusedInRow + 1;
if (unusedInRow >= XPUB_GAP_LIMIT) {
if (unusedInRow >= GAP_LIMIT) {
break;
}
}
@@ -113,7 +97,7 @@ export async function scanXpubWallet(client, xpub, options) {
return {
addresses,
scannedCount: addresses.length,
gapLimit: XPUB_GAP_LIMIT,
gapLimit: GAP_LIMIT,
maxed: addresses.length >= MAX_SCANNED_ADDRESSES,
};
}
@@ -1,33 +1,32 @@
import {
scanXpubWallet,
XPUB_GAP_LIMIT,
} from "./xpub-scan.js";
scanBranch,
GAP_LIMIT,
} from "./branch.js";
import {
getOutputDescriptorBranchIds,
isOutputDescriptor,
} from "../xpub/index.js";
} from "../derive/index.js";
export const xpubWalletBranches = /** @type {const} */ ([
const keyBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [0] },
{ id: "change", label: "Change", path: [1] },
{ id: "direct", label: "Direct", path: [] },
]);
const descriptorWalletBranches = /** @type {const} */ ([
const descriptorBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [] },
{ id: "change", label: "Change", path: [] },
]);
const UNKNOWN_TYPE_INDEX = Number.MAX_SAFE_INTEGER;
/**
* @typedef {(typeof xpubWalletBranches[number] | typeof descriptorWalletBranches[number])} WalletBranch
* @typedef {(typeof keyBranches[number] | typeof descriptorBranches[number])} WalletBranch
* @typedef {WalletBranch["id"]} WalletBranchId
*/
/**
* @typedef {import("../xpub/address.js").AddressScript} AddressScript
* @typedef {import("../xpub/index.js").AddressType} AddressType
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {import("./branch.js").WalletAddress} WalletAddress
*/
/**
@@ -36,27 +35,11 @@ const UNKNOWN_TYPE_INDEX = Number.MAX_SAFE_INTEGER;
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
* @property {number} sent
* @property {number} txCount
* @property {number | undefined} typeIndex
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {WalletAddress & {
* branchId: WalletBranchId,
* branchLabel: string,
* }} XpubWalletAddress
* }} ScannedAddress
*/
/**
@@ -68,16 +51,15 @@ const UNKNOWN_TYPE_INDEX = Number.MAX_SAFE_INTEGER;
*/
/**
* @typedef {Object} ScanXpubWalletOptions
* @property {number} start
* @typedef {Object} ScanBranchesOptions
* @property {AddressScript} script
* @property {(progress: ScanProgress) => void} [onProgress]
*/
/**
* @typedef {Object} ScanXpubWalletResult
* @property {XpubWalletAddress[]} addresses
* @property {XpubWalletAddress | undefined} receiveAddress
* @typedef {Object} ScanBranchesResult
* @property {ScannedAddress[]} addresses
* @property {ScannedAddress | undefined} receiveAddress
* @property {number} gapLimit
* @property {boolean} maxed
*/
@@ -90,24 +72,20 @@ function isUsedAddress(address) {
}
/**
* @param {XpubWalletAddress} address
*/
function getSortIndex(address) {
return address.typeIndex ?? UNKNOWN_TYPE_INDEX;
}
/**
* @param {XpubWalletAddress} a
* @param {XpubWalletAddress} b
* @param {ScannedAddress} a
* @param {ScannedAddress} b
*/
function compareWalletAddresses(a, b) {
return getSortIndex(a) - getSortIndex(b) || a.index - b.index;
if (a.typeIndex === undefined) return 1;
if (b.typeIndex === undefined) return -1;
return b.typeIndex - a.typeIndex || a.index - b.index;
}
/**
* @param {WalletAddress} address
* @param {WalletBranch} branch
* @returns {XpubWalletAddress}
* @returns {ScannedAddress}
*/
function addBranch(address, branch) {
return {
@@ -118,37 +96,36 @@ function addBranch(address, branch) {
}
/**
* @param {string} xpub
* @param {string} source
*/
function getWalletBranches(xpub) {
if (!isOutputDescriptor(xpub)) return xpubWalletBranches;
function getWalletBranches(source) {
if (!isOutputDescriptor(source)) return keyBranches;
const branchIds = new Set(getOutputDescriptorBranchIds(xpub));
const branches = descriptorWalletBranches.filter((branch) => {
const branchIds = new Set(getOutputDescriptorBranchIds(source));
const branches = descriptorBranches.filter((branch) => {
return branchIds.has(branch.id);
});
return branches.length ? branches : [descriptorWalletBranches[0]];
return branches.length ? branches : [descriptorBranches[0]];
}
/**
* @param {AddressClient} client
* @param {string} xpub
* @param {ScanXpubWalletOptions} options
* @returns {Promise<ScanXpubWalletResult>}
* @param {string} source
* @param {ScanBranchesOptions} options
* @returns {Promise<ScanBranchesResult>}
*/
export async function scanXpubBranches(client, xpub, options) {
const addresses = /** @type {XpubWalletAddress[]} */ ([]);
const branches = getWalletBranches(xpub);
export async function scanBranches(client, source, options) {
const addresses = /** @type {ScannedAddress[]} */ ([]);
const branches = getWalletBranches(source);
const receiveBranch =
branches.find((branch) => branch.id === "receive") ?? branches[0];
/** @type {XpubWalletAddress | undefined} */
/** @type {ScannedAddress | undefined} */
let receiveAddress;
let maxed = false;
for (const branch of branches) {
const scan = await scanXpubWallet(client, xpub, {
start: options.start,
const scan = await scanBranch(client, source, {
script: options.script,
path: branch.path,
branchId: branch.id,
@@ -181,7 +158,7 @@ export async function scanXpubBranches(client, xpub, options) {
return {
addresses: addresses.sort(compareWalletAddresses),
receiveAddress,
gapLimit: XPUB_GAP_LIMIT,
gapLimit: GAP_LIMIT,
maxed,
};
}
+58
View File
@@ -0,0 +1,58 @@
import { scanBranches } from "./branches.js";
/**
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {import("../derive/index.js").AddressType} AddressType
* @typedef {Awaited<ReturnType<typeof scanBranches>>["addresses"][number]} WalletAddress
*/
/**
* @typedef {Object} WalletScan
* @property {WalletAddress[]} addresses
* @property {WalletAddress | undefined} receiveAddress
* @property {number} btcUsdPrice
*/
/**
* @typedef {Object} WalletScanClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
* @property {(options?: { cache?: boolean }) => Promise<unknown>} getLivePrice
*/
/**
* @typedef {Object} WalletScanProgress
* @property {string} branchLabel
* @property {number} scannedCount
* @property {number} unusedInRow
*/
/**
* @param {Object} options
* @param {WalletScanClient} options.client
* @param {string} options.source
* @param {AddressScript} options.script
* @param {(progress: WalletScanProgress) => void} [options.onProgress]
* @returns {Promise<WalletScan>}
*/
export async function scanWalletAddresses({
client,
source,
script,
onProgress,
}) {
const scan = await scanBranches(client, source, {
script,
onProgress,
});
const addresses = /** @type {WalletAddress[]} */ (scan.addresses);
const btcUsdPrice = /** @type {number} */ (
await client.getLivePrice({ cache: false })
);
return {
addresses,
receiveAddress: scan.receiveAddress,
btcUsdPrice,
};
}
-49
View File
@@ -1,49 +0,0 @@
import { addressScripts } from "./address-scripts.js";
import { fetchWalletAddresses } from "./privacy/address-lookup.js";
import {
generateAddressesFromXpub,
isOutputDescriptor,
} from "./xpub/index.js";
import { parseOutputDescriptor } from "./xpub/descriptor.js";
const RECEIVE_PATH = /** @type {const} */ ([0]);
/**
* @typedef {import("./xpub/address.js").AddressScript} AddressScript
* @typedef {import("./privacy/xpub-scan.js").AddressClient} AddressClient
* @typedef {import("./privacy/address-lookup.js").WalletAddress} WalletAddress
*/
/**
* @param {WalletAddress} address
*/
function hasHistory(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {AddressClient} client
* @param {string} xpub
* @returns {Promise<AddressScript>}
*/
export async function inferAddressScript(client, xpub) {
if (isOutputDescriptor(xpub)) {
return parseOutputDescriptor(xpub).script;
}
for (const { id } of addressScripts) {
const generated = await generateAddressesFromXpub(xpub, {
start: 0,
count: 1,
script: id,
path: RECEIVE_PATH,
});
const [address] = await fetchWalletAddresses(client, generated);
if (address && hasHistory(address)) {
return id;
}
}
return addressScripts[0].id;
}
@@ -5,28 +5,22 @@
*/
/**
* @typedef {Object} WalletSelectorRenderOptions
* @property {StoredWallet[]} wallets
* @property {string} selectedWalletId
* @property {(walletId: string) => void} onSelect
*/
/**
* @typedef {Object} WalletSelectorInteractionOptions
* @property {() => string} getSelectedWalletId
* @typedef {Object} WalletSelectorOptions
* @property {() => string} getSelectedId
* @property {(walletId: string) => void} onSelect
*/
/**
* @param {HTMLElement} walletList
* @param {WalletSelectorRenderOptions} options
* @param {StoredWallet[]} wallets
* @param {WalletSelectorOptions} options
*/
export function renderWalletSelector(walletList, options) {
function renderButtons(walletList, wallets, options) {
walletList.replaceChildren();
for (const wallet of options.wallets) {
for (const wallet of wallets) {
const button = document.createElement("button");
const selected = wallet.id === options.selectedWalletId;
const selected = wallet.id === options.getSelectedId();
button.type = "button";
button.className = "wallets__wallet-button";
@@ -42,9 +36,9 @@ export function renderWalletSelector(walletList, options) {
/**
* @param {HTMLElement} walletList
* @param {WalletSelectorInteractionOptions} options
* @param {WalletSelectorOptions} options
*/
export function initWalletSelector(walletList, options) {
export function createSelector(walletList, options) {
function selectSnappedWallet() {
const buttons = [...walletList.querySelectorAll(".wallets__wallet-button")];
@@ -66,7 +60,7 @@ export function initWalletSelector(walletList, options) {
});
const id = closest.button.getAttribute("data-wallet-id");
if (id && id !== options.getSelectedWalletId()) {
if (id && id !== options.getSelectedId()) {
options.onSelect(id);
}
}
@@ -93,4 +87,16 @@ export function initWalletSelector(walletList, options) {
event.preventDefault();
walletList.scrollLeft = nextScrollLeft;
}, { passive: false });
return {
clear() {
walletList.replaceChildren();
},
/**
* @param {StoredWallet[]} wallets
*/
render(wallets) {
renderButtons(walletList, wallets, options);
},
};
}
+62
View File
@@ -0,0 +1,62 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} SetupOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onCreate
*/
/**
* @param {string} text
*/
function createDescriptionText(text) {
const paragraph = document.createElement("p");
paragraph.append(text);
return paragraph;
}
/**
* @param {SetupOptions} options
*/
export function createSetup(options) {
const section = createElement("section", "wallets__setup");
const title = document.createElement("h1");
const description = createElement("div", "wallets__setup-description");
const form = createElement("form", "wallets__setup-form");
const password = document.createElement("input");
const button = document.createElement("button");
const status = createElement("p", "wallets__status");
title.append("Wallets");
description.append(
createDescriptionText(
"A privacy-preserving xpub viewer that runs in your browser and never uploads your xpub.",
),
createDescriptionText(
"Import an xpub or watch-only descriptor to view a Bitcoin wallet without spending access.",
),
createDescriptionText(
"Addresses are derived locally, checked through prefix buckets, and saved encrypted in this browser.",
),
createDescriptionText(
"Privacy benefits can be drastically reduced if those addresses are already linked together on-chain.",
),
);
password.name = "password";
password.type = "password";
password.autocomplete = "new-password";
password.placeholder = "Set password";
password.required = true;
button.type = "submit";
button.append("Continue");
status.setAttribute("role", "status");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onCreate(password.value, button, status);
});
section.append(title, description, form, status);
return section;
}
+63
View File
@@ -0,0 +1,63 @@
main.wallets {
.wallets__setup {
display: grid;
gap: 1rem;
place-content: center;
max-width: 36rem;
min-height: 16rem;
margin-inline: auto;
text-align: center;
h1 {
margin: 0;
font-family: var(--font-serif);
font-size: 5rem;
font-weight: 400;
line-height: 0.9;
}
p {
margin: 0;
}
}
.wallets__setup-description {
display: grid;
gap: 0.625rem;
color: var(--gray);
font-size: var(--font-size-md);
line-height: var(--line-height-md);
}
.wallets__setup-form {
display: grid;
grid-template-columns: minmax(12rem, 18rem) auto;
gap: 0.75rem;
align-items: end;
justify-content: center;
}
}
@media (max-width: 56rem) {
main.wallets {
.wallets__setup {
h1 {
font-size: 4rem;
}
}
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__setup {
h1 {
font-size: 3rem;
}
}
.wallets__setup-form {
grid-template-columns: 1fr;
}
}
}
+45 -30
View File
@@ -11,30 +11,11 @@ main.wallets {
padding: var(--offset) var(--page-x);
scroll-padding-top: var(--offset);
&[data-wallets-page-locked] {
&:is([data-wallets-page-empty], [data-wallets-page-locked]) {
min-height: 100dvh;
align-content: center;
}
.wallets__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
}
.wallets__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: end;
}
.wallets__content {
display: grid;
gap: 1.5rem;
}
.wallets__status {
min-height: var(--line-height-sm);
margin: 0;
@@ -42,17 +23,51 @@ main.wallets {
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__header {
display: grid;
grid-template-columns: 1fr;
}
:is(input, select),
button:not(.wallets__wallet-button) {
min-width: 0;
height: var(--control-height);
border: 1px solid color-mix(in oklch, var(--gray) 45%, transparent);
border-radius: 0.375rem;
padding: 0 0.875rem;
color: var(--white);
background: color-mix(in oklch, var(--black) 72%, var(--white));
font: inherit;
line-height: 1;
}
.wallets__actions {
justify-content: start;
}
button:not(.wallets__wallet-button) {
cursor: pointer;
}
:is(input, select, button:not(.wallets__wallet-button)):focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
input::placeholder {
color: color-mix(in oklch, var(--gray) 70%, transparent);
}
:is(
.wallets__actions,
.wallets__empty,
.wallets__setup-form,
.wallets__unlock-form
) button,
.wallets__dialog-form button[type="submit"],
.wallets__receive-button,
.wallets__receive-actions button:first-child {
border-color: var(--orange);
color: var(--black);
background: var(--orange);
}
button:not(.wallets__wallet-button):disabled {
border-color: var(--gray);
color: var(--black);
background: var(--gray);
cursor: progress;
}
}
-140
View File
@@ -1,140 +0,0 @@
import { createAddressCellContent } from "./address-view.js";
import { createElement } from "./dom.js";
import { formatBtc } from "./format.js";
import {
createHistoryContent,
createHistoryMessage,
createHistoryRow,
replaceHistoryRowContent,
} from "./history-view.js";
import { createPrivateValue } from "./privacy-view.js";
const ADDRESS_TABLE_COLUMN_COUNT = 3;
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {number} balance
* @property {number} txCount
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @typedef {Object} AddressTableOptions
* @property {(address: WalletAddress) => Promise<AddressHistory>} fetchHistory
* @property {(error: unknown) => string} getErrorMessage
*/
/**
* @param {HTMLTableRowElement} row
* @param {Node | string} value
*/
function appendCell(row, value) {
const cell = document.createElement("td");
cell.append(value);
row.append(cell);
}
/**
* @param {WalletAddress} address
* @param {HTMLTableRowElement} parent
* @param {AddressTableOptions} options
*/
function createHistoryButton(address, parent, options) {
if (address.txCount === 0) {
return "";
}
const button = document.createElement("button");
/** @type {HTMLTableRowElement | undefined} */
let historyRow;
button.type = "button";
button.className = "wallets__history-button";
button.append("History");
button.addEventListener("click", async () => {
if (historyRow?.isConnected) {
historyRow.remove();
button.textContent = "History";
return;
}
button.disabled = true;
button.textContent = "Loading";
historyRow = createHistoryRow(
createHistoryMessage("Loading history"),
ADDRESS_TABLE_COLUMN_COUNT,
);
parent.after(historyRow);
try {
const history = await options.fetchHistory(address);
replaceHistoryRowContent(
historyRow,
createHistoryContent(history, address.address),
ADDRESS_TABLE_COLUMN_COUNT,
);
button.textContent = "Hide";
} catch (error) {
replaceHistoryRowContent(
historyRow,
createHistoryMessage(options.getErrorMessage(error)),
ADDRESS_TABLE_COLUMN_COUNT,
);
button.textContent = "History";
} finally {
button.disabled = false;
}
});
return button;
}
/**
* @param {WalletAddress[]} addresses
* @param {AddressTableOptions} options
* @returns {HTMLTableElement}
*/
export function createAddressTable(addresses, options) {
const table = createElement("table", "wallets__table");
const head = document.createElement("thead");
const body = document.createElement("tbody");
const header = document.createElement("tr");
for (const value of [
"address",
"balance",
"history",
]) {
const cell = document.createElement("th");
cell.scope = "col";
cell.append(value);
header.append(cell);
}
head.append(header);
for (const row of addresses) {
const item = document.createElement("tr");
appendCell(item, createAddressCellContent(row));
appendCell(item, createPrivateValue("span", formatBtc(row.balance), "fixed"));
appendCell(item, createHistoryButton(row, item, options));
body.append(item);
}
table.append(head, body);
return table;
}
@@ -110,7 +110,7 @@ async function deriveKey(password, salt, iterations) {
* @param {string} password
* @returns {Promise<EncryptedSecret>}
*/
export async function encryptSecret(secret, password) {
async function encrypt(secret, password) {
const salt = randomBytes(SALT_BYTES);
const iv = randomBytes(IV_BYTES);
const key = await deriveKey(password, salt, PBKDF2_ITERATIONS);
@@ -138,7 +138,7 @@ export async function encryptSecret(secret, password) {
* @param {EncryptedSecret} encrypted
* @param {string} password
*/
export async function decryptSecret(encrypted, password) {
async function decrypt(encrypted, password) {
if (encrypted.version !== ENCRYPTION_VERSION) {
throw new Error("Unsupported wallet encryption version");
}
@@ -158,3 +158,8 @@ export async function decryptSecret(encrypted, password) {
return decoder.decode(decrypted);
}
export const encryption = /** @type {const} */ ({
encrypt,
decrypt,
});
+158
View File
@@ -0,0 +1,158 @@
import { vaultStorage } from "./storage.js";
import { createRuntime } from "./runtime.js";
/**
* @typedef {import("./storage.js").StoredWallet} StoredWallet
* @typedef {import("./storage.js").AddWalletInput} AddWalletInput
* @typedef {import("../derive/address.js").AddressScript} AddressScript
* @typedef {ReturnType<typeof createRuntime>} WalletRuntime
*/
export function createVault() {
/** @type {StoredWallet[]} */
let wallets = [];
let selectedId = "";
let locked = hasVault();
let password = "";
/** @type {Map<string, WalletRuntime>} */
const runtimes = new Map();
function hasVault() {
return vaultStorage.has();
}
function syncSelected() {
selectedId = wallets.some((wallet) => wallet.id === selectedId)
? selectedId
: wallets[0]?.id ?? "";
}
function clear() {
wallets = [];
selectedId = "";
runtimes.clear();
}
/**
* @returns {StoredWallet | undefined}
*/
function selectedWallet() {
return wallets.find((wallet) => wallet.id === selectedId);
}
/**
* @returns {{ wallet: StoredWallet, runtime: WalletRuntime } | undefined}
*/
function current() {
const wallet = selectedWallet();
const runtime = wallet ? runtimes.get(wallet.id) : undefined;
return wallet && runtime ? { wallet, runtime } : undefined;
}
/**
* @param {StoredWallet} wallet
* @param {WalletRuntime} runtime
*/
function isCurrent(wallet, runtime) {
return runtimes.get(wallet.id) === runtime;
}
/**
* @param {string} walletId
*/
function select(walletId) {
selectedId = walletId;
syncSelected();
}
function lock() {
clear();
password = "";
locked = hasVault();
}
function reset() {
vaultStorage.reset();
clear();
password = "";
locked = false;
}
/**
* @param {string} pagePassword
*/
async function setup(pagePassword) {
await vaultStorage.setup(pagePassword);
clear();
password = pagePassword;
locked = false;
}
/**
* @param {string} pagePassword
*/
async function unlock(pagePassword) {
wallets = await vaultStorage.load(pagePassword);
syncSelected();
runtimes.clear();
password = pagePassword;
locked = false;
for (const wallet of wallets) {
runtimes.set(wallet.id, createRuntime(wallet.source));
}
}
/**
* @param {AddWalletInput} input
*/
async function addWallet(input) {
const added = await vaultStorage.addWallet(wallets, input, password);
wallets = added.wallets;
selectedId = added.wallet.id;
locked = false;
runtimes.set(added.wallet.id, createRuntime(added.wallet.source));
}
/**
* @param {StoredWallet} wallet
* @param {AddressScript} script
*/
async function updateWalletScript(wallet, script) {
wallets = await vaultStorage.updateWalletScript(wallets, {
walletId: wallet.id,
script,
}, password);
runtimes.set(wallet.id, createRuntime(wallet.source));
syncSelected();
}
return {
get wallets() {
return wallets;
},
get selectedId() {
return selectedId;
},
get hasPassword() {
return password !== "";
},
needsSetup() {
return !hasVault() && !password;
},
isLocked() {
return locked && hasVault();
},
current,
isCurrent,
select,
lock,
reset,
setup,
unlock,
addWallet,
updateWalletScript,
};
}
+57
View File
@@ -0,0 +1,57 @@
import { scanWalletAddresses } from "../scan/index.js";
/**
* @typedef {import("../scan/index.js").WalletScan} WalletScan
* @typedef {import("../scan/index.js").WalletScanClient} WalletScanClient
* @typedef {import("../scan/index.js").WalletScanProgress} WalletScanProgress
* @typedef {import("../derive/address.js").AddressScript} AddressScript
*
* @typedef {Object} LoadOptions
* @property {WalletScanClient} client
* @property {AddressScript} script
* @property {(progress: WalletScanProgress) => void} [onProgress]
*/
/**
* @param {string} source
*/
export function createRuntime(source) {
/** @type {WalletScan | undefined} */
let scan;
/** @type {Promise<WalletScan> | undefined} */
let pending;
/**
* @param {LoadOptions} options
*/
function load(options) {
if (scan) return Promise.resolve(scan);
if (!pending) {
pending = scanWalletAddresses({
client: options.client,
source,
script: options.script,
onProgress: options.onProgress,
}).then((nextScan) => {
scan = nextScan;
pending = undefined;
return nextScan;
}, (error) => {
pending = undefined;
throw error;
});
}
return pending;
}
return {
get scan() {
return scan;
},
load,
};
}
@@ -1,10 +1,10 @@
import { decryptSecret, encryptSecret } from "./encryption.js";
import { encryption } from "./encryption.js";
const STORAGE_KEY = "bitview.wallets.v2";
const STORAGE_KEY = "bitview.wallets.v3";
/**
* @typedef {import("./encryption.js").EncryptedSecret} EncryptedSecret
* @typedef {import("../xpub/address.js").AddressScript} AddressScript
* @typedef {import("../derive/address.js").AddressScript} AddressScript
*/
/**
@@ -12,7 +12,7 @@ const STORAGE_KEY = "bitview.wallets.v2";
* @property {string} id
* @property {string} name
* @property {AddressScript} script
* @property {string} xpub
* @property {string} source
* @property {number} createdAt
* @property {number} updatedAt
*/
@@ -21,7 +21,7 @@ const STORAGE_KEY = "bitview.wallets.v2";
* @typedef {Object} AddWalletInput
* @property {string} name
* @property {AddressScript} script
* @property {string} xpub
* @property {string} source
*/
/**
@@ -67,31 +67,34 @@ function now() {
return Date.now();
}
export function hasStoredWallets() {
function has() {
return Boolean(localStorage.getItem(STORAGE_KEY));
}
export function resetWalletVault() {
function reset() {
localStorage.removeItem(STORAGE_KEY);
}
/**
* @param {string} pagePassword
*/
export async function createWalletVault(pagePassword) {
async function setup(pagePassword) {
await writeWallets([], pagePassword);
}
/**
* @param {string} pagePassword
*/
export async function loadWallets(pagePassword) {
async function load(pagePassword) {
const value = localStorage.getItem(STORAGE_KEY);
const encrypted = value ? readEncryptedVault(JSON.parse(value)) : undefined;
if (!encrypted) return [];
return readVault(JSON.parse(await decryptSecret(encrypted, pagePassword))).wallets;
const decrypted = await encryption.decrypt(encrypted, pagePassword);
const vault = readVault(JSON.parse(decrypted));
return vault.wallets;
}
/**
@@ -102,7 +105,7 @@ async function writeWallets(wallets, pagePassword) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(
await encryptSecret(JSON.stringify({ wallets }), pagePassword),
await encryption.encrypt(JSON.stringify({ wallets }), pagePassword),
),
);
}
@@ -112,13 +115,13 @@ async function writeWallets(wallets, pagePassword) {
* @param {AddWalletInput} input
* @param {string} pagePassword
*/
export async function addWallet(wallets, input, pagePassword) {
async function addWallet(wallets, input, pagePassword) {
const time = now();
const wallet = {
id: createWalletId(),
name: input.name.trim(),
script: input.script,
xpub: input.xpub.trim(),
source: input.source.trim(),
createdAt: time,
updatedAt: time,
};
@@ -137,7 +140,7 @@ export async function addWallet(wallets, input, pagePassword) {
* @param {UpdateWalletScriptInput} input
* @param {string} pagePassword
*/
export async function updateWalletScript(wallets, input, pagePassword) {
async function updateWalletScript(wallets, input, pagePassword) {
const time = now();
const nextWallets = wallets.map((wallet) => {
return wallet.id === input.walletId
@@ -153,3 +156,12 @@ export async function updateWalletScript(wallets, input, pagePassword) {
return nextWallets;
}
export const vaultStorage = /** @type {const} */ ({
has,
reset,
setup,
load,
addWallet,
updateWalletScript,
});
@@ -1,16 +1,9 @@
import { createElement } from "./dom.js";
import { formatNumber } from "./format.js";
import {
arePrivateValuesHidden,
createPrivateText,
} from "./privacy-view.js";
import { createElement } from "../../dom.js";
import { formatNumber } from "../../format.js";
import { redaction } from "../../redaction/index.js";
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} [branchLabel]
* @property {number} historyBucketSize
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
@@ -47,9 +40,9 @@ export function createGroupedAddress(text) {
/**
* @param {string} address
*/
export function createPrivateAddress(address) {
const hidden = createPrivateText(address);
const element = arePrivateValuesHidden()
function createPrivateAddress(address) {
const hidden = redaction.createText(address);
const element = redaction.isHidden()
? createGroupedAddress(hidden)
: createGroupedAddress(address);
@@ -79,7 +72,11 @@ export function createAddressCellContent(row) {
const anonSet = createElement("span", "wallets__address-meta");
anonSet.append(`anon set: ${formatNumber(row.historyBucketSize)}`);
element.append(createAddressBadge(row), createPrivateAddress(row.address), anonSet);
element.append(
createAddressBadge(row),
createPrivateAddress(row.address),
anonSet,
);
return element;
}
@@ -0,0 +1,84 @@
import {
createHistoryContent,
createHistoryMessage,
createHistoryRow,
replaceHistoryRowContent,
} from "./index.js";
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @typedef {Object} AddressHistoryButtonOptions
* @property {(address: WalletAddress) => Promise<AddressHistory>} fetchHistory
* @property {(error: unknown) => string} getErrorMessage
*/
/**
* @param {WalletAddress} address
* @param {HTMLTableRowElement} parent
* @param {AddressHistoryButtonOptions} options
* @param {number} columnCount
*/
export function createAddressHistoryButton(
address,
parent,
options,
columnCount,
) {
if (address.txCount === 0) {
return "";
}
const button = document.createElement("button");
/** @type {HTMLTableRowElement | undefined} */
let historyRow;
button.type = "button";
button.className = "wallets__history-button";
button.append("History");
button.addEventListener("click", async () => {
if (historyRow?.isConnected) {
historyRow.remove();
button.textContent = "History";
return;
}
button.disabled = true;
button.textContent = "Loading";
historyRow = createHistoryRow(
createHistoryMessage("Loading history"),
columnCount,
);
parent.after(historyRow);
try {
const history = await options.fetchHistory(address);
replaceHistoryRowContent(
historyRow,
createHistoryContent(history, address.address),
columnCount,
);
button.textContent = "Hide";
} catch (error) {
replaceHistoryRowContent(
historyRow,
createHistoryMessage(options.getErrorMessage(error)),
columnCount,
);
button.textContent = "History";
} finally {
button.disabled = false;
}
});
return button;
}
@@ -1,16 +1,13 @@
import { mapConcurrent } from "../concurrent.js";
import { mapConcurrent } from "../../concurrent.js";
const HISTORY_CONCURRENCY = 4;
const MAX_SELECTED_ADDRESS_TXS = 100;
const historyByBucketKey = new Map();
const historyByBucketKey =
/** @type {Map<string, Promise<Map<string, unknown[]>>>} */ (new Map());
/**
* @typedef {Object} WalletAddress
* @property {string} address
* @property {number} txCount
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
@@ -49,13 +46,17 @@ function assertHistoryIsReasonable(address) {
* @returns {Promise<Map<string, unknown[]>>}
*/
async function fetchBucketHistory(client, addresses) {
const entries = await mapConcurrent(addresses, HISTORY_CONCURRENCY, async (address) => {
const transactions = /** @type {unknown[]} */ (
await client.getAddressTxs(address, { cache: false })
);
const entries = await mapConcurrent(
addresses,
HISTORY_CONCURRENCY,
async (address) => {
const transactions = /** @type {unknown[]} */ (
await client.getAddressTxs(address, { cache: false })
);
return /** @type {const} */ ([address, transactions]);
});
return /** @type {const} */ ([address, transactions]);
},
);
return new Map(entries);
}
@@ -65,7 +66,7 @@ async function fetchBucketHistory(client, addresses) {
* @param {WalletAddress} address
* @returns {Promise<AddressHistory>}
*/
export async function fetchAddressHistory(client, address) {
async function load(client, address) {
assertHistoryIsReasonable(address);
if (address.historyAddresses.length === 0) {
@@ -80,7 +81,12 @@ export async function fetchAddressHistory(client, address) {
let history = historyByBucketKey.get(key);
if (!history) {
history = fetchBucketHistory(client, address.historyAddresses);
history = fetchBucketHistory(client, address.historyAddresses).catch(
(error) => {
historyByBucketKey.delete(key);
throw error;
},
);
historyByBucketKey.set(key, history);
}
@@ -92,3 +98,7 @@ export async function fetchAddressHistory(client, address) {
bucketSize: address.historyBucketSize,
};
}
export const historyCache = /** @type {const} */ ({
load,
});
@@ -0,0 +1,96 @@
import { createElement } from "../../dom.js";
import { formatBtc } from "../../format.js";
import { redaction } from "../../redaction/index.js";
import { readHistoryTransaction } from "./transaction.js";
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @param {string} txid
*/
function formatTxid(txid) {
return txid.length > 16 ? `${txid.slice(0, 8)}...${txid.slice(-8)}` : txid;
}
/**
* @param {AddressHistory} history
* @param {string} address
*/
export function createHistoryContent(history, address) {
const element = createElement("div", "wallets__history");
const list = createElement("ol", "wallets__history-list");
for (const transaction of history.transactions) {
const item = document.createElement("li");
const txid = document.createElement("code");
const date = document.createElement("span");
const direction = document.createElement("span");
const amount = document.createElement("strong");
const fee = document.createElement("span");
const itemData = readHistoryTransaction(transaction, address);
redaction.setTitle(txid, itemData.txid);
redaction.setValue(txid, formatTxid(itemData.txid));
date.append(itemData.date);
direction.append(itemData.direction);
redaction.setValue(amount, formatBtc(itemData.amount), "fixed");
fee.append(
"fee ",
redaction.createValue("span", formatBtc(itemData.fee), "fixed"),
);
item.append(date, direction, amount, fee, txid);
list.append(item);
}
element.append(list);
return element;
}
/**
* @param {string} text
*/
export function createHistoryMessage(text) {
const element = createElement("p", "wallets__history-message");
element.append(text);
return element;
}
/**
* @param {Node} content
* @param {number} columnCount
*/
function createHistoryCell(content, columnCount) {
const cell = document.createElement("td");
cell.colSpan = columnCount;
cell.append(content);
return cell;
}
/**
* @param {Node} content
* @param {number} columnCount
*/
export function createHistoryRow(content, columnCount) {
const row = document.createElement("tr");
row.append(createHistoryCell(content, columnCount));
return row;
}
/**
* @param {HTMLTableRowElement} row
* @param {Node} content
* @param {number} columnCount
*/
export function replaceHistoryRowContent(row, content, columnCount) {
row.replaceChildren(createHistoryCell(content, columnCount));
}
@@ -1,25 +1,16 @@
main.wallets {
.wallets__history-button {
height: 2rem;
border: 1px solid color-mix(in oklch, var(--gray) 36%, transparent);
border-radius: 0.375rem;
padding: 0 0.625rem;
color: var(--white);
background: transparent;
font: inherit;
cursor: pointer;
}
.wallets__history-button:disabled {
color: var(--gray);
background: transparent;
cursor: progress;
}
.wallets__history-button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.wallets__history {
display: grid;
gap: 0.75rem;
@@ -0,0 +1,142 @@
/**
* @typedef {Object} HistoryTransaction
* @property {string} txid
* @property {string} date
* @property {string} direction
* @property {number} amount
* @property {number} fee
*/
/**
* @param {unknown} transaction
*/
function getTransactionId(transaction) {
return readString(readObject(transaction)?.txid) ?? "";
}
/**
* @param {unknown} value
*/
function readObject(value) {
return value && typeof value === "object"
? /** @type {Record<string, unknown>} */ (value)
: undefined;
}
/**
* @param {unknown} value
*/
function readNumber(value) {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
/**
* @param {unknown} value
*/
function readString(value) {
return typeof value === "string" ? value : undefined;
}
/**
* @param {unknown} value
* @param {string} key
*/
function readArray(value, key) {
const array = readObject(value)?.[key];
return Array.isArray(array) ? array : [];
}
/**
* @param {unknown} output
* @param {string} address
*/
function isAddressOutput(output, address) {
return readString(readObject(output)?.scriptpubkeyAddress) === address;
}
/**
* @param {unknown} output
*/
function getOutputValue(output) {
return readNumber(readObject(output)?.value) ?? 0;
}
/**
* @param {unknown} transaction
* @param {string} address
*/
function getTransactionReceived(transaction, address) {
return readArray(transaction, "vout").reduce((total, output) => {
return (
total + (isAddressOutput(output, address) ? getOutputValue(output) : 0)
);
}, 0);
}
/**
* @param {unknown} transaction
* @param {string} address
*/
function getTransactionSent(transaction, address) {
return readArray(transaction, "vin").reduce((total, input) => {
const prevout = readObject(input)?.prevout;
return (
total + (isAddressOutput(prevout, address) ? getOutputValue(prevout) : 0)
);
}, 0);
}
/**
* @param {unknown} transaction
*/
function getTransactionFee(transaction) {
return readNumber(readObject(transaction)?.fee) ?? 0;
}
/**
* @param {number} net
*/
function getTransactionDirection(net) {
if (net > 0) return "received";
if (net < 0) return "sent";
return "moved";
}
/**
* @param {unknown} transaction
*/
function getTransactionDate(transaction) {
const blockTime = readNumber(
readObject(readObject(transaction)?.status)?.blockTime,
);
if (blockTime !== undefined) {
return new Date(blockTime * 1_000).toLocaleDateString("en-US");
}
return "mempool";
}
/**
* @param {unknown} transaction
* @param {string} address
* @returns {HistoryTransaction}
*/
export function readHistoryTransaction(transaction, address) {
const received = getTransactionReceived(transaction, address);
const sent = getTransactionSent(transaction, address);
const net = received - sent;
return {
txid: getTransactionId(transaction),
date: getTransactionDate(transaction),
direction: getTransactionDirection(net),
amount: Math.abs(net),
fee: getTransactionFee(transaction),
};
}
+50
View File
@@ -0,0 +1,50 @@
import { createElement } from "../dom.js";
import { renderReceiveButton } from "./receive/index.js";
import { renderWalletSummary } from "./summary/index.js";
import { createAddressTable } from "./table/index.js";
/**
* @typedef {import("../scan/index.js").WalletScan} WalletScan
* @typedef {Parameters<typeof createAddressTable>[1]} WalletRenderOptions
*
* @typedef {Object} WalletPanel
* @property {HTMLElement} settings
* @property {HTMLElement} summary
* @property {HTMLElement} status
* @property {HTMLElement} results
* @property {HTMLElement[]} nodes
*/
/**
* @returns {WalletPanel}
*/
export function createWalletPanel() {
const settings = createElement("section", "wallets__settings");
const summary = createElement("section", "wallets__summary");
const status = createElement("p", "wallets__status");
const results = createElement("section", "wallets__results");
settings.setAttribute("aria-label", "Wallet settings");
status.setAttribute("role", "status");
summary.setAttribute("aria-label", "Wallets summary");
results.setAttribute("aria-label", "Wallets results");
return {
settings,
summary,
status,
results,
nodes: [settings, summary, status, results],
};
}
/**
* @param {WalletScan} scan
* @param {WalletPanel} panel
* @param {WalletRenderOptions} options
*/
export function renderWalletPanel(scan, panel, options) {
renderWalletSummary(panel.summary, scan.addresses, scan.btcUsdPrice);
renderReceiveButton(panel.settings, scan.receiveAddress);
panel.results.replaceChildren(createAddressTable(scan.addresses, options));
}
@@ -1,15 +1,31 @@
import * as leanQr from "../modules/lean-qr/2.7.1/index.mjs";
import { createGroupedAddress } from "./address-view.js";
import { createElement } from "./dom.js";
import { formatNumber } from "./format.js";
import * as leanQr from "../../../modules/lean-qr/2.7.1/index.mjs";
import { createGroupedAddress } from "../address/index.js";
import { createElement } from "../../dom.js";
import { formatNumber } from "../../format.js";
/**
* @typedef {Object} ReceiveAddress
* @property {number} index
* @property {string} address
* @property {string} branchLabel
* @typedef {import("../../scan/index.js").WalletAddress} ReceiveAddress
*/
/**
* @typedef {Object} QrCode
* @property {(options?: { scale?: number }) => string} toDataURL
*/
const generateQr =
/** @type {(value: string) => QrCode | undefined} */ (
/** @type {unknown} */ (leanQr.generate)
);
/**
* @param {string} value
*/
function createQrDataUrl(value) {
const qr = generateQr(value);
return qr?.toDataURL({ scale: 8 }) ?? "";
}
/**
* @param {ReceiveAddress} receiveAddress
*/
@@ -32,8 +48,7 @@ function createReceiveQr(receiveAddress) {
image.className = "wallets__receive-qr";
image.alt = `QR code for ${receiveAddress.address}`;
// @ts-ignore - lean-qr types do not resolve for file path imports.
image.src = leanQr.generate(uri)?.toDataURL({ scale: 8 }) ?? "";
image.src = createQrDataUrl(uri);
return image;
}
@@ -63,7 +78,10 @@ async function copyReceiveAddress(receiveAddress, copy) {
*/
function openReceiveDialog(receiveAddress) {
const main = document.querySelector("main.wallets") ?? document.body;
const dialog = createElement("dialog", "wallets__dialog wallets__receive-dialog");
const dialog = createElement(
"dialog",
"wallets__dialog wallets__receive-dialog",
);
const content = createElement("div", "wallets__receive-card");
const actions = createElement("div", "wallets__receive-actions");
const copy = document.createElement("button");
@@ -1,18 +1,4 @@
main.wallets {
.wallets__receive-button,
.wallets__receive-actions button {
min-width: 0;
height: var(--control-height);
border: 1px solid var(--orange);
border-radius: 0.375rem;
padding: 0 0.875rem;
color: var(--black);
background: var(--orange);
font: inherit;
line-height: 1;
cursor: pointer;
}
.wallets__receive-button:disabled {
border-color: color-mix(in oklch, var(--gray) 35%, transparent);
color: var(--gray);
@@ -20,12 +6,6 @@ main.wallets {
cursor: default;
}
.wallets__receive-button:focus-visible,
.wallets__receive-actions button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.wallets__receive-dialog {
width: min(100% - 2rem, 32rem);
}
@@ -62,11 +42,5 @@ main.wallets {
flex-wrap: wrap;
gap: 0.5rem;
justify-content: end;
button:last-child {
border-color: color-mix(in oklch, var(--gray) 45%, transparent);
color: var(--white);
background: color-mix(in oklch, var(--black) 72%, var(--white));
}
}
}
@@ -1,16 +1,14 @@
import {
createAddressScriptSelect,
readAddressScript,
} from "./address-scripts.js";
import {
createElement,
createField,
} from "./dom.js";
import { isOutputDescriptor } from "./xpub/index.js";
} from "./script.js";
import { createElement } from "../../dom.js";
import { createField } from "../../form/index.js";
import { isOutputDescriptor } from "../../derive/index.js";
/**
* @typedef {import("./address-scripts.js").AddressScript} AddressScript
* @typedef {import("./storage/wallets.js").StoredWallet} StoredWallet
* @typedef {import("../../derive/address.js").AddressScript} AddressScript
* @typedef {import("../../vault/index.js").StoredWallet} StoredWallet
*/
/**
@@ -23,8 +21,8 @@ import { isOutputDescriptor } from "./xpub/index.js";
* @param {StoredWallet} wallet
* @param {WalletSettingsOptions} options
*/
export function renderWalletSettings(element, wallet, options) {
if (isOutputDescriptor(wallet.xpub)) {
export function renderSettings(element, wallet, options) {
if (isOutputDescriptor(wallet.source)) {
element.replaceChildren();
return;
}
@@ -32,7 +30,7 @@ export function renderWalletSettings(element, wallet, options) {
const script = createAddressScriptSelect(
/** @type {AddressScript} */ (wallet.script),
);
const status = createElement("p", "wallets__status wallets__settings-status");
const status = createElement("p", "wallets__status");
status.setAttribute("role", "status");
script.addEventListener("change", () => {
@@ -1,12 +1,7 @@
export const addressScripts = /** @type {const} */ ([
{ id: "v0_p2wpkh", label: "P2WPKH" },
{ id: "v1_p2tr", label: "P2TR" },
{ id: "p2sh_p2wpkh", label: "Nested P2WPKH" },
{ id: "p2pkh", label: "P2PKH" },
]);
import { addressScripts } from "../../derive/script.js";
/**
* @typedef {typeof addressScripts[number]["id"]} AddressScript
* @typedef {import("../../derive/address.js").AddressScript} AddressScript
*/
/**
+34
View File
@@ -0,0 +1,34 @@
import { setStatus } from "../dom.js";
import { formatNumber } from "../format.js";
import { GAP_LIMIT } from "../scan/branch.js";
/**
* @typedef {import("../scan/index.js").WalletScanProgress} WalletScanProgress
*/
function createScanPendingMessage() {
return `Scanning until ${GAP_LIMIT} unused addresses`;
}
/**
* @param {HTMLElement} status
*/
function setPending(status) {
setStatus(status, createScanPendingMessage());
}
/**
* @param {HTMLElement} status
* @param {WalletScanProgress} progress
*/
function setProgress(status, progress) {
setStatus(
status,
`${progress.branchLabel}: scanned ${formatNumber(progress.scannedCount)} addresses, ${progress.unusedInRow}/${GAP_LIMIT} unused`,
);
}
export const scanStatus = /** @type {const} */ ({
setPending,
setProgress,
});
@@ -1,10 +1,9 @@
import { createElement } from "./dom.js";
import { formatBtc, formatUsd } from "./format.js";
import { createPrivateValue } from "./privacy-view.js";
import { createElement } from "../../dom.js";
import { formatBtc, formatUsd } from "../../format.js";
import { redaction } from "../../redaction/index.js";
/**
* @typedef {Object} WalletAddress
* @property {number} balance
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
@@ -12,9 +11,9 @@ import { createPrivateValue } from "./privacy-view.js";
* @param {number} btcUsdPrice
*/
function createBalanceSummary(balance, btcUsdPrice) {
const element = createElement("p", "wallets__metric wallets__balance");
const btc = createPrivateValue("strong", formatBtc(balance), "fixed");
const usd = createPrivateValue(
const element = createElement("p", "wallets__balance");
const btc = redaction.createValue("strong", formatBtc(balance), "fixed");
const usd = redaction.createValue(
"span",
formatUsd((balance / 100_000_000) * btcUsdPrice),
"fixed",
@@ -3,39 +3,24 @@ main.wallets {
min-height: 5rem;
}
.wallets__metric {
.wallets__balance {
display: grid;
gap: 0.375rem;
gap: 0.5rem;
margin: 0;
strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--white);
font-size: 1.5rem;
font-size: 3rem;
font-weight: 620;
line-height: 1;
}
span {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
}
.wallets__balance {
gap: 0.5rem;
strong {
font-size: 3rem;
}
span {
font-size: var(--font-size-lg);
line-height: var(--line-height-lg);
text-transform: none;
}
}
}
@@ -0,0 +1,78 @@
import { createAddressCellContent } from "../address/index.js";
import { createElement } from "../../dom.js";
import { formatBtc } from "../../format.js";
import { createAddressHistoryButton } from "../history/button.js";
import { redaction } from "../../redaction/index.js";
const ADDRESS_TABLE_COLUMN_COUNT = 3;
/**
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
*/
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @typedef {Object} AddressTableOptions
* @property {(address: WalletAddress) => Promise<AddressHistory>} fetchHistory
* @property {(error: unknown) => string} getErrorMessage
*/
/**
* @param {HTMLTableRowElement} row
* @param {Node | string} value
*/
function appendCell(row, value) {
const cell = document.createElement("td");
cell.append(value);
row.append(cell);
}
/**
* @param {WalletAddress[]} addresses
* @param {AddressTableOptions} options
* @returns {HTMLTableElement}
*/
export function createAddressTable(addresses, options) {
const table = createElement("table", "wallets__table");
const head = document.createElement("thead");
const body = document.createElement("tbody");
const header = document.createElement("tr");
for (const value of [
"address",
"balance",
"history",
]) {
const cell = document.createElement("th");
cell.scope = "col";
cell.append(value);
header.append(cell);
}
head.append(header);
for (const row of addresses) {
const item = document.createElement("tr");
appendCell(item, createAddressCellContent(row));
appendCell(
item,
redaction.createValue("span", formatBtc(row.balance), "fixed"),
);
appendCell(
item,
createAddressHistoryButton(row, item, options, ADDRESS_TABLE_COLUMN_COUNT),
);
body.append(item);
}
table.append(head, body);
return table;
}