global: private xpub support part 3

This commit is contained in:
nym21
2026-06-17 21:23:26 +02:00
parent 43df9e098c
commit 408d83c350
14 changed files with 637 additions and 507 deletions
+1 -2
View File
@@ -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>
+1 -7
View File
@@ -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),
};
}
+15 -5
View File
@@ -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,
});
@@ -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,
};
}