Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -0,0 +1,74 @@
|
||||
// Generated, do not edit. sha256:e9ea9293b00980ab0270b38c345d130a439421cd662f57bea63c105e66a1587f
|
||||
import { CONFIG } from '../../shared/config.js';
|
||||
import { activeConnection } from '../data/connections.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export async function explorerBase() {
|
||||
return (await activeConnection('btc_explorer')).url;
|
||||
}
|
||||
|
||||
const HTTP_TIMEOUT_MS = 12000;
|
||||
const HTTP_RETRIES = 1;
|
||||
const HTTP_BACKOFF_MS = 800;
|
||||
export async function fetchJson(url, label, options = {}) {
|
||||
let laatste;
|
||||
for (let poging = 0; poging <= HTTP_RETRIES; poging++) {
|
||||
if (poging) await new Promise(r => setTimeout(r, HTTP_BACKOFF_MS * poging));
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { ...options, signal: ctrl.signal });
|
||||
if (res.ok) return await res.json();
|
||||
laatste = new Error(t('integrations.price.callFailed', { label, status: res.status }));
|
||||
if (res.status < 500 && res.status !== 429) throw laatste;
|
||||
} catch (e) {
|
||||
if (e === laatste) throw e;
|
||||
laatste = e.name === 'AbortError'
|
||||
? new Error(t('integrations.price.callTimeout', { label, s: HTTP_TIMEOUT_MS / 1000 }))
|
||||
: e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
throw laatste;
|
||||
}
|
||||
|
||||
export async function addressInfo(addr) {
|
||||
const base = await explorerBase();
|
||||
const d = await fetchJson(`${base}/api/address/${addr}`, t('integrations.price.labelAddress', { adres: addr }));
|
||||
const balance_sats = d.chain_stats.funded_txo_sum - d.chain_stats.spent_txo_sum;
|
||||
const tx_count = d.chain_stats.tx_count + d.mempool_stats.tx_count;
|
||||
return { balance_sats, tx_count };
|
||||
}
|
||||
|
||||
const PAGE = 25;
|
||||
const MAX_PAGES = 200;
|
||||
const lastConfirmedTxid = batch => {
|
||||
for (let i = batch.length - 1; i >= 0; i--) {
|
||||
if (batch[i].status && batch[i].status.confirmed) return batch[i].txid;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export async function addressTxs(addr) {
|
||||
const base = await explorerBase();
|
||||
const all = await fetchJson(`${base}/api/address/${addr}/txs`, t('integrations.price.labelAddressTxs', { adres: addr }));
|
||||
|
||||
let cursor = lastConfirmedTxid(all);
|
||||
let pages = 0;
|
||||
while (cursor && pages < MAX_PAGES) {
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
let batch;
|
||||
try {
|
||||
batch = await fetchJson(`${base}/api/address/${addr}/txs/chain/${cursor}`, t('integrations.price.labelAddressTxsPage'));
|
||||
} catch (e) {
|
||||
console.warn(`[addressTxs] pagination stopped for ${addr}: ${e.message}`);
|
||||
break;
|
||||
}
|
||||
if (!batch.length) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < PAGE) break;
|
||||
cursor = batch[batch.length - 1].txid;
|
||||
pages++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Generated, do not edit. sha256:7227a0859017790e220d3094fb16cfe6c1e290c2297569d78de93f970684ea0d
|
||||
import { intradaySeries } from '../data/market.js';
|
||||
import { currentRate } from './providers.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
const FRESH_MS = 60_000;
|
||||
const memo = new Map();
|
||||
const NONE = Object.freeze({ points: null });
|
||||
|
||||
async function toBase(r) {
|
||||
if (r.status !== 'ok') return NONE;
|
||||
const rate = await currentRate(r.currency);
|
||||
if (rate == null) return NONE;
|
||||
const named = src => t('marketService.sourceLive', { source: src });
|
||||
const src = r.sourceLabel || r.source;
|
||||
const source = src ? named(src) : t('connections.kindMarket');
|
||||
const sources = new Map();
|
||||
for (const [ms, , own] of r.points) if (own) sources.set(ms, named(own));
|
||||
return { points: r.points.map(([ms, p]) => [ms, p * rate]), source, sources };
|
||||
}
|
||||
|
||||
export async function loadIntraday(assetId, sinceMs) {
|
||||
const hit = memo.get(assetId);
|
||||
if (hit && hit.since === sinceMs && Date.now() - hit.at < FRESH_MS) return hit.result;
|
||||
let result;
|
||||
try { result = await toBase(await intradaySeries(assetId, sinceMs)); }
|
||||
catch { result = NONE; }
|
||||
memo.set(assetId, { at: Date.now(), since: sinceMs, result });
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Generated, do not edit. sha256:48f77fc8fc8efc987ebeae10a9b33669b28691941eb865e07c2c66413d1ef0cf
|
||||
import { getPref, setPref } from '../data/preferences.js';
|
||||
|
||||
export const MOMENTUM = Object.freeze({
|
||||
SIZE: 60,
|
||||
MAX_AGE_MS: 6 * 3600000,
|
||||
});
|
||||
|
||||
const PREF_SCOPE = 'momentum';
|
||||
|
||||
const recent = (moves, nowMs) => moves.filter(m => nowMs - m.at <= MOMENTUM.MAX_AGE_MS);
|
||||
|
||||
export function addPrice(buffer, price, nowMs) {
|
||||
if (!Number.isFinite(price) || price <= 0) return buffer;
|
||||
const prev = buffer || { last: null, lastAt: 0, moves: [] };
|
||||
const fresh = prev.last != null && nowMs - prev.lastAt <= MOMENTUM.MAX_AGE_MS;
|
||||
if (fresh && price === prev.last) return prev;
|
||||
const kept = recent(prev.moves, nowMs);
|
||||
const moves = fresh ? [...kept, { at: nowMs, dir: price > prev.last ? 1 : -1 }].slice(-MOMENTUM.SIZE) : kept;
|
||||
return { last: price, lastAt: nowMs, moves };
|
||||
}
|
||||
|
||||
export function restore(saved, nowMs) {
|
||||
if (!saved || !Number.isFinite(saved.last) || !Number.isFinite(saved.lastAt) || !Array.isArray(saved.moves)) return null;
|
||||
const moves = saved.moves
|
||||
.filter(m => m && Number.isFinite(m.at) && (m.dir === 1 || m.dir === -1))
|
||||
.slice(-MOMENTUM.SIZE);
|
||||
return { last: saved.last, lastAt: saved.lastAt, moves: recent(moves, nowMs) };
|
||||
}
|
||||
|
||||
export function tally(buffer, nowMs) {
|
||||
const moves = recent(buffer?.moves || [], nowMs);
|
||||
let balance = 0, tone = null;
|
||||
for (const m of moves) {
|
||||
balance += m.dir;
|
||||
if (balance !== 0) tone = balance > 0 ? 'up' : 'down';
|
||||
}
|
||||
const up = moves.filter(m => m.dir > 0).length;
|
||||
return { up, down: moves.length - up, tone };
|
||||
}
|
||||
|
||||
const buffers = new Map();
|
||||
const loads = new Map();
|
||||
|
||||
export function loadMomentum(assetId, nowMs = Date.now()) {
|
||||
if (assetId == null) return Promise.resolve();
|
||||
if (!loads.has(assetId)) {
|
||||
loads.set(assetId, (async () => {
|
||||
try { buffers.set(assetId, restore(await getPref(PREF_SCOPE, String(assetId), null), nowMs)); }
|
||||
catch { buffers.set(assetId, null); }
|
||||
})());
|
||||
}
|
||||
return loads.get(assetId);
|
||||
}
|
||||
|
||||
export async function recordSpot(assetId, price, nowMs = Date.now()) {
|
||||
if (assetId == null) return;
|
||||
await loadMomentum(assetId, nowMs);
|
||||
const prev = buffers.get(assetId) || null;
|
||||
const next = addPrice(prev, price, nowMs);
|
||||
if (next === prev) return;
|
||||
buffers.set(assetId, next);
|
||||
try { await setPref(PREF_SCOPE, String(assetId), next); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
export function momentumOf(assetId, nowMs = Date.now()) {
|
||||
return tally(buffers.get(assetId) || null, nowMs);
|
||||
}
|
||||
|
||||
export function clearMomentum() { buffers.clear(); loads.clear(); }
|
||||
@@ -0,0 +1,566 @@
|
||||
// Generated, do not edit. sha256:1f114683952f59d1ca0874094fffe2f799e396366bc22e66b0b9cf051e48c0cd
|
||||
import { recordApiCall, apiMetricsTotals } from '../data/api-metrics.js';
|
||||
import { serverFoutTekst } from '../data/_rpc.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { priceSourceChain, ownPriceSourceChain } from '../data/price-source.js';
|
||||
import { upsertPriceHistory, listPriceHistory, getPricePoint, recordCurrentSpot, latestSpotPoint,
|
||||
listNoPriceDays, recordNoPriceDays } from '../data/price-history.js';
|
||||
import { getProvider } from '../data/providers.js';
|
||||
import { listAssets } from '../data/assets.js';
|
||||
import { CONFIG, sourceCapabilityOf } from '../../shared/config.js';
|
||||
import { baseCurrency, appSetting } from '../app-settings.js';
|
||||
import { recordSpot } from './momentum.js';
|
||||
import { serviceSpot, serviceDayHistory, serviceCoverage } from '../data/market.js';
|
||||
|
||||
const perfNow = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
|
||||
|
||||
function emitApiCall(detail) {
|
||||
if (typeof document !== 'undefined' && typeof document.dispatchEvent === 'function') {
|
||||
document.dispatchEvent(new CustomEvent('api-call', { detail }));
|
||||
}
|
||||
}
|
||||
|
||||
function serviceCalled(asset, capability, ok, t0) {
|
||||
emitApiCall({ provider_key: CONFIG.MARKET_SERVICE.PROVIDER_KEY, capability, symbol: asset.symbol, ok,
|
||||
latency_ms: Math.round(perfNow() - t0), ts: Date.now() });
|
||||
}
|
||||
|
||||
function vasteKoers(provider, capability, date) {
|
||||
if (capability !== 'spot' && capability !== 'history_point') {
|
||||
throw new Error(t('integrations.price.notFromFixedRate', { provider: provider.key, capability }));
|
||||
}
|
||||
const uit = { price: provider.fixedRate.fiatPrice };
|
||||
if (capability !== 'history_point') return uit;
|
||||
const time = Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
|
||||
if (!Number.isFinite(time)) throw new Error(t('integrations.price.invalidDate', { provider: provider.key, datum: date }));
|
||||
return { ...uit, time };
|
||||
}
|
||||
|
||||
const usageThisMonth = new Map();
|
||||
async function usageFor(provider) {
|
||||
if (!provider.callsPerMonth || !Number.isInteger(provider.id)) return null;
|
||||
const month = new Date().toISOString().slice(0, 7);
|
||||
let u = usageThisMonth.get(provider.id);
|
||||
if (!u || u.month !== month) {
|
||||
let calls = 0;
|
||||
try { calls = (await apiMetricsTotals(provider.id, `${month}-01T00:00`)).calls; } catch { }
|
||||
u = { month, calls };
|
||||
usageThisMonth.set(provider.id, u);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
export function forgetUsage() { usageThisMonth.clear(); }
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('providers-changed', forgetUsage);
|
||||
}
|
||||
|
||||
function intervalOf(provider, capability) {
|
||||
const e = provider && Array.isArray(provider.endpoints) && provider.endpoints.find(x => x.capability === capability);
|
||||
return e && e.minIntervalSeconds > 0 ? e.minIntervalSeconds : null;
|
||||
}
|
||||
|
||||
const lastCallAt = new Map();
|
||||
function sleep(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal && signal.aborted) { reject(stoppedError()); return; }
|
||||
const timer = setTimeout(() => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); }, ms);
|
||||
const onAbort = () => { clearTimeout(timer); reject(stoppedError()); };
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
function stoppedError() {
|
||||
const e = new Error('Stopped while waiting for the minimum interval of the source.');
|
||||
e.stopped = true;
|
||||
return e;
|
||||
}
|
||||
async function spacing(provider, capability, signal) {
|
||||
const seconds = capability === 'spot' ? null : intervalOf(provider, capability);
|
||||
if (!seconds) return false;
|
||||
const last = lastCallAt.get(`${provider.id}|${capability}`);
|
||||
const wait = last == null ? 0 : last + seconds * 1000 - Date.now();
|
||||
if (wait > 0) await sleep(wait, signal);
|
||||
return true;
|
||||
}
|
||||
export function forgetCallTimes() { lastCallAt.clear(); }
|
||||
|
||||
async function callPrice(provider, capability, symbol, date, { signal = null } = {}) {
|
||||
if (provider.fixedRate) return vasteKoers(provider, capability, date);
|
||||
const tegoed = await usageFor(provider);
|
||||
if (tegoed && tegoed.calls >= provider.callsPerMonth) {
|
||||
const e = new Error(t('integrations.price.limitReached', { provider: provider.name, limiet: provider.callsPerMonth }));
|
||||
e.limitReached = true;
|
||||
throw e;
|
||||
}
|
||||
if (await spacing(provider, capability, signal)) lastCallAt.set(`${provider.id}|${capability}`, Date.now());
|
||||
if (tegoed) tegoed.calls++;
|
||||
const t0 = perfNow();
|
||||
let status = 0, ok = false, data = null, err = null;
|
||||
try {
|
||||
const res = await fetch('/api/price/call', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, capability, symbol, date }),
|
||||
});
|
||||
status = res.status;
|
||||
data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || (data && data.error)) {
|
||||
err = new Error(serverFoutTekst(data) || t('integrations.priceCallFailed', { status }));
|
||||
if (data && data.key) { err.key = data.key; err.params = data.params || null; }
|
||||
} else ok = true;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
const latency = Math.round(perfNow() - t0);
|
||||
emitApiCall({ provider_key: provider.key, provider_name: provider.name, capability, symbol, ok, http_status: status, latency_ms: latency, ts: Date.now() });
|
||||
try { await recordApiCall({ providerId: provider.id, capability, ok, latency_ms: latency }); }
|
||||
catch { }
|
||||
if (err) throw err;
|
||||
return inBaseCurrency(provider, capability, symbol, date, data);
|
||||
}
|
||||
|
||||
const dayOf = time => new Date(time * 1000).toISOString().slice(0, 10);
|
||||
|
||||
async function rateLookup(currency) {
|
||||
const asset = (await listAssets()).find(a => a.symbol === currency && a.kind === 'fiat');
|
||||
if (!asset) return () => null;
|
||||
try { await ensurePriceHistoryFor(asset); } catch { }
|
||||
const rows = (await listPriceHistory(asset.id))
|
||||
.filter(r => r.resolution === 'day' && r.fiatPrice != null && r.fiatPrice > 0)
|
||||
.map(r => ({ day: r.observedAt.slice(0, 10), fiatPrice: r.fiatPrice }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
const maxAge = CONFIG.PRICE_HISTORY.FX_MAX_AGE_DAYS;
|
||||
return day => {
|
||||
let lo = 0, hi = rows.length - 1, hit = null;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (rows[mid].day <= day) { hit = rows[mid]; lo = mid + 1; } else hi = mid - 1;
|
||||
}
|
||||
if (!hit) return null;
|
||||
const age = (Date.parse(`${day}T00:00:00Z`) - Date.parse(`${hit.day}T00:00:00Z`)) / 86400000;
|
||||
return age <= maxAge ? hit.fiatPrice : null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function rateSourceFor(currency) {
|
||||
if (!currency || currency === baseCurrency()) return null;
|
||||
const asset = (await listAssets()).find(a => a.symbol === currency && a.kind === 'fiat');
|
||||
if (!asset) return null;
|
||||
return (await primaryProvider(asset, 'daily_history'))
|
||||
|| (await primaryProvider(asset, 'spot'))
|
||||
|| (await serviceRateSource(asset));
|
||||
}
|
||||
|
||||
async function serviceRateSource(asset) {
|
||||
if (marketSpotMode() === 'off') return null;
|
||||
try {
|
||||
const r = await serviceCoverage();
|
||||
if (r.status !== 'ok' || !r.assetIds.includes(asset.id)) return null;
|
||||
return await getProvider(CONFIG.MARKET_SERVICE.PROVIDER_KEY);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export const basePriceOf = spot => (spot && Number.isFinite(spot.price) ? spot.price : null);
|
||||
|
||||
export async function currentRate(currency) {
|
||||
if (currency === baseCurrency()) return 1;
|
||||
return (await rateLookup(currency))(todayIso());
|
||||
}
|
||||
|
||||
export async function rateOn(currency, day) {
|
||||
if (currency === baseCurrency()) return 1;
|
||||
return (await rateLookup(currency))(day);
|
||||
}
|
||||
|
||||
async function inBaseCurrency(provider, capability, symbol, date, data) {
|
||||
const endpoint = provider.endpoints.find(e => e.capability === capability);
|
||||
const currency = endpoint && endpoint.quoteCurrency;
|
||||
if (!currency || currency === baseCurrency()) return data;
|
||||
if (symbol === currency) throw new Error(`Provider ${provider.key}: ${currency} cannot be quoted in ${currency}.`);
|
||||
const rate = await rateLookup(currency);
|
||||
const convert = (p, day) => {
|
||||
const r = rate(day);
|
||||
if (!Number.isFinite(p.price) || r == null) return null;
|
||||
return { ...p, price: p.price * r };
|
||||
};
|
||||
if (capability === 'daily_history') {
|
||||
return (Array.isArray(data) ? data : []).map(p => convert(p, dayOf(p.time))).filter(Boolean);
|
||||
}
|
||||
const day = capability === 'history_point' ? (date || dayOf(data.time)) : todayIso();
|
||||
const uit = convert(data, day);
|
||||
if (!uit) throw new Error(t('integrations.price.noExchangeRate', { valuta: currency, datum: day, munt: baseCurrency() }));
|
||||
return uit;
|
||||
}
|
||||
function hasCapability(provider, capability) {
|
||||
return !!(provider && provider.endpoints.some(e => e.capability === capability));
|
||||
}
|
||||
const todayIso = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
async function newestStoredDate(assetId) {
|
||||
const rows = (await listPriceHistory(assetId)).filter(r => r.resolution === 'day');
|
||||
return rows.length ? rows[0].observedAt.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
const seriesFetchedOn = new Map();
|
||||
|
||||
export function forgetSeriesFetches() { seriesFetchedOn.clear(); serviceAskAfter.clear(); }
|
||||
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('providers-changed', forgetSeriesFetches);
|
||||
}
|
||||
|
||||
async function ensureHistoryWith(provider, asset) {
|
||||
if (!provider) return;
|
||||
const newest = await newestStoredDate(asset.id);
|
||||
if (newest && newest >= todayIso()) return;
|
||||
if (hasCapability(provider, 'daily_history')) {
|
||||
const sleutel = `${provider.key}|${asset.id}`;
|
||||
if (seriesFetchedOn.get(sleutel) === todayIso()) return;
|
||||
const points = await callPrice(provider, 'daily_history', asset.symbol);
|
||||
if (Array.isArray(points) && points.length) await upsertPriceHistory(asset.id, points, provider.key, 'day');
|
||||
seriesFetchedOn.set(sleutel, todayIso());
|
||||
return;
|
||||
}
|
||||
if (hasCapability(provider, 'spot')) {
|
||||
const p = await callPrice(provider, 'spot', asset.symbol);
|
||||
if (p && Number.isFinite(p.price)) {
|
||||
const time = Math.floor(Date.parse(`${todayIso()}T00:00:00Z`) / 1000);
|
||||
await upsertPriceHistory(asset.id, [{ time, price: p.price }], provider.key, 'day');
|
||||
}
|
||||
}
|
||||
}
|
||||
async function serviceDaysAnswer(asset, sinceMs) {
|
||||
const since = Math.max(sinceMs, Date.now() - CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS * 86_400_000);
|
||||
let r;
|
||||
const t0 = perfNow();
|
||||
try { r = await serviceDayHistory(asset.id, since); } catch { serviceCalled(asset, 'daily_history', false, t0); return { failed: true, retryLater: true }; }
|
||||
serviceCalled(asset, 'daily_history', r.status === 'ok', t0);
|
||||
if (r.status !== 'ok') {
|
||||
return { failed: true, retryLater: r.status === 'unavailable' && !!r.retryLater, refused: r.status === 'unavailable' && !r.retryLater };
|
||||
}
|
||||
const points = r.days.map(([ms, price, sourceName]) => ({ time: Math.floor(ms / 1000), price, ...(sourceName ? { sourceName } : {}) }));
|
||||
if (r.currency === baseCurrency()) return { points };
|
||||
if (asset.kind === 'fiat' && asset.symbol === r.currency) return { failed: true };
|
||||
const rate = await rateLookup(r.currency);
|
||||
return {
|
||||
points: points.map((p) => {
|
||||
const x = rate(dayOf(p.time));
|
||||
return x == null ? null : { ...p, price: p.price * x };
|
||||
}).filter(Boolean),
|
||||
};
|
||||
}
|
||||
async function serviceDays(asset, sinceMs) {
|
||||
return (await serviceDaysAnswer(asset, sinceMs)).points ?? null;
|
||||
}
|
||||
|
||||
const serviceAskAfter = new Map();
|
||||
|
||||
async function ensureHistoryFromService(asset) {
|
||||
const sleutel = `${CONFIG.MARKET_SERVICE.PROVIDER_KEY}|${asset.id}`;
|
||||
if (seriesFetchedOn.get(sleutel) === todayIso()) return true;
|
||||
if ((serviceAskAfter.get(sleutel) ?? 0) > Date.now()) return false;
|
||||
const yesterday = new Date(Date.parse(`${todayIso()}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10);
|
||||
const newest = (await listPriceHistory(asset.id))
|
||||
.filter(row => row.resolution === 'day' && row.observedAt.slice(0, 10) < todayIso())
|
||||
.map(row => row.observedAt.slice(0, 10)).sort().at(-1) ?? null;
|
||||
if (newest && newest >= yesterday) return true;
|
||||
const answer = await serviceDaysAnswer(asset, newest ? Date.parse(`${newest}T00:00:00Z`) + 86_400_000 : 0);
|
||||
if (!answer.points) {
|
||||
if (answer.refused) serviceAskAfter.set(sleutel, Date.parse(`${todayIso()}T00:00:00Z`) + 86_400_000);
|
||||
else if (answer.retryLater) serviceAskAfter.set(sleutel, Date.now() + CONFIG.MARKET_SERVICE.RETRY_PAUSE_MS);
|
||||
return false;
|
||||
}
|
||||
const points = answer.points;
|
||||
if (points.length) await upsertPriceHistory(asset.id, points, CONFIG.MARKET_SERVICE.PROVIDER_KEY, 'day');
|
||||
seriesFetchedOn.set(sleutel, todayIso());
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensurePriceHistoryFor(asset) {
|
||||
const mode = marketSpotMode();
|
||||
const grid = await primaryProvider(asset, 'daily_history');
|
||||
const own = grid || await primaryProvider(asset, 'spot');
|
||||
if (mode === 'first') {
|
||||
const chosen = grid && (await ownPriceSourceChain(asset.id, sourceCapabilityOf('daily_history'))).includes(grid.key);
|
||||
if (!chosen && await ensureHistoryFromService(asset)) return;
|
||||
}
|
||||
if (mode !== 'fallback') return ensureHistoryWith(own, asset);
|
||||
if (own) {
|
||||
try { return await ensureHistoryWith(own, asset); } catch (e) {
|
||||
if (await ensureHistoryFromService(asset)) return;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await ensureHistoryFromService(asset);
|
||||
}
|
||||
|
||||
export async function resolveChain(asset, capability) {
|
||||
if (!asset) return [];
|
||||
const keuze = sourceCapabilityOf(capability);
|
||||
if (!keuze) return [];
|
||||
const keys = await priceSourceChain(asset.id, keuze);
|
||||
const providers = await Promise.all(keys.map(key => getProvider(key)));
|
||||
return providers.filter(p => p && p.isActive && hasCapability(p, capability) && covers(p, asset.symbol));
|
||||
}
|
||||
function covers(provider, symbol) {
|
||||
return Array.isArray(provider.coverage) && provider.coverage.includes(symbol);
|
||||
}
|
||||
|
||||
export async function primaryProvider(asset, capability) {
|
||||
return (await resolveChain(asset, capability))[0] || null;
|
||||
}
|
||||
export async function primarySourceKey(asset, capability) {
|
||||
const p = await primaryProvider(asset, capability);
|
||||
return p ? p.key : null;
|
||||
}
|
||||
|
||||
const spotAnswers = new Map();
|
||||
async function recentSpot(provider, asset, seconds) {
|
||||
const sleutel = `${provider.id}|${asset.symbol}`;
|
||||
const houdbaar = at => Date.now() - at < seconds * 1000;
|
||||
const m = spotAnswers.get(sleutel);
|
||||
if (m && houdbaar(m.at)) return m.spot;
|
||||
try {
|
||||
const r = await latestSpotPoint(asset.id, provider.key);
|
||||
const at = r ? Date.parse(r.observedAt) : NaN;
|
||||
if (Number.isFinite(at) && houdbaar(at)) {
|
||||
const spot = { price: r.fiatPrice };
|
||||
spotAnswers.set(sleutel, { at, spot });
|
||||
return spot;
|
||||
}
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('providers-changed', () => spotAnswers.clear());
|
||||
}
|
||||
export function forgetSpotAnswers() { spotAnswers.clear(); }
|
||||
|
||||
async function spotVia(provider, asset) {
|
||||
const seconds = intervalOf(provider, 'spot');
|
||||
if (seconds) {
|
||||
const vers = await recentSpot(provider, asset, seconds);
|
||||
if (vers) return vers;
|
||||
}
|
||||
const spot = await callPrice(provider, 'spot', asset.symbol);
|
||||
if (seconds) spotAnswers.set(`${provider.id}|${asset.symbol}`, { at: Date.now(), spot });
|
||||
try { await recordCurrentSpot(asset.id, provider.key, spot); } catch { }
|
||||
return spot;
|
||||
}
|
||||
|
||||
const spotCache = new Map();
|
||||
const spotSources = new Map();
|
||||
|
||||
export function clearSpotCache() { spotCache.clear(); spotSources.clear(); }
|
||||
|
||||
export function lastSpotSource(asset) {
|
||||
return spotSources.get(asset && asset.id) ?? null;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('tick:spot', clearSpotCache);
|
||||
document.addEventListener('providers-changed', clearSpotCache);
|
||||
}
|
||||
|
||||
function marketSpotMode() {
|
||||
try { return appSetting('market_spot'); } catch { return 'off'; }
|
||||
}
|
||||
|
||||
async function askService(asset) {
|
||||
let r;
|
||||
const t0 = perfNow();
|
||||
try { r = await serviceSpot(asset.id); } catch { serviceCalled(asset, 'spot', false, t0); return null; }
|
||||
serviceCalled(asset, 'spot', r.status === 'ok', t0);
|
||||
if (r.status === 'unavailable') return { unavailable: true };
|
||||
if (r.status !== 'ok') return null;
|
||||
const rate = await currentRate(r.currency);
|
||||
if (rate == null) return null;
|
||||
const spot = { price: r.price * rate };
|
||||
try { await recordCurrentSpot(asset.id, CONFIG.MARKET_SERVICE.PROVIDER_KEY, spot); } catch { }
|
||||
return { spot, label: t('marketService.sourceLive', { source: r.sourceLabel || r.source || '?' }) };
|
||||
}
|
||||
|
||||
async function cachedServiceSpot(asset) {
|
||||
try {
|
||||
const r = await latestSpotPoint(asset.id, CONFIG.MARKET_SERVICE.PROVIDER_KEY);
|
||||
if (!r) return null;
|
||||
return { spot: { price: r.fiatPrice }, label: t('marketService.sourceCached', { when: r.observedAt.slice(0, 16).replace('T', ' ') }) };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function keep(sleutel, spot, label, fresh) {
|
||||
spotCache.set(sleutel, spot);
|
||||
spotSources.set(sleutel, label);
|
||||
if (fresh) recordSpot(sleutel, basePriceOf(spot));
|
||||
return spot;
|
||||
}
|
||||
|
||||
async function firstSpot(sleutel, asset, providers) {
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
return keep(sleutel, await spotVia(provider, asset), provider.name, true);
|
||||
} catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function liveSpotFor(asset) {
|
||||
const sleutel = asset && asset.id;
|
||||
if (spotCache.has(sleutel)) return spotCache.get(sleutel);
|
||||
const mode = marketSpotMode();
|
||||
let chain = await resolveChain(asset, 'spot');
|
||||
let dienst = null;
|
||||
if (mode === 'first') {
|
||||
const eigen = new Set(await ownPriceSourceChain(asset.id, sourceCapabilityOf('spot')));
|
||||
const gekozen = await firstSpot(sleutel, asset, chain.filter(p => eigen.has(p.key)));
|
||||
if (gekozen) return gekozen;
|
||||
dienst = await askService(asset);
|
||||
if (dienst?.spot) return keep(sleutel, dienst.spot, dienst.label, true);
|
||||
chain = chain.filter(p => !eigen.has(p.key));
|
||||
}
|
||||
const lokaal = await firstSpot(sleutel, asset, chain);
|
||||
if (lokaal) return lokaal;
|
||||
if (mode === 'fallback') {
|
||||
dienst = await askService(asset);
|
||||
if (dienst?.spot) return keep(sleutel, dienst.spot, dienst.label, true);
|
||||
}
|
||||
if (dienst?.unavailable) {
|
||||
const cached = await cachedServiceSpot(asset);
|
||||
if (cached) return keep(sleutel, cached.spot, cached.label, false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function liveSpotPriceFor(asset) {
|
||||
return basePriceOf(await liveSpotFor(asset));
|
||||
}
|
||||
|
||||
export async function fetchPriceVia(providerKey, asset, capability, dateKey) {
|
||||
const provider = await getProvider(providerKey);
|
||||
if (!provider || !provider.isActive || !hasCapability(provider, capability)) return null;
|
||||
return callPrice(provider, capability, asset.symbol, dateKey);
|
||||
}
|
||||
|
||||
export async function hasDailyGridFor(asset) {
|
||||
return !!(await primaryProvider(asset, 'daily_history'));
|
||||
}
|
||||
|
||||
async function historyPointWith(provider, asset, dateKey) {
|
||||
if (!hasCapability(provider, 'history_point')) return null;
|
||||
return callPrice(provider, 'history_point', asset.symbol, dateKey);
|
||||
}
|
||||
export async function historicalPointForVia(asset, dateKey) {
|
||||
for (const provider of await resolveChain(asset, 'history_point')) {
|
||||
try { const p = await historyPointWith(provider, asset, dateKey); if (p) return p; }
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensurePointsWith(provider, asset, dateKeys) {
|
||||
if (!hasCapability(provider, 'history_point')) return;
|
||||
for (const key of dateKeys) {
|
||||
if (await getPricePoint(asset.id, key, { derived: false })) continue;
|
||||
try {
|
||||
const p = await callPrice(provider, 'history_point', asset.symbol, key);
|
||||
if (p && Number.isFinite(p.price)) await upsertPriceHistory(asset.id, [p], provider.key, 'day');
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
export async function ensurePointsAtFor(asset, dateKeys) {
|
||||
return ensurePointsWith(await primaryProvider(asset, 'history_point'), asset, dateKeys);
|
||||
}
|
||||
|
||||
const shiftDay = (dayKey, n) => new Date(Date.parse(`${dayKey}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
|
||||
const isWeekend = dayKey => { const d = new Date(`${dayKey}T00:00:00Z`).getUTCDay(); return d === 0 || d === 6; };
|
||||
const skipsWeekend = asset => CONFIG.PRICE_HISTORY.WEEKEND_PRICE[asset.kind] === false;
|
||||
|
||||
export function daysToAsk(asset, fromDay, untilDay) {
|
||||
let n = 0;
|
||||
for (let day = fromDay; day <= untilDay; day = shiftDay(day, 1)) if (!(skipsWeekend(asset) && isWeekend(day))) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function isClose(row, manualId) {
|
||||
if (row.resolution !== 'day') return false;
|
||||
if (manualId != null && row.providerId === manualId) return true;
|
||||
const written = row.updatedAt || row.createdAt;
|
||||
return !!written && written.slice(0, 10) > row.observedAt.slice(0, 10);
|
||||
}
|
||||
|
||||
export async function fillClosesFor(asset, fromDay, untilDay, { today = todayIso(), signal = null } = {}) {
|
||||
const uit = { requested: 0, written: 0, limitReached: false, stopped: false };
|
||||
if (!asset) return uit;
|
||||
const series = await primaryProvider(asset, 'daily_history');
|
||||
if (series) { try { await ensureHistoryWith(series, asset); } catch { } }
|
||||
const last = untilDay < shiftDay(today, -1) ? untilDay : shiftDay(today, -1);
|
||||
if (marketSpotMode() !== 'off' && fromDay <= last) {
|
||||
const points = await serviceDays(asset, Date.parse(`${fromDay}T00:00:00Z`));
|
||||
const inGap = (points || []).filter(p => dayOf(p.time) >= fromDay && dayOf(p.time) <= last);
|
||||
if (inGap.length) {
|
||||
await upsertPriceHistory(asset.id, inGap, CONFIG.MARKET_SERVICE.PROVIDER_KEY, 'day');
|
||||
uit.written += inGap.length;
|
||||
}
|
||||
}
|
||||
const chain = await resolveChain(asset, 'history_point');
|
||||
if (!chain.length) return uit;
|
||||
if (fromDay > last) return uit;
|
||||
const manual = await getProvider('manual');
|
||||
const closed = new Set((await listPriceHistory(asset.id, fromDay))
|
||||
.filter(r => isClose(r, manual ? manual.id : null))
|
||||
.map(r => r.observedAt.slice(0, 10)));
|
||||
const op = new Set();
|
||||
const skipWeekend = skipsWeekend(asset);
|
||||
const noPrice = new Set((await listNoPriceDays(asset.id, fromDay)).map(r => `${r.providerId}|${r.day}`));
|
||||
const found = new Map();
|
||||
const delivered = new Set();
|
||||
for (let day = fromDay; day <= last; day = shiftDay(day, 1)) {
|
||||
if (closed.has(day)) continue;
|
||||
if (skipWeekend && isWeekend(day)) continue;
|
||||
if (signal && signal.aborted) { uit.stopped = true; break; }
|
||||
if (op.size === chain.length) { uit.limitReached = true; break; }
|
||||
const first = chain.find(p => !op.has(p.id));
|
||||
if (noPrice.has(`${first.id}|${day}`)) continue;
|
||||
uit.requested++;
|
||||
for (const provider of chain) {
|
||||
if (op.has(provider.id)) continue;
|
||||
if (noPrice.has(`${provider.id}|${day}`)) break;
|
||||
try {
|
||||
const p = await callPrice(provider, 'history_point', asset.symbol, day, { signal });
|
||||
if (p && Number.isFinite(p.price)) {
|
||||
await upsertPriceHistory(asset.id, [p], provider.key, 'day');
|
||||
uit.written++;
|
||||
delivered.add(provider.id);
|
||||
}
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e && e.limitReached) { op.add(provider.id); continue; }
|
||||
if (isNoPrice(e)) { if (!found.has(provider.id)) found.set(provider.id, []); found.get(provider.id).push(day); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [providerId, days] of found) {
|
||||
if (!delivered.has(providerId)) continue;
|
||||
const provider = chain.find(p => p.id === providerId);
|
||||
try { await recordNoPriceDays(asset.id, provider.key, days); } catch { }
|
||||
}
|
||||
if (op.size === chain.length) uit.limitReached = true;
|
||||
return uit;
|
||||
}
|
||||
|
||||
function isNoPrice(e) {
|
||||
if (!e) return false;
|
||||
if (e.key === 'integrations.price.noValidPrice') return true;
|
||||
return e.key === 'integrations.price.httpFailed' && e.params && Number(e.params.status) === 404;
|
||||
}
|
||||
|
||||
export async function refreshRecentCloses(assets, { today = todayIso() } = {}) {
|
||||
const until = shiftDay(today, -1);
|
||||
const from = shiftDay(today, -CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS);
|
||||
let written = 0;
|
||||
for (const asset of assets || []) {
|
||||
try { written += (await fillClosesFor(asset, from, until, { today })).written; }
|
||||
catch { }
|
||||
}
|
||||
return { written };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Generated, do not edit. sha256:a16dd8b1a3974fb4acdf7f84e8ff322821ad367d6c5137af68317f24a7ee0d3d
|
||||
import { HDKey, base58check, bech32, bech32m, secp256k1, sha256, ripemd160,
|
||||
hexToBytes, concatBytes, utf8ToBytes } from '../vendor/crypto/noble-scure.js';
|
||||
import { addressTxs, fetchJson, explorerBase } from './btc-explorer.js';
|
||||
|
||||
const b58 = base58check(sha256);
|
||||
const hash160 = bytes => ripemd160(sha256(bytes));
|
||||
const XPUB_VERSION = hexToBytes('0488b21e');
|
||||
const TAP_TWEAK_TAG = sha256(utf8ToBytes('TapTweak'));
|
||||
const bytesToBig = b => { let n = 0n; for (const x of b) n = (n << 8n) | BigInt(x); return n; };
|
||||
const bigTo32 = n => { const o = new Uint8Array(32); for (let i = 31; i >= 0; i--) { o[i] = Number(n & 0xffn); n >>= 8n; } return o; };
|
||||
|
||||
function taprootOutputKey(pub33) {
|
||||
const xonly = pub33.slice(1);
|
||||
const t = bytesToBig(sha256(concatBytes(TAP_TWEAK_TAG, TAP_TWEAK_TAG, xonly))) % secp256k1.Point.Fn.ORDER;
|
||||
const P = secp256k1.Point.fromBytes(concatBytes(Uint8Array.of(0x02), xonly));
|
||||
const Q = P.add(secp256k1.Point.BASE.multiply(t));
|
||||
return bigTo32(Q.toAffine().x);
|
||||
}
|
||||
|
||||
const SCAN = {
|
||||
GAP_LIMIT: 20,
|
||||
DELAY_MS: 250,
|
||||
};
|
||||
|
||||
const apiBase = async () => (await explorerBase()) + '/api';
|
||||
|
||||
const wacht = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function typeUitPrefix(key) {
|
||||
if (key.startsWith('ypub')) return 'p2sh-p2wpkh';
|
||||
if (key.startsWith('zpub') || key.startsWith('xpub')) return 'p2wpkh';
|
||||
return 'p2pkh';
|
||||
}
|
||||
|
||||
function naarXpub(extKey) {
|
||||
const data = b58.decode(extKey);
|
||||
const out = new Uint8Array(data.length);
|
||||
out.set(XPUB_VERSION, 0);
|
||||
out.set(data.slice(4), 4);
|
||||
return b58.encode(out);
|
||||
}
|
||||
|
||||
function adresVanPubkey(pubkey, type) {
|
||||
if (type === 'p2tr') {
|
||||
return bech32m.encode('bc', [1, ...bech32m.toWords(taprootOutputKey(pubkey))]);
|
||||
}
|
||||
const h = hash160(pubkey);
|
||||
if (type === 'p2wpkh') {
|
||||
return bech32.encode('bc', [0, ...bech32.toWords(h)]);
|
||||
}
|
||||
if (type === 'p2sh-p2wpkh') {
|
||||
const redeem = new Uint8Array(22);
|
||||
redeem[0] = 0x00; redeem[1] = 0x14; redeem.set(h, 2);
|
||||
const sh = hash160(redeem);
|
||||
const payload = new Uint8Array(21); payload[0] = 0x05; payload.set(sh, 1);
|
||||
return b58.encode(payload);
|
||||
}
|
||||
const payload = new Uint8Array(21); payload[0] = 0x00; payload.set(h, 1);
|
||||
return b58.encode(payload);
|
||||
}
|
||||
|
||||
function maakDeriver(extKey, scriptType) {
|
||||
const type = scriptType || typeUitPrefix(extKey);
|
||||
const hd = HDKey.fromExtendedKey(naarXpub(extKey));
|
||||
return (chain, index) => {
|
||||
const child = hd.deriveChild(chain).deriveChild(index);
|
||||
return adresVanPubkey(child.publicKey, type);
|
||||
};
|
||||
}
|
||||
|
||||
export function firstReceiveAddress(extKey, scriptType) {
|
||||
return maakDeriver(extKey, scriptType)(0, 0);
|
||||
}
|
||||
|
||||
async function haalAdresInfoOp(adres) {
|
||||
const info = await fetchJson(`${await apiBase()}/address/${adres}`, `Adres ${adres}`);
|
||||
const bevestigd = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum;
|
||||
const onbevestigd = info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum;
|
||||
const tx_aantal = info.chain_stats.tx_count + info.mempool_stats.tx_count;
|
||||
return { saldo_satoshi: bevestigd, saldo_onbevestigd: onbevestigd, tx_aantal };
|
||||
}
|
||||
|
||||
async function scanChain(deriver, chain, onVoortgang) {
|
||||
const resultaten = [];
|
||||
let legeReeks = 0, index = 0;
|
||||
const chainLabel = chain === 0 ? 'ontvangst' : 'change';
|
||||
while (legeReeks < SCAN.GAP_LIMIT) {
|
||||
const adres = deriver(chain, index);
|
||||
onVoortgang?.({ fase: 'scan', chain: chainLabel, index, adres });
|
||||
const info = await haalAdresInfoOp(adres);
|
||||
await wacht(SCAN.DELAY_MS);
|
||||
if (info.tx_aantal > 0) {
|
||||
resultaten.push({
|
||||
adres, chain, chainLabel, index,
|
||||
saldo_satoshi: info.saldo_satoshi,
|
||||
tx_aantal: info.tx_aantal,
|
||||
heeftSaldo: info.saldo_satoshi > 0,
|
||||
});
|
||||
legeReeks = 0;
|
||||
} else {
|
||||
legeReeks++;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return resultaten;
|
||||
}
|
||||
|
||||
export async function scanMovements(addresses, accountId, sourceId, onVoortgang, { ownAddresses = [] } = {}) {
|
||||
const own = new Set([...addresses, ...ownAddresses]);
|
||||
const gezien = new Set();
|
||||
const rijen = [];
|
||||
const failed = [];
|
||||
let i = 0;
|
||||
for (const adres of addresses) {
|
||||
onVoortgang?.({ fase: 'tx', index: i++, total: addresses.length, adres });
|
||||
let txs;
|
||||
try { txs = await addressTxs(adres); }
|
||||
catch (e) { console.warn(`[scanMovements] adres mislukt: ${adres}`, e); failed.push(adres); onVoortgang?.({ fase: 'tx-error', adres, error: e.message }); continue; }
|
||||
await wacht(SCAN.DELAY_MS);
|
||||
for (const tx of txs) {
|
||||
if (gezien.has(tx.txid)) continue;
|
||||
gezien.add(tx.txid);
|
||||
const inSum = (tx.vin || []).reduce((s, v) => s + (v.prevout && own.has(v.prevout.scriptpubkey_address) ? v.prevout.value : 0), 0);
|
||||
const outSum = (tx.vout || []).reduce((s, o) => s + (own.has(o.scriptpubkey_address) ? o.value : 0), 0);
|
||||
const net = outSum - inSum;
|
||||
if (net === 0) continue;
|
||||
const direction = net > 0 ? 'in' : 'out';
|
||||
const fee = tx.fee ?? 0;
|
||||
const value_sats = direction === 'out' ? Math.max(0, Math.abs(net) - fee) : Math.abs(net);
|
||||
const fee_sats = direction === 'out' ? fee : 0;
|
||||
const pool = direction === 'in' ? (tx.vout || []) : (tx.vin || []).map(v => v.prevout).filter(Boolean);
|
||||
let repAddr = null, repVal = -1;
|
||||
for (const o of pool) {
|
||||
if (own.has(o.scriptpubkey_address) && o.value > repVal) { repVal = o.value; repAddr = o.scriptpubkey_address; }
|
||||
}
|
||||
const conf = tx.status && tx.status.confirmed && tx.status.block_height && tx.status.block_time;
|
||||
rijen.push({
|
||||
accountId, sourceId, txid: tx.txid,
|
||||
direction, valueSats: value_sats, feeSats: fee_sats,
|
||||
blockHeight: conf ? tx.status.block_height : null,
|
||||
blockTime: conf ? new Date(tx.status.block_time * 1000).toISOString() : null,
|
||||
address: repAddr,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { rows: rijen, failed };
|
||||
}
|
||||
|
||||
export async function scanWallet(xpub, scriptType, onVoortgang) {
|
||||
onVoortgang?.({ fase: 'start' });
|
||||
const deriver = maakDeriver(xpub, scriptType);
|
||||
const [extern, intern] = await Promise.all([
|
||||
scanChain(deriver, 0, onVoortgang),
|
||||
scanChain(deriver, 1, onVoortgang),
|
||||
]);
|
||||
const alleAdressen = [...extern, ...intern];
|
||||
const totaalSaldo_satoshi = alleAdressen.reduce((s, a) => s + a.saldo_satoshi, 0);
|
||||
onVoortgang?.({ fase: 'klaar' });
|
||||
return { xpub, totaalSaldo_satoshi, alleAdressen };
|
||||
}
|
||||
Reference in new issue
Block a user