mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-19 19:26:12 -07:00
global: private xpub support part 3
This commit is contained in:
@@ -127,9 +127,8 @@
|
||||
<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" />
|
||||
<link rel="stylesheet" href="/wallets/wallet/transactions/style.css" />
|
||||
<!-- /IMPORTMAP -->
|
||||
|
||||
<script>
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -171,12 +170,7 @@ export function createWalletsPage() {
|
||||
* @param {ReturnType<typeof createWalletPanel>} panel
|
||||
*/
|
||||
function renderWalletData(scan, panel) {
|
||||
renderWalletPanel(scan, panel, {
|
||||
fetchHistory(address) {
|
||||
return historyCache.load(brk, address);
|
||||
},
|
||||
getErrorMessage,
|
||||
});
|
||||
renderWalletPanel(scan, panel, brk);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
createHistoryContent,
|
||||
createHistoryMessage,
|
||||
createHistoryRow,
|
||||
replaceHistoryRowContent,
|
||||
} from "./index.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AddressHistory
|
||||
* @property {unknown[]} transactions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AddressHistoryButtonOptions
|
||||
* @property {(address: WalletAddress) => Promise<AddressHistory>} fetchHistory
|
||||
* @property {(error: unknown) => string} getErrorMessage
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {WalletAddress} address
|
||||
* @param {HTMLTableRowElement} parent
|
||||
* @param {AddressHistoryButtonOptions} options
|
||||
* @param {number} columnCount
|
||||
*/
|
||||
export function createAddressHistoryButton(
|
||||
address,
|
||||
parent,
|
||||
options,
|
||||
columnCount,
|
||||
) {
|
||||
if (address.txCount === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const button = document.createElement("button");
|
||||
/** @type {HTMLTableRowElement | undefined} */
|
||||
let historyRow;
|
||||
|
||||
button.type = "button";
|
||||
button.className = "wallets__history-button";
|
||||
button.append("History");
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
if (historyRow?.isConnected) {
|
||||
historyRow.remove();
|
||||
button.textContent = "History";
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = "Loading";
|
||||
historyRow = createHistoryRow(
|
||||
createHistoryMessage("Loading history"),
|
||||
columnCount,
|
||||
);
|
||||
parent.after(historyRow);
|
||||
|
||||
try {
|
||||
const history = await options.fetchHistory(address);
|
||||
|
||||
replaceHistoryRowContent(
|
||||
historyRow,
|
||||
createHistoryContent(history, address.address),
|
||||
columnCount,
|
||||
);
|
||||
button.textContent = "Hide";
|
||||
} catch (error) {
|
||||
replaceHistoryRowContent(
|
||||
historyRow,
|
||||
createHistoryMessage(options.getErrorMessage(error)),
|
||||
columnCount,
|
||||
);
|
||||
button.textContent = "History";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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,54 +0,0 @@
|
||||
main.wallets {
|
||||
.wallets__history-button {
|
||||
height: 2rem;
|
||||
padding: 0 0.625rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wallets__history-button:disabled {
|
||||
color: var(--gray);
|
||||
background: transparent;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.wallets__history {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.wallets__history-message {
|
||||
margin: 0;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.wallets__history-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(5.5rem, 0.7fr)
|
||||
minmax(4.75rem, 0.6fr)
|
||||
minmax(8rem, 0.8fr)
|
||||
minmax(7.5rem, 0.8fr)
|
||||
minmax(10rem, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--white);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--gray);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* @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),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createElement } from "../dom.js";
|
||||
import { renderReceiveButton } from "./receive/index.js";
|
||||
import { renderWalletSummary } from "./summary/index.js";
|
||||
import { createAddressTable } from "./table/index.js";
|
||||
import { transactionCache } from "./transactions/cache.js";
|
||||
import { renderTransactions } from "./transactions/index.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../scan/index.js").WalletScan} WalletScan
|
||||
* @typedef {Parameters<typeof createAddressTable>[1]} WalletRenderOptions
|
||||
* @typedef {Parameters<typeof transactionCache.load>[0]} TransactionClient
|
||||
*
|
||||
* @typedef {Object} WalletPanel
|
||||
* @property {HTMLElement} settings
|
||||
@@ -41,10 +42,19 @@ export function createWalletPanel() {
|
||||
/**
|
||||
* @param {WalletScan} scan
|
||||
* @param {WalletPanel} panel
|
||||
* @param {WalletRenderOptions} options
|
||||
* @param {TransactionClient} client
|
||||
*/
|
||||
export function renderWalletPanel(scan, panel, options) {
|
||||
export function renderWalletPanel(scan, panel, client) {
|
||||
renderWalletSummary(panel.summary, scan.addresses, scan.btcUsdPrice);
|
||||
renderReceiveButton(panel.settings, scan.receiveAddress);
|
||||
panel.results.replaceChildren(createAddressTable(scan.addresses, options));
|
||||
panel.results.replaceChildren("Loading activity");
|
||||
void transactionCache.load(client, scan.addresses).then((transactions) => {
|
||||
if (panel.results.isConnected) {
|
||||
renderTransactions(panel.results, transactions);
|
||||
}
|
||||
}, () => {
|
||||
if (panel.results.isConnected) {
|
||||
panel.results.replaceChildren("Activity unavailable");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
main.wallets {
|
||||
.wallets__results {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wallets__table {
|
||||
width: 100%;
|
||||
min-width: 44rem;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklch, var(--gray) 22%, transparent);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--white);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { addressHistory } from "./history.js";
|
||||
import { readWalletTransaction } from "./transaction.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
|
||||
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TransactionClient
|
||||
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressTxs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {WalletAddress} address
|
||||
*/
|
||||
function isUsedAddress(address) {
|
||||
return address.txCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WalletTransaction} a
|
||||
* @param {WalletTransaction} b
|
||||
*/
|
||||
function compareTransactions(a, b) {
|
||||
if (a.time === undefined && b.time === undefined) {
|
||||
return a.txid.localeCompare(b.txid);
|
||||
}
|
||||
|
||||
if (a.time === undefined) return -1;
|
||||
if (b.time === undefined) return 1;
|
||||
|
||||
return b.time - a.time;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TransactionClient} client
|
||||
* @param {readonly WalletAddress[]} addresses
|
||||
* @returns {Promise<WalletTransaction[]>}
|
||||
*/
|
||||
async function load(client, addresses) {
|
||||
const transactionsById = /** @type {Map<string, WalletTransaction>} */ (
|
||||
new Map()
|
||||
);
|
||||
const usedAddresses = addresses.filter(isUsedAddress);
|
||||
|
||||
for (const address of usedAddresses) {
|
||||
const history = await addressHistory.load(client, address);
|
||||
|
||||
for (const transaction of history.transactions) {
|
||||
const walletTransaction = readWalletTransaction(transaction, usedAddresses);
|
||||
|
||||
if (walletTransaction.txid) {
|
||||
transactionsById.set(walletTransaction.txid, walletTransaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...transactionsById.values()].sort(compareTransactions);
|
||||
}
|
||||
|
||||
export const transactionCache = /** @type {const} */ ({
|
||||
load,
|
||||
});
|
||||
+1
-1
@@ -99,6 +99,6 @@ async function load(client, address) {
|
||||
};
|
||||
}
|
||||
|
||||
export const historyCache = /** @type {const} */ ({
|
||||
export const addressHistory = /** @type {const} */ ({
|
||||
load,
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createElement } from "../../dom.js";
|
||||
import { formatBtc } from "../../format.js";
|
||||
import { redaction } from "../../redaction/index.js";
|
||||
import { createAddressCellContent } from "../address/index.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("./transaction.js").WalletTransaction} WalletTransaction
|
||||
*/
|
||||
|
||||
const typeLabels = /** @type {const} */ ({
|
||||
receive: "Received",
|
||||
send: "Sent",
|
||||
consolidation: "Consolidated",
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} txid
|
||||
*/
|
||||
function formatTxid(txid) {
|
||||
return txid.length > 16 ? `${txid.slice(0, 8)}...${txid.slice(-8)}` : txid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} sats
|
||||
*/
|
||||
function formatSignedBtc(sats) {
|
||||
if (sats > 0) return `+${formatBtc(sats)}`;
|
||||
if (sats < 0) return `-${formatBtc(Math.abs(sats))}`;
|
||||
|
||||
return formatBtc(sats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WalletTransaction} transaction
|
||||
*/
|
||||
function getTransactionDetail(transaction) {
|
||||
if (transaction.type === "consolidation") {
|
||||
return `${transaction.addresses.length} wallet addresses · fee only`;
|
||||
}
|
||||
|
||||
if (transaction.type === "send") {
|
||||
return `to external wallet · fee ${formatBtc(transaction.fee)}`;
|
||||
}
|
||||
|
||||
return transaction.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WalletTransaction} transaction
|
||||
*/
|
||||
function createTransactionDetails(transaction) {
|
||||
const dialog = createElement("dialog", "wallets__dialog wallets__tx-dialog");
|
||||
const content = createElement("div", "wallets__tx-details");
|
||||
const title = document.createElement("h2");
|
||||
const txid = document.createElement("code");
|
||||
const meta = document.createElement("p");
|
||||
const list = createElement("div", "wallets__tx-addresses");
|
||||
const close = document.createElement("button");
|
||||
|
||||
title.append(typeLabels[transaction.type]);
|
||||
redaction.setTitle(txid, transaction.txid);
|
||||
redaction.setValue(txid, transaction.txid);
|
||||
meta.append(
|
||||
transaction.status,
|
||||
" · ",
|
||||
redaction.createValue("span", formatSignedBtc(transaction.amount), "fixed"),
|
||||
" · fee ",
|
||||
redaction.createValue("span", formatBtc(transaction.fee), "fixed"),
|
||||
);
|
||||
for (const address of transaction.addresses) {
|
||||
list.append(createAddressCellContent(address.walletAddress));
|
||||
}
|
||||
close.type = "button";
|
||||
close.append("Close");
|
||||
content.append(title, txid, meta, list, close);
|
||||
dialog.append(content);
|
||||
close.addEventListener("click", () => {
|
||||
dialog.close();
|
||||
});
|
||||
dialog.addEventListener("close", () => {
|
||||
dialog.remove();
|
||||
});
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WalletTransaction} transaction
|
||||
*/
|
||||
function createTransactionRow(transaction) {
|
||||
const row = createElement("li", "wallets__tx");
|
||||
const main = createElement("div", "wallets__tx-main");
|
||||
const label = document.createElement("strong");
|
||||
const amount = redaction.createValue(
|
||||
"span",
|
||||
formatSignedBtc(transaction.amount),
|
||||
"fixed",
|
||||
);
|
||||
const detail = createElement("p", "wallets__tx-detail");
|
||||
const txid = document.createElement("code");
|
||||
const more = document.createElement("button");
|
||||
|
||||
label.append(typeLabels[transaction.type]);
|
||||
amount.dataset.walletsTxAmount =
|
||||
transaction.amount >= 0 ? "positive" : "negative";
|
||||
redaction.setTitle(txid, transaction.txid);
|
||||
redaction.setValue(txid, formatTxid(transaction.txid));
|
||||
more.type = "button";
|
||||
more.append("View more");
|
||||
detail.append(getTransactionDetail(transaction), " · ", txid);
|
||||
main.append(label, amount);
|
||||
row.append(main, detail, more);
|
||||
more.addEventListener("click", () => {
|
||||
const dialog = createTransactionDetails(transaction);
|
||||
const mainElement = document.querySelector("main.wallets") ?? document.body;
|
||||
|
||||
mainElement.append(dialog);
|
||||
dialog.showModal();
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly WalletTransaction[]} transactions
|
||||
*/
|
||||
function groupTransactionsByDate(transactions) {
|
||||
const groups = /** @type {Map<string, WalletTransaction[]>} */ (new Map());
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const group = groups.get(transaction.date) ?? [];
|
||||
|
||||
group.push(transaction);
|
||||
groups.set(transaction.date, group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {readonly WalletTransaction[]} transactions
|
||||
*/
|
||||
export function renderTransactions(element, transactions) {
|
||||
const activity = createElement("section", "wallets__activity");
|
||||
const title = document.createElement("h2");
|
||||
const groups = groupTransactionsByDate(transactions);
|
||||
|
||||
title.append("Activity");
|
||||
activity.append(title);
|
||||
|
||||
if (transactions.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
|
||||
empty.append("No activity yet");
|
||||
activity.append(empty);
|
||||
element.replaceChildren(activity);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [date, group] of groups) {
|
||||
const section = createElement("section", "wallets__tx-group");
|
||||
const heading = document.createElement("h3");
|
||||
const list = createElement("ol", "wallets__tx-list");
|
||||
|
||||
heading.append(date);
|
||||
for (const transaction of group) {
|
||||
list.append(createTransactionRow(transaction));
|
||||
}
|
||||
section.append(heading, list);
|
||||
activity.append(section);
|
||||
}
|
||||
|
||||
element.replaceChildren(activity);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
main.wallets {
|
||||
.wallets__results {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wallets__activity {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--white);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 400;
|
||||
line-height: var(--line-height-lg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.wallets__tx-group {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wallets__tx-list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wallets__tx {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 1rem;
|
||||
align-items: center;
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid color-mix(in oklch, var(--gray) 18%, transparent);
|
||||
}
|
||||
|
||||
.wallets__tx-main {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: var(--white);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--white);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span[data-wallets-tx-amount="positive"] {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
span[data-wallets-tx-amount="negative"] {
|
||||
color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
.wallets__tx-detail {
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
color: var(--gray);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wallets__tx button {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
height: 2rem;
|
||||
padding-inline: 0.625rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wallets__tx-dialog {
|
||||
width: min(100% - 2rem, 42rem);
|
||||
}
|
||||
|
||||
.wallets__tx-details {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--white);
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wallets__tx-addresses {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
main.wallets {
|
||||
.wallets__tx {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wallets__tx button {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @typedef {import("../../scan/index.js").WalletAddress} WalletAddress
|
||||
*
|
||||
* @typedef {Object} WalletTransactionAddress
|
||||
* @property {WalletAddress} walletAddress
|
||||
* @property {number} received
|
||||
* @property {number} sent
|
||||
* @property {number} net
|
||||
*
|
||||
* @typedef {Object} WalletTransaction
|
||||
* @property {string} txid
|
||||
* @property {string} date
|
||||
* @property {number | undefined} time
|
||||
* @property {string} status
|
||||
* @property {"receive" | "send" | "consolidation"} type
|
||||
* @property {number} amount
|
||||
* @property {number} fee
|
||||
* @property {WalletTransactionAddress[]} addresses
|
||||
* @property {unknown} raw
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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 getOutputAddress(output) {
|
||||
return readString(readObject(output)?.scriptpubkeyAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 getTransactionId(transaction) {
|
||||
return readString(readObject(transaction)?.txid) ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} transaction
|
||||
*/
|
||||
function getTransactionFee(transaction) {
|
||||
return readNumber(readObject(transaction)?.fee) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} transaction
|
||||
*/
|
||||
function getTransactionTime(transaction) {
|
||||
return readNumber(readObject(readObject(transaction)?.status)?.blockTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number | undefined} blockTime
|
||||
*/
|
||||
function getTransactionDate(blockTime) {
|
||||
if (blockTime !== undefined) {
|
||||
return new Date(blockTime * 1_000).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return "Mempool";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} transaction
|
||||
*/
|
||||
function getTransactionStatus(transaction) {
|
||||
return readObject(readObject(transaction)?.status)?.confirmed === true
|
||||
? "confirmed"
|
||||
: "mempool";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} sent
|
||||
* @param {number} externalOutputValue
|
||||
*/
|
||||
function getTransactionType(sent, externalOutputValue) {
|
||||
if (sent === 0) return "receive";
|
||||
if (externalOutputValue === 0) return "consolidation";
|
||||
|
||||
return "send";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} address
|
||||
* @param {number} value
|
||||
* @param {ReadonlySet<string>} walletAddressSet
|
||||
*/
|
||||
function isExternalOutput(address, value, walletAddressSet) {
|
||||
if (address === undefined) return value > 0;
|
||||
|
||||
return !walletAddressSet.has(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} transaction
|
||||
* @param {ReadonlySet<string>} walletAddressSet
|
||||
*/
|
||||
function getExternalOutputValue(transaction, walletAddressSet) {
|
||||
return readArray(transaction, "vout").reduce((total, output) => {
|
||||
const address = getOutputAddress(output);
|
||||
const value = getOutputValue(output);
|
||||
|
||||
return (
|
||||
total +
|
||||
(isExternalOutput(address, value, walletAddressSet) ? value : 0)
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} transaction
|
||||
* @param {readonly WalletAddress[]} walletAddresses
|
||||
* @returns {WalletTransaction}
|
||||
*/
|
||||
export function readWalletTransaction(transaction, walletAddresses) {
|
||||
const walletAddressSet = new Set(
|
||||
walletAddresses.map((walletAddress) => walletAddress.address),
|
||||
);
|
||||
const addresses = walletAddresses.map((walletAddress) => {
|
||||
const received = getTransactionReceived(transaction, walletAddress.address);
|
||||
const sent = getTransactionSent(transaction, walletAddress.address);
|
||||
|
||||
return {
|
||||
walletAddress,
|
||||
received,
|
||||
sent,
|
||||
net: received - sent,
|
||||
};
|
||||
}).filter((address) => {
|
||||
return address.received > 0 || address.sent > 0;
|
||||
});
|
||||
const received = addresses.reduce((total, address) => {
|
||||
return total + address.received;
|
||||
}, 0);
|
||||
const sent = addresses.reduce((total, address) => {
|
||||
return total + address.sent;
|
||||
}, 0);
|
||||
const externalOutputValue = getExternalOutputValue(
|
||||
transaction,
|
||||
walletAddressSet,
|
||||
);
|
||||
const net = received - sent;
|
||||
const time = getTransactionTime(transaction);
|
||||
|
||||
return {
|
||||
txid: getTransactionId(transaction),
|
||||
date: getTransactionDate(time),
|
||||
time,
|
||||
status: getTransactionStatus(transaction),
|
||||
type: getTransactionType(sent, externalOutputValue),
|
||||
amount: net,
|
||||
fee: getTransactionFee(transaction),
|
||||
addresses,
|
||||
raw: transaction,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user