mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-19 19:26:12 -07:00
global: private xpub support part 2
This commit is contained in:
+14
-8
@@ -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,
|
||||
@@ -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/;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+16
-104
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+37
-60
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+7
-2
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
+12
-15
@@ -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;
|
||||
}
|
||||
+25
-15
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
+29
-11
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-11
@@ -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", () => {
|
||||
+2
-7
@@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user