Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } });
|
||||
Reference in new issue
Block a user