Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
// Generated, do not edit. sha256:437d88ce2721c02e69b9a2fbd8a4429ac52560b575699f0f5ce5e41e7cc24369
import { normalizeMovements, normalizeNwcMovements, nodeBalance, channelTxids } from './lightning-tracker.js';
import { upsertWalletTxs, labelChannelTxs } from '../data/wallet-txs.js';
import { recordSourceBalance } from '../data/wallet-sources.js';
export const isLightningSource = s => s?.kind === 'lnd' || s?.kind === 'nwc';
export function walletTxRows(movements, source) {
const rows = [];
let skipped = 0;
for (const m of movements || []) {
const txid = m.hash || m.ref || null;
if (!txid || !m.at) { skipped++; continue; }
const direction = m.direction === 'out' ? 'out' : 'in';
rows.push({
accountId: source.accountId, sourceId: source.id, layer: 'lightning', txid, direction,
valueSats: Math.max(0, Math.round(Number(m.sats) || 0)),
feeSats: direction === 'out' ? Math.max(0, Math.round(Number(m.fee_sats) || 0)) : null,
blockTime: m.at, blockHeight: null, address: null, kind: m.self ? 'self' : null, memo: m.memo ?? null,
});
}
return { rows, skipped };
}
const msatToSats = msat => Math.max(0, Math.floor(Number(msat || 0) / 1000));
export async function scanLightningSource(source, { tracker = null, nwcClient = null } = {}) {
const tr = tracker || { normalizeMovements, normalizeNwcMovements, nodeBalance, channelTxids };
let movements, balanceSats, kanaalTxids = [];
if (source.kind === 'lnd') {
if (!source.connectionId) throw new Error('A LND source without a connection.');
movements = await tr.normalizeMovements(source.connectionId);
balanceSats = (await tr.nodeBalance(source.connectionId)).channelLocal;
kanaalTxids = [...await tr.channelTxids(source.connectionId)];
} else if (source.kind === 'nwc') {
movements = await tr.normalizeNwcMovements(source.id);
const nwc = nwcClient || await import('./nwc.js');
balanceSats = msatToSats((await nwc.getBalance(source.id))?.balance);
} else {
throw new Error(`Not a lightning source: '${source.kind}'.`);
}
const { rows, skipped } = walletTxRows(movements, source);
if (rows.length) await upsertWalletTxs(rows);
await recordSourceBalance(source.id, balanceSats);
const channelLabels = kanaalTxids.length ? await labelChannelTxs(source.accountId, kanaalTxids) : 0;
return { count: rows.length, skipped, balanceSats, channelLabels, channelTxids: kanaalTxids };
}
export function countMovements(txs, isLinked) {
const groups = new Map();
for (const tx of txs || []) {
const key = tx.layer === 'lightning' ? `ln:${tx.accountId}:${tx.txid}` : `id:${tx.id}`;
groups.set(key, groups.get(key) || isLinked(tx));
}
return { total: groups.size, linked: [...groups.values()].filter(Boolean).length };
}
export function lightningAccountSats(sources) {
const ln = (sources || []).filter(isLightningSource);
const heeftLnd = ln.some(s => s.kind === 'lnd');
return ln.filter(s => s.kind === 'lnd' || !heeftLnd).reduce((sum, s) => sum + (Number(s.balanceSats) || 0), 0);
}
+198
View File
@@ -0,0 +1,198 @@
// Generated, do not edit. sha256:20b466379c38e65d9cee77881aaa8890de723421f475561a2047de7bd3f03d95
import * as lnd from './lnd.js';
function toHex(v) {
const s = String(v || '');
if (!s) return '';
if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return s.toLowerCase();
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
let h = '';
for (let i = 0; i < bin.length; i++) h += bin.charCodeAt(i).toString(16).padStart(2, '0');
return h;
}
const toSats = v => Math.round(Number(v || 0));
const secToIso = s => (Number(s) ? new Date(Number(s) * 1000).toISOString() : null);
const nsToIso = ns => (Number(ns) ? new Date(Math.floor(Number(ns) / 1e6)).toISOString() : null);
const PAGE = 200;
const MAX_PAGES = 50;
async function fetchPayments(connId) {
const out = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await lnd.listPayments(connId, { max_payments: PAGE, index_offset: offset, include_incomplete: false });
const batch = (res && res.payments) || [];
out.push(...batch);
const next = Number(res && res.last_index_offset) || 0;
if (!batch.length || next <= offset) break;
offset = next;
}
return out;
}
async function fetchInvoices(connId) {
const out = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await lnd.listInvoices(connId, { num_max_invoices: PAGE, index_offset: offset });
const batch = (res && res.invoices) || [];
out.push(...batch);
const next = Number(res && res.last_index_offset) || 0;
if (!batch.length || next <= offset) break;
offset = next;
}
return out;
}
async function fetchForwarding(connId) {
const out = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await lnd.forwardingHistory(connId, { num_max_events: PAGE, index_offset: offset });
const batch = (res && res.forwarding_events) || [];
out.push(...batch);
const next = Number(res && res.last_offset_index) || 0;
if (!batch.length || next <= offset) break;
offset = next;
}
return out;
}
export async function normalizeMovements(connId) {
const [payments, invoices, forwards] = await Promise.all([
fetchPayments(connId), fetchInvoices(connId), fetchForwarding(connId),
]);
const okPayments = payments.filter(p => p.status === 'SUCCEEDED');
const okInvoices = invoices.filter(i => i.state === 'SETTLED' || i.settled === true);
const invoiceHashes = new Set(okInvoices.map(i => toHex(i.r_hash)));
const selfHashes = new Set(okPayments.map(p => toHex(p.payment_hash)).filter(h => invoiceHashes.has(h)));
const movements = [];
for (const p of okPayments) {
const hash = toHex(p.payment_hash);
const isSelf = selfHashes.has(hash);
movements.push({
kind: 'payment',
type: isSelf ? 'transfer' : 'sell',
direction: 'out',
hash,
at: nsToIso(p.creation_time_ns) || secToIso(p.creation_date),
sats: toSats(p.value_sat),
fee_sats: toSats(p.fee_sat),
self: isSelf,
memo: null,
});
}
for (const i of okInvoices) {
const hash = toHex(i.r_hash);
if (selfHashes.has(hash)) continue;
movements.push({
kind: 'invoice',
type: 'buy',
direction: 'in',
hash,
at: secToIso(i.settle_date) || secToIso(i.creation_date),
sats: toSats(i.amt_paid_sat || i.value),
fee_sats: 0,
self: false,
memo: i.memo || null,
});
}
for (const f of forwards) {
const fee = toSats(f.fee);
if (!fee) continue;
movements.push({
kind: 'forward',
type: 'interest',
direction: 'route',
hash: null,
ref: `fwd:${f.timestamp_ns || f.timestamp || ''}:${f.chan_id_in || ''}:${f.chan_id_out || ''}`,
at: nsToIso(f.timestamp_ns) || secToIso(f.timestamp),
sats: fee,
fee_sats: 0,
self: false,
memo: null,
});
}
movements.sort((a, b) => (a.at || '').localeCompare(b.at || ''));
return movements;
}
export function nwcMemo(x) {
const beschrijving = typeof x?.description === 'string' ? x.description : '';
const zap = x?.metadata && typeof x.metadata.nostr === 'object' ? x.metadata.nostr : null;
if (zap && !beschrijving.trim().startsWith('{')) return { kind: 9734, ...zap };
return beschrijving || (typeof x?.metadata?.comment === 'string' ? x.metadata.comment : null) || null;
}
export async function normalizeNwcMovements(sourceId) {
const nwc = await import('./nwc.js');
const all = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await nwc.listTransactions(sourceId, { limit: PAGE, offset, unpaid: false });
const batch = (res && res.transactions) || [];
all.push(...batch);
if (batch.length < PAGE) break;
offset += batch.length;
}
const inHashes = new Set(all.filter(x => x.type === 'incoming').map(x => String(x.payment_hash || '')).filter(Boolean));
const selfHashes = new Set(all.filter(x => x.type === 'outgoing').map(x => String(x.payment_hash || '')).filter(h => inHashes.has(h)));
const movements = [];
for (const x of all) {
const hash = String(x.payment_hash || '');
const isSelf = !!hash && selfHashes.has(hash);
if (x.type === 'incoming' && isSelf) continue;
movements.push({
kind: 'nwc',
type: isSelf ? 'transfer' : (x.type === 'incoming' ? 'buy' : 'sell'),
direction: x.type === 'incoming' ? 'in' : 'out',
hash: hash || null,
at: secToIso(x.settled_at) || secToIso(x.created_at),
sats: Math.round(Number(x.amount || 0) / 1000),
fee_sats: Math.round(Number(x.fees_paid || 0) / 1000),
self: isSelf,
memo: nwcMemo(x),
});
}
movements.sort((a, b) => (a.at || '').localeCompare(b.at || ''));
return movements;
}
export async function nodeBalance(connId) {
const [chain, chan, chans] = await Promise.all([
lnd.blockchainBalance(connId), lnd.channelBalance(connId), lnd.listChannels(connId),
]);
const amt = a => toSats(a && typeof a === 'object' ? a.sat : a);
const onchainConfirmed = toSats(chain && chain.confirmed_balance);
const onchainUnconfirmed = toSats(chain && chain.unconfirmed_balance);
const channelLocal = (chans && Array.isArray(chans.channels) ? chans.channels : [])
.reduce((s, ch) => s + toSats(ch && ch.local_balance) + (ch && ch.initiator ? toSats(ch.commit_fee) : 0), 0);
const unsettledLocal = amt(chan && chan.unsettled_local_balance);
const pendingOpen = amt(chan && chan.pending_open_local_balance) || toSats(chan && chan.pending_open_balance);
const anchor = onchainConfirmed + channelLocal;
const inflight = onchainUnconfirmed + unsettledLocal + pendingOpen;
return { onchainConfirmed, onchainUnconfirmed, channelLocal, unsettledLocal, pendingOpen, anchor, inflight };
}
const ZERO_TXID = /^0+$/;
export async function channelTxids(connId) {
const [open, closed] = await Promise.all([lnd.listChannels(connId), lnd.listClosedChannels(connId)]);
const out = new Set();
const funding = point => String(point || '').split(':')[0].toLowerCase();
for (const ch of (open && open.channels) || []) if (funding(ch.channel_point)) out.add(funding(ch.channel_point));
for (const ch of (closed && closed.channels) || []) {
if (funding(ch.channel_point)) out.add(funding(ch.channel_point));
const sluit = String(ch.closing_tx_hash || '').toLowerCase();
if (sluit && !ZERO_TXID.test(sluit)) out.add(sluit);
}
return out;
}
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:bf0605d21f5259254ae5ab85694c9ae241adda242e38f4332cc0db1ae9405d9d
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
async function api(path, { method = 'GET', body = null } = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), CONFIG.LND.CLIENT_TIMEOUT_MS);
try {
const res = await fetch(path, {
method,
signal: ctl.signal,
...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
return json;
} catch (e) {
if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.LND.CLIENT_TIMEOUT_MS / 1000 }));
throw e;
} finally { clearTimeout(timer); }
}
export const status = id => api(`/api/lnd/status?connection=${encodeURIComponent(id)}`);
export const setConfig = (id, { url, macaroon, tls_cert }) =>
api('/api/lnd/config', { method: 'POST', body: { connection: id, url, macaroon, tls_cert } });
export const purgeConfig = id => api('/api/lnd/delete', { method: 'POST', body: { connection: id } });
export const call = (id, endpoint, { query = null, body = null } = {}) =>
api('/api/lnd/call', { method: 'POST', body: { connection: id, endpoint, query, body } });
export const getInfo = id => call(id, 'getinfo');
export const channelBalance = id => call(id, 'balance_channels');
export const blockchainBalance = id => call(id, 'balance_blockchain');
export const listChannels = id => call(id, 'channels');
export const listClosedChannels = id => call(id, 'channels_closed');
export const listPayments = (id, query = {}) => call(id, 'payments', { query });
export const listInvoices = (id, query = {}) => call(id, 'invoices', { query });
export const forwardingHistory = (id, body = {}) => call(id, 'forwarding', { body });
export async function testLnd(id) {
const info = await getInfo(id);
return {
alias: info.alias || null,
synced: !!info.synced_to_chain,
blockHeight: Number(info.block_height) || 0,
};
}
+19
View File
@@ -0,0 +1,19 @@
// Generated, do not edit. sha256:c864b8912c6f9fb6e1f43a0e7802dcf91da8cc548250568950b56366eecfca04
import { zapContext, npubShort, compactMemo } from '../../shared/zap.js';
import { t } from '../i18n/index.js';
export function memoLine(memo, direction, max = 80) {
const z = zapContext(memo);
let text;
if (z) {
const wie = npubShort(direction === 'in' ? z.sender : z.recipient);
const kop = direction === 'in'
? (wie ? t('tracking.zapFrom', { who: wie }) : t('tracking.zapIn'))
: (wie ? t('tracking.zapTo', { who: wie }) : t('tracking.zapOut'));
text = z.message ? `${kop}: "${z.message}"` : kop;
} else {
text = compactMemo(memo);
}
if (!text) return null;
return { text: text.length > max ? `${text.slice(0, max - 1)}…` : text, title: text };
}
+36
View File
@@ -0,0 +1,36 @@
// Generated, do not edit. sha256:5e5e2f16e143c15a74105363f7c477f9e5d2936d0860e2793eadda36b591c7e6
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
async function api(path, { method = 'GET', body = null } = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), CONFIG.NWC.CLIENT_TIMEOUT_MS);
try {
const res = await fetch(path, {
method,
signal: ctl.signal,
...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
return json;
} catch (e) {
if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.NWC.CLIENT_TIMEOUT_MS / 1000 }));
throw e;
} finally { clearTimeout(timer); }
}
export const validateUri = uri => api('/api/nwc/validate', { method: 'POST', body: { uri } });
export const status = sourceId => api(`/api/nwc/status?source=${encodeURIComponent(sourceId)}`);
export const setUri = (sourceId, uri) => api('/api/nwc/config', { method: 'POST', body: { source: sourceId, uri } });
export const purgeUri = sourceId => api('/api/nwc/delete', { method: 'POST', body: { source: sourceId } });
export const call = (sourceId, method, params = {}) =>
api('/api/nwc/call', { method: 'POST', body: { source: sourceId, method, params } });
export const getInfo = sourceId => call(sourceId, 'get_info');
export const getBalance = sourceId => call(sourceId, 'get_balance');
export const listTransactions = (sourceId, params = {}) => call(sourceId, 'list_transactions', params);
export const testNwc = sourceId => api('/api/nwc/test', { method: 'POST', body: { source: sourceId } });