diff --git a/frontend/src/app/universe/protocol-strip/protocol-strip.component.scss b/frontend/src/app/universe/protocol-strip/protocol-strip.component.scss
index d4a01832b7d..f84866eebc5 100644
--- a/frontend/src/app/universe/protocol-strip/protocol-strip.component.scss
+++ b/frontend/src/app/universe/protocol-strip/protocol-strip.component.scss
@@ -80,6 +80,12 @@
font-weight: 700;
color: var(--universe-protocol-runes);
+ // No sample yet: not a measurement, so it is quieter still and says so.
+ &.unknown {
+ color: var(--u-text-muted);
+ font-variant-numeric: normal;
+ }
+
// A zero is a real measurement, not a failure, so it stays legible but quiet.
&.zero {
color: var(--u-text-muted);
diff --git a/frontend/src/app/universe/protocol-strip/protocol-strip.component.ts b/frontend/src/app/universe/protocol-strip/protocol-strip.component.ts
index f4a04277648..0d1e93ce35f 100644
--- a/frontend/src/app/universe/protocol-strip/protocol-strip.component.ts
+++ b/frontend/src/app/universe/protocol-strip/protocol-strip.component.ts
@@ -3,21 +3,24 @@ import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { Observable, catchError, combineLatest, map, of } from 'rxjs';
import { UniverseApiService } from '@app/universe/universe-api.service';
-import { PulseState, UniversePulseService } from '@app/universe/universe-pulse.service';
+import { PulseObservation, PulseState, UniversePulseService } from '@app/universe/universe-pulse.service';
import { ExplorerProtocolDefinition, ProtocolsResponse } from '@app/universe/universe.types';
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
interface StripEntry {
readonly protocolId: string;
readonly displayName: string;
- readonly count: number;
+ /** Exact count within the sample, or null when there is no sample to count in. */
+ readonly count: number | null;
}
interface StripViewModel {
readonly entries: readonly StripEntry[];
readonly checked: number;
readonly supportedCount: number;
- readonly authorityAnswering: boolean;
+ readonly observation: PulseObservation;
+ /** When the sample was last extended; shown when the tally is stale. */
+ readonly lastSampleAt: number | null;
}
/**
@@ -58,7 +61,8 @@ export class ProtocolStripComponent implements OnInit, OnDestroy {
return {
supportedCount: supported.length,
checked: pulse.checked,
- authorityAnswering: pulse.authorityAnswering,
+ observation: pulse.observation,
+ lastSampleAt: pulse.lastSampleAt,
entries: stripEntries(supported, pulse),
};
}),
@@ -82,16 +86,22 @@ function isSupported(protocol: ExplorerProtocolDefinition): boolean {
);
}
-/** Supported protocols, busiest first, each with its exact live count. */
+/**
+ * Supported protocols, busiest first, each with its exact count within the
+ * sample. Before the authority has resolved anything there is no sample, so
+ * every count is null rather than a zero nobody measured; a stale sample
+ * keeps its counts, and the note beside it says how old they are.
+ */
export function stripEntries(
supported: readonly ExplorerProtocolDefinition[],
pulse: PulseState,
): StripEntry[] {
+ const sampled = pulse.observation === 'observed' || pulse.observation === 'stale';
return supported
.map((protocol) => ({
protocolId: protocol.id,
displayName: protocol.shortName || protocol.displayName || protocol.id,
- count: pulse.protocolCounts.get(protocol.id) ?? 0,
+ count: sampled ? pulse.protocolCounts.get(protocol.id) ?? 0 : null,
}))
- .sort((a, b) => b.count - a.count || a.displayName.localeCompare(b.displayName));
+ .sort((a, b) => (b.count ?? 0) - (a.count ?? 0) || a.displayName.localeCompare(b.displayName));
}
diff --git a/frontend/src/app/universe/pulse/pulse.component.html b/frontend/src/app/universe/pulse/pulse.component.html
index 4c9f7fb49ab..8683b3e35f6 100644
--- a/frontend/src/app/universe/pulse/pulse.component.html
+++ b/frontend/src/app/universe/pulse/pulse.component.html
@@ -19,9 +19,13 @@
How this is cou
Sampling stopped at this page's limit. Reload to start a new sample.
-
+
+ The asset authority is not answering, and it has not resolved any transaction
+ since this page opened, so nothing is claimed.
+
+
The asset authority stopped answering. The tally below is frozen at the last
- transaction it confirmed, and nothing new is being claimed.
+ transaction it confirmed at {{ vm.pulse.lastSampleAt | date: 'mediumTime' }}, and nothing new is being claimed.
diff --git a/frontend/src/app/universe/universe-api.service.spec.ts b/frontend/src/app/universe/universe-api.service.spec.ts
index d214a8600b7..0fb60bcf338 100644
--- a/frontend/src/app/universe/universe-api.service.spec.ts
+++ b/frontend/src/app/universe/universe-api.service.spec.ts
@@ -282,4 +282,76 @@ describe('UniverseApiService backend route network prefix', () => {
service.getTaprootAssets$().subscribe();
expect(urls).toEqual(['/api/v1/taproot-assets/assets']);
});
+
+ /**
+ * FE-D04: the propagation read alone was built on the root base, so a
+ * Signet reader's observatory asked the root backend for propagation while
+ * its nodes and templates came from Signet. All three reads now resolve to
+ * the same partition, with the txid still encoded.
+ */
+ it.each(['signet', 'testnet4'])('sends every observatory read, propagation included, to the %s backend', network => {
+ const { service, urls } = buildOn(network);
+ service.getObserverNodes$().subscribe();
+ service.getPropagationObservation$().subscribe();
+ service.getPropagationObservation$('ab/cd?e').subscribe();
+ service.getBlockTemplateComparison$().subscribe();
+ expect(urls).toEqual([
+ '/' + network + '/api/v1/network/nodes',
+ '/' + network + '/api/v1/network/propagation',
+ '/' + network + '/api/v1/network/propagation/ab%2Fcd%3Fe',
+ '/' + network + '/api/v1/network/templates',
+ ]);
+ });
+
+ it.each(['', 'mainnet'])('keeps the observatory on the root backend for the root network %j', network => {
+ const { service, urls } = buildOn(network);
+ service.getObserverNodes$().subscribe();
+ service.getPropagationObservation$().subscribe();
+ service.getPropagationObservation$('a'.repeat(64)).subscribe();
+ service.getBlockTemplateComparison$().subscribe();
+ expect(urls).toEqual([
+ '/api/v1/network/nodes',
+ '/api/v1/network/propagation',
+ '/api/v1/network/propagation/' + 'a'.repeat(64),
+ '/api/v1/network/templates',
+ ]);
+ });
+
+ it('treats the configured ROOT_NETWORK as the root partition for the observatory', () => {
+ const urls: string[] = [];
+ const httpClient = { get: (url: string) => { urls.push(url); return of({}); } } as unknown as HttpClient;
+ const stateService = { isBrowser: true, network: 'signet', env: { ROOT_NETWORK: 'signet' } } as unknown as StateService;
+ const service = new UniverseApiService(httpClient, stateService, ownerKeyStub as never);
+ service.getPropagationObservation$().subscribe();
+ service.getObserverNodes$().subscribe();
+ expect(urls).toEqual(['/api/v1/network/propagation', '/api/v1/network/nodes']);
+ });
+
+ it('addresses the observatory through the gateway during server-side rendering', () => {
+ const urls: string[] = [];
+ const httpClient = { get: (url: string) => { urls.push(url); return of({}); } } as unknown as HttpClient;
+ const stateService = {
+ isBrowser: false,
+ network: 'signet',
+ env: { NGINX_PROTOCOL: 'https', NGINX_HOSTNAME: 'explorer.internal', NGINX_PORT: '443', ROOT_NETWORK: 'mainnet' },
+ } as unknown as StateService;
+ const service = new UniverseApiService(httpClient, stateService, ownerKeyStub as never);
+ service.getPropagationObservation$('a'.repeat(64)).subscribe();
+ service.getObserverNodes$().subscribe();
+ expect(urls).toEqual([
+ 'https://explorer.internal:443/signet/api/v1/network/propagation/' + 'a'.repeat(64),
+ 'https://explorer.internal:443/signet/api/v1/network/nodes',
+ ]);
+ });
+
+ it('does not fall back to the root backend when the Signet propagation read fails', () => {
+ const urls: string[] = [];
+ const httpClient = { get: (url: string) => { urls.push(url); return throwError(() => ({ status: 503 })); } } as unknown as HttpClient;
+ const stateService = { isBrowser: true, network: 'signet', env: { ROOT_NETWORK: 'mainnet' } } as unknown as StateService;
+ const service = new UniverseApiService(httpClient, stateService, ownerKeyStub as never);
+ let failure: unknown;
+ service.getPropagationObservation$().subscribe({ error: error => failure = error });
+ expect(failure).toEqual({ status: 503 });
+ expect(urls).toEqual(['/signet/api/v1/network/propagation']);
+ });
});
diff --git a/frontend/src/app/universe/universe-api.service.ts b/frontend/src/app/universe/universe-api.service.ts
index 34c428294f3..d486c798309 100644
--- a/frontend/src/app/universe/universe-api.service.ts
+++ b/frontend/src/app/universe/universe-api.service.ts
@@ -149,7 +149,11 @@ export class UniverseApiService {
const prefix = network && network !== 'mainnet' && network !== this.stateService.env?.ROOT_NETWORK ? '/' + network : '';
return this.apiBaseUrl + prefix;
}
- private selectedNetwork$(): Observable {
+ /**
+ * The Bitcoin network every overlay request below is addressed to. Public so
+ * a page can label what it shows from the same source its request used.
+ */
+ selectedNetwork$(): Observable {
return defer(() => (this.stateService.networkChanged$ ?? of(this.stateService.network)).pipe(
startWith(this.stateService.network),
map(() => this.network),
@@ -791,7 +795,9 @@ export class UniverseApiService {
const path = txid
? '/api/v1/network/propagation/' + encodeURIComponent(txid)
: '/api/v1/network/propagation';
- return this.httpClient.get(this.apiBaseUrl + path);
+ // Same partition as the nodes and templates reads beside it: a Signet
+ // reader was sent to the root backend for propagation alone.
+ return this.httpClient.get(this.backendBase + path);
}
getBlockTemplateComparison$(): Observable {
diff --git a/frontend/src/app/universe/universe-pulse.service.spec.ts b/frontend/src/app/universe/universe-pulse.service.spec.ts
index 086609a90f0..12a0fe7f5a5 100644
--- a/frontend/src/app/universe/universe-pulse.service.spec.ts
+++ b/frontend/src/app/universe/universe-pulse.service.spec.ts
@@ -1,8 +1,15 @@
-import { describe, expect, it } from 'vitest';
-import { protocolIdsOf } from '@app/universe/universe-pulse.service';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { BehaviorSubject, Subject, of } from 'rxjs';
+import { PulseState, UniversePulseService, protocolIdsOf } from '@app/universe/universe-pulse.service';
import { sharesOf } from '@app/universe/pulse/pulse.component';
import { stripEntries } from '@app/universe/protocol-strip/protocol-strip.component';
-import type { ExplorerTransactionAssetFlow } from '@app/universe/universe.types';
+import type { StateService } from '@app/services/state.service';
+import type { UniverseApiService } from '@app/universe/universe-api.service';
+import type {
+ ExplorerTransactionAssetFlow,
+ TransactionBatchItem,
+ TransactionBatchResponse,
+} from '@app/universe/universe.types';
function flow(patch: Partial = {}): ExplorerTransactionAssetFlow {
return {
@@ -100,16 +107,308 @@ describe('stripEntries', () => {
{ id: 'ordinals', shortName: 'Ordinals' },
] as never;
- it('lists every supported protocol, including those with no activity', () => {
- const entries = stripEntries(supported, { protocolCounts: new Map() } as never);
+ it('lists every supported protocol with a measured zero once a sample exists', () => {
+ const entries = stripEntries(supported, { observation: 'observed', checked: 3, protocolCounts: new Map() } as never);
expect(entries.map((entry) => entry.protocolId).sort()).toEqual(['ordinals', 'runes']);
expect(entries.every((entry) => entry.count === 0)).toBe(true);
});
+ /**
+ * The Signet strip said it was reading arrivals against four protocols and
+ * showed four zeros while the authority had never answered: a zero nobody
+ * measured. Before a sample exists there is no count to show.
+ */
+ it.each(['unknown', 'unavailable'] as const)('shows no count at all while the observation is %s', (observation) => {
+ const entries = stripEntries(supported, { observation, checked: 0, protocolCounts: new Map() } as never);
+ expect(entries).toHaveLength(2);
+ expect(entries.every((entry) => entry.count === null)).toBe(true);
+ });
+
+ it('keeps the last counts when the sample is stale', () => {
+ const entries = stripEntries(supported, {
+ observation: 'stale', checked: 9, protocolCounts: new Map([['ordinals', 2]]),
+ } as never);
+ expect(entries[0]).toMatchObject({ protocolId: 'ordinals', count: 2 });
+ expect(entries[1]).toMatchObject({ protocolId: 'runes', count: 0 });
+ });
+
it('puts the busiest protocol first', () => {
const entries = stripEntries(supported, {
- protocolCounts: new Map([['ordinals', 7]]),
+ observation: 'observed', checked: 7, protocolCounts: new Map([['ordinals', 7]]),
} as never);
expect(entries[0].protocolId).toBe('ordinals');
});
});
+
+/** The service under a controllable arrivals feed and a controllable authority. */
+function harness(options: { network?: string } = {}): {
+ service: UniversePulseService;
+ transactions$: BehaviorSubject<{ txid: string }[] | null>;
+ networkChanged$: Subject;
+ requests: { batch: string[]; response: Subject }[];
+ state: () => PulseState;
+ arrive: (...txids: string[]) => void;
+ answer: (index: number, results: TransactionBatchItem[]) => Promise;
+ fail: (index: number) => Promise;
+ tick: () => Promise;
+ stateService: { network: string; transactions$: BehaviorSubject<{ txid: string }[] | null> };
+} {
+ const transactions$ = new BehaviorSubject<{ txid: string }[] | null>(null);
+ const networkChanged$ = new Subject();
+ const stateService = {
+ isBrowser: true,
+ network: options.network ?? '',
+ networkChanged$,
+ transactions$,
+ isTabHidden$: of(false),
+ };
+ const requests: { batch: string[]; response: Subject }[] = [];
+ const api = {
+ getTransactionFlows$: (batch: string[]) => {
+ const response = new Subject();
+ requests.push({ batch: batch.slice(), response });
+ return response;
+ },
+ };
+ const service = new UniversePulseService(stateService as unknown as StateService, api as unknown as UniverseApiService);
+ let current!: PulseState;
+ service.state$.subscribe((value) => { current = value; });
+ const tick = async (): Promise => { await vi.advanceTimersByTimeAsync(2500); };
+ return {
+ service, transactions$, networkChanged$, requests, stateService,
+ state: () => current,
+ arrive: (...txids) => stateService.transactions$.next(txids.map((txid) => ({ txid }))),
+ answer: async (index, results) => { requests[index].response.next({ results }); requests[index].response.complete(); await vi.advanceTimersByTimeAsync(0); },
+ fail: async (index) => { requests[index].response.error(new Error('controlled authority outage')); await vi.advanceTimersByTimeAsync(0); },
+ tick,
+ };
+}
+
+function txid(seed: number): string {
+ return seed.toString(16).padStart(64, '0');
+}
+
+function ok(id: string, protocolIds: string[] = []): TransactionBatchItem {
+ return {
+ txid: id, status: 'ok',
+ flow: flow({ txid: id, actions: protocolIds.map((protocolId) => ({ protocolId })) as never }),
+ };
+}
+
+function refused(id: string, status: TransactionBatchItem['status'] = 'unavailable'): TransactionBatchItem {
+ return { txid: id, status, flow: null };
+}
+
+describe('UniversePulseService observation', () => {
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => { vi.useRealTimers(); });
+
+ /**
+ * The service started with authorityAnswering true, before anything had
+ * been asked, and an empty queue never asked; the strip then claimed to be
+ * reading arrivals while the public Signet page had no feed at all.
+ */
+ it('starts unknown with nothing arrived and no stream connected', async () => {
+ const h = harness();
+ h.service.start();
+ await h.tick();
+ expect(h.state()).toMatchObject({ observation: 'unknown', checked: 0, lastSampleAt: null });
+ expect(h.requests).toHaveLength(0);
+ h.service.stop();
+ });
+
+ it('stays unknown while arrivals are queued but nothing has been resolved', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1), txid(2));
+ await h.tick();
+ expect(h.requests).toHaveLength(1);
+ expect(h.state().observation).toBe('unknown');
+ // Items the authority does not know about are not evidence either way.
+ await h.answer(0, [refused(txid(1), 'not-found'), refused(txid(2), 'invalid')]);
+ expect(h.state()).toMatchObject({ observation: 'unknown', checked: 0 });
+ h.service.stop();
+ });
+
+ it('is unavailable when the first batch is refused by the authority, whatever the transport said', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1), txid(2));
+ await h.tick();
+ await h.answer(0, [refused(txid(1)), refused(txid(2), 'unconfigured')]);
+ expect(h.state()).toMatchObject({ observation: 'unavailable', checked: 0, lastSampleAt: null });
+ h.service.stop();
+ });
+
+ it('counts only the valid resolved items of a mixed batch', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1), txid(2), txid(3));
+ await h.tick();
+ await h.answer(0, [ok(txid(1), ['runes']), refused(txid(2)), ok(txid(3))]);
+ expect(h.state()).toMatchObject({ observation: 'observed', checked: 2, withAssets: 1 });
+ expect(h.state().protocolCounts.get('runes')).toBe(1);
+ expect(h.state().lastSampleAt).not.toBeNull();
+ h.service.stop();
+ });
+
+ it('records a measured zero as observed', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ await h.answer(0, [ok(txid(1))]);
+ expect(h.state()).toMatchObject({ observation: 'observed', checked: 1, withAssets: 0 });
+ expect(h.state().protocolCounts.size).toBe(0);
+ h.service.stop();
+ });
+
+ it('tallies activity per protocol within the checked denominator', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1), txid(2));
+ await h.tick();
+ await h.answer(0, [ok(txid(1), ['runes', 'ordinals']), ok(txid(2), ['runes'])]);
+ expect(h.state().checked).toBe(2);
+ expect(h.state().protocolCounts.get('runes')).toBe(2);
+ expect(h.state().protocolCounts.get('ordinals')).toBe(1);
+ expect(h.state().recent.map((event) => event.txid)).toEqual([txid(2), txid(1)]);
+ h.service.stop();
+ });
+
+ it('marks the sample stale, keeping its counts and time, when the authority fails after a sample', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ await h.answer(0, [ok(txid(1), ['runes'])]);
+ const sampledAt = h.state().lastSampleAt;
+ h.arrive(txid(2));
+ await h.tick();
+ await h.answer(1, [refused(txid(2))]);
+ expect(h.state()).toMatchObject({ observation: 'stale', checked: 1, lastSampleAt: sampledAt });
+ expect(h.state().protocolCounts.get('runes')).toBe(1);
+ h.service.stop();
+ });
+
+ it('needs several transport failures in a row before it stops claiming the authority answers', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ await h.answer(0, [ok(txid(1))]);
+ for (let round = 1; round <= 3; round += 1) {
+ h.arrive(txid(10 + round));
+ await h.tick();
+ await h.fail(round);
+ expect(h.state().observation).toBe(round < 3 ? 'observed' : 'stale');
+ }
+ h.service.stop();
+ });
+
+ it('recovers to observed on the next resolved item', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ await h.answer(0, [refused(txid(1))]);
+ expect(h.state().observation).toBe('unavailable');
+ h.arrive(txid(2));
+ await h.tick();
+ await h.answer(1, [ok(txid(2), ['runes'])]);
+ expect(h.state()).toMatchObject({ observation: 'observed', checked: 1 });
+ h.service.stop();
+ });
+
+ it('never asks about the same arrival twice within a sample', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ await h.fail(0);
+ h.arrive(txid(1), txid(2));
+ await h.tick();
+ expect(h.requests[1].batch).toEqual([txid(2)]);
+ h.service.stop();
+ });
+
+ it('stops sampling at the session ceiling and says so', async () => {
+ const h = harness();
+ h.service.start();
+ // 4000 resolved: 160 batches of 25.
+ for (let batch = 0; batch < 160; batch += 1) {
+ const ids = Array.from({ length: 25 }, (_, i) => txid(batch * 25 + i + 1));
+ h.arrive(...ids);
+ await h.tick();
+ await h.answer(batch, ids.map((id) => ok(id)));
+ }
+ expect(h.state().checked).toBe(4000);
+ h.arrive(txid(5000));
+ await h.tick();
+ expect(h.requests).toHaveLength(160);
+ expect(h.state().ceilingReached).toBe(true);
+ h.service.stop();
+ });
+
+ it('discards a resolution in flight when the network switches', async () => {
+ const h = harness({ network: '' });
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ await h.answer(0, [ok(txid(1), ['runes'])]);
+ h.arrive(txid(2));
+ await h.tick();
+ expect(h.requests).toHaveLength(2);
+
+ // The state service swaps its arrivals feed before announcing the switch.
+ h.stateService.network = 'signet';
+ h.stateService.transactions$ = new BehaviorSubject<{ txid: string }[] | null>(null);
+ h.networkChanged$.next('signet');
+ expect(h.state()).toMatchObject({ observation: 'unknown', checked: 0 });
+
+ await h.answer(1, [ok(txid(2), ['runes'])]);
+ expect(h.state()).toMatchObject({ observation: 'unknown', checked: 0 });
+
+ // The new feed is the one sampled now, and the old txid is not "seen".
+ h.arrive(txid(2));
+ await h.tick();
+ expect(h.requests).toHaveLength(3);
+ expect(h.requests[2].batch).toEqual([txid(2)]);
+ await h.answer(2, [ok(txid(2), ['ordinals'])]);
+ expect(h.state()).toMatchObject({ observation: 'observed', checked: 1 });
+ expect(h.state().protocolCounts.get('runes')).toBeUndefined();
+ h.service.stop();
+ });
+
+ it('ignores a completion that arrives after stop, even once a new sample has started', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1));
+ await h.tick();
+ h.service.stop();
+ expect(h.state()).toMatchObject({ observation: 'unknown', checked: 0 });
+
+ h.service.start();
+ await h.answer(0, [ok(txid(1), ['runes'])]);
+ expect(h.state()).toMatchObject({ observation: 'unknown', checked: 0 });
+
+ // The new sample is not blocked by the old request's bookkeeping.
+ h.arrive(txid(1));
+ await h.tick();
+ expect(h.requests).toHaveLength(2);
+ h.service.stop();
+ });
+
+ it('publishes one denominator that the strip shares', async () => {
+ const h = harness();
+ h.service.start();
+ h.arrive(txid(1), txid(2), txid(3));
+ await h.tick();
+ await h.answer(0, [ok(txid(1), ['runes']), ok(txid(2)), refused(txid(3))]);
+ const entries = stripEntries([{ id: 'runes', shortName: 'RUNES' }, { id: 'ordinals', shortName: 'Ordinals' }] as never, h.state());
+ expect(h.state().checked).toBe(2);
+ expect(entries.map((entry) => entry.count)).toEqual([1, 0]);
+ expect(entries.reduce((sum, entry) => sum + (entry.count ?? 0), 0)).toBeLessThanOrEqual(h.state().checked);
+ h.service.stop();
+ });
+});
diff --git a/frontend/src/app/universe/universe-pulse.service.ts b/frontend/src/app/universe/universe-pulse.service.ts
index 5eea3002912..93ff759aef9 100644
--- a/frontend/src/app/universe/universe-pulse.service.ts
+++ b/frontend/src/app/universe/universe-pulse.service.ts
@@ -15,7 +15,7 @@ import {
* existing socket. This service takes that stream, asks the asset authority
* what those transactions actually do, and keeps a running tally.
*
- * Two rules keep it honest and cheap:
+ * Three rules keep it honest and cheap:
*
* 1. The denominator is always published. A protocol count means nothing
* without "out of how many transactions we checked", so the tally carries
@@ -23,6 +23,10 @@ import {
* 2. Work is strictly bounded. One request at a time, at most
* UNIVERSE_TRANSACTION_BATCH_LIMIT transactions per request, nothing at all
* while the tab is hidden, and a hard ceiling per session.
+ * 3. Nothing is claimed before it is observed. The authority is "answering"
+ * only after it has resolved at least one transaction in this sample;
+ * until then its state is unknown, and after it stops answering the last
+ * sample is published as stale with its time, never as a fresh zero.
*/
const POLL_INTERVAL_MS = 2500;
@@ -30,6 +34,8 @@ const MAXIMUM_QUEUE = 400;
const MAXIMUM_RECENT = 40;
/** Ceiling on resolutions per page visit, so a long session cannot drift into unbounded work. */
const SESSION_RESOLVE_CEILING = 4000;
+/** Transport failures in a row before the authority is reported as not answering. */
+const FAILURE_THRESHOLD = 3;
export interface PulseEvent {
readonly txid: string;
@@ -39,6 +45,18 @@ export interface PulseEvent {
readonly seenAt: number;
}
+/**
+ * What the sample can say about the authority.
+ *
+ * - unknown: nothing has been resolved yet, so nothing is claimed either way.
+ * - observed: the authority resolved at least one transaction, and the tally
+ * is current.
+ * - unavailable: the authority failed before resolving anything.
+ * - stale: the authority failed after a sample existed; the tally is that
+ * sample, frozen at `lastSampleAt`.
+ */
+export type PulseObservation = 'unknown' | 'observed' | 'unavailable' | 'stale';
+
export interface PulseState {
/** Transactions the authority answered for since this page opened. */
readonly checked: number;
@@ -47,8 +65,9 @@ export interface PulseState {
/** Protocol id to number of transactions it appeared in, within `checked`. */
readonly protocolCounts: ReadonlyMap;
readonly recent: readonly PulseEvent[];
- /** True while the authority is answering. False after repeated failures. */
- readonly authorityAnswering: boolean;
+ readonly observation: PulseObservation;
+ /** When the authority last resolved a transaction in this sample; null before it has. */
+ readonly lastSampleAt: number | null;
readonly startedAt: number;
/** True once the session ceiling is reached and sampling has stopped. */
readonly ceilingReached: boolean;
@@ -59,7 +78,8 @@ const EMPTY_STATE: PulseState = {
withAssets: 0,
protocolCounts: new Map(),
recent: [],
- authorityAnswering: true,
+ observation: 'unknown',
+ lastSampleAt: null,
startedAt: 0,
ceilingReached: false,
};
@@ -74,10 +94,19 @@ export class UniversePulseService implements OnDestroy {
private inFlight = false;
private consecutiveFailures = 0;
private subscriptions = new Subscription();
+ /** The arrivals feed for the sampled network; replaced on a network switch. */
+ private arrivals?: Subscription;
/** Reference count: several surfaces can show the pulse at once. */
private consumers = 0;
/** Mirrors the tab visibility stream so the sampling loop can read it synchronously. */
private tabHidden = false;
+ /**
+ * Identifies the sample a request belongs to. A network switch or a final
+ * stop starts a new one, and a request that completes for an older sample
+ * is dropped rather than applied to the current tally.
+ */
+ private generation = 0;
+ private sampledNetwork: string | undefined;
constructor(
private stateService: StateService,
@@ -97,18 +126,22 @@ export class UniversePulseService implements OnDestroy {
if (this.consumers > 1) {
return;
}
- this.stateSubject.next({ ...EMPTY_STATE, startedAt: Date.now() });
+ this.sampledNetwork = this.stateService.network;
+ this.beginSample();
- this.subscriptions.add(
- this.stateService.transactions$.subscribe((transactions) => {
- if (!transactions?.length) {
- return;
- }
- for (const transaction of transactions) {
- this.enqueue(transaction?.txid);
- }
- }),
- );
+ // The arrivals feed is per network: a switch discards the sample and
+ // resubscribes, so a Signet tally can never continue a mainnet one.
+ if (this.stateService.networkChanged$) {
+ this.subscriptions.add(
+ this.stateService.networkChanged$.subscribe((network) => {
+ if (network === this.sampledNetwork) {
+ return;
+ }
+ this.sampledNetwork = network;
+ this.beginSample();
+ }),
+ );
+ }
this.subscriptions.add(
this.stateService.isTabHidden$.subscribe((hidden) => {
@@ -127,13 +160,16 @@ export class UniversePulseService implements OnDestroy {
if (this.consumers === 0) {return;}
this.consumers -= 1;
if (this.consumers > 0) {return;}
+ this.generation += 1;
this.subscriptions.unsubscribe();
this.subscriptions = new Subscription();
+ this.arrivals = undefined;
this.queue = [];
this.seen = new Set();
this.inFlight = false;
this.consecutiveFailures = 0;
this.tabHidden = false;
+ this.sampledNetwork = undefined;
this.stateSubject.next(EMPTY_STATE);
}
@@ -142,6 +178,29 @@ export class UniversePulseService implements OnDestroy {
this.stop();
}
+ /** Discards any queued or in-flight work and starts a fresh, empty sample. */
+ private beginSample(): void {
+ this.generation += 1;
+ this.queue = [];
+ this.seen = new Set();
+ this.inFlight = false;
+ this.consecutiveFailures = 0;
+ this.stateSubject.next({ ...EMPTY_STATE, startedAt: Date.now() });
+
+ this.arrivals?.unsubscribe();
+ this.arrivals = this.stateService.transactions$?.subscribe((transactions) => {
+ if (!transactions?.length) {
+ return;
+ }
+ for (const transaction of transactions) {
+ this.enqueue(transaction?.txid);
+ }
+ });
+ if (this.arrivals) {
+ this.subscriptions.add(this.arrivals);
+ }
+ }
+
private enqueue(txid: string | undefined): void {
if (typeof txid !== 'string' || !/^[0-9a-f]{64}$/.test(txid)) {return;}
if (this.seen.has(txid)) {return;}
@@ -166,26 +225,41 @@ export class UniversePulseService implements OnDestroy {
return;
}
+ const generation = this.generation;
const batch = this.queue.splice(0, UNIVERSE_TRANSACTION_BATCH_LIMIT);
this.inFlight = true;
try {
const response = await firstValueFrom(this.api.getTransactionFlows$(batch));
- this.consecutiveFailures = 0;
+ if (generation !== this.generation) {return;}
this.apply(response?.results ?? []);
} catch {
- this.consecutiveFailures += 1;
- if (this.consecutiveFailures >= 3) {
- this.stateSubject.next({
- ...this.stateSubject.value,
- authorityAnswering: false,
- });
- }
+ if (generation !== this.generation) {return;}
+ this.noteFailure(false);
} finally {
- this.inFlight = false;
+ if (generation === this.generation) {
+ this.inFlight = false;
+ }
}
}
+ /**
+ * A batch is evidence only through its items. One valid resolved item
+ * proves the authority answered; a batch whose every item the authority
+ * refused is a failure of the authority, whatever the transport said.
+ * Items it merely did not know about (invalid or not found) prove nothing
+ * either way and leave the observation as it was.
+ */
private apply(results: readonly TransactionBatchItem[]): void {
+ const resolved = results.filter((item) => item.status === 'ok' && !!item.flow);
+ if (!resolved.length) {
+ const refused = results.some((item) => item.status === 'unavailable' || item.status === 'unconfigured');
+ if (refused) {
+ this.noteFailure(true);
+ }
+ return;
+ }
+ this.consecutiveFailures = 0;
+
const previous = this.stateSubject.value;
const protocolCounts = new Map(previous.protocolCounts);
const recent = [...previous.recent];
@@ -193,8 +267,7 @@ export class UniversePulseService implements OnDestroy {
let withAssets = previous.withAssets;
const now = Date.now();
- for (const item of results) {
- if (item.status !== 'ok' || !item.flow) {continue;}
+ for (const item of resolved) {
checked += 1;
const protocolIds = protocolIdsOf(item.flow);
if (!protocolIds.length) {continue;}
@@ -216,11 +289,27 @@ export class UniversePulseService implements OnDestroy {
withAssets,
protocolCounts,
recent: recent.slice(0, MAXIMUM_RECENT),
- authorityAnswering: true,
+ observation: 'observed',
+ lastSampleAt: now,
startedAt: previous.startedAt || now,
ceilingReached: previous.ceilingReached,
});
}
+
+ /**
+ * Records a failed batch. The authority refusing every item is reported at
+ * once; a transport failure only after several in a row, so one dropped
+ * request does not flap the label. Either way the tally is kept: it is the
+ * last sample, marked stale with its time, or nothing if there was none.
+ */
+ private noteFailure(refusedByAuthority: boolean): void {
+ this.consecutiveFailures += 1;
+ if (!refusedByAuthority && this.consecutiveFailures < FAILURE_THRESHOLD) {return;}
+ const current = this.stateSubject.value;
+ const observation: PulseObservation = current.lastSampleAt === null ? 'unavailable' : 'stale';
+ if (current.observation === observation) {return;}
+ this.stateSubject.next({ ...current, observation });
+ }
}
/** Every protocol this flow proves involvement of, deduplicated and sorted. */
From aa9e6112211ae77703520d08732d35fbd7a9c419 Mon Sep 17 00:00:00 2001
From: Bitcoin Universe
Date: Thu, 17 Sep 2026 05:30:27 +0000
Subject: [PATCH 04/19] feat(private-submission): frontend follows the
owner-token relay contract
The broadcast page keeps the owner token returned once at creation in memory,
sends it on readback and abort, understands the durable relay states
(relaying, submitted, confirmed, rejected, cancelled, abort-too-late) and
reports an abort that came too late as exactly that.
Co-Authored-By: Claude Opus 5
---
...ate-submission-broadcast.component.spec.ts | 12 +--
.../private-submission-broadcast.component.ts | 76 ++++++++++++++++---
.../private-submission.models.ts | 36 ++++++++-
.../private-submission.service.ts | 22 +++++-
4 files changed, 123 insertions(+), 23 deletions(-)
diff --git a/frontend/src/app/universe/private-submission/private-submission-broadcast.component.spec.ts b/frontend/src/app/universe/private-submission/private-submission-broadcast.component.spec.ts
index f8f377180ed..2c26e3db9cd 100644
--- a/frontend/src/app/universe/private-submission/private-submission-broadcast.component.spec.ts
+++ b/frontend/src/app/universe/private-submission/private-submission-broadcast.component.spec.ts
@@ -165,11 +165,11 @@ describe('private broadcast: the receipt shows the exact backend status', () =>
});
it('refresh and abort use the submission token and re-read the record', () => {
- const get = vi.fn(() => of(record('aborted')));
- const abort = vi.fn(() => of({ success: true, status: 'aborted' }));
+ const get = vi.fn(() => of(record('cancelled')));
+ const abort = vi.fn(() => of({ success: true, status: 'cancelled' }));
const c = component({
getCapabilities$: () => of(capabilities),
- submitPrivate$: () => of(record('queued')),
+ submitPrivate$: () => of({ ...record('queued'), owner_token: 'a'.repeat(64) }),
getPrivateSubmission$: get,
abortPrivate$: abort,
});
@@ -178,9 +178,9 @@ describe('private broadcast: the receipt shows the exact backend status', () =>
c.submitPrivate();
expect(c.broadcastReceipt?.can_abort).toBe(true);
c.abort();
- expect(abort).toHaveBeenCalledWith('tok');
- expect(get).toHaveBeenCalledWith('tok');
- expect(c.broadcastReceipt?.status).toBe('aborted');
+ expect(abort).toHaveBeenCalledWith('tok', 'a'.repeat(64));
+ expect(get).toHaveBeenCalledWith('tok', 'a'.repeat(64));
+ expect(c.broadcastReceipt?.status).toBe('cancelled');
});
});
diff --git a/frontend/src/app/universe/private-submission/private-submission-broadcast.component.ts b/frontend/src/app/universe/private-submission/private-submission-broadcast.component.ts
index f1750408c66..d053be928e5 100644
--- a/frontend/src/app/universe/private-submission/private-submission-broadcast.component.ts
+++ b/frontend/src/app/universe/private-submission/private-submission-broadcast.component.ts
@@ -11,6 +11,7 @@ import {
import {
PrivateBroadcastRecord,
PrivateBroadcastStatus,
+ PRIVATE_BROADCAST_STATUSES,
PrivateSubmissionApiService,
SubmissionCapabilities,
SubmissionMethod,
@@ -97,6 +98,42 @@ export function statusPresentation(status: PrivateBroadcastStatus): {
};
case 'failed':
return { heading: 'Broadcast failed', badge: 'FAILED', tone: 'danger' };
+ case 'relaying':
+ return {
+ heading: 'Relaying through the owned private endpoint',
+ badge: 'RELAYING',
+ tone: 'warning',
+ };
+ case 'submitted':
+ return {
+ heading: 'Accepted by the owned node; awaiting confirmation',
+ badge: 'SUBMITTED',
+ tone: 'warning',
+ };
+ case 'confirmed':
+ return {
+ heading: 'Confirmed on the owned node',
+ badge: 'CONFIRMED',
+ tone: 'success',
+ };
+ case 'rejected':
+ return {
+ heading: 'Rejected by the owned node',
+ badge: 'REJECTED',
+ tone: 'danger',
+ };
+ case 'cancelled':
+ return {
+ heading: 'Cancelled before relay',
+ badge: 'CANCELLED',
+ tone: 'secondary',
+ };
+ case 'abort-too-late':
+ return {
+ heading: 'Relay had already started; nothing was undone',
+ badge: 'ABORT TOO LATE',
+ tone: 'warning',
+ };
default:
return {
heading: 'Unknown state',
@@ -311,6 +348,7 @@ export class PrivateSubmissionBroadcastComponent implements OnInit, OnDestroy {
method: SubmissionMethod;
network: string;
token?: string;
+ ownerToken?: string;
};
constructor(private api: PrivateSubmissionApiService) {}
ngOnInit(): void {
@@ -361,7 +399,14 @@ export class PrivateSubmissionBroadcastComponent implements OnInit, OnDestroy {
return statusPresentation(this.broadcastReceipt?.status ?? 'failed');
}
isTerminal(s: PrivateBroadcastStatus): boolean {
- return ['broadcast_completed', 'aborted', 'failed'].includes(s);
+ return [
+ 'broadcast_completed',
+ 'aborted',
+ 'failed',
+ 'confirmed',
+ 'rejected',
+ 'cancelled',
+ ].includes(s);
}
submitPrivate(): void {
if (!this.canSubmit || !this.method) return;
@@ -398,7 +443,9 @@ export class PrivateSubmissionBroadcastComponent implements OnInit, OnDestroy {
this.request?.unsubscribe();
this.refreshing = true;
this.loadError = null;
- this.request = this.api.getPrivateSubmission$(token).subscribe({
+ this.request = this.api
+ .getPrivateSubmission$(token, this.expected.ownerToken)
+ .subscribe({
next: (r) => {
this.broadcastReceipt = this.acceptRecord(r);
this.refreshing = false;
@@ -417,11 +464,18 @@ export class PrivateSubmissionBroadcastComponent implements OnInit, OnDestroy {
this.request?.unsubscribe();
this.refreshing = false;
this.aborting = true;
- this.request = this.api.abortPrivate$(token).subscribe({
+ this.request = this.api
+ .abortPrivate$(token, this.expected?.ownerToken)
+ .subscribe({
next: (r) => {
this.aborting = false;
- if (r?.success !== true || r.status !== 'aborted') {
- this.loadError = 'The relay did not confirm an abort.';
+ if (r?.success !== true || !['aborted', 'cancelled'].includes(r.status)) {
+ // abort-too-late is a truthful answer: the relay already started and
+ // nothing was reversed. Refresh status shows the record's real state.
+ this.loadError =
+ r?.status === 'abort-too-late'
+ ? 'The relay had already started; the abort changed nothing.'
+ : 'The relay did not confirm an abort.';
return;
}
this.refreshStatus();
@@ -449,13 +503,7 @@ export class PrivateSubmissionBroadcastComponent implements OnInit, OnDestroy {
r.network !== e.network ||
this.api.network !== e.network ||
(e.token !== undefined && r.submission_token !== e.token) ||
- ![
- 'queued',
- 'acknowledged',
- 'aborted',
- 'broadcast_completed',
- 'failed',
- ].includes(r.status) ||
+ !PRIVATE_BROADCAST_STATUSES.includes(r.status) ||
typeof r.can_abort !== 'boolean' ||
!Number.isSafeInteger(r.retry_count) ||
r.retry_count < 0 ||
@@ -466,6 +514,10 @@ export class PrivateSubmissionBroadcastComponent implements OnInit, OnDestroy {
return null;
}
e.token = r.submission_token;
+ if (typeof r.owner_token === 'string' && /^[0-9a-f]{64}$/i.test(r.owner_token)) {
+ // Kept in memory only for this page's readback and abort; never rendered.
+ e.ownerToken = r.owner_token;
+ }
return r;
}
ngOnDestroy(): void {
diff --git a/frontend/src/app/universe/private-submission/private-submission.models.ts b/frontend/src/app/universe/private-submission/private-submission.models.ts
index 33940f55d95..94b226f3907 100644
--- a/frontend/src/app/universe/private-submission/private-submission.models.ts
+++ b/frontend/src/app/universe/private-submission/private-submission.models.ts
@@ -53,12 +53,46 @@ export interface PrivateBroadcastRecord {
network: string;
queued_at_utc: string;
status:
- 'queued' | 'acknowledged' | 'aborted' | 'broadcast_completed' | 'failed';
+ | 'queued'
+ | 'acknowledged'
+ | 'aborted'
+ | 'broadcast_completed'
+ | 'failed'
+ | 'relaying'
+ | 'submitted'
+ | 'confirmed'
+ | 'rejected'
+ | 'cancelled'
+ | 'abort-too-late';
retry_count: number;
can_abort: boolean;
last_error?: string;
+ /** Returned exactly once, by the POST that created the record; needed for readback and abort. */
+ owner_token?: string;
+ duplicate?: boolean;
+ relay_endpoint_id?: string;
+ relayed_at_utc?: string;
+ confirmed_block_height?: number;
+ updated_at_utc?: string;
}
+export const PRIVATE_BROADCAST_STATUSES: readonly PrivateBroadcastRecord['status'][] = [
+ 'queued',
+ 'acknowledged',
+ 'aborted',
+ 'broadcast_completed',
+ 'failed',
+ 'relaying',
+ 'submitted',
+ 'confirmed',
+ 'rejected',
+ 'cancelled',
+ 'abort-too-late',
+];
+
+/** Header carrying the owner token on private submission readback and abort. */
+export const PRIVATE_SUBMISSION_OWNER_TOKEN_HEADER = 'X-Submission-Owner-Token';
+
export interface AcceleratorProvider {
provider_id: string;
identity_key: string;
diff --git a/frontend/src/app/universe/private-submission/private-submission.service.ts b/frontend/src/app/universe/private-submission/private-submission.service.ts
index e9760c4f828..d7f112fa442 100644
--- a/frontend/src/app/universe/private-submission/private-submission.service.ts
+++ b/frontend/src/app/universe/private-submission/private-submission.service.ts
@@ -9,7 +9,12 @@ import {
SubmissionMethod,
PrivateBroadcastRecord,
AcceleratorProvider,
+ PRIVATE_SUBMISSION_OWNER_TOKEN_HEADER,
} from './private-submission.models';
+
+function ownerHeaders(ownerToken?: string): Record {
+ return ownerToken ? { [PRIVATE_SUBMISSION_OWNER_TOKEN_HEADER]: ownerToken } : {};
+}
export * from './private-submission.models';
export type PrivateBroadcastStatus = PrivateBroadcastRecord['status'];
/**
@@ -90,20 +95,29 @@ export class PrivateSubmissionApiService {
);
}
+ /**
+ * Readback and abort are owner-authenticated: the backend returns the owner
+ * token once, on creation, and requires it back in a header. The token is
+ * never placed in the URL.
+ */
public getPrivateSubmission$(
- token: string
+ token: string,
+ ownerToken?: string
): Observable {
return this.http.get(
- `${this.baseUrl}/submission/private/${encodeURIComponent(token)}`
+ `${this.baseUrl}/submission/private/${encodeURIComponent(token)}`,
+ { headers: ownerHeaders(ownerToken) }
);
}
public abortPrivate$(
- token: string
+ token: string,
+ ownerToken?: string
): Observable<{ success: boolean; status: string }> {
return this.http.post<{ success: boolean; status: string }>(
`${this.baseUrl}/submission/private/${encodeURIComponent(token)}/abort`,
- {}
+ {},
+ { headers: ownerHeaders(ownerToken) }
);
}
From 5db5153988b9f52dd0455632627026fe2219db02 Mon Sep 17 00:00:00 2001
From: Bitcoin Universe
Date: Thu, 17 Sep 2026 05:33:07 +0000
Subject: [PATCH 05/19] fix(backend): truthful indexer outcomes, durable admin
runs, shared replay fence, real tx status, sigop accounting, fiat provenance
BE-DEF-007/008/009: scheduleSingleTask releases its slot (identity-compared)
before running so a task can reschedule itself; runSingleTask resets the
running flag in finally and returns a typed SingleTaskOutcome. The admin
task handler verifies only a completed outcome whose persisted checkpoint
reads back complete; reindex reports scheduled only when the indexer accepted.
BE-DEF-010: track-txs never defaults to confirmed; each id is resolved as
mempool, replaced, confirmed (real block height/hash from the node), unknown
or unavailable, deduplicated and bounded.
BE-DEF-011: the JavaScript block template accumulates sigops per placed
transaction and resets per bounded block.
BE-DEF-003/004/006/013: cancellable is persisted in the run document; runs get
an initial lease and an owner token; lock conflicts leave a FAILED record;
executors heartbeat with compare-and-set while work runs; reconciliation is
batched, revalidates the lease, and returns a structured result that surfaces
storage failures. Schema 110 adds admin_adapter_runs.owner_token.
BE-DEF-005: admin nonces are claimed atomically in a shared store (MySQL
admin_nonces table, or Redis SET NX PX); no shared store fails closed; the
in-memory store is only used on the explicit EXPLORER_ADMIN_REPLAY_STORE=memory
opt-in and never evicts a live nonce.
BE-DEF-012: stored fiat rates are retained but not advertised as current when
the feed is disabled, the network is unpriced, or the observation is stale;
the prices route and websocket conversions carry the observation state.
Co-Authored-By: Claude Opus 5
---
.../admin-adapter-execute-route.test.ts | 131 +++++++
.../admin-adapter-indexer-operations.test.ts | 120 +++++++
.../__tests__/admin-adapter-replay.test.ts | 134 ++++++++
.../src/__tests__/admin-adapter-runs.test.ts | 319 ++++++++++++++++++
backend/src/__tests__/admin-adapter.test.ts | 60 +++-
.../admin-resource-stream-boundaries.test.ts | 2 +-
.../src/__tests__/indexer-single-task.test.ts | 181 ++++++++++
.../price-updater-stale-rates.test.ts | 118 +++++++
.../src/__tests__/tx-selection-sigops.test.ts | 75 ++++
.../src/__tests__/websocket-track-txs.test.ts | 136 ++++++++
.../admin-adapter/admin-adapter.operations.ts | 125 ++++++-
.../api/admin-adapter/admin-adapter.replay.ts | 127 +++++++
.../api/admin-adapter/admin-adapter.routes.ts | 35 +-
.../api/admin-adapter/admin-adapter.runs.ts | 216 ++++++++++--
.../admin-adapter/admin-adapter.security.ts | 203 ++++++++---
backend/src/api/database-migration.ts | 17 +-
backend/src/api/prices/prices.routes.ts | 4 +-
backend/src/api/redis-cache.ts | 18 +
backend/src/api/tx-selection-worker.ts | 9 +-
backend/src/api/websocket-handler.ts | 153 +++++++--
backend/src/indexer.ts | 115 +++++--
backend/src/mempool.interfaces.ts | 6 +-
backend/src/tasks/price-updater.ts | 114 ++++++-
23 files changed, 2243 insertions(+), 175 deletions(-)
create mode 100644 backend/src/__tests__/admin-adapter-execute-route.test.ts
create mode 100644 backend/src/__tests__/admin-adapter-indexer-operations.test.ts
create mode 100644 backend/src/__tests__/admin-adapter-replay.test.ts
create mode 100644 backend/src/__tests__/admin-adapter-runs.test.ts
create mode 100644 backend/src/__tests__/indexer-single-task.test.ts
create mode 100644 backend/src/__tests__/price-updater-stale-rates.test.ts
create mode 100644 backend/src/__tests__/tx-selection-sigops.test.ts
create mode 100644 backend/src/__tests__/websocket-track-txs.test.ts
create mode 100644 backend/src/api/admin-adapter/admin-adapter.replay.ts
diff --git a/backend/src/__tests__/admin-adapter-execute-route.test.ts b/backend/src/__tests__/admin-adapter-execute-route.test.ts
new file mode 100644
index 00000000000..c90bcaa1d75
--- /dev/null
+++ b/backend/src/__tests__/admin-adapter-execute-route.test.ts
@@ -0,0 +1,131 @@
+/**
+ * The execute route's handling of the run lifecycle, with the run store
+ * replaced by a recorder: which transitions it asks for, with which owner
+ * token, and what it answers when the lock is held by another run.
+ */
+const store = {
+ reconcileAbandonedRuns: jest.fn(),
+ create: jest.fn(),
+ acquireLock: jest.fn(),
+ transition: jest.fn(),
+ withHeartbeat: jest.fn(),
+ get: jest.fn(),
+ list: jest.fn(),
+ auditEntries: jest.fn(),
+ requestCancel: jest.fn(),
+};
+const operation = {
+ id: 'explorer.capabilities.refresh',
+ version: '1',
+ name: 'Refresh capability probes',
+ risk: 'SAFE',
+ availability: 'enabled',
+ availabilityReason: null,
+ cancellable: false,
+ rollbackSupported: false,
+ lock: 'explorer:explorer.capabilities.refresh',
+ buildPreview: jest.fn(),
+ execute: jest.fn(),
+};
+
+jest.mock('../config', () => ({ __esModule: true, default: { DATABASE: { ENABLED: true }, REDIS: { ENABLED: false } } }));
+jest.mock('../api/backend-info', () => ({ __esModule: true, default: { getBackendInfo: () => ({}) } }));
+jest.mock('../api/blocks', () => ({ __esModule: true, default: { getCurrentBlockHeight: () => 0 } }));
+jest.mock('../api/capabilities', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/mempool', () => ({ __esModule: true, default: { getMempool: () => ({}) } }));
+jest.mock('../api/admin-adapter/admin-adapter.snapshot', () => ({ buildExplorerSnapshot: jest.fn(), capabilityLabel: () => '', capabilityState: () => 'unknown' }));
+jest.mock('../api/admin-adapter/admin-adapter.operations', () => ({
+ findExplorerOperation: () => operation,
+ listExplorerOperations: () => [operation],
+}));
+jest.mock('../api/admin-adapter/admin-adapter.replay', () => ({ createAdminReplayStore: () => ({ kind: 'memory', claim: async () => 'claimed' }) }));
+jest.mock('../api/admin-adapter/admin-adapter.runs', () => {
+ class AdminRunNotFound extends Error {}
+ class AdminRunConflict extends Error {}
+ return { __esModule: true, default: store, AdminRunNotFound, AdminRunConflict };
+});
+
+import adminAdapterRoutes from '../api/admin-adapter/admin-adapter.routes';
+import { AdminRunConflict } from '../api/admin-adapter/admin-adapter.runs';
+
+const RUN_ID = '11111111-1111-4111-8111-111111111111';
+const TOKEN = '22222222-2222-4222-8222-222222222222';
+
+function mountExecute(): (request: any, response: any) => Promise {
+ const routes: Record = {};
+ const app = {
+ use: jest.fn(),
+ get: (path: string, handler: any) => { routes[`GET ${path}`] = handler; },
+ post: (path: string, handler: any) => { routes[`POST ${path}`] = handler; },
+ };
+ adminAdapterRoutes.initRoutes(app as any);
+ return routes['POST /internal/admin/v1/operations/:operationId/execute'];
+}
+
+function respond(): { response: any; done: Promise<{ status: number; body: any }> } {
+ const response: any = { locals: {}, status: jest.fn(), json: jest.fn() };
+ let status = 200;
+ response.status.mockImplementation((code: number) => { status = code; return response; });
+ const done = new Promise<{ status: number; body: any }>((resolve) => {
+ response.json.mockImplementation((body: any) => { resolve({ status, body }); return response; });
+ });
+ return { response, done };
+}
+
+function run(state: string, extra: Record = {}) {
+ return { runId: RUN_ID, state, reason: null, idempotencyKey: null, ...extra };
+}
+
+describe('admin execute route lifecycle', () => {
+ let execute: (request: any, response: any) => Promise;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ execute = mountExecute();
+ operation.buildPreview.mockResolvedValue({ available: true, target: 'explorer/test/capabilities' });
+ store.reconcileAbandonedRuns.mockResolvedValue({ reconciled: 0, remaining: 0, verified: true });
+ store.create.mockResolvedValue({ run: run('QUEUED'), replayed: false, ownerToken: TOKEN });
+ store.transition.mockImplementation(async (_runId: string, state: string) => run(state));
+ store.withHeartbeat.mockImplementation(async (_runId: string, _token: string, work: () => Promise) => work());
+ });
+
+ it('records a lock conflict as a FAILED run and answers 409 naming it', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ store.acquireLock.mockRejectedValue(new AdminRunConflict('Another operation (other) is already working on this target.'));
+ const { response, done } = respond();
+ await execute({ params: { operationId: operation.id }, headers: {}, body: { input: {} } }, response);
+ const answer = await done;
+ expect(answer.status).toBe(409);
+ expect(answer.body.code).toBe('RUN_CONFLICT');
+ expect(answer.body.message).toContain(RUN_ID);
+ expect(store.transition).toHaveBeenCalledTimes(1);
+ expect(store.transition).toHaveBeenCalledWith(RUN_ID, 'FAILED', expect.objectContaining({
+ error: expect.objectContaining({ class: 'lock_conflict', retryable: true }),
+ }), TOKEN);
+ expect(operation.execute).not.toHaveBeenCalled();
+ });
+
+ it('runs the operation under a heartbeat and finishes it with the owner token', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ store.acquireLock.mockResolvedValue(undefined);
+ operation.execute.mockResolvedValue({ summary: 'ok', result: {}, verification: { verified: true, evidence: ['x'] } });
+ const { response, done } = respond();
+ await execute({ params: { operationId: operation.id }, headers: {}, body: { input: {} } }, response);
+ const answer = await done;
+ expect(answer.status).toBe(200);
+ expect(answer.body.run.state).toBe('SUCCEEDED');
+ expect(store.withHeartbeat).toHaveBeenCalledWith(RUN_ID, TOKEN, expect.any(Function));
+ const states = store.transition.mock.calls.map((call) => [call[1], call[3]]);
+ expect(states).toEqual([['PRECHECK', TOKEN], ['RUNNING', TOKEN], ['VERIFYING', TOKEN], ['SUCCEEDED', TOKEN]]);
+ });
+
+ it('marks a failed operation FAILED with its error under the owner token', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ store.acquireLock.mockResolvedValue(undefined);
+ operation.execute.mockRejectedValue(new Error('The blocksPrices task failed: isolated task failure'));
+ const { response, done } = respond();
+ await execute({ params: { operationId: operation.id }, headers: {}, body: { input: {} } }, response);
+ const answer = await done;
+ expect(answer.body.run.state).toBe('FAILED');
+ expect(store.transition).toHaveBeenLastCalledWith(RUN_ID, 'FAILED', expect.objectContaining({
+ error: expect.objectContaining({ class: 'operation_failed', message: expect.stringContaining('isolated task failure') }),
+ }), TOKEN);
+ });
+});
diff --git a/backend/src/__tests__/admin-adapter-indexer-operations.test.ts b/backend/src/__tests__/admin-adapter-indexer-operations.test.ts
new file mode 100644
index 00000000000..39995f689b4
--- /dev/null
+++ b/backend/src/__tests__/admin-adapter-indexer-operations.test.ts
@@ -0,0 +1,120 @@
+/**
+ * The indexer and reconcile operation handlers consume typed outcomes. Only
+ * a completed task whose persisted rows read back complete may be verified.
+ */
+const indexerState = { runSingleTask: jest.fn(), reindex: jest.fn() };
+const dbState = { query: jest.fn() };
+const runStoreState = { reconcileAbandonedRuns: jest.fn() };
+
+jest.mock('../config', () => ({
+ __esModule: true,
+ default: { DATABASE: { ENABLED: true }, REDIS: { ENABLED: false }, FIAT_PRICE: { ENABLED: true }, MEMPOOL: { INDEXING_BLOCKS_AMOUNT: -1, NETWORK: 'signet' } },
+}));
+jest.mock('../database', () => ({ __esModule: true, default: { query: (...args: unknown[]) => dbState.query(...args) } }));
+jest.mock('../api/backend-info', () => ({ __esModule: true, default: { getBackendInfo: () => ({}) } }));
+jest.mock('../api/bitcoin/bitcoin-client', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/bitcoin/address-index', () => ({ $probeAddressIndex: jest.fn() }));
+jest.mock('../api/blocks', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/capabilities', () => ({ __esModule: true, default: {} }));
+jest.mock('../indexer', () => ({ __esModule: true, default: indexerState }));
+jest.mock('../api/mempool', () => ({ __esModule: true, default: {} }));
+jest.mock('../tasks/pools-updater', () => ({ __esModule: true, default: {} }));
+jest.mock('../tasks/price-updater', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/redis-cache', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/admin-adapter/admin-adapter.runs', () => ({ __esModule: true, default: runStoreState }));
+
+import { findExplorerOperation } from '../api/admin-adapter/admin-adapter.operations';
+
+const context = (input: Record) => ({
+ runId: 'run', correlationId: 'c', actor: 'a', reason: null, idempotencyKey: null, input,
+});
+
+describe('explorer.indexer.task.run', () => {
+ const operation = findExplorerOperation('explorer.indexer.task.run');
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each(['disabled', 'already-running', 'deferred', 'network-inapplicable'] as const)(
+ 'never verifies a task that answered %s',
+ /** @asyncUnsafe Jest owns the test promise. */ async (status) => {
+ indexerState.runSingleTask.mockResolvedValue({ task: 'coinStatsIndex', status, reason: `fixture ${status}` });
+ const outcome = await operation.execute(context({ task: 'coinStatsIndex' }));
+ expect(outcome.verification.verified).toBe(false);
+ expect(outcome.result.status).toBe(status);
+ expect(outcome.result.reason).toBe(`fixture ${status}`);
+ expect(outcome.verification.evidence.join(' ')).toContain(status);
+ expect(dbState.query).not.toHaveBeenCalled();
+ },
+ );
+
+ it('turns a failed task into a run failure carrying the error', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexerState.runSingleTask.mockResolvedValue({ task: 'coinStatsIndex', status: 'failed', error: 'isolated coinstats failure' });
+ await expect(operation.execute(context({ task: 'coinStatsIndex' }))).rejects.toThrow('isolated coinstats failure');
+ });
+
+ it('verifies a completed task only when the persisted rows read back complete', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexerState.runSingleTask.mockResolvedValue({
+ task: 'coinStatsIndex', status: 'completed', checkpoint: { startedAt: '2026-09-17T12:00:00.000Z', finishedAt: '2026-09-17T12:00:05.000Z' },
+ });
+ dbState.query.mockResolvedValueOnce([[{ remaining: 0 }]]).mockResolvedValueOnce([[{ tip: 250_000 }]]);
+ const verified = await operation.execute(context({ task: 'coinStatsIndex' }));
+ expect(verified.verification.verified).toBe(true);
+ expect(verified.result).toMatchObject({ status: 'completed', remaining: 0, indexedTip: 250_000, startedAt: '2026-09-17T12:00:00.000Z', finishedAt: '2026-09-17T12:00:05.000Z' });
+
+ dbState.query.mockResolvedValueOnce([[{ remaining: 12 }]]).mockResolvedValueOnce([[{ tip: 249_000 }]]);
+ const incomplete = await operation.execute(context({ task: 'coinStatsIndex' }));
+ expect(incomplete.verification.verified).toBe(false);
+ expect(incomplete.result.remaining).toBe(12);
+
+ dbState.query.mockRejectedValueOnce(new Error('isolated unavailable database fixture'));
+ const unreadable = await operation.execute(context({ task: 'coinStatsIndex' }));
+ expect(unreadable.verification.verified).toBe(false);
+ expect(unreadable.result.checkpointError).toBe('isolated unavailable database fixture');
+ });
+
+ it('reads the blocks_prices checkpoint for the blocksPrices task', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexerState.runSingleTask.mockResolvedValue({ task: 'blocksPrices', status: 'completed', checkpoint: { startedAt: 'a', finishedAt: 'b' } });
+ dbState.query.mockResolvedValueOnce([[{ remaining: 0 }]]).mockResolvedValueOnce([[{ tip: 100 }]]);
+ const outcome = await operation.execute(context({ task: 'blocksPrices' }));
+ expect(outcome.verification.verified).toBe(true);
+ expect(String(dbState.query.mock.calls[0][0])).toContain('blocks_prices');
+ });
+});
+
+describe('explorer.indexer.reindex', () => {
+ const operation = findExplorerOperation('explorer.indexer.reindex');
+
+ it('reports scheduled only when the indexer accepted, and never completion', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexerState.reindex.mockReturnValue(true);
+ const accepted = await operation.execute(context({ confirmation: 'REINDEX EXPLORER BLOCKS' }));
+ expect(accepted.result).toEqual({ scheduled: true, indexingCompleted: false });
+ expect(accepted.summary).toContain('not complete');
+
+ indexerState.reindex.mockReturnValue(false);
+ await expect(operation.execute(context({ confirmation: 'REINDEX EXPLORER BLOCKS' }))).rejects.toThrow(/refused/);
+ });
+});
+
+describe('explorer.runs.reconcile', () => {
+ const operation = findExplorerOperation('explorer.runs.reconcile');
+
+ it('verifies only a clean pass with nothing remaining', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ runStoreState.reconcileAbandonedRuns.mockResolvedValue({ reconciled: 3, remaining: 0, verified: true });
+ const clean = await operation.execute(context({}));
+ expect(clean.verification.verified).toBe(true);
+ expect(clean.result).toEqual({ reconciled: 3, remaining: 0, storageError: null });
+
+ runStoreState.reconcileAbandonedRuns.mockResolvedValue({ reconciled: 200, remaining: 7, verified: false });
+ const partial = await operation.execute(context({}));
+ expect(partial.verification.verified).toBe(false);
+ expect(partial.result.remaining).toBe(7);
+
+ runStoreState.reconcileAbandonedRuns.mockResolvedValue({ reconciled: 0, remaining: null, verified: false, error: 'isolated unavailable database fixture' });
+ const failed = await operation.execute(context({}));
+ expect(failed.verification.verified).toBe(false);
+ expect(failed.result.storageError).toBe('isolated unavailable database fixture');
+ expect(failed.summary).toContain('storage error');
+ });
+});
diff --git a/backend/src/__tests__/admin-adapter-replay.test.ts b/backend/src/__tests__/admin-adapter-replay.test.ts
new file mode 100644
index 00000000000..ae1e170d1fb
--- /dev/null
+++ b/backend/src/__tests__/admin-adapter-replay.test.ts
@@ -0,0 +1,134 @@
+/**
+ * The shared replay stores behind the admin guard, against a stand-in for the
+ * admin_nonces table (a primary key that refuses duplicates, an UPDATE that
+ * reports touched rows) and a stand-in for Redis SET NX PX.
+ */
+const configState = { DATABASE: { ENABLED: true }, REDIS: { ENABLED: false } };
+
+class DuplicateKey extends Error { code = 'ER_DUP_ENTRY'; errno = 1062; }
+
+const table = {
+ rows: new Map(),
+ failing: null as string | null,
+ /** @asyncUnsafe test double */
+ async query(sql: string, params: any[] = []): Promise {
+ if (table.failing !== null) throw new Error(table.failing);
+ const s = sql.replace(/\s+/g, ' ').trim();
+ if (s.startsWith('DELETE FROM admin_nonces WHERE expires_at <= ?')) {
+ for (const [key, expiresAt] of table.rows) if (expiresAt.getTime() <= params[0].getTime()) table.rows.delete(key);
+ return [{ affectedRows: 0 }];
+ }
+ if (s.startsWith('INSERT INTO admin_nonces')) {
+ const key = `${params[0]}\n${params[1]}`;
+ if (table.rows.has(key)) throw new DuplicateKey('dup');
+ table.rows.set(key, params[2]);
+ return [{ affectedRows: 1 }];
+ }
+ if (s.startsWith('UPDATE admin_nonces SET expires_at = ? WHERE scope = ? AND nonce = ? AND expires_at <= ?')) {
+ const key = `${params[1]}\n${params[2]}`;
+ const current = table.rows.get(key);
+ if (!current || current.getTime() > params[3].getTime()) return [{ affectedRows: 0 }];
+ table.rows.set(key, params[0]);
+ return [{ affectedRows: 1 }];
+ }
+ throw new Error('fixture does not understand: ' + s);
+ },
+};
+
+const redis = {
+ keys: new Map(),
+ failing: null as string | null,
+ /** @asyncUnsafe test double */
+ async $claimOnce(key: string, ttlMs: number): Promise {
+ if (redis.failing !== null) throw new Error(redis.failing);
+ if (redis.keys.has(key)) return false;
+ redis.keys.set(key, ttlMs);
+ return true;
+ },
+};
+
+jest.mock('../config', () => ({ __esModule: true, default: configState }));
+jest.mock('../database', () => ({ __esModule: true, default: { query: (sql: string, params?: any[]) => table.query(sql, params) } }));
+jest.mock('../api/redis-cache', () => ({ __esModule: true, default: redis }));
+
+import {
+ ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE,
+ MysqlAdminReplayStore,
+ RedisAdminReplayStore,
+ createAdminReplayStore,
+} from '../api/admin-adapter/admin-adapter.replay';
+import { ADMIN_NONCE_RETENTION_MS } from '../api/admin-adapter/admin-adapter.security';
+
+const NOW = Date.UTC(2026, 8, 17, 12, 0, 0);
+
+describe('database replay store', () => {
+ beforeEach(() => {
+ table.rows.clear();
+ table.failing = null;
+ });
+
+ it('lets two workers sharing the table accept a nonce exactly once', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const workerA = new MysqlAdminReplayStore();
+ const workerB = new MysqlAdminReplayStore();
+ expect(await workerA.claim('key@1.0.0', 'isolated-nonce-1234', NOW)).toBe('claimed');
+ expect(await workerB.claim('key@1.0.0', 'isolated-nonce-1234', NOW + 1)).toBe('replayed');
+ expect(await workerA.claim('key@1.0.0', 'isolated-nonce-1234', NOW + 2)).toBe('replayed');
+ // A different key scope is a different claim.
+ expect(await workerB.claim('other@1.0.0', 'isolated-nonce-1234', NOW + 3)).toBe('claimed');
+ });
+
+ it('never releases a live nonce under pressure and reuses it only after retention', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const store = new MysqlAdminReplayStore();
+ expect(await store.claim('key@1.0.0', 'nonce-a', NOW)).toBe('claimed');
+ for (let i = 0; i < 25_000; i++) {
+ table.rows.set(`key@1.0.0\nfiller-${i}`, new Date(NOW + ADMIN_NONCE_RETENTION_MS));
+ }
+ expect(await store.claim('key@1.0.0', 'nonce-a', NOW + ADMIN_NONCE_RETENTION_MS - 1)).toBe('replayed');
+ expect(await store.claim('key@1.0.0', 'nonce-a', NOW + ADMIN_NONCE_RETENTION_MS)).toBe('claimed');
+ });
+
+ it('answers unavailable when the table cannot be reached, never claimed', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const store = new MysqlAdminReplayStore();
+ table.failing = 'isolated unavailable database fixture';
+ expect(await store.claim('key@1.0.0', 'nonce-a', NOW)).toBe('unavailable');
+ });
+});
+
+describe('redis replay store', () => {
+ beforeEach(() => {
+ redis.keys.clear();
+ redis.failing = null;
+ });
+
+ it('claims with SET NX and refuses the second claim', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const workerA = new RedisAdminReplayStore();
+ const workerB = new RedisAdminReplayStore();
+ expect(await workerA.claim('key@1.0.0', 'nonce-a', NOW)).toBe('claimed');
+ expect(await workerB.claim('key@1.0.0', 'nonce-a', NOW)).toBe('replayed');
+ expect(redis.keys.get('admin-nonce:key@1.0.0:nonce-a')).toBe(ADMIN_NONCE_RETENTION_MS);
+ });
+
+ it('answers unavailable when Redis is not connected', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ redis.failing = 'Redis is not connected';
+ expect(await new RedisAdminReplayStore().claim('key@1.0.0', 'nonce-a', NOW)).toBe('unavailable');
+ });
+});
+
+describe('replay store selection', () => {
+ it('prefers the database, then Redis, and otherwise fails closed', () => {
+ expect(createAdminReplayStore({}, { database: true, redis: true }).kind).toBe('database');
+ expect(createAdminReplayStore({}, { database: false, redis: true }).kind).toBe('redis');
+ expect(createAdminReplayStore({}, { database: false, redis: false }).kind).toBe('unavailable');
+ });
+
+ it('honours the explicit override and refuses one that cannot be met', () => {
+ expect(createAdminReplayStore({ [ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE]: 'redis' }, { database: true, redis: true }).kind).toBe('redis');
+ expect(createAdminReplayStore({ [ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE]: 'redis' }, { database: true, redis: false }).kind).toBe('unavailable');
+ expect(createAdminReplayStore({ [ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE]: 'bogus' }, { database: true, redis: true }).kind).toBe('unavailable');
+ });
+
+ it('uses memory only on the explicit single-process opt-in', () => {
+ expect(createAdminReplayStore({ [ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE]: 'memory' }, { database: false, redis: false }).kind).toBe('memory');
+ expect(createAdminReplayStore({}, { database: false, redis: false }).kind).not.toBe('memory');
+ });
+});
diff --git a/backend/src/__tests__/admin-adapter-runs.test.ts b/backend/src/__tests__/admin-adapter-runs.test.ts
new file mode 100644
index 00000000000..59da551b7e1
--- /dev/null
+++ b/backend/src/__tests__/admin-adapter-runs.test.ts
@@ -0,0 +1,319 @@
+/**
+ * The durable run lifecycle against a small in-memory stand-in for the two
+ * admin tables. The stand-in answers exactly the statements the store issues
+ * and reproduces the one database behaviour the store relies on: a duplicate
+ * primary key is an error, and an UPDATE reports how many rows it touched.
+ */
+const configState = { DATABASE: { ENABLED: true }, REDIS: { ENABLED: false } };
+const clock = { now: Date.UTC(2026, 8, 17, 12, 0, 0) };
+
+interface RunRow { [key: string]: any }
+interface LockRow { lock_key: string; run_id: string; acquired_at: Date; expires_at: Date }
+
+class DuplicateKey extends Error { code = 'ER_DUP_ENTRY'; errno = 1062; }
+
+class FakeDb {
+ runs = new Map();
+ locks = new Map();
+ failing: string | null = null;
+ refuseReconcileUpdates = false;
+ statements: string[] = [];
+
+ private abandoned(row: RunRow, now: Date, cutoff: Date): boolean {
+ const open = !['SUCCEEDED', 'FAILED', 'CANCELLED', 'NEEDS_REVIEW', 'ROLLED_BACK', 'ROLLBACK_FAILED'].includes(row.state);
+ if (!open) return false;
+ if (row.lease_expires_at !== null) return row.lease_expires_at.getTime() <= now.getTime();
+ return row.state === 'QUEUED' && row.queued_at.getTime() <= cutoff.getTime();
+ }
+
+ /** @asyncUnsafe test double */
+ async query(sql: string, params: any[] = []): Promise {
+ this.statements.push(sql);
+ if (this.failing !== null) {
+ throw new Error(this.failing);
+ }
+ const s = sql.replace(/\s+/g, ' ').trim();
+ if (s.startsWith('INSERT INTO admin_adapter_runs')) {
+ const columns = s.slice(s.indexOf('(') + 1, s.indexOf(')')).split(',').map((c) => c.trim());
+ const row: RunRow = {
+ run_id: null, operation_id: null, operation_version: null, state: null, target: null, correlation_id: null,
+ idempotency_key: null, actor: null, reason: null, queued_at: null, started_at: null, updated_at: null,
+ finished_at: null, heartbeat_at: null, lease_expires_at: null, owner_token: null, progress_percent: null,
+ cancel_requested: 0, document: null,
+ };
+ let index = 0;
+ for (const column of columns) {
+ if (column === 'cancel_requested') { row.cancel_requested = 0; continue; }
+ row[column] = params[index++];
+ }
+ if (this.runs.has(row.run_id)) throw new DuplicateKey('dup');
+ this.runs.set(row.run_id, row);
+ return [{ affectedRows: 1 }];
+ }
+ if (s.startsWith('SELECT * FROM admin_adapter_runs WHERE run_id = ?')) {
+ const row = this.runs.get(params[0]);
+ return [row ? [{ ...row }] : []];
+ }
+ if (s.startsWith('SELECT * FROM admin_adapter_runs WHERE operation_id = ? AND idempotency_key = ?')) {
+ const found = [...this.runs.values()].find((r) => r.operation_id === params[0] && r.idempotency_key === params[1]);
+ return [found ? [{ ...found }] : []];
+ }
+ if (s.startsWith('SELECT document FROM admin_adapter_runs WHERE run_id = ?')) {
+ const row = this.runs.get(params[0]);
+ return [row ? [{ document: row.document }] : []];
+ }
+ if (s.startsWith('SELECT cancel_requested FROM admin_adapter_runs')) {
+ const row = this.runs.get(params[0]);
+ return [row ? [{ cancel_requested: row.cancel_requested }] : []];
+ }
+ if (s.startsWith('UPDATE admin_adapter_runs SET cancel_requested = 1')) {
+ const row = this.runs.get(params[1]);
+ if (row) { row.cancel_requested = 1; row.updated_at = params[0]; }
+ return [{ affectedRows: row ? 1 : 0 }];
+ }
+ if (s.startsWith('UPDATE admin_adapter_runs SET state = ?, updated_at = ?, started_at = COALESCE')) {
+ const [state, updatedAt, startedAt, finishedAt, heartbeatAt, lease, progress, document, runId, expectedState, ownerToken] = params;
+ const row = this.runs.get(runId);
+ const guarded = s.includes('AND state = ? AND owner_token = ?');
+ if (!row || (guarded && (row.state !== expectedState || row.owner_token !== ownerToken))) {
+ return [{ affectedRows: 0 }];
+ }
+ Object.assign(row, {
+ state, updated_at: updatedAt, started_at: row.started_at ?? startedAt, finished_at: finishedAt,
+ heartbeat_at: heartbeatAt, lease_expires_at: lease, progress_percent: progress, document,
+ });
+ return [{ affectedRows: 1 }];
+ }
+ if (s.startsWith('UPDATE admin_adapter_runs SET heartbeat_at = ?')) {
+ const [heartbeatAt, updatedAt, lease, runId, ownerToken] = params;
+ const row = this.runs.get(runId);
+ if (!row || row.state !== 'RUNNING' || row.owner_token !== ownerToken) return [{ affectedRows: 0 }];
+ Object.assign(row, { heartbeat_at: heartbeatAt, updated_at: updatedAt, lease_expires_at: lease });
+ return [{ affectedRows: 1 }];
+ }
+ if (s.startsWith('SELECT run_id, state FROM admin_adapter_runs WHERE')) {
+ const [now, cutoff] = params;
+ const limit = Number(/LIMIT (\d+)/.exec(s)?.[1] ?? 200);
+ const rows = [...this.runs.values()].filter((r) => this.abandoned(r, now, cutoff)).slice(0, limit);
+ return [rows.map((r) => ({ run_id: r.run_id, state: r.state }))];
+ }
+ if (s.startsWith('SELECT COUNT(*) AS remaining FROM admin_adapter_runs WHERE')) {
+ const [now, cutoff] = params;
+ return [[{ remaining: [...this.runs.values()].filter((r) => this.abandoned(r, now, cutoff)).length }]];
+ }
+ if (s.startsWith('UPDATE admin_adapter_runs SET state = ?, updated_at = ?, finished_at = ?, lease_expires_at = NULL, document = JSON_SET')) {
+ const [state, updatedAt, finishedAt, errorClass, message, retryable, runId, now, cutoff] = params;
+ const row = this.runs.get(runId);
+ if (!row || this.refuseReconcileUpdates || !this.abandoned(row, now, cutoff)) return [{ affectedRows: 0 }];
+ const document = JSON.parse(row.document);
+ document.error = { class: errorClass, message, retryable };
+ Object.assign(row, { state, updated_at: updatedAt, finished_at: finishedAt, lease_expires_at: null, document: JSON.stringify(document) });
+ return [{ affectedRows: 1 }];
+ }
+ if (s.startsWith('DELETE FROM admin_adapter_locks WHERE expires_at <= ?')) {
+ for (const [key, lock] of this.locks) if (lock.expires_at.getTime() <= params[0].getTime()) this.locks.delete(key);
+ return [{ affectedRows: 0 }];
+ }
+ if (s.startsWith('DELETE FROM admin_adapter_locks WHERE run_id = ?')) {
+ for (const [key, lock] of this.locks) if (lock.run_id === params[0]) this.locks.delete(key);
+ return [{ affectedRows: 0 }];
+ }
+ if (s.startsWith('INSERT INTO admin_adapter_locks')) {
+ const [lockKey, runId, acquiredAt, expiresAt] = params;
+ if (this.locks.has(lockKey)) throw new DuplicateKey('dup');
+ this.locks.set(lockKey, { lock_key: lockKey, run_id: runId, acquired_at: acquiredAt, expires_at: expiresAt });
+ return [{ affectedRows: 1 }];
+ }
+ if (s.startsWith('SELECT run_id FROM admin_adapter_locks WHERE lock_key = ?')) {
+ const lock = this.locks.get(params[0]);
+ return [lock ? [{ run_id: lock.run_id }] : []];
+ }
+ if (s.startsWith('UPDATE admin_adapter_locks SET expires_at = ? WHERE run_id = ?')) {
+ let touched = 0;
+ for (const lock of this.locks.values()) if (lock.run_id === params[1]) { lock.expires_at = params[0]; touched++; }
+ return [{ affectedRows: touched }];
+ }
+ if (s.startsWith('SELECT * FROM admin_adapter_runs WHERE LOCATE') || s.startsWith('SELECT * FROM admin_adapter_runs ORDER BY queued_at')) {
+ return [[...this.runs.values()].map((r) => ({ ...r }))];
+ }
+ throw new Error('FakeDb does not understand: ' + s);
+ }
+}
+
+const db = new FakeDb();
+
+jest.mock('../config', () => ({ __esModule: true, default: configState }));
+jest.mock('../database', () => ({ __esModule: true, default: { query: (sql: string, params?: any[]) => db.query(sql, params) } }));
+jest.mock('../api/backend-info', () => ({ __esModule: true, default: { getBackendInfo: () => ({}) } }));
+
+import runStore, { AdminRunConflict, HEARTBEAT_MS, LEASE_MS, toRun } from '../api/admin-adapter/admin-adapter.runs';
+
+function creation(overrides: Record = {}) {
+ return {
+ operationId: 'explorer.capabilities.refresh',
+ operationVersion: '1',
+ target: 'explorer/test/capabilities',
+ actor: 'test',
+ reason: null,
+ correlationId: 'corr',
+ idempotencyKey: null,
+ cancellable: false,
+ rollbackSupported: false,
+ redactedInput: {},
+ ...overrides,
+ };
+}
+
+describe('admin adapter durable runs', () => {
+ beforeEach(() => {
+ db.runs.clear();
+ db.locks.clear();
+ db.failing = null;
+ db.refuseReconcileUpdates = false;
+ db.statements = [];
+ jest.useFakeTimers({ now: clock.now });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('persists a non-cancellable operation as non-cancellable and refuses to cancel it', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const { run, ownerToken } = await runStore.create(creation());
+ expect(run.cancellable).toBe(false);
+ expect(ownerToken).toMatch(/^[0-9a-f-]{36}$/);
+ expect((await runStore.get(run.runId)).cancellable).toBe(false);
+ await runStore.transition(run.runId, 'PRECHECK', {}, ownerToken as string);
+ const running = await runStore.transition(run.runId, 'RUNNING', {}, ownerToken as string);
+ expect(running.cancellable).toBe(false);
+ await expect(runStore.requestCancel(run.runId)).rejects.toThrow(/does not support cancellation/);
+ const after = await runStore.get(run.runId);
+ expect(after.state).toBe('RUNNING');
+ expect(db.runs.get(run.runId)?.cancel_requested).toBe(0);
+ expect(await runStore.cancelRequested(run.runId)).toBe(false);
+ // The lists read the same persisted truth.
+ expect((await runStore.list())[0].cancellable).toBe(false);
+ expect((await runStore.auditEntries())[0].cancellable).toBe(false);
+ });
+
+ it('reads an older row without the persisted flag as not cancellable', () => {
+ const row = {
+ run_id: 'legacy', operation_id: 'explorer.capabilities.refresh', operation_version: '1', state: 'RUNNING',
+ target: 'explorer/test/capabilities', correlation_id: 'c', actor: 'a', cancel_requested: 0,
+ queued_at: new Date(clock.now), updated_at: new Date(clock.now), progress_percent: 10, document: '{}',
+ };
+ expect(toRun(row).cancellable).toBe(false);
+ expect(toRun({ ...row, document: JSON.stringify({ cancellable: true }) }).cancellable).toBe(true);
+ });
+
+ it('gives a new run an initial lease and recovers it as FAILED when that lease expires', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const { run, ownerToken } = await runStore.create(creation());
+ await runStore.acquireLock('explorer:test', run.runId);
+ const row = db.runs.get(run.runId) as RunRow;
+ expect(row.lease_expires_at.getTime()).toBe(clock.now + LEASE_MS);
+ expect(row.owner_token).toBe(ownerToken);
+
+ // Nothing to do while the lease is live.
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 0, remaining: 0, verified: true });
+ expect(db.locks.size).toBe(1);
+
+ jest.setSystemTime(clock.now + LEASE_MS + 1);
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 1, remaining: 0, verified: true });
+ const recovered = await runStore.get(run.runId);
+ expect(recovered.state).toBe('FAILED');
+ expect(recovered.error).toEqual({ class: 'abandoned_before_start', message: expect.stringContaining('nothing was executed'), retryable: true });
+ expect(db.locks.size).toBe(0);
+ });
+
+ it('recovers a QUEUED row written before initial leases existed', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const { run } = await runStore.create(creation());
+ const row = db.runs.get(run.runId) as RunRow;
+ row.lease_expires_at = null;
+ row.owner_token = null;
+ row.queued_at = new Date(clock.now - LEASE_MS - 1);
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 1, remaining: 0, verified: true });
+ expect((await runStore.get(run.runId)).state).toBe('FAILED');
+ });
+
+ it('records a lock conflict as a terminal, inspectable failure', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const first = await runStore.create(creation());
+ await runStore.acquireLock('explorer:test:target', first.run.runId);
+ const second = await runStore.create(creation());
+ await expect(runStore.acquireLock('explorer:test:target', second.run.runId)).rejects.toThrow(AdminRunConflict);
+ const failed = await runStore.transition(second.run.runId, 'FAILED', {
+ error: { class: 'lock_conflict', message: 'held by ' + first.run.runId, retryable: true },
+ }, second.ownerToken as string);
+ expect(failed.state).toBe('FAILED');
+ expect(failed.error?.class).toBe('lock_conflict');
+ expect(failed.finishedAt).not.toBeNull();
+ // The holder keeps its lock.
+ expect(db.locks.get('explorer:test:target')?.run_id).toBe(first.run.runId);
+ });
+
+ it('refuses a transition from an executor whose run was taken over', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const { run, ownerToken } = await runStore.create(creation());
+ await runStore.transition(run.runId, 'PRECHECK', {}, ownerToken as string);
+ await runStore.transition(run.runId, 'RUNNING', {}, ownerToken as string);
+ (db.runs.get(run.runId) as RunRow).owner_token = 'someone-else';
+ await expect(runStore.transition(run.runId, 'VERIFYING', {}, ownerToken as string)).rejects.toThrow(/changed underneath/);
+ expect((await runStore.get(run.runId)).state).toBe('RUNNING');
+ });
+
+ it('keeps a long run RUNNING and locked while its heartbeat continues', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const { run, ownerToken } = await runStore.create(creation());
+ await runStore.acquireLock('explorer:long', run.runId);
+ await runStore.transition(run.runId, 'PRECHECK', {}, ownerToken as string);
+ await runStore.transition(run.runId, 'RUNNING', {}, ownerToken as string);
+
+ let finish: () => void = () => undefined;
+ const work = new Promise((resolve) => { finish = () => resolve('done'); });
+ const running = runStore.withHeartbeat(run.runId, ownerToken as string, () => work);
+
+ const durationMs = 150_000;
+ for (let elapsed = 0; elapsed < durationMs; elapsed += HEARTBEAT_MS) {
+ jest.advanceTimersByTime(HEARTBEAT_MS);
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ // A reconciler passing at any point sees a live lease and leaves the run alone.
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 0, remaining: 0, verified: true });
+ const row = db.runs.get(run.runId) as RunRow;
+ expect(row.state).toBe('RUNNING');
+ expect(row.lease_expires_at.getTime()).toBeGreaterThan(Date.now());
+ expect(db.locks.get('explorer:long')?.run_id).toBe(run.runId);
+ }
+ finish();
+ expect(await running).toBe('done');
+ expect(jest.getTimerCount()).toBe(0);
+ expect((db.runs.get(run.runId) as RunRow).heartbeat_at.getTime()).toBeGreaterThan(clock.now + durationMs - HEARTBEAT_MS - 1);
+
+ // Once the executor stops heartbeating, the lease runs out and recovery moves it to review.
+ jest.setSystemTime(Date.now() + LEASE_MS + 1);
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 1, remaining: 0, verified: true });
+ const reviewed = await runStore.get(run.runId);
+ expect(reviewed.state).toBe('NEEDS_REVIEW');
+ expect(reviewed.error?.class).toBe('abandoned_in_flight');
+ expect(db.locks.size).toBe(0);
+ });
+
+ it('drains a backlog larger than one batch and reports remaining truthfully', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ for (let i = 0; i < 201; i++) {
+ await runStore.create(creation({ correlationId: `c${i}` }));
+ }
+ jest.setSystemTime(clock.now + LEASE_MS + 1);
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 201, remaining: 0, verified: true });
+
+ for (let i = 0; i < 3; i++) {
+ await runStore.create(creation({ correlationId: `late${i}` }));
+ }
+ jest.setSystemTime(Date.now() + LEASE_MS + 1);
+ db.refuseReconcileUpdates = true;
+ expect(await runStore.reconcileAbandonedRuns()).toEqual({ reconciled: 0, remaining: 3, verified: false });
+ });
+
+ it('surfaces a storage failure instead of claiming everything was reconciled', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ await runStore.create(creation());
+ jest.setSystemTime(clock.now + LEASE_MS + 1);
+ db.failing = 'isolated unavailable database fixture';
+ const outcome = await runStore.reconcileAbandonedRuns();
+ expect(outcome).toEqual({ reconciled: 0, remaining: null, verified: false, error: 'isolated unavailable database fixture' });
+ });
+});
diff --git a/backend/src/__tests__/admin-adapter.test.ts b/backend/src/__tests__/admin-adapter.test.ts
index 224e146cf19..bc86c266b5c 100644
--- a/backend/src/__tests__/admin-adapter.test.ts
+++ b/backend/src/__tests__/admin-adapter.test.ts
@@ -5,13 +5,16 @@ import { join } from 'path';
import { Readable } from 'stream';
import adminControl from '@bitcoinuniverse/ecosystem-contracts/admin-control';
import {
+ ADMIN_NONCE_RETENTION_MS,
AdminAdapterNonceStore,
adminAdapterGuard,
adminAdapterJsonParser,
hasSignedAdminElevation,
isPrivateRemoteAddress,
parseAdminAdapterKeys,
+ UnavailableAdminReplayStore,
verifyAdminAdapterRequest,
+ verifyAdminAdapterRequestWithStore,
} from '../api/admin-adapter/admin-adapter.security';
import {
ALLOWED_INDEXER_TASKS,
@@ -107,7 +110,8 @@ describe('Admin parser and signed elevation through actual middleware', () => {
else process.env.EXPLORER_ADMIN_ADAPTER_KEYS = previousKeys;
});
- function invoke(signedRequest: ReturnType, captureBeforeGeneralParser = true): Promise<{ request: any; response: any; reached: boolean }> {
+ const guardStore = new AdminAdapterNonceStore();
+ function invoke(signedRequest: ReturnType, captureBeforeGeneralParser = true, store: AdminAdapterNonceStore | UnavailableAdminReplayStore = guardStore): Promise<{ request: any; response: any; reached: boolean }> {
return new Promise((resolve, reject) => {
const request = Object.assign(Readable.from([signedRequest.rawBody]), {
method: signedRequest.method, originalUrl: signedRequest.originalUrl, url: signedRequest.originalUrl,
@@ -120,7 +124,7 @@ describe('Admin parser and signed elevation through actual middleware', () => {
const middleware: RequestHandler[] = [
...(captureBeforeGeneralParser ? [adminAdapterJsonParser()] : []),
express.urlencoded({ extended: true, limit: '10mb' }), express.text({ type: ['text/plain', 'application/base64'], limit: '10mb' }),
- express.json({ limit: '10mb' }), adminAdapterJsonParser(), adminAdapterGuard(),
+ express.json({ limit: '10mb' }), adminAdapterJsonParser(), adminAdapterGuard(store),
];
const next = (index: number): void => {
if (index === middleware.length) { resolve({ request, response, reached: true }); return; }
@@ -170,6 +174,58 @@ describe('Admin parser and signed elevation through actual middleware', () => {
const result = await invoke(signed({ rawBody: Buffer.from(JSON.stringify({ input: {}, adminAuthorization: { elevated: 'true' } })) }));
expect(result.reached).toBe(true); expect(hasSignedAdminElevation(result.response)).toBe(false);
});
+ it('refuses a replay through the guard store shared by every request', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const request = signed();
+ expect((await invoke(request)).reached).toBe(true);
+ const replay = await invoke(request);
+ expect(replay.reached).toBe(false); expect(replay.response.status).toHaveBeenCalledWith(401);
+ });
+ it('fails closed when no shared replay store is available', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const result = await invoke(signed(), true, new UnavailableAdminReplayStore('isolated fixture'));
+ expect(result.reached).toBe(false); expect(result.response.status).toHaveBeenCalledWith(503);
+ expect(result.response.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'REPLAY_STORE_UNAVAILABLE' }));
+ });
+ it('fails closed by default when the guard is mounted without a store', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const request = signed();
+ const response: any = { locals: {}, setHeader: jest.fn(), removeHeader: jest.fn(), status: jest.fn() };
+ response.status.mockReturnValue(response);
+ const refused = new Promise((resolve) => { response.json = jest.fn(() => { resolve(); return response; }); });
+ adminAdapterGuard()({ ...request, socket: { remoteAddress: '127.0.0.1' }, rawBody: request.rawBody } as any, response, () => { throw new Error('must not reach the handler'); });
+ await refused;
+ expect(response.status).toHaveBeenCalledWith(503);
+ });
+});
+
+describe('AdminAdapterNonceStore', () => {
+ it('accepts a nonce exactly once and never evicts a live one under capacity pressure', () => {
+ const store = new AdminAdapterNonceStore(2);
+ expect(store.accept('nonce-a', 100_000)).toBe(true);
+ expect(store.accept('nonce-b', 100_001)).toBe(true);
+ // Capacity is reached: a new nonce is refused rather than forgetting an old one.
+ expect(store.accept('nonce-c', 100_002)).toBe(false);
+ expect(store.accept('nonce-a', 100_003)).toBe(false);
+ expect(store.accept('nonce-b', 100_003)).toBe(false);
+ // Only once retention has passed may the slots be reused.
+ expect(store.accept('nonce-c', 100_003 + ADMIN_NONCE_RETENTION_MS)).toBe(true);
+ });
+
+ it('keeps nonces apart per key scope', () => {
+ const store = new AdminAdapterNonceStore();
+ expect(store.accept('shared', 1, 'key-a@1.0.0')).toBe(true);
+ expect(store.accept('shared', 1, 'key-b@1.0.0')).toBe(true);
+ expect(store.accept('shared', 1, 'key-a@1.0.0')).toBe(false);
+ });
+
+ it('verifies with the shared-store path exactly as with the synchronous one', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const store = new AdminAdapterNonceStore();
+ const request = signed();
+ expect((await verifyAdminAdapterRequestWithStore(request, store)).ok).toBe(true);
+ expect((await verifyAdminAdapterRequestWithStore(request, store)).ok).toBe(false);
+ const unavailable = await verifyAdminAdapterRequestWithStore(signed(), new UnavailableAdminReplayStore('fixture'));
+ expect(unavailable.ok).toBe(false);
+ expect(unavailable.ok === false && unavailable.status).toBe(503);
+ expect(unavailable.ok === false && unavailable.code).toBe('REPLAY_STORE_UNAVAILABLE');
+ });
});
describe('isPrivateRemoteAddress', () => {
diff --git a/backend/src/__tests__/admin-resource-stream-boundaries.test.ts b/backend/src/__tests__/admin-resource-stream-boundaries.test.ts
index f6afdd96b92..81962a6075f 100644
--- a/backend/src/__tests__/admin-resource-stream-boundaries.test.ts
+++ b/backend/src/__tests__/admin-resource-stream-boundaries.test.ts
@@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import vm from 'vm'; import ts fro
function load(relative:string,deps:any,extra:any={}){const exports:any={};vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname,'..',relative),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,esModuleInterop:true}}).outputText,{exports,require:(id:string)=>deps[id]||{},setInterval,clearInterval,Error,...extra});return exports.default;}
class Missing extends Error{}
const identity={adminEnvelope:()=>({}),adminTimestamp:()=> '2026-09-15T00:00:00Z',explorerEnvironment:()=> 'test',explorerNetwork:()=> 'regtest',explorerRelease:()=>({backendSha:'abc',version:'1'})};
-function setup(store:any={},extra:any={}){const handlers:any={};const app:any={use:()=>app,get:(url:string,fn:any)=>{handlers[url]=fn;return app;},post:()=>app};const source=load('api/admin-adapter/admin-adapter.routes.ts',{'crypto':{randomUUID:()=> 'id'},'@bitcoinuniverse/ecosystem-contracts/admin-control':{ADMIN_RESOURCE_KINDS:['run','release'],ADMIN_CONTROL_SUPPORTED_VERSIONS:[]},'../../config':{DATABASE:{ENABLED:true}},'../../logger':{info:()=>{},err:()=>{}},'./admin-adapter.security':{adminAdapterJsonParser:()=>{},adminAdapterGuard:()=>{}},'./admin-adapter.identity':identity,'./admin-adapter.runs':{__esModule:true,default:store,AdminRunNotFound:Missing},'../backend-info':{getBackendInfo:()=>({})},'../blocks':{getCurrentBlockHeight:()=>1},'../mempool':{getMempool:()=>({})},'./admin-adapter.snapshot':{buildExplorerSnapshot:async()=>({components:[],dependencies:[],indexers:[]})}},extra);source.initRoutes(app);return handlers;}
+function setup(store:any={},extra:any={}){const handlers:any={};const app:any={use:()=>app,get:(url:string,fn:any)=>{handlers[url]=fn;return app;},post:()=>app};const source=load('api/admin-adapter/admin-adapter.routes.ts',{'crypto':{randomUUID:()=> 'id'},'@bitcoinuniverse/ecosystem-contracts/admin-control':{ADMIN_RESOURCE_KINDS:['run','release'],ADMIN_CONTROL_SUPPORTED_VERSIONS:[]},'../../config':{DATABASE:{ENABLED:true}},'../../logger':{info:()=>{},err:()=>{}},'./admin-adapter.security':{adminAdapterJsonParser:()=>{},adminAdapterGuard:()=>{}},'./admin-adapter.replay':{createAdminReplayStore:()=>({kind:'memory',claim:async()=>'claimed'})},'./admin-adapter.identity':identity,'./admin-adapter.runs':{__esModule:true,default:store,AdminRunNotFound:Missing},'../backend-info':{getBackendInfo:()=>({})},'../blocks':{getCurrentBlockHeight:()=>1},'../mempool':{getMempool:()=>({})},'./admin-adapter.snapshot':{buildExplorerSnapshot:async()=>({components:[],dependencies:[],indexers:[]})}},extra);source.initRoutes(app);return handlers;}
function response(){const r:any=new EventEmitter();r.statusCode=200;r.status=(n:number)=>{r.statusCode=n;return r;};r.json=(b:any)=>{r.body=b;return r;};r.setHeader=()=>{};r.flushHeaders=()=>{};r.end=jest.fn();r.write=jest.fn(()=>true);return r;}
const run={runId:'old',operationId:'verify',state:'SUCCEEDED',target:'node'};
test('resource lookup reads an exact run even outside latest200',async()=>{const store={get:jest.fn(async()=>run),list:jest.fn(async()=>[])};const r=response();await setup(store)['/internal/admin/v1/resources/:kind/:id']({params:{kind:'run',id:'old'}},r);expect(r.statusCode).toBe(200);expect(r.body.resource.id).toBe('old');expect(store.get).toHaveBeenCalledWith('old');expect(store.list).not.toHaveBeenCalled();});
diff --git a/backend/src/__tests__/indexer-single-task.test.ts b/backend/src/__tests__/indexer-single-task.test.ts
new file mode 100644
index 00000000000..c472127a898
--- /dev/null
+++ b/backend/src/__tests__/indexer-single-task.test.ts
@@ -0,0 +1,181 @@
+const configState = {
+ MEMPOOL: { NETWORK: 'mainnet', INDEXING_BLOCKS_AMOUNT: -1, ENABLED: true },
+ DATABASE: { ENABLED: true },
+ FIAT_PRICE: { ENABLED: true },
+};
+const commonState = { indexingEnabled: true };
+const miningState = {
+ indexBlockPrices: jest.fn(async () => undefined),
+ indexCoinStatsIndex: jest.fn(async () => undefined),
+};
+const pricesState = { latestPriceId: jest.fn(async (): Promise => 7) };
+const priceUpdaterState = { historyInserted: true };
+
+jest.mock('../config', () => ({ __esModule: true, default: configState }));
+jest.mock('../api/common', () => ({ Common: { indexingEnabled: () => commonState.indexingEnabled } }));
+jest.mock('../api/mining/mining', () => ({
+ __esModule: true,
+ default: {
+ $indexBlockPrices: () => miningState.indexBlockPrices(),
+ $indexCoinStatsIndex: () => miningState.indexCoinStatsIndex(),
+ },
+}));
+jest.mock('../repositories/PricesRepository', () => ({
+ __esModule: true,
+ default: { $getLatestPriceId: () => pricesState.latestPriceId() },
+}));
+jest.mock('../tasks/price-updater', () => ({ __esModule: true, default: priceUpdaterState }));
+jest.mock('../api/blocks', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/bitcoin/bitcoin-client', () => ({ __esModule: true, default: {} }));
+jest.mock('../replication/AuditReplication', () => ({ __esModule: true, default: {} }));
+jest.mock('../replication/StatisticsReplication', () => ({ __esModule: true, default: {} }));
+jest.mock('../repositories/AccelerationRepository', () => ({ __esModule: true, default: {} }));
+jest.mock('../repositories/BlocksAuditsRepository', () => ({ __esModule: true, default: {} }));
+jest.mock('../repositories/BlocksRepository', () => ({ __esModule: true, default: {} }));
+
+import indexer from '../indexer';
+
+/** Lets every promise chain queued so far settle while timers are faked. */
+async function flush(): Promise {
+ for (let i = 0; i < 10; i++) {
+ await Promise.resolve();
+ }
+}
+
+describe('indexer single tasks', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ configState.MEMPOOL.NETWORK = 'mainnet';
+ configState.FIAT_PRICE.ENABLED = true;
+ commonState.indexingEnabled = true;
+ priceUpdaterState.historyInserted = true;
+ miningState.indexBlockPrices.mockReset().mockResolvedValue(undefined);
+ miningState.indexCoinStatsIndex.mockReset().mockResolvedValue(undefined);
+ pricesState.latestPriceId.mockReset().mockResolvedValue(7);
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it('executes a first and a second scheduled call', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexer.scheduleSingleTask('coinStatsIndex', 1000);
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(miningState.indexCoinStatsIndex).toHaveBeenCalledTimes(1);
+
+ indexer.scheduleSingleTask('coinStatsIndex', 1000);
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(miningState.indexCoinStatsIndex).toHaveBeenCalledTimes(2);
+ });
+
+ it('lets a task reschedule itself while it runs', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ // A blocksPrices run without price history defers itself with a new timer.
+ priceUpdaterState.historyInserted = false;
+ indexer.scheduleSingleTask('blocksPrices', 1000);
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(miningState.indexBlockPrices).not.toHaveBeenCalled();
+ expect(jest.getTimerCount()).toBe(1);
+
+ priceUpdaterState.historyInserted = true;
+ jest.advanceTimersByTime(10_000);
+ await flush();
+ expect(miningState.indexBlockPrices).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not let a replaced timer cancel its replacement', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexer.scheduleSingleTask('coinStatsIndex', 1000);
+ indexer.scheduleSingleTask('coinStatsIndex', 5000, true);
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(miningState.indexCoinStatsIndex).not.toHaveBeenCalled();
+ jest.advanceTimersByTime(4000);
+ await flush();
+ expect(miningState.indexCoinStatsIndex).toHaveBeenCalledTimes(1);
+ // The replacement fired; a further throttled call must still be accepted.
+ indexer.scheduleSingleTask('coinStatsIndex', 1000);
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(miningState.indexCoinStatsIndex).toHaveBeenCalledTimes(2);
+ });
+
+ it('throttles a second call while one is pending', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ indexer.scheduleSingleTask('coinStatsIndex', 1000);
+ indexer.scheduleSingleTask('coinStatsIndex', 1000);
+ expect(jest.getTimerCount()).toBe(1);
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(miningState.indexCoinStatsIndex).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports a rejected job as failed and permits a later retry', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ miningState.indexBlockPrices.mockRejectedValueOnce(new Error('isolated task failure'));
+ const failed = await indexer.runSingleTask('blocksPrices');
+ expect(failed).toEqual({ task: 'blocksPrices', status: 'failed', error: 'isolated task failure' });
+
+ const retried = await indexer.runSingleTask('blocksPrices');
+ expect(retried.status).toBe('completed');
+ expect(retried.checkpoint?.startedAt).toBeDefined();
+ expect(retried.checkpoint?.finishedAt).toBeDefined();
+ expect(miningState.indexBlockPrices).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not run a duplicate concurrent task twice', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ let release: () => void = () => undefined;
+ miningState.indexCoinStatsIndex.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(undefined); }));
+ const first = indexer.runSingleTask('coinStatsIndex');
+ await flush();
+ const second = await indexer.runSingleTask('coinStatsIndex');
+ expect(second.status).toBe('already-running');
+ release();
+ expect((await first).status).toBe('completed');
+ expect(miningState.indexCoinStatsIndex).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports disabled indexing without touching the job', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ commonState.indexingEnabled = false;
+ const outcome = await indexer.runSingleTask('coinStatsIndex');
+ expect(outcome.status).toBe('disabled');
+ expect(miningState.indexCoinStatsIndex).not.toHaveBeenCalled();
+ });
+
+ it('reports disabled fiat prices for the blocksPrices task', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ configState.FIAT_PRICE.ENABLED = false;
+ const outcome = await indexer.runSingleTask('blocksPrices');
+ expect(outcome.status).toBe('disabled');
+ expect(miningState.indexBlockPrices).not.toHaveBeenCalled();
+ });
+
+ it('reports network-inapplicable for blocksPrices on signet', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ configState.MEMPOOL.NETWORK = 'signet';
+ const outcome = await indexer.runSingleTask('blocksPrices');
+ expect(outcome.status).toBe('network-inapplicable');
+ expect(miningState.indexBlockPrices).not.toHaveBeenCalled();
+ });
+
+ it('reports deferred when price history is unavailable and schedules a real retry', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ pricesState.latestPriceId.mockResolvedValueOnce(null);
+ const outcome = await indexer.runSingleTask('blocksPrices');
+ expect(outcome.status).toBe('deferred');
+ expect(miningState.indexBlockPrices).not.toHaveBeenCalled();
+ expect(jest.getTimerCount()).toBe(1);
+ jest.advanceTimersByTime(10_000);
+ await flush();
+ expect(miningState.indexBlockPrices).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not hide a coinStatsIndex failure from the caller', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ miningState.indexCoinStatsIndex.mockRejectedValueOnce(new Error('isolated coinstats failure'));
+ const outcome = await indexer.runSingleTask('coinStatsIndex');
+ expect(outcome).toEqual({ task: 'coinStatsIndex', status: 'failed', error: 'isolated coinstats failure' });
+ });
+
+ it('accepts a reindex only when indexing is enabled', () => {
+ expect(indexer.reindex()).toBe(true);
+ commonState.indexingEnabled = false;
+ expect(indexer.reindex()).toBe(false);
+ });
+});
diff --git a/backend/src/__tests__/price-updater-stale-rates.test.ts b/backend/src/__tests__/price-updater-stale-rates.test.ts
new file mode 100644
index 00000000000..c5aae57e69e
--- /dev/null
+++ b/backend/src/__tests__/price-updater-stale-rates.test.ts
@@ -0,0 +1,118 @@
+/**
+ * A stored fiat observation is never advertised as the current price unless
+ * the feed is enabled for this network and the observation is fresh; the
+ * stored row is exposed with its timestamp, source and an explicit state.
+ */
+const configState = {
+ MEMPOOL: { NETWORK: 'mainnet', PRICE_UPDATES_PER_HOUR: 1 },
+ DATABASE: { ENABLED: true },
+ FIAT_PRICE: { ENABLED: false, API_KEY: '' },
+};
+const repositoryState = { stored: null as Record | null };
+
+jest.mock('../config', () => ({ __esModule: true, default: configState }));
+jest.mock('../repositories/PricesRepository', () => ({
+ __esModule: true,
+ MAX_PRICES: {},
+ default: {
+ $getLatestConversionRates: /** @asyncUnsafe test double */ async () => repositoryState.stored ?? emptyPrices(),
+ },
+}));
+jest.mock('../tasks/price-feeds/bitfinex-api', () => ({ __esModule: true, default: class { currencies = []; } }));
+jest.mock('../tasks/price-feeds/bitflyer-api', () => ({ __esModule: true, default: class { currencies = []; } }));
+jest.mock('../tasks/price-feeds/coinbase-api', () => ({ __esModule: true, default: class { currencies = []; } }));
+jest.mock('../tasks/price-feeds/gemini-api', () => ({ __esModule: true, default: class { currencies = []; } }));
+jest.mock('../tasks/price-feeds/kraken-api', () => ({ __esModule: true, default: class { currencies = []; } }));
+jest.mock('../tasks/price-feeds/free-currency-api', () => ({ __esModule: true, default: class {} }));
+
+import priceUpdater, { PRICE_MAX_AGE_SECONDS } from '../tasks/price-updater';
+
+function emptyPrices(): Record {
+ const prices: Record = { time: 0 };
+ for (const currency of ['USD', 'EUR', 'GBP', 'CAD', 'CHF', 'AUD', 'JPY', 'BGN', 'BRL', 'CNY', 'CZK', 'DKK', 'HKD', 'HRK', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR']) {
+ prices[currency] = -1;
+ }
+ return prices;
+}
+
+const NOW_MS = Date.UTC(2026, 8, 17, 12, 0, 0);
+const STALE_TIME = Math.round(NOW_MS / 1000) - 30 * 24 * 3600;
+const FRESH_TIME = Math.round(NOW_MS / 1000) - 600;
+
+function storedFixture(time: number): Record {
+ return { ...emptyPrices(), time, USD: 61_234, EUR: 56_789 };
+}
+
+function reset(): void {
+ const subject = priceUpdater as unknown as Record;
+ subject.latestPrices = priceUpdater.getEmptyPricesObj();
+ subject.latestGoodPrices = priceUpdater.getEmptyPricesObj();
+ subject.storedPrices = null;
+ subject.liveSource = null;
+}
+
+describe('stored fiat rates with the feed disabled', () => {
+ beforeEach(() => {
+ reset();
+ configState.MEMPOOL.NETWORK = 'mainnet';
+ configState.FIAT_PRICE.ENABLED = false;
+ repositoryState.stored = storedFixture(STALE_TIME);
+ });
+
+ it('does not advertise the stored observation as the current price', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ await priceUpdater.$initializeLatestPriceWithDb(NOW_MS);
+ expect(priceUpdater.getLatestPrices().USD).toBe(-1);
+ const advertised = priceUpdater.getAdvertisedPrices(NOW_MS);
+ expect(advertised.USD).toBe(-1);
+ expect(advertised).toMatchObject({
+ state: 'disabled',
+ disabled: true,
+ stale: true,
+ source: 'database',
+ observedAt: STALE_TIME,
+ ageSeconds: 30 * 24 * 3600,
+ });
+ expect(advertised.storedObservation).toMatchObject({ time: STALE_TIME, USD: 61_234, EUR: 56_789 });
+ });
+
+ it('reports network-inapplicable on signet even with a stored row', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ configState.MEMPOOL.NETWORK = 'signet';
+ configState.FIAT_PRICE.ENABLED = true;
+ repositoryState.stored = storedFixture(FRESH_TIME);
+ await priceUpdater.$initializeLatestPriceWithDb(NOW_MS);
+ expect(priceUpdater.getLatestPrices().USD).toBe(-1);
+ expect(priceUpdater.getAdvertisedPrices(NOW_MS)).toMatchObject({ state: 'network-inapplicable', disabled: true, USD: -1 });
+ });
+});
+
+describe('stored fiat rates with the feed enabled', () => {
+ beforeEach(() => {
+ reset();
+ configState.MEMPOOL.NETWORK = 'mainnet';
+ configState.FIAT_PRICE.ENABLED = true;
+ });
+
+ it('serves a fresh stored observation as live from the database', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ repositoryState.stored = storedFixture(FRESH_TIME);
+ await priceUpdater.$initializeLatestPriceWithDb(NOW_MS);
+ expect(priceUpdater.getLatestPrices().USD).toBe(61_234);
+ expect(priceUpdater.getAdvertisedPrices(NOW_MS)).toMatchObject({
+ state: 'live', stale: false, disabled: false, source: 'database', observedAt: FRESH_TIME, USD: 61_234, storedObservation: null,
+ });
+ });
+
+ it('keeps a stale stored observation out of the live price', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ repositoryState.stored = storedFixture(Math.round(NOW_MS / 1000) - PRICE_MAX_AGE_SECONDS - 1);
+ await priceUpdater.$initializeLatestPriceWithDb(NOW_MS);
+ expect(priceUpdater.getLatestPrices().USD).toBe(-1);
+ const advertised = priceUpdater.getAdvertisedPrices(NOW_MS);
+ expect(advertised).toMatchObject({ state: 'stale', stale: true, disabled: false, source: 'database', USD: -1 });
+ expect(advertised.storedObservation?.USD).toBe(61_234);
+ });
+
+ it('reports unavailable when nothing is stored', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ repositoryState.stored = null;
+ await priceUpdater.$initializeLatestPriceWithDb(NOW_MS);
+ expect(priceUpdater.getAdvertisedPrices(NOW_MS)).toMatchObject({ state: 'unavailable', stale: false, source: null, observedAt: null, storedObservation: null });
+ });
+});
diff --git a/backend/src/__tests__/tx-selection-sigops.test.ts b/backend/src/__tests__/tx-selection-sigops.test.ts
new file mode 100644
index 00000000000..8b2b3285448
--- /dev/null
+++ b/backend/src/__tests__/tx-selection-sigops.test.ts
@@ -0,0 +1,75 @@
+jest.mock('../config', () => ({
+ __esModule: true,
+ default: { MEMPOOL: { BLOCK_WEIGHT_UNITS: 4_000_000 } },
+}));
+jest.mock('worker_threads', () => ({ parentPort: null }));
+
+import { makeBlockTemplates } from '../api/tx-selection-worker';
+import { CompactThreadTransaction } from '../mempool.interfaces';
+
+const MAX_BLOCK_SIGOPS = 80_000;
+
+function transaction(uid: number, overrides: Partial = {}): CompactThreadTransaction {
+ return {
+ uid,
+ fee: 200_000,
+ weight: 100_000,
+ feePerVsize: 8,
+ effectiveFeePerVsize: 8,
+ sigops: 16_000,
+ inputs: [],
+ ...overrides,
+ };
+}
+
+function sigopsOf(block: number[], mempool: Map): number {
+ return block.reduce((sum, uid) => sum + (mempool.get(uid)?.sigops ?? 0), 0);
+}
+
+describe('JavaScript block template sigop accounting', () => {
+ it('keeps every bounded projected block within 80000 sigops for independent transactions', () => {
+ const mempool = new Map();
+ for (let uid = 1; uid <= 6; uid++) {
+ mempool.set(uid, transaction(uid));
+ }
+ const { blocks } = makeBlockTemplates(mempool);
+ // Six transactions of 16000 sigops each total 96000, which is more than one
+ // block allows and far below the weight limit, so a second block is needed.
+ expect(blocks.length).toBe(2);
+ expect(blocks[0].length).toBe(5);
+ expect(blocks[1]).toEqual([6]);
+ for (const block of blocks.slice(0, 7)) {
+ expect(sigopsOf(block, mempool)).toBeLessThanOrEqual(MAX_BLOCK_SIGOPS);
+ }
+ expect(blocks.flat().sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
+ });
+
+ it('counts a parent once when it is placed as part of a child package', () => {
+ const mempool = new Map();
+ // Parent 1 (low fee) is pulled in by child 2 (high fee); their package
+ // costs 32000 sigops. Three more independent 16000-sigop transactions
+ // bring the total to exactly the limit, so all fit into one block only if
+ // the parent is not counted twice.
+ mempool.set(1, transaction(1, { fee: 1_000, feePerVsize: 0.04, effectiveFeePerVsize: 0.04 }));
+ mempool.set(2, transaction(2, { fee: 400_000, feePerVsize: 16, effectiveFeePerVsize: 16, inputs: [1] }));
+ mempool.set(3, transaction(3));
+ mempool.set(4, transaction(4));
+ mempool.set(5, transaction(5));
+ const { blocks } = makeBlockTemplates(mempool);
+ expect(blocks.length).toBe(1);
+ expect(sigopsOf(blocks[0], mempool)).toBe(MAX_BLOCK_SIGOPS);
+ expect(blocks[0].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it('resets the sigop budget for each new bounded block', () => {
+ const mempool = new Map();
+ for (let uid = 1; uid <= 15; uid++) {
+ mempool.set(uid, transaction(uid));
+ }
+ const { blocks } = makeBlockTemplates(mempool);
+ expect(blocks.map((block) => block.length)).toEqual([5, 5, 5]);
+ for (const block of blocks) {
+ expect(sigopsOf(block, mempool)).toBeLessThanOrEqual(MAX_BLOCK_SIGOPS);
+ }
+ });
+});
diff --git a/backend/src/__tests__/websocket-track-txs.test.ts b/backend/src/__tests__/websocket-track-txs.test.ts
new file mode 100644
index 00000000000..27ff8544628
--- /dev/null
+++ b/backend/src/__tests__/websocket-track-txs.test.ts
@@ -0,0 +1,136 @@
+const mempoolState: { txs: Record } = { txs: {} };
+const rbfState: { replaced: Record } = { replaced: {} };
+const nodeState = { getRawTransaction: jest.fn() };
+
+jest.mock('../config', () => ({
+ __esModule: true,
+ default: {
+ MEMPOOL: { NETWORK: 'signet', BACKEND: 'electrum', INITIAL_BLOCKS_AMOUNT: 8, MAX_TRACKED_ADDRESSES: 1, API_URL_PREFIX: '/api/v1/' },
+ FIAT_PRICE: { ENABLED: false },
+ DATABASE: { ENABLED: false },
+ REDIS: { ENABLED: false },
+ STATISTICS: { ENABLED: false },
+ MEMPOOL_SERVICES: { ACCELERATIONS: false },
+ },
+}));
+jest.mock('../api/mempool', () => ({
+ __esModule: true,
+ default: { getMempool: () => mempoolState.txs, getMempoolInfo: () => ({}), getVBytesPerSecond: () => 0, getLatestTransactions: () => [], getAccelerationPositions: () => undefined, isInSync: () => true },
+}));
+jest.mock('../api/rbf-cache', () => ({
+ __esModule: true,
+ default: { getReplacedBy: (txid: string) => rbfState.replaced[txid] },
+}));
+jest.mock('../api/bitcoin/bitcoin-api-factory', () => ({
+ __esModule: true,
+ default: { $getRawTransaction: (...args: unknown[]) => nodeState.getRawTransaction(...args), getHealthStatus: () => [] },
+}));
+jest.mock('../api/blocks', () => ({ __esModule: true, default: { getBlocks: () => [], getCurrentBlockHeight: () => 0 } }));
+jest.mock('../api/backend-info', () => ({ __esModule: true, default: { getBackendInfo: () => ({}) } }));
+jest.mock('../api/mempool-blocks', () => ({ __esModule: true, default: { getMempoolBlocks: () => [] } }));
+jest.mock('../api/loading-indicators', () => ({ __esModule: true, default: { getLoadingIndicators: () => ({}) } }));
+jest.mock('../api/transaction-utils', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/difficulty-adjustment', () => ({ __esModule: true, default: { getDifficultyAdjustment: () => null } }));
+jest.mock('../api/fee-api', () => ({ __esModule: true, default: { getPreciseRecommendedFee: () => ({}) } }));
+jest.mock('../repositories/BlocksAuditsRepository', () => ({ __esModule: true, default: {} }));
+jest.mock('../repositories/BlocksSummariesRepository', () => ({ __esModule: true, default: {} }));
+jest.mock('../repositories/AccelerationRepository', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/audit', () => ({ __esModule: true, default: {} }));
+jest.mock('../tasks/price-updater', () => ({ __esModule: true, default: { getLatestPrices: () => ({}), getAdvertisedPrices: () => ({}), getPriceObservation: () => ({}) } }));
+jest.mock('../api/services/acceleration', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/statistics/statistics', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/services/wallets', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/bitcoin/bitcoin-second-client', () => ({ __esModule: true, default: {} }));
+jest.mock('../api/cpfp', () => ({ calculateMempoolTxCpfp: () => undefined }));
+jest.mock('../api/services/stratum', () => ({ __esModule: true, default: {} }));
+
+import websocketHandler from '../api/websocket-handler';
+
+const TXID_MEMPOOL = '1'.repeat(64);
+const TXID_REPLACED = '2'.repeat(64);
+const TXID_CONFIRMED = '3'.repeat(64);
+const TXID_UNKNOWN = '4'.repeat(64);
+const TXID_DOWN = '5'.repeat(64);
+const TXID_NODE_MEMPOOL = '6'.repeat(64);
+const HASH = 'a'.repeat(64);
+
+function connect(): { send: (message: Record) => Promise[]> } {
+ const events: Record = {};
+ const clientEvents: Record = {};
+ const messages: Record[] = [];
+ const server = { on: (name: string, fn: any) => { events[name] = fn; } };
+ (websocketHandler as any).webSocketServers = [];
+ websocketHandler.addWebsocketServer(server as any);
+ websocketHandler.setupConnectionHandling();
+ const client = { on: (name: string, fn: any) => { clientEvents[name] = fn; }, send: (data: string) => messages.push(JSON.parse(data)), close: () => { throw new Error('unexpected close'); } };
+ events.connection(client, { headers: {}, socket: { remoteAddress: '127.0.0.1' } });
+ return {
+ send: /** @asyncUnsafe test helper */ async (message) => {
+ messages.length = 0;
+ await clientEvents.message(JSON.stringify(message));
+ return messages;
+ },
+ };
+}
+
+describe('websocket track-txs initial status', () => {
+ beforeEach(() => {
+ mempoolState.txs = { [TXID_MEMPOOL]: { txid: TXID_MEMPOOL, position: { block: 0, vsize: 100 } } };
+ rbfState.replaced = { [TXID_REPLACED]: 'b'.repeat(64) };
+ nodeState.getRawTransaction.mockReset().mockImplementation(async (txid: string) => {
+ if (txid === TXID_CONFIRMED) {
+ return { txid, status: { confirmed: true, block_height: 250_000, block_hash: HASH } };
+ }
+ if (txid === TXID_NODE_MEMPOOL) {
+ return { txid, status: { confirmed: false } };
+ }
+ if (txid === TXID_DOWN) {
+ throw new Error('ECONNREFUSED');
+ }
+ throw new Error('No such mempool or blockchain transaction');
+ });
+ });
+
+ it('never reports a never-seen transaction as confirmed', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const socket = connect();
+ const [message] = await socket.send({ 'track-txs': [TXID_UNKNOWN] });
+ expect(message['tracked-txs'][TXID_UNKNOWN]).toEqual({ confirmed: false, status: 'unknown' });
+ expect(nodeState.getRawTransaction).toHaveBeenCalledWith(TXID_UNKNOWN, true, false);
+ });
+
+ it('distinguishes mempool, replaced, confirmed, node-mempool and unavailable', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const socket = connect();
+ const [message] = await socket.send({
+ 'track-txs': [TXID_MEMPOOL, TXID_REPLACED, TXID_CONFIRMED, TXID_NODE_MEMPOOL, TXID_DOWN],
+ });
+ const tracked = message['tracked-txs'];
+ expect(tracked[TXID_MEMPOOL]).toEqual({ confirmed: false, status: 'mempool', position: { block: 0, vsize: 100 } });
+ expect(tracked[TXID_REPLACED]).toEqual({ confirmed: false, status: 'replaced', replacedBy: 'b'.repeat(64) });
+ expect(tracked[TXID_CONFIRMED]).toEqual({ confirmed: true, status: 'confirmed', blockHeight: 250_000, blockHash: HASH });
+ expect(tracked[TXID_NODE_MEMPOOL]).toEqual({ confirmed: false, status: 'mempool' });
+ expect(tracked[TXID_DOWN]).toEqual({ status: 'unavailable' });
+ expect(tracked[TXID_DOWN].confirmed).toBeUndefined();
+ // Local and replaced transactions never reach the node.
+ expect(nodeState.getRawTransaction).toHaveBeenCalledTimes(3);
+ });
+
+ it('does not confirm on a node answer without a real block identity', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ nodeState.getRawTransaction.mockResolvedValueOnce({ txid: TXID_CONFIRMED, status: { confirmed: true } });
+ const socket = connect();
+ const [message] = await socket.send({ 'track-txs': [TXID_CONFIRMED] });
+ expect(message['tracked-txs'][TXID_CONFIRMED].confirmed).toBe(false);
+ });
+
+ it('deduplicates ids and refuses an oversized request', /** @asyncUnsafe Jest owns the test promise. */ async () => {
+ const socket = connect();
+ const [message] = await socket.send({ 'track-txs': [TXID_UNKNOWN, TXID_UNKNOWN, 'not-a-txid'] });
+ expect(Object.keys(message['tracked-txs'])).toEqual([TXID_UNKNOWN]);
+ expect(nodeState.getRawTransaction).toHaveBeenCalledTimes(1);
+
+ const many = Array.from({ length: 101 }, (_, i) => i.toString(16).padStart(64, '0'));
+ const [refused] = await socket.send({ 'track-txs': many });
+ expect(refused['track-txs-error']).toContain('maximum of 100');
+ expect(refused['tracked-txs']).toBeUndefined();
+ expect(nodeState.getRawTransaction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/backend/src/api/admin-adapter/admin-adapter.operations.ts b/backend/src/api/admin-adapter/admin-adapter.operations.ts
index a4098f30c4c..332fc137c31 100644
--- a/backend/src/api/admin-adapter/admin-adapter.operations.ts
+++ b/backend/src/api/admin-adapter/admin-adapter.operations.ts
@@ -25,6 +25,36 @@ import {
import { explorerEnvironment, releaseShaOrNull } from './admin-adapter.identity';
import runStore from './admin-adapter.runs';
+/**
+ * Reads back what an indexing task is supposed to have persisted, so a run
+ * is only called complete when the rows say so. Every query error is returned,
+ * never swallowed, because an unreadable checkpoint proves nothing.
+ *
+ * @asyncUnsafe The caller wraps the readback in try/catch.
+ */
+async function readIndexerCheckpoint(task: AllowedIndexerTask): Promise<{ remaining: number; indexedTip: number | null }> {
+ if (task === 'blocksPrices') {
+ const [remainingRows]: any[] = await DB.query(
+ 'SELECT COUNT(*) AS remaining FROM blocks LEFT JOIN blocks_prices ON blocks.height = blocks_prices.height LEFT JOIN prices ON blocks_prices.price_id = prices.id WHERE blocks_prices.height IS NULL OR prices.id IS NULL',
+ );
+ const [tipRows]: any[] = await DB.query('SELECT MAX(height) AS tip FROM blocks_prices');
+ return {
+ remaining: Number(remainingRows[0]?.remaining ?? 0),
+ indexedTip: tipRows[0]?.tip === null || tipRows[0]?.tip === undefined ? null : Number(tipRows[0].tip),
+ };
+ }
+ const [remainingRows]: any[] = await DB.query(
+ 'SELECT COUNT(*) AS remaining FROM blocks WHERE (utxoset_size IS NULL OR total_input_amt IS NULL) AND stale = 0',
+ );
+ const [tipRows]: any[] = await DB.query(
+ 'SELECT MAX(height) AS tip FROM blocks WHERE utxoset_size IS NOT NULL AND total_input_amt IS NOT NULL AND stale = 0',
+ );
+ return {
+ remaining: Number(remainingRows[0]?.remaining ?? 0),
+ indexedTip: tipRows[0]?.tip === null || tipRows[0]?.tip === undefined ? null : Number(tipRows[0].tip),
+ };
+}
+
/**
* The handlers behind the catalog.
*
@@ -375,16 +405,28 @@ const HANDLERS: Record = {
});
},
async execute() {
- const reconciled = await runStore.reconcileAbandonedRuns();
+ const outcome = await runStore.reconcileAbandonedRuns();
+ const verified = outcome.verified && outcome.error === undefined && outcome.remaining === 0;
return {
- summary:
- reconciled === 0
- ? 'No abandoned runs were found.'
- : `${reconciled} abandoned runs were moved to NEEDS_REVIEW.`,
- result: { reconciled },
+ summary: outcome.error !== undefined
+ ? `Reconciliation stopped on a storage error after ${outcome.reconciled} runs.`
+ : (outcome.remaining ?? 0) > 0
+ ? `${outcome.reconciled} abandoned runs were moved to NEEDS_REVIEW; ${outcome.remaining} remain.`
+ : outcome.reconciled === 0
+ ? 'No abandoned runs were found.'
+ : `${outcome.reconciled} abandoned runs were moved to NEEDS_REVIEW.`,
+ result: {
+ reconciled: outcome.reconciled,
+ remaining: outcome.remaining,
+ storageError: outcome.error ?? null,
+ },
verification: {
- verified: true,
- evidence: ['Every run with an expired lease now carries a terminal state.'],
+ verified,
+ evidence: verified
+ ? ['The expired-lease query answered and no run with an expired lease remains.']
+ : outcome.error !== undefined
+ ? [`The run store answered with an error: ${outcome.error}`]
+ : [`${outcome.remaining} runs with an expired lease still remain.`],
},
};
},
@@ -484,18 +526,62 @@ const HANDLERS: Record = {
redactedInput: { task },
});
},
- async execute(context) {
+ async execute(context): Promise {
const task = String(context.input.task ?? '');
if (!(ALLOWED_INDEXER_TASKS as readonly string[]).includes(task)) {
throw new Error(`Only ${ALLOWED_INDEXER_TASKS.join(' and ')} can be run this way.`);
}
- await indexer.runSingleTask(task as AllowedIndexerTask);
+ const outcome = await indexer.runSingleTask(task as AllowedIndexerTask);
+ if (outcome.status === 'failed') {
+ throw new Error(`The ${task} task failed: ${outcome.error ?? 'unknown error'}`);
+ }
+ if (outcome.status !== 'completed') {
+ return {
+ summary: `The ${task} task did not run: ${outcome.reason ?? outcome.status}.`,
+ result: { task, status: outcome.status, reason: outcome.reason ?? null },
+ verification: {
+ verified: false,
+ evidence: [`The indexer answered ${outcome.status}: ${outcome.reason ?? 'no reason given'}.`],
+ },
+ };
+ }
+ let checkpoint: Awaited>;
+ try {
+ checkpoint = await readIndexerCheckpoint(task as AllowedIndexerTask);
+ } catch (e) {
+ return {
+ summary: `The ${task} task returned, but its persisted checkpoint could not be read.`,
+ result: {
+ task,
+ status: outcome.status,
+ startedAt: outcome.checkpoint?.startedAt ?? null,
+ finishedAt: outcome.checkpoint?.finishedAt ?? null,
+ checkpointError: e instanceof Error ? e.message : String(e),
+ },
+ verification: {
+ verified: false,
+ evidence: ['The checkpoint readback failed, so completion is unproven.'],
+ },
+ };
+ }
+ const verified = checkpoint.remaining === 0;
return {
- summary: `The ${task} task ran to completion.`,
- result: { task },
+ summary: verified
+ ? `The ${task} task completed and no block is left without its ${task} data.`
+ : `The ${task} task returned but ${checkpoint.remaining} blocks are still missing ${task} data.`,
+ result: {
+ task,
+ status: outcome.status,
+ startedAt: outcome.checkpoint?.startedAt ?? null,
+ finishedAt: outcome.checkpoint?.finishedAt ?? null,
+ remaining: checkpoint.remaining,
+ indexedTip: checkpoint.indexedTip,
+ },
verification: {
- verified: true,
- evidence: [`The indexer returned from ${task} without throwing.`],
+ verified,
+ evidence: verified
+ ? [`The database reports 0 blocks missing ${task} data; the newest indexed height is ${checkpoint.indexedTip ?? 'unknown'}.`]
+ : [`The database still reports ${checkpoint.remaining} blocks missing ${task} data.`],
},
};
},
@@ -530,13 +616,16 @@ const HANDLERS: Record = {
if (String(context.input.confirmation ?? '') !== 'REINDEX EXPLORER BLOCKS') {
throw new Error('This operation requires the exact typed confirmation.');
}
- indexer.reindex();
+ const scheduled = indexer.reindex();
+ if (!scheduled) {
+ throw new Error('The indexer refused the request because indexing is disabled in this deployment.');
+ }
return {
- summary: 'The indexing loop was released to run again.',
- result: { scheduled: true },
+ summary: 'The indexing loop was released to run again. Indexing itself runs in the background and is not complete.',
+ result: { scheduled, indexingCompleted: false },
verification: {
verified: true,
- evidence: ['The indexer accepted the request to run again.'],
+ evidence: ['The indexer accepted the request to run again; this proves scheduling, not completion.'],
},
};
},
diff --git a/backend/src/api/admin-adapter/admin-adapter.replay.ts b/backend/src/api/admin-adapter/admin-adapter.replay.ts
new file mode 100644
index 00000000000..c01baedf8f1
--- /dev/null
+++ b/backend/src/api/admin-adapter/admin-adapter.replay.ts
@@ -0,0 +1,127 @@
+import config from '../../config';
+import DB from '../../database';
+import logger from '../../logger';
+import redisCache from '../redis-cache';
+import {
+ ADMIN_NONCE_RETENTION_MS,
+ AdminAdapterNonceStore,
+ type AdminReplayClaim,
+ type AdminReplayStore,
+ UnavailableAdminReplayStore,
+} from './admin-adapter.security';
+
+/**
+ * The shared replay stores behind the admin guard.
+ *
+ * A nonce is claimed once, for the whole freshness window, in a store every
+ * backend worker shares. The MySQL store is preferred because the adapter
+ * already needs the database for durable runs; Redis is the shared store when
+ * the database is off; with neither the guard fails closed. The in-memory
+ * store is only honoured on an explicit single-process opt-in, and is logged
+ * as such, because it cannot stop a replay against a second process.
+ */
+
+export const ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE = 'EXPLORER_ADMIN_REPLAY_STORE';
+
+/** How often at most the MySQL store sweeps expired rows. */
+const SWEEP_INTERVAL_MS = 60_000;
+
+function isDuplicateKey(e: unknown): boolean {
+ const error = e as { code?: string; errno?: number } | null;
+ return error?.code === 'ER_DUP_ENTRY' || error?.errno === 1062;
+}
+
+/** One row per claimed nonce in the owned database. */
+export class MysqlAdminReplayStore implements AdminReplayStore {
+ readonly kind = 'database';
+ private lastSweepMs = 0;
+
+ /** @asyncUnsafe Every rejection is turned into an unavailable claim. */
+ async claim(scope: string, nonce: string, nowMs: number): Promise {
+ const now = new Date(nowMs);
+ const expiresAt = new Date(nowMs + ADMIN_NONCE_RETENTION_MS);
+ try {
+ if (nowMs - this.lastSweepMs >= SWEEP_INTERVAL_MS) {
+ this.lastSweepMs = nowMs;
+ // Only rows past their retention go; a live nonce is never removed.
+ await DB.query('DELETE FROM admin_nonces WHERE expires_at <= ?', [now]);
+ }
+ try {
+ await DB.query('INSERT INTO admin_nonces (scope, nonce, expires_at) VALUES (?, ?, ?)', [
+ scope,
+ nonce,
+ expiresAt,
+ ]);
+ return 'claimed';
+ } catch (e) {
+ if (!isDuplicateKey(e)) {
+ throw e;
+ }
+ // The row exists. It may only be reused once its retention ended;
+ // the update is atomic on that condition.
+ const [result]: any[] = await DB.query(
+ 'UPDATE admin_nonces SET expires_at = ? WHERE scope = ? AND nonce = ? AND expires_at <= ?',
+ [expiresAt, scope, nonce, now],
+ );
+ return Number(result?.affectedRows ?? 0) === 1 ? 'claimed' : 'replayed';
+ }
+ } catch (e) {
+ logger.warn('[admin-adapter] The database replay store could not claim a nonce: ' + (e instanceof Error ? e.message : e));
+ return 'unavailable';
+ }
+ }
+}
+
+/** SET NX PX in the shared Redis, keyed by scope and nonce. */
+export class RedisAdminReplayStore implements AdminReplayStore {
+ readonly kind = 'redis';
+
+ /** @asyncUnsafe Every rejection is turned into an unavailable claim. */
+ async claim(scope: string, nonce: string, _nowMs: number): Promise {
+ try {
+ const claimed = await redisCache.$claimOnce(`admin-nonce:${scope}:${nonce}`, ADMIN_NONCE_RETENTION_MS);
+ return claimed ? 'claimed' : 'replayed';
+ } catch (e) {
+ logger.warn('[admin-adapter] The Redis replay store could not claim a nonce: ' + (e instanceof Error ? e.message : e));
+ return 'unavailable';
+ }
+ }
+}
+
+/**
+ * Picks the store for this process and logs the choice once. The environment
+ * variable is an override for operators; unset, the choice follows what is
+ * already enabled. `memory` is never chosen implicitly.
+ */
+export function createAdminReplayStore(
+ environment: Record = process.env,
+ enabled: { database: boolean; redis: boolean } = {
+ database: config.DATABASE.ENABLED === true,
+ redis: config.REDIS.ENABLED === true,
+ },
+): AdminReplayStore {
+ const requested = String(environment[ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE] ?? '').trim().toLowerCase();
+ let store: AdminReplayStore;
+ if (requested === 'memory') {
+ logger.warn(
+ `[admin-adapter] ${ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE}=memory: admin replay protection is single-process only and cannot stop a replay against another worker.`,
+ );
+ store = new AdminAdapterNonceStore();
+ } else if (requested === 'database' || (requested === '' && enabled.database)) {
+ store = enabled.database
+ ? new MysqlAdminReplayStore()
+ : new UnavailableAdminReplayStore(`${ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE}=database was requested but the database is disabled`);
+ } else if (requested === 'redis' || (requested === '' && enabled.redis)) {
+ store = enabled.redis
+ ? new RedisAdminReplayStore()
+ : new UnavailableAdminReplayStore(`${ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE}=redis was requested but Redis is disabled`);
+ } else if (requested !== '') {
+ store = new UnavailableAdminReplayStore(`${ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE}=${requested} is not a known replay store`);
+ } else {
+ store = new UnavailableAdminReplayStore(
+ `neither the database nor Redis is enabled, so no shared replay store exists (set ${ADMIN_REPLAY_STORE_ENVIRONMENT_VARIABLE}=memory only for a single-process deployment)`,
+ );
+ }
+ logger.info(`[admin-adapter] Admin replay store: ${store.kind}.`);
+ return store;
+}
diff --git a/backend/src/api/admin-adapter/admin-adapter.routes.ts b/backend/src/api/admin-adapter/admin-adapter.routes.ts
index 52d24cdac5d..1542d57073e 100644
--- a/backend/src/api/admin-adapter/admin-adapter.routes.ts
+++ b/backend/src/api/admin-adapter/admin-adapter.routes.ts
@@ -13,6 +13,7 @@ import blocks from '../blocks';
import capabilities from '../capabilities';
import memPool from '../mempool';
import { adminAdapterGuard, adminAdapterJsonParser, hasSignedAdminElevation } from './admin-adapter.security';
+import { createAdminReplayStore } from './admin-adapter.replay';
import {
adminEnvelope,
adminTimestamp,
@@ -214,7 +215,7 @@ class AdminAdapterRoutes {
public initRoutes(app: Application): void {
// Also support standalone registration; the full app captures these bytes
// before its general parsers consume the request stream.
- app.use(PREFIX, adminAdapterJsonParser(), adminAdapterGuard());
+ app.use(PREFIX, adminAdapterJsonParser(), adminAdapterGuard(createAdminReplayStore()));
app.get(`${PREFIX}/manifest`, async (_request: Request, response: Response) => {
try {
@@ -376,7 +377,7 @@ class AdminAdapterRoutes {
const correlationId =
String(request.headers['x-bu-admin-correlation-id'] ?? '').slice(0, 128) || randomUUID();
const actor = String(request.headers['x-bu-admin-actor'] ?? '').slice(0, 200) || 'control-center';
- const { run, replayed } = await runStore.create({
+ const { run, replayed, ownerToken } = await runStore.create({
operationId: operation.id,
operationVersion: operation.version,
target: previewBody.target,
@@ -388,29 +389,42 @@ class AdminAdapterRoutes {
rollbackSupported: operation.rollbackSupported,
redactedInput: input,
});
- if (replayed) {
+ if (replayed || ownerToken === null) {
response.json({ ...adminEnvelope(), run });
return;
}
if (operation.lock) {
- await runStore.acquireLock(`${operation.lock}:${previewBody.target}`, run.runId);
+ try {
+ await runStore.acquireLock(`${operation.lock}:${previewBody.target}`, run.runId);
+ } catch (e) {
+ // The run was already inserted QUEUED. Leave a terminal, inspectable
+ // record rather than a row that waits forever for a lock it lost.
+ const message = e instanceof Error ? e.message : String(e);
+ await runStore.transition(run.runId, 'FAILED', {
+ error: { class: 'lock_conflict', message: message.slice(0, 700), retryable: true },
+ logs: [{ at: adminTimestamp(), level: 'error', message: message.slice(0, 1000) }],
+ }, ownerToken);
+ throw e instanceof AdminRunConflict
+ ? new AdminRunConflict(`${message} Run ${run.runId} was recorded as FAILED.`)
+ : e;
+ }
}
await runStore.transition(run.runId, 'PRECHECK', {
logs: [{ at: adminTimestamp(), level: 'info', message: 'Preconditions verified.' }],
- });
- await runStore.transition(run.runId, 'RUNNING', { progressPercent: 10 });
+ }, ownerToken);
+ await runStore.transition(run.runId, 'RUNNING', { progressPercent: 10 }, ownerToken);
try {
- const outcome = await operation.execute({
+ const outcome = await runStore.withHeartbeat(run.runId, ownerToken, () => operation.execute({
runId: run.runId,
correlationId,
actor,
reason: run.reason,
idempotencyKey: run.idempotencyKey,
input,
- });
- await runStore.transition(run.runId, 'VERIFYING', { progressPercent: 90 });
+ }));
+ await runStore.transition(run.runId, 'VERIFYING', { progressPercent: 90 }, ownerToken);
const finished = await runStore.transition(
run.runId,
outcome.verification.verified ? 'SUCCEEDED' : 'NEEDS_REVIEW',
@@ -432,6 +446,7 @@ class AdminAdapterRoutes {
]),
],
},
+ ownerToken,
);
response.json({ ...adminEnvelope(), run: finished });
} catch (e) {
@@ -439,7 +454,7 @@ class AdminAdapterRoutes {
const failed = await runStore.transition(run.runId, 'FAILED', {
error: { class: 'operation_failed', message: message.slice(0, 700), retryable: true },
logs: [{ at: adminTimestamp(), level: 'error', message: message.slice(0, 1000) }],
- });
+ }, ownerToken);
response.json({ ...adminEnvelope(), run: failed });
}
} catch (e) {
diff --git a/backend/src/api/admin-adapter/admin-adapter.runs.ts b/backend/src/api/admin-adapter/admin-adapter.runs.ts
index 7b55bf3fcce..ee84e1c25fc 100644
--- a/backend/src/api/admin-adapter/admin-adapter.runs.ts
+++ b/backend/src/api/admin-adapter/admin-adapter.runs.ts
@@ -9,10 +9,43 @@ import { adminTimestamp, optionalAdminTimestamp } from './admin-adapter.identity
const { isAdminRunTerminal, isAdminRunTransitionAllowed, redactAdminPayload } =
adminControl as typeof import('@bitcoinuniverse/ecosystem-contracts/admin-control');
-/** How long a running operation may go silent before it is abandoned. */
-const LEASE_MS = 120_000;
-/** How long an advisory lock is held before another run may reclaim it. */
+/**
+ * How long a run may go silent before it is abandoned.
+ *
+ * The catalog allows operations of up to 900 seconds, so the lease is not
+ * sized to the operation. It is sized to the heartbeat: a live executor renews
+ * it every HEARTBEAT_MS (well under LEASE_MS) for as long as its work runs, so
+ * an abandoned run is noticed within LEASE_MS of the last heartbeat however
+ * long the operation was allowed to take. A run inserted QUEUED gets the same
+ * initial lease so a crash before the first transition is recovered too.
+ */
+export const LEASE_MS = 120_000;
+/** How often a live executor renews its lease and lock. */
+export const HEARTBEAT_MS = 30_000;
+/** How long an advisory lock is held before another run may reclaim it. Renewed by the heartbeat. */
const LOCK_MS = 15 * 60_000;
+/** How many expired runs one reconciliation pass handles per query. */
+const RECONCILE_BATCH = 200;
+/** How many batches one reconciliation pass may process before it reports what remains. */
+const RECONCILE_MAX_BATCHES = 25;
+/** States that are not terminal. Kept in one place so every query agrees. */
+const OPEN_STATES_SQL = "state NOT IN ('SUCCEEDED','FAILED','CANCELLED','NEEDS_REVIEW','ROLLED_BACK','ROLLBACK_FAILED')";
+/**
+ * A run counts as abandoned when its lease expired, or, for rows written before
+ * the initial lease existed, when it has sat QUEUED for longer than a lease.
+ */
+const ABANDONED_SQL = `${OPEN_STATES_SQL} AND ((lease_expires_at IS NOT NULL AND lease_expires_at <= ?) OR (lease_expires_at IS NULL AND state = 'QUEUED' AND queued_at <= ?))`;
+
+export interface ReconcileResult {
+ /** Runs moved to a terminal state by this pass. */
+ reconciled: number;
+ /** Runs with an expired lease still open after this pass; null when the count could not be read. */
+ remaining: number | null;
+ /** True only when every query answered and no expired run remains. */
+ verified: boolean;
+ /** The storage failure that stopped the pass, when one did. */
+ error?: string;
+}
export class AdminRunNotFound extends Error {}
export class AdminRunConflict extends Error {}
@@ -25,6 +58,8 @@ interface RunDocument {
error: AdminRun['error'];
rollback: AdminRun['rollback'];
redactedInput: Record;
+ /** Persisted at creation from the catalog. Absent on older rows, which reads as false. */
+ cancellable?: boolean;
}
function emptyDocument(redactedInput: Record = {}): RunDocument {
@@ -36,6 +71,7 @@ function emptyDocument(redactedInput: Record = {}): RunDocument
error: null,
rollback: { supported: false, state: null },
redactedInput,
+ cancellable: false,
};
}
@@ -53,7 +89,7 @@ function parseDocument(value: unknown): RunDocument {
return emptyDocument();
}
-function toRun(row: any): AdminRun {
+export function toRun(row: any): AdminRun {
const document = parseDocument(row.document);
return {
runId: row.run_id,
@@ -71,7 +107,9 @@ function toRun(row: any): AdminRun {
finishedAt: optionalAdminTimestamp(row.finished_at),
heartbeatAt: optionalAdminTimestamp(row.heartbeat_at),
progressPercent: row.progress_percent === null ? null : Number(row.progress_percent),
- cancellable: Number(row.cancel_requested) === 0 && !isAdminRunTerminal(row.state),
+ // Only what the catalog declared at creation, never inferred from the
+ // absence of a cancel request. Older rows without the field read as false.
+ cancellable: document.cancellable === true && Number(row.cancel_requested) === 0 && !isAdminRunTerminal(row.state),
replayed: false,
steps: document.steps,
logs: document.logs,
@@ -118,22 +156,24 @@ class AdminAdapterRunStore {
cancellable: boolean;
rollbackSupported: boolean;
redactedInput: Record;
- }): Promise<{ run: AdminRun; replayed: boolean }> {
+ }): Promise<{ run: AdminRun; replayed: boolean; ownerToken: string | null }> {
this.assertAvailable();
if (input.idempotencyKey) {
const existing = await this.findByIdempotencyKey(input.operationId, input.idempotencyKey);
if (existing) {
- return { run: { ...existing, replayed: true }, replayed: true };
+ return { run: { ...existing, replayed: true }, replayed: true, ownerToken: null };
}
}
const runId = randomUUID();
+ const ownerToken = randomUUID();
const now = new Date();
const document = emptyDocument(
redactAdminPayload(input.redactedInput) as Record,
);
document.rollback = { supported: input.rollbackSupported, state: null };
+ document.cancellable = input.cancellable === true;
await DB.query(
- 'INSERT INTO admin_adapter_runs (run_id, operation_id, operation_version, state, target, correlation_id, idempotency_key, actor, reason, queued_at, updated_at, cancel_requested, document) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)',
+ 'INSERT INTO admin_adapter_runs (run_id, operation_id, operation_version, state, target, correlation_id, idempotency_key, actor, reason, queued_at, updated_at, lease_expires_at, owner_token, cancel_requested, document) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)',
[
runId,
input.operationId,
@@ -146,11 +186,13 @@ class AdminAdapterRunStore {
input.reason,
now,
now,
+ new Date(now.getTime() + LEASE_MS),
+ ownerToken,
JSON.stringify(document),
],
);
const run = await this.get(runId);
- return { run: { ...run, cancellable: input.cancellable }, replayed: false };
+ return { run, replayed: false, ownerToken };
}
/** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
@@ -177,11 +219,19 @@ class AdminAdapterRunStore {
return rows[0] ? toRun(rows[0]) : null;
}
- /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ /**
+ * Moves a run to its next state. With an owner token the update is a
+ * compare-and-set on run id, the state just read and the token, so an
+ * executor whose run was reconciled away underneath it gets a conflict
+ * instead of overwriting the reviewer's record.
+ *
+ * @asyncUnsafe Callers turn a rejection into an exact HTTP answer.
+ */
async transition(
runId: string,
next: AdminRunState,
patch: Partial & { progressPercent?: number | null } = {},
+ ownerToken?: string,
): Promise {
this.assertAvailable();
const current = await this.get(runId);
@@ -201,8 +251,9 @@ class AdminAdapterRunStore {
const now = new Date();
const terminal = isAdminRunTerminal(next);
- await DB.query(
- 'UPDATE admin_adapter_runs SET state = ?, updated_at = ?, started_at = COALESCE(started_at, ?), finished_at = ?, heartbeat_at = ?, lease_expires_at = ?, progress_percent = ?, document = ? WHERE run_id = ?',
+ const guard = ownerToken === undefined ? '' : ' AND state = ? AND owner_token = ?';
+ const [result]: any[] = await DB.query(
+ `UPDATE admin_adapter_runs SET state = ?, updated_at = ?, started_at = COALESCE(started_at, ?), finished_at = ?, heartbeat_at = ?, lease_expires_at = ?, progress_percent = ?, document = ? WHERE run_id = ?${guard}`,
[
next,
now,
@@ -213,14 +264,70 @@ class AdminAdapterRunStore {
patch.progressPercent === undefined ? current.progressPercent : patch.progressPercent,
JSON.stringify(document),
runId,
+ ...(ownerToken === undefined ? [] : [current.state, ownerToken]),
],
);
+ if (ownerToken !== undefined && Number(result?.affectedRows ?? 0) !== 1) {
+ throw new AdminRunConflict(
+ `Run ${runId} changed underneath its executor (expected ${current.state}); its record was left as found.`,
+ );
+ }
if (terminal) {
await DB.query('DELETE FROM admin_adapter_locks WHERE run_id = ?', [runId]);
}
return this.get(runId);
}
+ /**
+ * Renews the lease and lock of a RUNNING run, but only for the executor that
+ * owns it. Returns false when the run is no longer RUNNING under this token,
+ * which means a reconciler or another actor already took it over.
+ *
+ * @asyncUnsafe The heartbeat loop reports a rejection and keeps going.
+ */
+ async heartbeat(runId: string, ownerToken: string): Promise {
+ this.assertAvailable();
+ const now = new Date();
+ const [result]: any[] = await DB.query(
+ "UPDATE admin_adapter_runs SET heartbeat_at = ?, updated_at = ?, lease_expires_at = ? WHERE run_id = ? AND state = 'RUNNING' AND owner_token = ?",
+ [now, now, new Date(now.getTime() + LEASE_MS), runId, ownerToken],
+ );
+ if (Number(result?.affectedRows ?? 0) !== 1) {
+ return false;
+ }
+ await DB.query('UPDATE admin_adapter_locks SET expires_at = ? WHERE run_id = ?', [
+ new Date(now.getTime() + LOCK_MS),
+ runId,
+ ]);
+ return true;
+ }
+
+ /**
+ * Runs the operation while renewing its lease every HEARTBEAT_MS. The
+ * rejection of the work itself is passed through untouched.
+ *
+ * @asyncUnsafe The caller turns a rejection of the work into a FAILED run.
+ */
+ async withHeartbeat(runId: string, ownerToken: string, work: () => Promise): Promise {
+ const timer = setInterval(() => {
+ this.heartbeat(runId, ownerToken)
+ .then((alive) => {
+ if (!alive) {
+ logger.warn(`[admin-adapter] Run ${runId} lost its lease while its operation was still running.`);
+ }
+ })
+ .catch((e) => {
+ logger.warn(`[admin-adapter] Run ${runId} could not renew its lease: ` + (e instanceof Error ? e.message : e));
+ });
+ }, HEARTBEAT_MS);
+ timer.unref?.();
+ try {
+ return await work();
+ } finally {
+ clearInterval(timer);
+ }
+ }
+
/** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
async requestCancel(runId: string): Promise {
const run = await this.get(runId);
@@ -267,32 +374,83 @@ class AdminAdapterRunStore {
}
}
- /** @asyncSafe */
- async reconcileAbandonedRuns(): Promise {
+ /**
+ * Moves runs whose lease expired to a terminal state, in bounded batches.
+ *
+ * A run that never started (QUEUED or PRECHECK) becomes FAILED, the only
+ * terminal state the contract allows from there; a run that was in flight
+ * becomes NEEDS_REVIEW because nobody can prove what it did. Each update
+ * re-checks the lease, so a run whose executor heartbeats between the read
+ * and the write is left alone and keeps its lock.
+ *
+ * @asyncSafe Every storage failure is returned in the result, never thrown.
+ */
+ async reconcileAbandonedRuns(): Promise {
if (!this.available()) {
- return 0;
+ return { reconciled: 0, remaining: null, verified: false, error: 'The explorer database is switched off.' };
}
+ let reconciled = 0;
try {
- const [rows]: any[] = await DB.query(
- "SELECT run_id FROM admin_adapter_runs WHERE lease_expires_at IS NOT NULL AND lease_expires_at <= ? AND state NOT IN ('SUCCEEDED','FAILED','CANCELLED','NEEDS_REVIEW','ROLLED_BACK','ROLLBACK_FAILED') LIMIT 200",
- [new Date()],
- );
- for (const row of rows) {
- await DB.query(
- "UPDATE admin_adapter_runs SET state = 'NEEDS_REVIEW', updated_at = ?, finished_at = ?, lease_expires_at = NULL WHERE run_id = ?",
- [new Date(), new Date(), row.run_id],
+ for (let batch = 0; batch < RECONCILE_MAX_BATCHES; batch++) {
+ const now = new Date();
+ const [rows]: any[] = await DB.query(
+ `SELECT run_id, state FROM admin_adapter_runs WHERE ${ABANDONED_SQL} LIMIT ${RECONCILE_BATCH}`,
+ [now, new Date(now.getTime() - LEASE_MS)],
);
- await DB.query('DELETE FROM admin_adapter_locks WHERE run_id = ?', [row.run_id]);
+ if (!Array.isArray(rows) || rows.length === 0) {
+ break;
+ }
+ let movedInBatch = 0;
+ for (const row of rows) {
+ const at = new Date();
+ const neverStarted = row.state === 'QUEUED' || row.state === 'PRECHECK';
+ const message = neverStarted
+ ? 'The run was abandoned before its operation started; nothing was executed.'
+ : 'The run went silent while its operation was in flight; the outcome is unknown.';
+ const [result]: any[] = await DB.query(
+ `UPDATE admin_adapter_runs SET state = ?, updated_at = ?, finished_at = ?, lease_expires_at = NULL, document = JSON_SET(document, '$.error', JSON_OBJECT('class', ?, 'message', ?, 'retryable', ?)) WHERE run_id = ? AND ${ABANDONED_SQL}`,
+ [
+ neverStarted ? 'FAILED' : 'NEEDS_REVIEW',
+ at,
+ at,
+ neverStarted ? 'abandoned_before_start' : 'abandoned_in_flight',
+ message,
+ neverStarted,
+ row.run_id,
+ at,
+ new Date(at.getTime() - LEASE_MS),
+ ],
+ );
+ if (Number(result?.affectedRows ?? 0) === 1) {
+ await DB.query('DELETE FROM admin_adapter_locks WHERE run_id = ?', [row.run_id]);
+ movedInBatch++;
+ }
+ }
+ reconciled += movedInBatch;
+ if (rows.length < RECONCILE_BATCH) {
+ break;
+ }
+ if (movedInBatch === 0) {
+ // Every row in this batch was renewed under us; stop rather than spin.
+ break;
+ }
}
- if (rows.length > 0) {
+ const now = new Date();
+ const [countRows]: any[] = await DB.query(
+ `SELECT COUNT(*) AS remaining FROM admin_adapter_runs WHERE ${ABANDONED_SQL}`,
+ [now, new Date(now.getTime() - LEASE_MS)],
+ );
+ const remaining = Number(countRows?.[0]?.remaining ?? 0);
+ if (reconciled > 0) {
logger.warn(
- `[admin-adapter] Moved ${rows.length} abandoned operation runs to NEEDS_REVIEW after their lease expired.`,
+ `[admin-adapter] Moved ${reconciled} abandoned operation runs to a terminal state after their lease expired${remaining > 0 ? `; ${remaining} remain` : ''}.`,
);
}
- return rows.length;
+ return { reconciled, remaining, verified: remaining === 0 };
} catch (e) {
- logger.debug('[admin-adapter] Could not reconcile abandoned runs: ' + (e instanceof Error ? e.message : e));
- return 0;
+ const error = e instanceof Error ? e.message : String(e);
+ logger.warn(`[admin-adapter] Could not reconcile abandoned runs after ${reconciled}: ` + error);
+ return { reconciled, remaining: null, verified: false, error };
}
}
diff --git a/backend/src/api/admin-adapter/admin-adapter.security.ts b/backend/src/api/admin-adapter/admin-adapter.security.ts
index 412a6b9df27..ed04a42b31a 100644
--- a/backend/src/api/admin-adapter/admin-adapter.security.ts
+++ b/backend/src/api/admin-adapter/admin-adapter.security.ts
@@ -77,41 +77,91 @@ export function isPrivateRemoteAddress(address: string): boolean {
return /^f[cd][0-9a-f]{2}:/.test(value) || value.startsWith('fe80:');
}
+/** How long a claimed nonce has to stay claimed: the whole freshness window. */
+export const ADMIN_NONCE_RETENTION_MS = ADMIN_SERVICE_NONCE_TTL_SECONDS * 1000;
+
+/**
+ * The answer of a replay store. `unavailable` is the fail-closed answer: the
+ * request is refused because nobody can say whether the nonce was seen.
+ */
+export type AdminReplayClaim = 'claimed' | 'replayed' | 'unavailable';
+
+/**
+ * A shared, atomic claim on a nonce. Every worker that verifies admin requests
+ * must consult the same store, otherwise a signed request replays cleanly
+ * against the next process.
+ */
+export interface AdminReplayStore {
+ readonly kind: string;
+ claim(scope: string, nonce: string, nowMs: number): Promise;
+}
+
/**
- * Remembers nonces for exactly as long as a timestamp can stay fresh, so a
- * replay inside the window is refused and memory stays bounded.
+ * Remembers nonces in this process for exactly as long as a timestamp can
+ * stay fresh. Unexpired nonces are never evicted: a store that forgot a live
+ * nonce to make room would reopen the replay it exists to close, so when the
+ * bound is reached new nonces are refused instead.
+ *
+ * Single-process only. Production selects a shared store; this one is for
+ * unit tests and for an explicit single-process opt-in.
*/
-export class AdminAdapterNonceStore {
+export class AdminAdapterNonceStore implements AdminReplayStore {
+ readonly kind = 'memory';
private seen = new Map();
constructor(private maxEntries = 20_000) {}
- accept(nonce: string, nowMs: number): boolean {
+ /** Synchronous acceptance for the pure verifier. Nonces are keyed by scope. */
+ accept(nonce: string, nowMs: number, scope = ''): boolean {
this.evict(nowMs);
- if (this.seen.has(nonce)) {
+ const key = `${scope}\n${nonce}`;
+ if (this.seen.has(key)) {
return false;
}
- this.seen.set(nonce, nowMs);
- if (this.seen.size > this.maxEntries) {
- const oldest = this.seen.keys().next();
- if (!oldest.done) {
- this.seen.delete(oldest.value);
- }
+ if (this.seen.size >= this.maxEntries) {
+ return false;
}
+ this.seen.set(key, nowMs);
return true;
}
+ claim(scope: string, nonce: string, nowMs: number): Promise {
+ return Promise.resolve(this.accept(nonce, nowMs, scope) ? 'claimed' : 'replayed');
+ }
+
+ /** Only expired entries are removed, never a live one. */
private evict(nowMs: number): void {
- const horizon = nowMs - ADMIN_SERVICE_NONCE_TTL_SECONDS * 1000;
- for (const [nonce, at] of this.seen) {
+ const horizon = nowMs - ADMIN_NONCE_RETENTION_MS;
+ for (const [key, at] of this.seen) {
if (at >= horizon) {
break;
}
- this.seen.delete(nonce);
+ this.seen.delete(key);
+ }
+ }
+}
+
+/** The fail-closed store: every claim is unavailable, with the reason logged once. */
+export class UnavailableAdminReplayStore implements AdminReplayStore {
+ readonly kind = 'unavailable';
+ private logged = false;
+
+ constructor(private reason: string) {}
+
+ claim(): Promise {
+ if (!this.logged) {
+ this.logged = true;
+ logger.err(`[admin-adapter] Admin requests are refused: ${this.reason}`);
}
+ return Promise.resolve('unavailable');
}
}
+/** The key/version pair a nonce is claimed under, so keys cannot collide. */
+export function adminNonceScope(keyId: string, contractVersion: string): string {
+ return `${keyId}@${contractVersion}`;
+}
+
function header(request: Request, name: string): string {
const value = request.headers[name];
if (Array.isArray(value)) {
@@ -130,15 +180,12 @@ export type AdminAdapterVerdict =
| { ok: true; authorization: { keyId: string; elevated: boolean } }
| { ok: false; status: number; code: string; message: string; reason: string };
-const nonces = new AdminAdapterNonceStore();
+/** A verified signature whose nonce has not been claimed yet. */
+export type AdminAdapterSignatureVerdict =
+ | { ok: true; authorization: { keyId: string; elevated: boolean }; nonce: string; scope: string }
+ | { ok: false; status: number; code: string; message: string; reason: string };
-/**
- * The whole verification, expressed as a pure-ish function so it can be tested
- * without an HTTP server. `rawBody` is the exact bytes received, because
- * re-serialising a parsed object would change whitespace and key order and
- * break every signature.
- */
-export function verifyAdminAdapterRequest(input: {
+export interface AdminAdapterRequestInput {
method: string;
originalUrl: string;
headers: Record;
@@ -146,9 +193,64 @@ export function verifyAdminAdapterRequest(input: {
remoteAddress: string;
keys?: AdminAdapterKey[];
nowMs?: number;
- nonceStore?: AdminAdapterNonceStore;
+}
+
+/**
+ * The whole verification, expressed as a pure-ish function so it can be tested
+ * without an HTTP server. `rawBody` is the exact bytes received, because
+ * re-serialising a parsed object would change whitespace and key order and
+ * break every signature.
+ *
+ * This variant claims the nonce in the given single-process store and exists
+ * for unit tests and the isolated harness. The guard uses
+ * `verifyAdminAdapterRequestWithStore` against the shared store.
+ */
+export function verifyAdminAdapterRequest(input: AdminAdapterRequestInput & {
+ nonceStore: AdminAdapterNonceStore;
}): AdminAdapterVerdict {
- const rejected = (reason: string): AdminAdapterVerdict => ({
+ const verdict = checkAdminAdapterSignature(input);
+ if (!verdict.ok) {
+ return verdict;
+ }
+ if (!input.nonceStore.accept(verdict.nonce, input.nowMs ?? Date.now(), verdict.scope)) {
+ return unauthorized('A signed service request replayed a nonce.');
+ }
+ return { ok: true, authorization: verdict.authorization };
+}
+
+/**
+ * The verification the guard runs: every signature check, then one atomic
+ * claim in the shared replay store. An unavailable store refuses the request
+ * with its own code so the operator can tell it apart from a bad signature.
+ *
+ * @asyncUnsafe The guard catches a rejection and refuses the request.
+ */
+export async function verifyAdminAdapterRequestWithStore(
+ input: AdminAdapterRequestInput,
+ store: AdminReplayStore,
+): Promise {
+ const verdict = checkAdminAdapterSignature(input);
+ if (!verdict.ok) {
+ return verdict;
+ }
+ const claim = await store.claim(verdict.scope, verdict.nonce, input.nowMs ?? Date.now());
+ if (claim === 'replayed') {
+ return unauthorized('A signed service request replayed a nonce.');
+ }
+ if (claim !== 'claimed') {
+ return {
+ ok: false,
+ status: 503,
+ code: 'REPLAY_STORE_UNAVAILABLE',
+ message: 'The Explorer admin adapter cannot verify request freshness right now.',
+ reason: `A signed service request was refused because the ${store.kind} replay store is unavailable.`,
+ };
+ }
+ return { ok: true, authorization: verdict.authorization };
+}
+
+function unauthorized(reason: string): { ok: false; status: number; code: string; message: string; reason: string } {
+ return {
ok: false,
status: 401,
code: 'UNAUTHORIZED',
@@ -156,7 +258,12 @@ export function verifyAdminAdapterRequest(input: {
// failed. The operator still gets the reason in the log.
message: 'Admin adapter request verification failed.',
reason,
- });
+ };
+}
+
+/** Every check except the nonce claim. */
+export function checkAdminAdapterSignature(input: AdminAdapterRequestInput): AdminAdapterSignatureVerdict {
+ const rejected = unauthorized;
if (!isPrivateRemoteAddress(input.remoteAddress)) {
return {
@@ -242,11 +349,6 @@ export function verifyAdminAdapterRequest(input: {
return rejected('A signed service request signature did not verify.');
}
- const store = input.nonceStore ?? nonces;
- if (!store.accept(nonce, input.nowMs ?? Date.now())) {
- return rejected('A signed service request replayed a nonce.');
- }
-
// This declaration is covered by the existing signed body digest. An
// unsigned forwarding header cannot upgrade a request's authority.
let elevated = false;
@@ -258,7 +360,7 @@ export function verifyAdminAdapterRequest(input: {
return rejected('A signed admin request did not contain valid JSON.');
}
}
- return { ok: true, authorization: { keyId, elevated } };
+ return { ok: true, authorization: { keyId, elevated }, nonce, scope: adminNonceScope(keyId, contractVersion) };
}
/** Mount before any general body parser so signatures retain the exact bytes. */
@@ -291,22 +393,41 @@ export function adminAdapterResponseHeaders(response: Response): void {
response.removeHeader('Access-Control-Allow-Headers');
}
-export function adminAdapterGuard() {
+/**
+ * The guard. The replay store is injected so the choice of store is made once,
+ * at mount time, and logged; without one every request fails closed.
+ */
+export function adminAdapterGuard(
+ store: AdminReplayStore = new UnavailableAdminReplayStore('no replay store was provided to the admin guard'),
+) {
return (request: Request, response: Response, next: NextFunction): void => {
adminAdapterResponseHeaders(response);
- const verdict = verifyAdminAdapterRequest({
+ const refuse = (verdict: { status: number; code: string; message: string; reason: string }): void => {
+ logger.warn(`[admin-adapter] ${verdict.reason}`);
+ response.status(verdict.status).json({ code: verdict.code, message: verdict.message });
+ };
+ verifyAdminAdapterRequestWithStore({
method: request.method,
originalUrl: request.originalUrl,
headers: request.headers as Record,
rawBody: (request as Request & { rawBody?: Buffer }).rawBody ?? Buffer.alloc(0),
remoteAddress: request.socket.remoteAddress ?? '',
- });
- if (verdict.ok) {
- response.locals.adminAdapterAuthorization = verdict.authorization;
- next();
- return;
- }
- logger.warn(`[admin-adapter] ${verdict.reason}`);
- response.status(verdict.status).json({ code: verdict.code, message: verdict.message });
+ }, store)
+ .then((verdict) => {
+ if (verdict.ok) {
+ response.locals.adminAdapterAuthorization = verdict.authorization;
+ next();
+ return;
+ }
+ refuse(verdict);
+ })
+ .catch((e) => {
+ refuse({
+ status: 503,
+ code: 'REPLAY_STORE_UNAVAILABLE',
+ message: 'The Explorer admin adapter cannot verify request freshness right now.',
+ reason: 'The replay store threw while claiming a nonce: ' + (e instanceof Error ? e.message : String(e)),
+ });
+ });
};
}
diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts
index 8628a094f50..c45f90ee7c6 100644
--- a/backend/src/api/database-migration.ts
+++ b/backend/src/api/database-migration.ts
@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
- private static currentVersion = 109;
+ private static currentVersion = 110;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@@ -1467,6 +1467,21 @@ class DatabaseMigration {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await this.updateToSchemaVersion(109);
}
+ if (databaseSchemaVersion < 110) {
+ // Admin adapter: a token so only the executor that owns a run can renew
+ // or finish it, and one row per claimed request nonce shared by every
+ // backend worker so a signed request cannot replay against another
+ // process. Both additive; existing runs keep working with a NULL token.
+ await this.$executeQuery('ALTER TABLE admin_adapter_runs ADD owner_token CHAR(36) NULL');
+ await this.$executeQuery(`CREATE TABLE IF NOT EXISTS admin_nonces (
+ scope VARCHAR(160) NOT NULL,
+ nonce VARCHAR(128) NOT NULL,
+ expires_at DATETIME(3) NOT NULL,
+ PRIMARY KEY (scope, nonce),
+ INDEX admin_nonces_expiry (expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
+ await this.updateToSchemaVersion(110);
+ }
if (databaseSchemaVersion < 106 && config.MEMPOOL.NETWORK === 'liquid') {
// In a specific setup it's possible that 3G6neksSBMp51kHJ2if8SeDUrzT8iVETWT and bc1qwnevjp8nsq7adu3hxlvdvslrf242q4vuavfg0y929jp2zntp3vgq7cq6z2
diff --git a/backend/src/api/prices/prices.routes.ts b/backend/src/api/prices/prices.routes.ts
index cdf435f3280..08b2f7613cd 100644
--- a/backend/src/api/prices/prices.routes.ts
+++ b/backend/src/api/prices/prices.routes.ts
@@ -14,7 +14,9 @@ class PricesRoutes {
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString());
- res.json(pricesUpdater.getLatestPrices());
+ // The served price plus its provenance: a disabled or stale observation
+ // is marked as such rather than presented as the current quote.
+ res.json(pricesUpdater.getAdvertisedPrices());
}
}
diff --git a/backend/src/api/redis-cache.ts b/backend/src/api/redis-cache.ts
index 80a947c12fd..8cbd768f9e7 100644
--- a/backend/src/api/redis-cache.ts
+++ b/backend/src/api/redis-cache.ts
@@ -435,6 +435,24 @@ class RedisCache {
public setIgnoreBlocksCache(): void {
this.ignoreBlocksCache = true;
}
+
+ /**
+ * Atomically claims a key for ttlMs (SET NX PX). Returns true when this call
+ * made the claim and false when the key already existed. Throws when Redis
+ * is disabled or not connected, so the caller can fail closed.
+ *
+ * @asyncUnsafe The caller turns a rejection into an unavailable answer.
+ */
+ public async $claimOnce(key: string, ttlMs: number): Promise {
+ if (!config.REDIS.ENABLED) {
+ throw new Error('Redis is disabled');
+ }
+ if (!this.connected) {
+ throw new Error('Redis is not connected');
+ }
+ const answer = await this.client.set(key, '1', { NX: true, PX: Math.max(1, Math.round(ttlMs)) });
+ return answer === 'OK';
+ }
}
export default new RedisCache();
diff --git a/backend/src/api/tx-selection-worker.ts b/backend/src/api/tx-selection-worker.ts
index 98dd429096f..fcc1347921f 100644
--- a/backend/src/api/tx-selection-worker.ts
+++ b/backend/src/api/tx-selection-worker.ts
@@ -32,7 +32,7 @@ if (parentPort) {
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*/
-function makeBlockTemplates(mempool: Map)
+export function makeBlockTemplates(mempool: Map)
: { blocks: number[][], rates: Map, clusters: Map } {
const start = Date.now();
const auditPool: Map = new Map();
@@ -85,7 +85,7 @@ function makeBlockTemplates(mempool: Map)
// (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: number[][] = [];
let blockWeight = 4000;
- const blockSigops = 0;
+ let blockSigops = 0;
let transactions: AuditTransaction[] = [];
const modified: PairingHeap = new PairingHeap((a, b): boolean => {
if (a.score === b.score) {
@@ -152,6 +152,10 @@ function makeBlockTemplates(mempool: Map)
mempoolTx.cpfpChecked = true;
transactions.push(ancestor);
blockWeight += ancestor.weight;
+ // Each ancestor is counted once, here, as it is placed. The package
+ // check above used ancestorSigops, which updateDescendants trims as
+ // ancestors get placed, so nothing is counted twice.
+ blockSigops += ancestor.sigops;
used.push(ancestor);
}
@@ -184,6 +188,7 @@ function makeBlockTemplates(mempool: Map)
// reset for the next block
transactions = [];
blockWeight = 4000;
+ blockSigops = 0;
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
for (const overflowTx of overflow.reverse()) {
diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts
index 55545cec62d..76b96e39f59 100644
--- a/backend/src/api/websocket-handler.ts
+++ b/backend/src/api/websocket-handler.ts
@@ -38,6 +38,22 @@ import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import { calculateMempoolTxCpfp } from './cpfp';
import stratumApi, { StratumJob } from './services/stratum';
+/** Most transaction ids one connection may track at once. */
+const MAX_TRACKED_TXS = 100;
+/** Authoritative status lookups in flight for one track-txs request. */
+const TRACKED_TX_LOOKUP_CONCURRENCY = 4;
+/** Upper bound on one authoritative status lookup. */
+const TRACKED_TX_LOOKUP_DEADLINE_MS = 10_000;
+
+function isTransactionNotFound(e: unknown): boolean {
+ const error = e as { response?: { status?: number }; message?: string } | null;
+ if (error?.response?.status === 404) {
+ return true;
+ }
+ const message = typeof error?.message === 'string' ? error.message : '';
+ return message.indexOf('No such mempool or blockchain transaction') > -1;
+}
+
// valid 'want' subscriptions
const wantable = [
'blocks',
@@ -99,7 +115,7 @@ class WebsocketHandler {
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'blocks': _blocks,
- 'conversions': priceUpdater.getLatestPrices(),
+ 'conversions': priceUpdater.getAdvertisedPrices(),
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
'transactions': memPool.getLatestTransactions(),
'backendInfo': backendInfo.getBackendInfo(),
@@ -229,45 +245,27 @@ class WebsocketHandler {
const txids: string[] = [];
if (Array.isArray(parsedMessage['track-txs'])) {
for (const txid of parsedMessage['track-txs']) {
- if (/^[a-fA-F0-9]{64}$/.test(txid)) {
+ if (typeof txid === 'string' && /^[a-fA-F0-9]{64}$/.test(txid) && !txids.includes(txid)) {
txids.push(txid);
}
}
}
- const txs: { [txid: string]: TxTrackingInfo } = {};
- for (const txid of txids) {
- const txInfo: TxTrackingInfo = {
- confirmed: true,
- };
- const rbfCacheTxid = rbfCache.getReplacedBy(txid);
- if (rbfCacheTxid) {
- txInfo.replacedBy = rbfCacheTxid;
- txInfo.confirmed = false;
- }
- const tx = memPool.getMempool()[txid];
- if (tx && tx.position) {
- txInfo.position = {
- ...tx.position
- };
- if (tx.acceleration) {
- txInfo.accelerated = tx.acceleration;
- }
- }
- if (tx) {
- txInfo.confirmed = false;
- }
- txs[txid] = txInfo;
- }
-
- if (txids.length) {
- client['track-txs'] = txids;
- } else {
+ if (txids.length > MAX_TRACKED_TXS) {
+ response['track-txs-error'] = `"too many transactions requested, this connection supports tracking a maximum of ${MAX_TRACKED_TXS} transactions"`;
client['track-txs'] = null;
- }
+ } else {
+ const txs = await this.$resolveTrackedTxs(txids);
+
+ if (txids.length) {
+ client['track-txs'] = txids;
+ } else {
+ client['track-txs'] = null;
+ }
- if (Object.keys(txs).length) {
- response['tracked-txs'] = JSON.stringify(txs);
+ if (Object.keys(txs).length) {
+ response['tracked-txs'] = JSON.stringify(txs);
+ }
}
}
@@ -466,14 +464,99 @@ class WebsocketHandler {
}
}
+ /**
+ * The initial status of every tracked transaction, resolved with bounded
+ * concurrency. Absence from this process's mempool is never treated as
+ * confirmation: a transaction that is neither here nor replaced is looked
+ * up at the node, and only a real block height and hash may confirm it.
+ *
+ * @asyncSafe Every lookup failure becomes an unavailable status.
+ */
+ private async $resolveTrackedTxs(txids: string[]): Promise<{ [txid: string]: TxTrackingInfo }> {
+ const txs: { [txid: string]: TxTrackingInfo } = {};
+ const queue = txids.slice();
+ const worker = async (): Promise => {
+ while (queue.length) {
+ const txid = queue.shift();
+ if (!txid) {
+ return;
+ }
+ try {
+ txs[txid] = await this.$trackedTxStatus(txid);
+ } catch (e) {
+ logger.debug(`Could not resolve tracked transaction ${txid}: ` + (e instanceof Error ? e.message : e));
+ txs[txid] = { status: 'unavailable' };
+ }
+ }
+ };
+ try {
+ await Promise.all(Array.from({ length: Math.min(TRACKED_TX_LOOKUP_CONCURRENCY, txids.length) }, worker));
+ } catch (e) {
+ // Each worker answers its own failures; this only guards the join.
+ logger.debug('Tracked transaction lookups did not complete: ' + (e instanceof Error ? e.message : e));
+ }
+ return txs;
+ }
+
+ /** @asyncSafe */
+ private async $trackedTxStatus(txid: string): Promise {
+ const rbfCacheTxid = rbfCache.getReplacedBy(txid);
+ if (rbfCacheTxid) {
+ return { replacedBy: rbfCacheTxid, confirmed: false, status: 'replaced' };
+ }
+ const tx = memPool.getMempool()[txid];
+ if (tx) {
+ const txInfo: TxTrackingInfo = { confirmed: false, status: 'mempool' };
+ if (tx.position) {
+ txInfo.position = { ...tx.position };
+ if (tx.acceleration) {
+ txInfo.accelerated = tx.acceleration;
+ }
+ }
+ return txInfo;
+ }
+ let deadline: NodeJS.Timeout | undefined;
+ try {
+ const lookup = bitcoinApi.$getRawTransaction(txid, true, false);
+ // A lookup that outlives the deadline must not surface as an unhandled rejection.
+ lookup.catch(() => undefined);
+ const expired = new Promise((_resolve, reject) => {
+ deadline = setTimeout(() => reject(new Error('transaction status lookup timed out')), TRACKED_TX_LOOKUP_DEADLINE_MS);
+ });
+ const found = await Promise.race([lookup, expired]);
+ const status = found?.status;
+ if (
+ status?.confirmed === true
+ && typeof status.block_height === 'number' && Number.isInteger(status.block_height) && status.block_height >= 0
+ && typeof status.block_hash === 'string' && /^[a-fA-F0-9]{64}$/.test(status.block_hash)
+ ) {
+ return { confirmed: true, status: 'confirmed', blockHeight: status.block_height, blockHash: status.block_hash };
+ }
+ // Known to the node but not yet in a block, or in a block the node
+ // could not identify: either way it is not confirmed.
+ return { confirmed: false, status: 'mempool' };
+ } catch (e) {
+ if (isTransactionNotFound(e)) {
+ return { confirmed: false, status: 'unknown' };
+ }
+ logger.debug(`Could not resolve tracked transaction ${txid}: ` + (e instanceof Error ? e.message : e));
+ return { status: 'unavailable' };
+ } finally {
+ if (deadline !== undefined) {
+ clearTimeout(deadline);
+ }
+ }
+ }
+
handleNewConversionRates(conversionRates: ApiPrice) {
if (!this.webSocketServers.length) {
throw new Error('No WebSocket.Server have been set');
}
- this.updateSocketDataFields({ 'conversions': conversionRates });
+ const advertised = { ...conversionRates, ...priceUpdater.getPriceObservation() };
+ this.updateSocketDataFields({ 'conversions': advertised });
- const response = JSON.stringify({ conversions: conversionRates });
+ const response = JSON.stringify({ conversions: advertised });
// TODO - Fix indentation after PR is merged
for (const server of this.webSocketServers) {
server.clients.forEach((client) => {
diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts
index 327bbbebf0a..17ee671f526 100644
--- a/backend/src/indexer.ts
+++ b/backend/src/indexer.ts
@@ -21,6 +21,30 @@ export interface CoreIndex {
type TaskName = 'blocksPrices' | 'coinStatsIndex';
+export type SingleTaskStatus =
+ | 'completed'
+ | 'already-running'
+ | 'disabled'
+ | 'deferred'
+ | 'network-inapplicable'
+ | 'failed';
+
+/**
+ * What actually happened when a single task was asked to run. A caller that
+ * needs authoritative completion (the admin adapter) must only trust
+ * `completed`, and even then confirm the persisted rows itself.
+ */
+export interface SingleTaskOutcome {
+ task: TaskName;
+ status: SingleTaskStatus;
+ /** Why the task did not run, for every status other than completed. */
+ reason?: string;
+ /** The failure message, for status failed. */
+ error?: string;
+ /** Wall-clock bounds of the work, for status completed. */
+ checkpoint?: { startedAt: string; finishedAt: string };
+}
+
class Indexer {
private runIndexer = true;
private indexerRunning = false;
@@ -77,14 +101,20 @@ class Indexer {
return null;
}
- public reindex(): void {
- if (Common.indexingEnabled()) {
- if (this.reindexTimeout) {
- clearTimeout(this.reindexTimeout);
- this.reindexTimeout = undefined;
- }
- this.runIndexer = true;
+ /**
+ * Releases the indexing loop to run again. Returns whether the request was
+ * accepted; it is refused when indexing is disabled in this deployment.
+ */
+ public reindex(): boolean {
+ if (!Common.indexingEnabled()) {
+ return false;
+ }
+ if (this.reindexTimeout) {
+ clearTimeout(this.reindexTimeout);
+ this.reindexTimeout = undefined;
}
+ this.runIndexer = true;
+ return true;
}
private scheduleNextRun(timeout: number): void {
@@ -110,17 +140,26 @@ class Indexer {
return;
} else { // debounce
clearTimeout(this.tasksScheduled[task]);
+ delete this.tasksScheduled[task];
}
}
- this.tasksScheduled[task] = setTimeout(async () => {
+ const handle = setTimeout(async () => {
+ // Release the slot before the work starts so the task can schedule its
+ // own successor. Identity-compared: a replaced timer's callback never
+ // fires, but it must not be able to clear a newer handle either way.
+ if (this.tasksScheduled[task] === handle) {
+ delete this.tasksScheduled[task];
+ }
try {
- await this.runSingleTask(task);
+ const outcome = await this.runSingleTask(task);
+ if (outcome.status === 'failed') {
+ logger.err(`Scheduled task ${task} failed: ${outcome.error ?? 'unknown error'}`);
+ }
} catch (e) {
logger.err(`Unexpected error in scheduled task ${task}: ` + (e instanceof Error ? e.message : e));
- } finally {
- clearTimeout(this.tasksScheduled[task]);
}
}, timeout);
+ this.tasksScheduled[task] = handle;
}
/**
@@ -130,41 +169,55 @@ class Indexer {
*
* @asyncSafe
*/
- public async runSingleTask(task: TaskName): Promise {
- if (!Common.indexingEnabled() || this.tasksRunning[task]) {
- return;
+ public async runSingleTask(task: TaskName): Promise {
+ if (!Common.indexingEnabled()) {
+ return { task, status: 'disabled', reason: 'Indexing is disabled in this deployment.' };
+ }
+ if (this.tasksRunning[task]) {
+ return { task, status: 'already-running', reason: `The ${task} task is already running.` };
}
this.tasksRunning[task] = true;
+ const startedAt = new Date().toISOString();
- switch (task) {
- case 'blocksPrices': {
- if (!['testnet', 'signet', 'testnet4', 'regtest'].includes(config.MEMPOOL.NETWORK) && config.FIAT_PRICE.ENABLED) {
- let lastestPriceId;
+ try {
+ switch (task) {
+ case 'blocksPrices': {
+ if (['testnet', 'signet', 'testnet4', 'regtest'].includes(config.MEMPOOL.NETWORK)) {
+ return { task, status: 'network-inapplicable', reason: `Coins on ${config.MEMPOOL.NETWORK} have no fiat price.` };
+ }
+ if (!config.FIAT_PRICE.ENABLED) {
+ return { task, status: 'disabled', reason: 'Fiat prices are switched off in this deployment.' };
+ }
+ let lastestPriceId: number | null | undefined;
try {
lastestPriceId = await PricesRepository.$getLatestPriceId();
} catch (e) {
logger.debug('failed to fetch latest price id from db: ' + (e instanceof Error ? e.message : e));
- } if (priceUpdater.historyInserted === false || lastestPriceId === null) {
+ }
+ if (priceUpdater.historyInserted === false || lastestPriceId === null || lastestPriceId === undefined) {
logger.debug(`Blocks prices indexer is waiting for the price updater to complete`, logger.tags.mining);
this.scheduleSingleTask(task, 10000);
- } else {
- logger.debug(`Blocks prices indexer will run now`, logger.tags.mining);
- await mining.$indexBlockPrices();
+ return { task, status: 'deferred', reason: 'Price history is not available yet; the task was rescheduled.' };
}
+ logger.debug(`Blocks prices indexer will run now`, logger.tags.mining);
+ await mining.$indexBlockPrices();
+ return { task, status: 'completed', checkpoint: { startedAt, finishedAt: new Date().toISOString() } };
}
- } break;
- case 'coinStatsIndex': {
- logger.debug(`Indexing coinStatsIndex now`);
- try {
+ case 'coinStatsIndex': {
+ logger.debug(`Indexing coinStatsIndex now`);
await mining.$indexCoinStatsIndex();
- } catch (e) {
- logger.debug(`failed to index coinstatsindex: ` + (e instanceof Error ? e.message : e));
+ return { task, status: 'completed', checkpoint: { startedAt, finishedAt: new Date().toISOString() } };
}
- } break;
+ }
+ return { task, status: 'failed', error: `Unknown task ${String(task)}.` };
+ } catch (e) {
+ const error = e instanceof Error ? e.message : String(e);
+ logger.debug(`failed to run ${task}: ` + error);
+ return { task, status: 'failed', error };
+ } finally {
+ this.tasksRunning[task] = false;
}
-
- this.tasksRunning[task] = false;
}
/** @asyncSafe */
diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts
index 4064d957878..2a8a89f33eb 100644
--- a/backend/src/mempool.interfaces.ts
+++ b/backend/src/mempool.interfaces.ts
@@ -474,7 +474,11 @@ export interface TxTrackingInfo {
acceleratedBy?: number[],
acceleratedAt?: number,
feeDelta?: number,
- confirmed?: boolean
+ confirmed?: boolean,
+ /** Additive: how the initial status was established. Absent on later delta frames. */
+ status?: 'confirmed' | 'mempool' | 'replaced' | 'unknown' | 'unavailable',
+ blockHeight?: number,
+ blockHash?: string,
}
export interface WebsocketResponse {
diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts
index 34646e30f75..464da2ddf8e 100644
--- a/backend/src/tasks/price-updater.ts
+++ b/backend/src/tasks/price-updater.ts
@@ -34,6 +34,35 @@ export interface ConversionRates {
[currency: string]: number
}
+/** Networks whose coins have no fiat value; nothing prices them. */
+const UNPRICED_NETWORKS = ['testnet', 'signet', 'testnet4', 'regtest'];
+
+/**
+ * How old a stored observation may be and still be advertised as the
+ * current price. Matches the staleness bound the capability report and the
+ * admin snapshot already apply to the newest price.
+ */
+export const PRICE_MAX_AGE_SECONDS = 3 * 60 * 60;
+
+export type PriceObservationState = 'live' | 'stale' | 'disabled' | 'network-inapplicable' | 'unavailable';
+
+/**
+ * The provenance of whatever price is being served. Additive to the price
+ * object so an old consumer keeps reading currency fields while a new one can
+ * tell a live quote from a retained observation.
+ */
+export interface PriceObservation {
+ state: PriceObservationState;
+ source: 'feed' | 'database' | null;
+ /** Unix seconds of the observation being reported, live or retained. */
+ observedAt: number | null;
+ ageSeconds: number | null;
+ stale: boolean;
+ disabled: boolean;
+ /** The newest retained observation when it is not being served as live. */
+ storedObservation: ApiPrice | null;
+}
+
function getMedian(arr: number[]): number {
const sortedArr = arr.slice().sort((a, b) => a - b);
const mid = Math.floor(sortedArr.length / 2);
@@ -57,6 +86,9 @@ class PriceUpdater {
private currencies: string[] = ['USD', 'EUR', 'GBP', 'CAD', 'CHF', 'AUD', 'JPY'];
private latestPrices: ApiPrice;
private latestGoodPrices: ApiPrice;
+ /** The newest observation read from the database, kept with its timestamp even when it is not served. */
+ private storedPrices: ApiPrice | null = null;
+ private liveSource: 'feed' | 'database' | null = null;
private currencyConversionFeed: ConversionFeed | undefined;
private newCurrencies: string[] = ['BGN', 'BRL', 'CNY', 'CZK', 'DKK', 'HKD', 'HRK', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR'];
private lastTimeConversionsRatesFetched: number = 0;
@@ -77,10 +109,69 @@ class PriceUpdater {
this.setCyclePosition();
}
+ /** Only a price this deployment may serve as current; every field is -1 otherwise. */
public getLatestPrices(): ApiPrice {
return this.latestGoodPrices;
}
+ /** Whether this deployment prices its coin at all. */
+ public pricingApplies(): boolean {
+ return config.FIAT_PRICE.ENABLED === true && !UNPRICED_NETWORKS.includes(config.MEMPOOL.NETWORK);
+ }
+
+ private ageSeconds(observation: ApiPrice | null, nowMs: number): number | null {
+ if (!observation || typeof observation.time !== 'number' || observation.time <= 0) {
+ return null;
+ }
+ return Math.max(0, Math.round(nowMs / 1000 - observation.time));
+ }
+
+ /** The state of the served price, computed from what is actually held. */
+ public getPriceObservation(nowMs: number = Date.now()): PriceObservation {
+ const disabled = config.FIAT_PRICE.ENABLED !== true;
+ const networkInapplicable = UNPRICED_NETWORKS.includes(config.MEMPOOL.NETWORK);
+ const live = this.latestGoodPrices.USD > 0;
+ const liveAge = this.ageSeconds(this.latestGoodPrices, nowMs);
+ const stored = this.storedPrices && this.storedPrices.USD > 0 ? this.storedPrices : null;
+ if (disabled || networkInapplicable) {
+ return {
+ state: networkInapplicable ? 'network-inapplicable' : 'disabled',
+ source: stored ? 'database' : null,
+ observedAt: stored?.time ?? null,
+ ageSeconds: this.ageSeconds(stored, nowMs),
+ stale: stored !== null,
+ disabled: true,
+ storedObservation: stored,
+ };
+ }
+ if (live) {
+ const stale = liveAge !== null && liveAge > PRICE_MAX_AGE_SECONDS;
+ return {
+ state: stale ? 'stale' : 'live',
+ source: this.liveSource,
+ observedAt: this.latestGoodPrices.time ?? null,
+ ageSeconds: liveAge,
+ stale,
+ disabled: false,
+ storedObservation: null,
+ };
+ }
+ return {
+ state: stored ? 'stale' : 'unavailable',
+ source: stored ? 'database' : null,
+ observedAt: stored?.time ?? null,
+ ageSeconds: this.ageSeconds(stored, nowMs),
+ stale: stored !== null,
+ disabled: false,
+ storedObservation: stored,
+ };
+ }
+
+ /** What the prices route and the websocket advertise: the served price plus its provenance. */
+ public getAdvertisedPrices(nowMs: number = Date.now()): ApiPrice & PriceObservation {
+ return { ...this.getLatestPrices(), ...this.getPriceObservation(nowMs) };
+ }
+
public getEmptyPricesObj(): ApiPrice {
return {
time: 0,
@@ -130,9 +221,25 @@ class PriceUpdater {
*
* @asyncUnsafe
*/
- public async $initializeLatestPriceWithDb(): Promise {
- this.latestPrices = await PricesRepository.$getLatestConversionRates();
- this.latestGoodPrices = JSON.parse(JSON.stringify(this.latestPrices));
+ public async $initializeLatestPriceWithDb(nowMs: number = Date.now()): Promise {
+ const stored = await PricesRepository.$getLatestConversionRates();
+ this.storedPrices = stored.USD > 0 ? stored : null;
+ if (!this.pricingApplies()) {
+ if (this.storedPrices) {
+ logger.info(`Fiat prices are not served on this deployment; the stored observation from ${new Date((this.storedPrices.time ?? 0) * 1000).toISOString()} is retained but not advertised as current.`);
+ }
+ return;
+ }
+ const age = this.ageSeconds(this.storedPrices, nowMs);
+ if (!this.storedPrices || age === null || age > PRICE_MAX_AGE_SECONDS) {
+ if (this.storedPrices) {
+ logger.warn(`The stored fiat price is ${Math.round((age ?? 0) / 3600)} hours old and is not served as current until the feed answers.`);
+ }
+ return;
+ }
+ this.latestPrices = JSON.parse(JSON.stringify(stored));
+ this.latestGoodPrices = JSON.parse(JSON.stringify(stored));
+ this.liveSource = 'database';
}
/** @asyncSafe */
@@ -190,6 +297,7 @@ class PriceUpdater {
if (price > 0) {
this.latestGoodPrices[currency] = price;
this.latestGoodPrices.time = Math.round(new Date().getTime() / 1000);
+ this.liveSource = 'feed';
}
}
From bc2a692a1b304d41eddd4e8620aed4cb3ae9191b Mon Sep 17 00:00:00 2001
From: Bitcoin Universe
Date: Thu, 17 Sep 2026 05:33:23 +0000
Subject: [PATCH 06/19] fix(backend): Network Observatory reads the owned relay
and template telemetry (BE-DEF-014)
Nodes, propagation and templates come from the relay collector's mempool
poll lifecycles, the template collector's candidate templates and the
local mempool. One observer is labelled as one observer; regions, clock
offsets and inter-node latency are unknown; unobserved transactions and
empty collectors answer with explicit states.
Co-Authored-By: Claude Opus 5
---
.../network-observatory.service.test.ts | 150 +++++++++---
.../network-observatory.service.ts | 226 ++++++++++++++++--
.../network-observatory.types.ts | 93 +++++--
3 files changed, 403 insertions(+), 66 deletions(-)
diff --git a/backend/src/api/network-observatory/network-observatory.service.test.ts b/backend/src/api/network-observatory/network-observatory.service.test.ts
index 8ed22e55fe7..7c198afcba5 100644
--- a/backend/src/api/network-observatory/network-observatory.service.test.ts
+++ b/backend/src/api/network-observatory/network-observatory.service.test.ts
@@ -1,39 +1,117 @@
+jest.mock('../bitcoin/bitcoin-client', () => ({ __esModule: true, default: {} }));
+jest.mock('../mempool-blocks', () => ({ __esModule: true, default: { getMempoolBlocksWithTransactions: () => [] } }));
+
import { Application, Request, Response } from 'express';
+import config from '../../config';
+import { RelayCollectorService } from '../intelligence/relay/relay-collector.service';
+import { templateCollectorService } from '../intelligence/templates/template-collector.service';
import networkObservatoryRoutes from './network-observatory.routes';
-import { NetworkObservatoryEvidenceError, networkObservatoryService } from './network-observatory.service';
+import { NetworkObservatoryEvidenceError, NetworkObservatoryService, ObservatoryReaders } from './network-observatory.service';
+
+const TX = 'ab'.repeat(32);
+const OTHER = 'cd'.repeat(32);
/**
- * These assertions replace a suite that asserted the constants the service
- * used to return: at least four nodes online, a propagation timeline whose
- * median latency was positive because the deltas were literals, and a
- * template comparison at a height above 800000. Passing those proved the
- * constants were present, not that any node had reported.
+ * The collectors are the real ones with their sources stubbed: a relay
+ * collector fed poll deltas, and the template collector fed getblocktemplate
+ * answers. Nothing here invents a second observer.
*/
+function setup(local: string[] | null = [TX]) {
+ let now = 100_000;
+ const snapshot = jest.fn(async () => ({
+ network: config.MEMPOOL.NETWORK, observed_at_utc: new Date(now).toISOString(), age_ms: 0, freshness_limit_ms: 30000,
+ peers: [{ addr: '192.0.2.1:8333', id: 4, transport_protocol_type: 'v2' }, { addr: '192.0.2.2:8333', id: 5, transport_protocol_type: 'v1' }],
+ info: { subversion: '/Satoshi:30.0.0/', protocolversion: 70016, relayfee: 0.00001, networks: [] },
+ } as any));
+ const relay = new RelayCollectorService({ now: () => now, snapshotReader: snapshot, policyReader: async () => ({ fullrbf: true }) });
+ templateCollectorService.resetForTests();
+ templateCollectorService.readProjection = () => null;
+ const readers: ObservatoryReaders = { relay, templates: templateCollectorService, localMempoolTxids: () => (local ? new Set(local) : null) };
+ const service = new NetworkObservatoryService(readers);
+ return { service, relay, snapshot, setNow: (value: number) => { now = value; }, now: () => now };
+}
+
describe('NetworkObservatoryService', () => {
- const unavailable = (code: string) => expect.objectContaining({ code, status: 503 });
+ it('reports the single owned observer as one node with unmeasured dimensions unknown', async () => {
+ const { service, relay } = setup();
+ relay.observeMempoolPoll([{ txid: TX }], [], true, 100_000);
+ const nodes = await service.$getNodes();
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0]).toMatchObject({
+ id: expect.stringContaining('owned-mempool-poller-'), region: 'unknown', clientVersion: '/Satoshi:30.0.0/', protocolVersion: 70016, fullRbf: true,
+ minRelayFeeRate: 1, clockOffsetMs: null, connectedPeers: 2, mempoolTxCount: 1, status: 'online', network: config.MEMPOOL.NETWORK, policySourceAvailable: true,
+ });
+ expect(nodes[0].scope).toMatch(/One observer/);
+ expect(JSON.stringify(nodes)).not.toMatch(/192\.0\.2/);
+ });
- it('reports the missing observer fleet rather than invented nodes, timelines and templates', async () => {
- await expect(networkObservatoryService.$getNodes()).rejects.toThrow(unavailable('unavailable-observer-fleet'));
- await expect(networkObservatoryService.$getPropagation()).rejects.toThrow(unavailable('unavailable-observer-fleet'));
- await expect(networkObservatoryService.$getPropagation('ab'.repeat(32))).rejects.toThrow(unavailable('unavailable-observer-fleet'));
- await expect(networkObservatoryService.$getTemplates()).rejects.toThrow(unavailable('unavailable-observer-fleet'));
+ it('marks the observer degraded when its polls are stale and reports the owned node outage as unavailable', async () => {
+ const { service, relay, snapshot, setNow } = setup();
+ relay.observeMempoolPoll([], [], true, 100_000);
+ setNow(140_000);
+ expect((await service.$getNodes())[0].status).toBe('degraded');
+ snapshot.mockRejectedValueOnce(new Error('node unavailable'));
+ await expect(service.$getNodes()).rejects.toMatchObject({ code: 'owned-node-unavailable', status: 503 });
+ snapshot.mockRejectedValueOnce(new Error('node unavailable'));
+ await expect(service.$getPropagation(TX)).rejects.toBeInstanceOf(NetworkObservatoryEvidenceError);
});
- it('never resolves an absent source as an empty directory', async () => {
- for (const read of [
- () => networkObservatoryService.$getNodes(),
- () => networkObservatoryService.$getPropagation(),
- () => networkObservatoryService.$getTemplates(),
- ]) {
- let resolved: unknown = 'unresolved';
- try {
- resolved = await read();
- } catch (e) {
- expect(e).toBeInstanceOf(NetworkObservatoryEvidenceError);
- continue;
- }
- throw new Error(`resolved with ${JSON.stringify(resolved)}`);
- }
+ it('returns a transaction\'s recorded lifecycle from the one observer, with observer identity, clock uncertainty and retention', async () => {
+ const { service, relay, setNow } = setup();
+ relay.observeMempoolPoll([], [], true, 100_000);
+ relay.observeMempoolPoll([{ txid: TX }], [], true, 101_000);
+ relay.observeMempoolPoll([], [{ txid: TX }], true, 103_000);
+ setNow(103_000);
+ const propagation = await service.$getPropagation(TX);
+ expect(propagation).toMatchObject({
+ txid: TX, state: 'observed', network: config.MEMPOOL.NETWORK, firstSeenTimestamp: 101_000, lastSeenTimestamp: 103_000,
+ medianLatencyMs: null, p95LatencyMs: null, spreadDeltaMs: null,
+ observer: { observers: 1, clockOffsetMs: null, clockUncertaintyMs: null },
+ window: { collection: 'observing', retainedTransactions: 1, lastCompletePollUtc: new Date(103_000).toISOString() },
+ });
+ expect(propagation.nodeObservations).toEqual([
+ expect.objectContaining({ arrivedAt: 101_000, deltaFromFirstMs: 0, accepted: true, presence: 'present', previousCompletePollUtc: new Date(100_000).toISOString() }),
+ expect.objectContaining({ arrivedAt: 103_000, deltaFromFirstMs: 2_000, accepted: false, presence: 'left_mempool' }),
+ ]);
+ expect(new Set(propagation.nodeObservations.map(o => o.nodeId)).size).toBe(1);
+ expect(propagation.expiresAtUtc).toBe(new Date(103_000 + 24 * 60 * 60 * 1000).toISOString());
+ });
+
+ it('answers an unobserved transaction and an empty observer with explicit states rather than a timeline', async () => {
+ const { service, relay } = setup();
+ const empty = await service.$getPropagation();
+ expect(empty).toMatchObject({ txid: null, state: 'no-observations', nodeObservations: [], window: { collection: 'not_started' } });
+ relay.observeMempoolPoll([{ txid: OTHER }], [], true, 100_000);
+ const unknown = await service.$getPropagation(TX);
+ expect(unknown).toMatchObject({ txid: TX, state: 'not-observed', nodeObservations: [], firstSeenTimestamp: null });
+ const latest = await service.$getPropagation();
+ expect(latest).toMatchObject({ txid: OTHER, state: 'observed' });
+ await expect(service.$getPropagation('not-a-txid')).rejects.toMatchObject({ code: 'invalid-txid', status: 400 });
+ });
+
+ it('compares the recorded candidate templates for the latest height with each other and the local mempool', async () => {
+ const { service } = setup([TX]);
+ templateCollectorService.fetchCoreTemplate = async () => ({ height: 100, previousblockhash: 'p'.repeat(64), transactions: [{ txid: TX, hash: TX, fee: 100, weight: 400 }, { txid: OTHER, hash: OTHER, fee: 50, weight: 400 }], coinbasevalue: 5000 });
+ await templateCollectorService.collectCoreTemplate(1_000);
+ templateCollectorService.readProjection = () => ({ transactionIds: [TX], totalFees: 100, blockVSize: 100, nTx: 1 });
+ templateCollectorService.collectProjection(2_000);
+ const comparison = await service.$getTemplates();
+ expect(comparison).toMatchObject({ state: 'observed', blockHeight: 100, generatedAt: 2_000, consensusMempoolTxCount: 1, missingFromLocalCount: 1, feeRateSpreadSatVb: null, observer: { observers: 1 } });
+ expect(comparison.candidateTemplates).toHaveLength(2);
+ const core = comparison.candidateTemplates.find(t => t.sourceType === 'core_gbt')!;
+ expect(core).toMatchObject({ poolName: 'Bitcoin Core getblocktemplate', txCount: 2, totalFeesSats: '150', uniqueTxids: [OTHER], missingFromLocalMempool: 1, expectedMedianFeeRate: null });
+ const projection = comparison.candidateTemplates.find(t => t.sourceType === 'mempool_projection')!;
+ expect(projection).toMatchObject({ txCount: 1, uniqueTxids: [], missingFromLocalMempool: 0 });
+ expect(comparison.sources.map(s => s.status)).toEqual(['active', 'active']);
+ });
+
+ it('reports no templates observed with the sources\' own status instead of an empty list of pools', async () => {
+ const { service } = setup(null);
+ templateCollectorService.fetchCoreTemplate = async () => { throw new Error('rpc offline'); };
+ await templateCollectorService.collectCoreTemplate(1_000);
+ const comparison = await service.$getTemplates();
+ expect(comparison).toMatchObject({ state: 'no-templates-observed', blockHeight: null, candidateTemplates: [], consensusMempoolTxCount: null, missingFromLocalCount: null });
+ expect(comparison.sources.find(s => s.sourceId === 'src-core-gbt')).toMatchObject({ status: 'offline', lastError: 'rpc offline' });
});
});
@@ -49,19 +127,23 @@ describe('Network observatory HTTP responses', () => {
return gets;
}
- it('answers every read with a 503 that names the missing source', async () => {
+ it('mounts the four routes and maps a source outage to a 503 that names it', async () => {
const gets = mount();
- expect(gets.size).toBe(4);
- for (const handler of gets.values()) {
+ expect([...gets.keys()]).toEqual([
+ config.MEMPOOL.API_URL_PREFIX + 'network/nodes',
+ config.MEMPOOL.API_URL_PREFIX + 'network/propagation',
+ config.MEMPOOL.API_URL_PREFIX + 'network/propagation/:txid',
+ config.MEMPOOL.API_URL_PREFIX + 'network/templates',
+ ]);
+ // The default service reads the process-wide relay collector, whose owned node is not reachable in a unit test.
+ for (const path of [config.MEMPOOL.API_URL_PREFIX + 'network/nodes', config.MEMPOOL.API_URL_PREFIX + 'network/propagation/:txid']) {
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
- await handler({ params: { txid: 'ab'.repeat(32) } } as unknown as Request, res as unknown as Response);
+ await gets.get(path)!({ params: { txid: TX } } as unknown as Request, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(503);
const body = res.json.mock.calls[0][0];
- expect(body.stage).toMatch(/^unavailable-/);
- expect(typeof body.error).toBe('string');
+ expect(body.stage).toMatch(/owned-node-unavailable|unavailable/);
expect(body).not.toHaveProperty('nodes');
expect(body).not.toHaveProperty('nodeObservations');
- expect(body).not.toHaveProperty('candidateTemplates');
}
});
});
diff --git a/backend/src/api/network-observatory/network-observatory.service.ts b/backend/src/api/network-observatory/network-observatory.service.ts
index 5d40b12f5cd..5eadb5da379 100644
--- a/backend/src/api/network-observatory/network-observatory.service.ts
+++ b/backend/src/api/network-observatory/network-observatory.service.ts
@@ -1,5 +1,13 @@
+import config from '../../config';
+import memPool from '../mempool';
+import { RELAY_LIMITS, RelayEvidenceError, relayCollectorService, RelayCollectorService, TransactionRelayLifecycle } from '../intelligence/relay/relay-collector.service';
+import { TEMPLATE_LIMITS, templateCollectorService, TemplateCollectorService } from '../intelligence/templates/template-collector.service';
import {
BlockTemplateComparison,
+ CandidateTemplate,
+ NodeArrival,
+ ObservationWindow,
+ ObserverIdentity,
ObserverNode,
PropagationObservation,
} from './network-observatory.types';
@@ -15,33 +23,219 @@ export class NetworkObservatoryEvidenceError extends Error {
}
}
-const fleetUnavailable =
- 'Cross-node observations are unavailable. Node, propagation and template reads require the owned observer fleet (the Universe Bitcoin nodes with their first-seen and getblocktemplate telemetry export), which is not connected on this deployment.';
+/** The owned telemetry this deployment has; injectable for tests. */
+export interface ObservatoryReaders {
+ relay: Pick;
+ templates: Pick;
+ /** The local mempool's txids, or null when the mempool is not enabled here. */
+ localMempoolTxids: () => Set | null;
+}
+
+type RelayOverview = Awaited>;
+
+const SCOPE =
+ 'One observer: this backend\'s mempool poll of its owned Bitcoin Core node. Detection time is local poll time, not peer arrival time. Regions, clock offsets, inter-node latency and multi-observer propagation are not measured and are reported as null or unknown.';
/**
- * Observer fleet, propagation and block-template evidence.
+ * Observer, propagation and block-template evidence, read from the owned
+ * telemetry this backend already collects: the relay collector's first-seen
+ * mempool poll lifecycles and the template collector's candidate templates.
*
- * Every read here needs the owned observer fleet reporting first-seen times
- * and candidate templates. None is connected, so each read reports the
- * absent source. The revision this replaces answered from constants: four
- * nodes marked online in four regions, a propagation timeline computed from
- * the request clock for any txid (and a fixed txid when none was given), and
- * three candidate templates with pool names nobody had polled.
+ * The revision this replaces threw for every read; the one before that
+ * answered from constants (four nodes in four regions, a timeline computed
+ * from the request clock, three pool templates nobody had polled). This one
+ * answers with what one local observer has actually recorded and labels it
+ * as exactly that.
*/
export class NetworkObservatoryService {
- /** @asyncSafe */
+ constructor(private readonly readers: ObservatoryReaders = {
+ relay: relayCollectorService,
+ templates: templateCollectorService,
+ localMempoolTxids: () => (config.MEMPOOL.ENABLED ? new Set(Object.keys(memPool.getMempool())) : null),
+
+ }) {}
+
+ /** @asyncSafe Every rejection is translated to the typed error. */
+ private async overview(): Promise {
+ try {
+ return await this.readers.relay.getOverview();
+ } catch (e) {
+ if (e instanceof RelayEvidenceError) {
+ throw new NetworkObservatoryEvidenceError(e.code, e.message, e.status);
+ }
+ throw new NetworkObservatoryEvidenceError('unavailable-owned-node', 'The owned node observation could not be read: ' + (e instanceof Error ? e.message : String(e)));
+ }
+ }
+
+ private identity(overview: RelayOverview): ObserverIdentity {
+ return {
+ observerId: overview.collection.observer_id,
+ observers: 1,
+ clockOffsetMs: null,
+ clockUncertaintyMs: null,
+ method: overview.source.method,
+ };
+ }
+
+ private window(overview: RelayOverview): ObservationWindow {
+ return {
+ observedAtUtc: overview.source.observed_at_utc,
+ ageMs: overview.source.age_ms,
+ freshnessLimitMs: overview.source.freshness_limit_ms,
+ retentionMs: RELAY_LIMITS.retentionMs,
+ retainedTransactions: overview.collection.retained_transactions,
+ lastPollUtc: overview.collection.last_poll_utc,
+ lastCompletePollUtc: overview.collection.last_complete_poll_utc,
+ collection: overview.collection.status as ObservationWindow['collection'],
+ };
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
public async $getNodes(): Promise {
- throw new NetworkObservatoryEvidenceError('unavailable-observer-fleet', fleetUnavailable);
+ const overview = await this.overview();
+ const local = this.readers.localMempoolTxids();
+ return overview.sensors.map(sensor => ({
+ id: sensor.id,
+ name: sensor.name,
+ region: 'unknown',
+ clientVersion: sensor.client_version,
+ protocolVersion: sensor.protocol_version,
+ fullRbf: sensor.full_rbf,
+ minRelayFeeRate: sensor.min_relay_feerate,
+ clockOffsetMs: null,
+ connectedPeers: sensor.connected_peers_count,
+ mempoolTxCount: local ? local.size : null,
+ status: overview.collection.status === 'observing' ? 'online' : 'degraded',
+ network: sensor.network,
+ observedAtUtc: sensor.last_heartbeat,
+ policySourceAvailable: sensor.policy_source_available,
+ scope: SCOPE,
+ }));
+ }
+
+ private arrivals(record: TransactionRelayLifecycle, observerName: string): NodeArrival[] {
+ const first = Date.parse(record.first_observed_utc);
+ return record.events.map(event => ({
+ nodeId: event.source_id,
+ nodeName: observerName,
+ arrivedAt: Date.parse(event.observed_at_utc),
+ deltaFromFirstMs: Math.max(0, Date.parse(event.observed_at_utc) - first),
+ accepted: event.payload.presence === 'present',
+ presence: event.payload.presence,
+ completePoll: event.payload.complete_poll,
+ previousCompletePollUtc: event.payload.previous_complete_poll_utc,
+ }));
}
- /** @asyncSafe */
- public async $getPropagation(_txid?: string): Promise {
- throw new NetworkObservatoryEvidenceError('unavailable-observer-fleet', fleetUnavailable);
+ /**
+ * The lifecycle of one transaction as this observer saw it, or the most
+ * recently observed one when no txid is given. A transaction this
+ * observer never retained is 'not-observed'; an observer that has not
+ * polled yet is 'no-observations'. Neither is invented.
+ * @asyncUnsafe Callers turn a rejection into an exact HTTP answer.
+ */
+ public async $getPropagation(txid?: string): Promise {
+ const overview = await this.overview();
+ const observer = this.identity(overview);
+ const window = this.window(overview);
+ const base = { network: config.MEMPOOL.NETWORK, observer, window, medianLatencyMs: null, p95LatencyMs: null, spreadDeltaMs: null, scope: SCOPE } as const;
+ let record: TransactionRelayLifecycle | null = null;
+ if (txid !== undefined) {
+ try {
+ record = this.readers.relay.getPropagationForTx(txid);
+ } catch (e) {
+ if (e instanceof RelayEvidenceError && e.code === 'transaction-not-observed') {
+ return { ...base, txid: txid.toLowerCase(), state: 'not-observed', firstSeenTimestamp: null, lastSeenTimestamp: null, nodeObservations: [], expiresAtUtc: null };
+ }
+ if (e instanceof RelayEvidenceError) { throw new NetworkObservatoryEvidenceError(e.code, e.message, e.status); }
+ throw e;
+ }
+ } else {
+ record = overview.recent_propagation_sample[0] ?? null;
+ if (!record) {
+ return { ...base, txid: null, state: 'no-observations', firstSeenTimestamp: null, lastSeenTimestamp: null, nodeObservations: [], expiresAtUtc: null };
+ }
+ }
+ const observerName = overview.sensors[0]?.name ?? 'Owned Bitcoin Core node';
+ return {
+ ...base,
+ txid: record.txid,
+ state: 'observed',
+ firstSeenTimestamp: Date.parse(record.first_observed_utc),
+ lastSeenTimestamp: Date.parse(record.last_observed_utc),
+ nodeObservations: this.arrivals(record, observerName),
+ expiresAtUtc: record.retention.expires_at_utc,
+ };
}
- /** @asyncSafe */
+ /**
+ * The candidate templates recorded for the latest height, compared with
+ * each other and with the local mempool. A deployment whose collector has
+ * not recorded a template yet says so rather than listing none.
+ * @asyncUnsafe Callers turn a rejection into an exact HTTP answer.
+ */
public async $getTemplates(): Promise {
- throw new NetworkObservatoryEvidenceError('unavailable-observer-fleet', fleetUnavailable);
+ const sources = this.readers.templates.getSources().map(source => ({
+ sourceId: source.source_id, name: source.name, status: source.status, lastTemplateAt: source.last_template_at, lastError: source.last_error,
+ }));
+ const observer: ObserverIdentity = {
+ observerId: 'owned-template-collector-' + config.MEMPOOL.NETWORK,
+ observers: 1,
+ clockOffsetMs: null,
+ clockUncertaintyMs: null,
+ method: 'owned Core getblocktemplate on a cold schedule and this backend\'s next-block projection',
+ };
+ const retention = { templates: TEMPLATE_LIMITS.templates, txidsPerTemplate: TEMPLATE_LIMITS.txidsInResponse };
+ const scope = 'Templates from the owned Core node and this backend\'s projection only; no pool template is observed. Fee-rate spread across pools is not measured.';
+ const all = this.readers.templates.getTemplatesForHeight();
+ if (!all.length) {
+ return { network: config.MEMPOOL.NETWORK, state: 'no-templates-observed', blockHeight: null, generatedAt: null, candidateTemplates: [], consensusMempoolTxCount: null, missingFromLocalCount: null, feeRateSpreadSatVb: null, sources, observer, retention, scope };
+ }
+ const height = Math.max(...all.map(template => template.height));
+ // The latest template per source at the latest height; older heights are history, not candidates.
+ const latestBySource = new Map();
+ for (const template of all) {
+ if (template.height !== height) { continue; }
+ const previous = latestBySource.get(template.source_id);
+ if (!previous || Date.parse(template.observed_at_utc) >= Date.parse(previous.observed_at_utc)) { latestBySource.set(template.source_id, template); }
+ }
+ const candidates = [...latestBySource.values()];
+ const local = this.readers.localMempoolTxids();
+ const counts = new Map();
+ for (const template of candidates) { for (const id of template.txids) { counts.set(id, (counts.get(id) ?? 0) + 1); } }
+ const missing = new Set();
+ const candidateTemplates: CandidateTemplate[] = candidates.map(template => {
+ const notLocal = local ? template.txids.filter(id => !local.has(id)) : null;
+ for (const id of notLocal ?? []) { missing.add(id); }
+ return {
+ poolName: template.source_name,
+ templateId: template.template_id,
+ sourceId: template.source_id,
+ sourceType: template.source_type,
+ observedAtUtc: template.observed_at_utc,
+ prevBlockHash: template.prev_block_hash,
+ txCount: template.tx_count,
+ totalWeight: template.total_weight,
+ totalFeesSats: String(template.total_fees_sats),
+ expectedMedianFeeRate: null,
+ uniqueTxids: template.txids.filter(id => counts.get(id) === 1).slice(0, 200),
+ missingFromLocalMempool: notLocal ? notLocal.length : null,
+ };
+ });
+ return {
+ network: config.MEMPOOL.NETWORK,
+ state: 'observed',
+ blockHeight: height,
+ generatedAt: Math.max(...candidates.map(template => Date.parse(template.observed_at_utc))),
+ candidateTemplates,
+ consensusMempoolTxCount: local ? local.size : null,
+ missingFromLocalCount: local ? missing.size : null,
+ feeRateSpreadSatVb: null,
+ sources,
+ observer,
+ retention,
+ scope,
+ };
}
}
diff --git a/backend/src/api/network-observatory/network-observatory.types.ts b/backend/src/api/network-observatory/network-observatory.types.ts
index f2c1266bd2c..3d4f00029cc 100644
--- a/backend/src/api/network-observatory/network-observatory.types.ts
+++ b/backend/src/api/network-observatory/network-observatory.types.ts
@@ -1,28 +1,66 @@
/**
* Types for the Cross-Node Mempool, Relay, Policy, and Block-Template Observatory.
+ *
+ * Every read carries the identity of the observer that produced it and the
+ * time it was produced. This deployment has one observer: this backend's
+ * own mempool poll and its owned Core node. Dimensions a single local
+ * observer cannot measure (regions, clock offsets, inter-node latency,
+ * per-pool templates) are `null` or 'unknown', never estimated.
*/
+/** Who observed, and how far its clock can be trusted. */
+export interface ObserverIdentity {
+ readonly observerId: string;
+ readonly observers: 1;
+ readonly clockOffsetMs: number | null;
+ readonly clockUncertaintyMs: number | null;
+ readonly method: string;
+}
+
+/** Freshness and bound of what is retained. */
+export interface ObservationWindow {
+ readonly observedAtUtc: string;
+ readonly ageMs: number;
+ readonly freshnessLimitMs: number;
+ readonly retentionMs: number;
+ readonly retainedTransactions: number;
+ readonly lastPollUtc: string | null;
+ readonly lastCompletePollUtc: string | null;
+ readonly collection: 'not_started' | 'observing' | 'interrupted' | 'stale';
+}
+
export interface ObserverNode {
readonly id: string;
readonly name: string;
readonly region: string;
readonly clientVersion: string;
- readonly protocolVersion: number;
- readonly fullRbf: boolean;
- readonly minRelayFeeRate: number;
- readonly clockOffsetMs: number;
+ readonly protocolVersion: number | null;
+ readonly fullRbf: boolean | null;
+ readonly minRelayFeeRate: number | null;
+ readonly clockOffsetMs: number | null;
readonly connectedPeers: number;
- readonly mempoolTxCount: number;
+ readonly mempoolTxCount: number | null;
readonly status: 'online' | 'syncing' | 'degraded';
+ readonly network: string;
+ readonly observedAtUtc: string;
+ readonly policySourceAvailable: boolean;
+ readonly scope: string;
}
export interface PropagationObservation {
- readonly txid: string;
- readonly firstSeenTimestamp: number;
+ readonly txid: string | null;
+ readonly network: string;
+ readonly state: 'observed' | 'not-observed' | 'no-observations';
+ readonly firstSeenTimestamp: number | null;
+ readonly lastSeenTimestamp: number | null;
readonly nodeObservations: readonly NodeArrival[];
- readonly medianLatencyMs: number;
- readonly p95LatencyMs: number;
- readonly spreadDeltaMs: number;
+ readonly medianLatencyMs: number | null;
+ readonly p95LatencyMs: number | null;
+ readonly spreadDeltaMs: number | null;
+ readonly observer: ObserverIdentity;
+ readonly window: ObservationWindow;
+ readonly expiresAtUtc: string | null;
+ readonly scope: string;
}
export interface NodeArrival {
@@ -32,22 +70,45 @@ export interface NodeArrival {
readonly deltaFromFirstMs: number;
readonly accepted: boolean;
readonly rejectionReason?: string;
+ readonly presence: 'present' | 'left_mempool';
+ readonly completePoll: boolean;
+ readonly previousCompletePollUtc: string | null;
}
export interface BlockTemplateComparison {
- readonly blockHeight: number;
- readonly generatedAt: number;
+ readonly network: string;
+ readonly state: 'observed' | 'no-templates-observed';
+ readonly blockHeight: number | null;
+ readonly generatedAt: number | null;
readonly candidateTemplates: readonly CandidateTemplate[];
- readonly consensusMempoolTxCount: number;
- readonly missingFromLocalCount: number;
- readonly feeRateSpreadSatVb: number;
+ readonly consensusMempoolTxCount: number | null;
+ readonly missingFromLocalCount: number | null;
+ readonly feeRateSpreadSatVb: number | null;
+ readonly sources: readonly TemplateSourceStatus[];
+ readonly observer: ObserverIdentity;
+ readonly retention: { readonly templates: number; readonly txidsPerTemplate: number };
+ readonly scope: string;
+}
+
+export interface TemplateSourceStatus {
+ readonly sourceId: string;
+ readonly name: string;
+ readonly status: 'active' | 'degraded' | 'offline' | 'not_collected';
+ readonly lastTemplateAt: string | null;
+ readonly lastError: string | null;
}
export interface CandidateTemplate {
readonly poolName: string;
+ readonly templateId: string;
+ readonly sourceId: string;
+ readonly sourceType: 'core_gbt' | 'mempool_projection';
+ readonly observedAtUtc: string;
+ readonly prevBlockHash: string;
readonly txCount: number;
readonly totalWeight: number;
readonly totalFeesSats: string;
- readonly expectedMedianFeeRate: number;
+ readonly expectedMedianFeeRate: number | null;
readonly uniqueTxids: readonly string[];
+ readonly missingFromLocalMempool: number | null;
}
From 84ff13dc779c94e9843d5a39041234efe7490fd1 Mon Sep 17 00:00:00 2001
From: Bitcoin Universe
Date: Thu, 17 Sep 2026 05:39:02 +0000
Subject: [PATCH 07/19] fix(ci): enforce the owned-origin boundary, clear
external runtime defaults, align container pins
RC-ORIGIN-001: scripts/universe/check-origins.mjs now parses hostnames with
the WHATWG URL class, normalises case and trailing dots, rejects forbidden
providers by host equality and dot-suffix, flags infix lookalikes, and applies
an explicit runtime-origin policy: call sites and runtime configuration may
only name relative paths, loopback, container names, bitcoinuniverse.io hosts,
or the documented Liquid asset catalogue metadata exception. Build inputs are
allowed in build context only, citations in documentation and comments, and
credentials in a URL are reported without ever being printed. Source and
bundle scans are separate; the blanket production/, docker/, rust/ and
workflow exemptions are gone. scripts/universe/check-origins.test.mjs pins the
six audit fixtures, lookalikes, credentials, ports, redirects and the
metadata and citation contexts.
RC-ORIGIN-002: docker/backend/start.sh, docker/frontend/entrypoint.sh and the
production templates no longer default any endpoint to mempool.space or its
onion; the start scripts refuse a disallowed, malformed or credential-bearing
endpoint with exit 78 and name only the field and host. Fiat prices and the
mining pool refresh are off unless configured. The frontend's onion switch is
now the explicit ONION_SERVICES_API field instead of a hostname-based provider
change. scripts/universe/docker-runtime-defaults.test.mjs renders both start
scripts and proves the minimal, owned and rejected cases. Docs list the owned
endpoint prerequisites and the exact scope of the metadata exception.
RC-PACKAGE-001: the container images pin Node 24.19.0 / npm 11.17.0 and Rust
1.89.0 (verified at build time), install from the lock file, and use the
fork's production frontend build. .github/workflows/docker.yml is a
build-only gate for develop/main pull requests touching the templates, with
publication only from an explicit dispatch; workflow-safety.test.mjs pins
that shape.
Co-Authored-By: Claude Opus 5
---
.github/workflows/docker.yml | 118 +++--
.github/workflows/e2e_parameterized.yml | 2 +-
backend/mempool-config.sample.json | 6 +-
backend/mempool-config.test.json | 2 +-
docker/README.md | 38 +-
docker/backend/Dockerfile | 17 +-
docker/backend/start.sh | 80 ++-
docker/frontend/Dockerfile | 12 +-
docker/frontend/entrypoint.sh | 88 ++-
docs/operations/CONFIGURATION.md | 32 +-
docs/operations/INSTALL.md | 2 +-
frontend/mempool-frontend-config.sample.json | 3 +-
frontend/src/app/services/state.service.ts | 10 +-
production/mempool-config.liquid.json | 45 +-
production/mempool-config.liquidtestnet.json | 45 +-
.../mempool-config.mainnet-lightning.json | 45 +-
production/mempool-config.mainnet.json | 106 +---
.../mempool-config.signet-lightning.json | 45 +-
.../mempool-config.testnet-lightning.json | 45 +-
production/mempool-config.testnet.json | 45 +-
production/mempool-config.testnet4.json | 45 +-
production/mempool-frontend-config.bitb.json | 2 +-
.../mempool-frontend-config.liquid.json | 2 +-
.../mempool-frontend-config.mainnet.json | 4 +-
production/mempool-frontend-config.meta.json | 2 +-
production/mempool-frontend-config.onbtc.json | 2 +-
production/mempool-frontend-config.river.json | 2 +-
.../mempool-frontend-config.strategy.json | 2 +-
production/mempool-frontend-config.xxi.json | 2 +-
rust/gbt/rust-toolchain | 2 +-
scripts/universe/check-branding.mjs | 2 +
scripts/universe/check-origins.mjs | 499 +++++++++++++++---
scripts/universe/check-origins.test.mjs | 208 ++++++++
.../universe/docker-runtime-defaults.test.mjs | 217 ++++++++
scripts/universe/workflow-safety.test.mjs | 32 ++
35 files changed, 1232 insertions(+), 577 deletions(-)
create mode 100644 scripts/universe/check-origins.test.mjs
create mode 100644 scripts/universe/docker-runtime-defaults.test.mjs
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index e8bc3253c05..438a31c30b2 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -1,18 +1,50 @@
-name: Docker build on tag
+name: Container images
env:
DOCKER_CLI_EXPERIMENTAL: enabled
- TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
DOCKER_BUILDKIT: 1 # Enable BuildKit for better performance
COMPOSE_DOCKER_CLI_BUILD: 0
+# Two routes, never mixed:
+# - A pull request to develop or main that touches the container templates or
+# the runtime configuration they render gets a build-only gate: the images
+# are built and started locally on the runner and nothing is pushed.
+# - Publication to the registry happens only from an explicit dispatch that
+# says so. There is no tag trigger and no label shortcut: nobody publishes
+# an image by pushing a ref or adding a label to a pull request.
on:
- push:
- tags:
- - v[0-9]+.[0-9]+.[0-9]+
- - v[0-9]+.[0-9]+.[0-9]+-*
pull_request:
- branches: [master]
- types: [opened, synchronize, reopened, labeled]
+ branches: [develop, main]
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'docker/**'
+ - 'production/mempool-config.*.json'
+ - 'production/mempool-frontend-config.*.json'
+ - 'backend/mempool-config.sample.json'
+ - 'frontend/mempool-frontend-config.sample.json'
+ - 'nginx.conf'
+ - 'nginx-mempool.conf'
+ - 'rust/gbt/rust-toolchain'
+ - 'backend/package.json'
+ - 'backend/package-lock.json'
+ - 'frontend/package.json'
+ - 'frontend/package-lock.json'
+ - '.github/workflows/docker.yml'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Image tag to publish (for example universe-2026.09.17)'
+ required: true
+ type: string
+ publish:
+ description: 'Push the built images to the registry'
+ required: true
+ type: boolean
+ default: false
+ latest:
+ description: 'Also move the latest tag to this build (publish must be true)'
+ required: false
+ type: boolean
+ default: false
permissions:
contents: read
@@ -21,7 +53,7 @@ jobs:
test-images:
env:
COMPOSE_PROJECT_NAME: mempool-images-${{ github.run_id }}-${{ github.run_attempt }}
- # Always run on tag pushes and all pull requests
+ # Build-only: runs for every pull request in scope and every dispatch.
runs-on: [self-hosted, linux, x64, universe-super]
timeout-minutes: 30
name: Test built Docker images
@@ -38,10 +70,15 @@ jobs:
fi
echo "SHORT_SHA=${SHA:0:8}" >> $GITHUB_ENV
- - name: Set TAG from pushed tag or package.json
+ - name: Set TAG from the dispatch input or package.json
+ env:
+ DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: |
- if [ "${{ github.event_name }}" = "push" ]; then
- TAG="${GITHUB_REF/refs\/tags\//}"
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ case "$DISPATCH_TAG" in
+ ''|*[!A-Za-z0-9._-]*) echo "Error: tag must be a plain image tag"; exit 1 ;;
+ esac
+ TAG="$DISPATCH_TAG"
else
FRONTEND_VERSION=$(jq -r '.version' frontend/package.json)
BACKEND_VERSION=$(jq -r '.version' backend/package.json)
@@ -226,11 +263,12 @@ jobs:
build:
needs: [test-images]
- # Run on tag pushes OR on PRs with "docker-push" label (after test-images passes)
+ # Publication: only an explicit dispatch that asked for it, and only after
+ # the build-only gate passed on the same commit.
if: |
needs.test-images.result == 'success' &&
- (github.event_name == 'push' ||
- (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'docker-push')))
+ github.event_name == 'workflow_dispatch' &&
+ github.event.inputs.publish == 'true'
strategy:
matrix:
service:
@@ -242,26 +280,24 @@ jobs:
outputs:
image-digest-frontend: ${{ matrix.service == 'frontend' && steps.docker-build.outputs.digest || '' }}
image-digest-backend: ${{ matrix.service == 'backend' && steps.docker-build.outputs.digest || '' }}
- tag: ${{ matrix.service == 'frontend' && (steps.set-tag-push.outputs.tag || steps.set-tag-pr.outputs.tag) || '' }}
+ tag: ${{ matrix.service == 'frontend' && steps.set-tag.outputs.tag || '' }}
steps:
# Shared runner capacity is provisioned outside jobs. Never replace host
# swap, hide Docker data, or restart the shared daemon during a build.
- # Only for tag pushes: use the Git tag as TAG
- - name: Set TAG from pushed tag
- if: github.event_name == 'push'
- id: set-tag-push
+ - name: Set TAG from the dispatch input
+ id: set-tag
+ env:
+ DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: |
- TAG="${GITHUB_REF/refs\/tags\//}"
- echo "TAG=${TAG}" >> $GITHUB_ENV
- echo "tag=${TAG}" >> $GITHUB_OUTPUT
+ case "$DISPATCH_TAG" in
+ ''|*[!A-Za-z0-9._-]*) echo "Error: tag must be a plain image tag"; exit 1 ;;
+ esac
+ echo "TAG=${DISPATCH_TAG}" >> $GITHUB_ENV
+ echo "tag=${DISPATCH_TAG}" >> $GITHUB_OUTPUT
- name: Add SHORT_SHA env property with commit short sha
run: |
- if [ "${{ github.event_name }}" = "pull_request" ]; then
- SHA="${{ github.event.pull_request.head.sha }}"
- else
- SHA="${GITHUB_SHA}"
- fi
+ SHA="${GITHUB_SHA}"
echo "SHORT_SHA=${SHA:0:8}" >> $GITHUB_ENV
@@ -274,20 +310,6 @@ jobs:
- name: Checkout project
uses: actions/checkout@v4
- # For PRs: use package.json version + short sha as TAG
- - name: Set TAG from service package.json for pull requests
- if: github.event_name == 'pull_request'
- id: set-tag-pr
- run: |
- if [ "${{ matrix.service }}" = "frontend" ]; then
- VERSION=$(jq -r '.version' frontend/package.json)
- else
- VERSION=$(jq -r '.version' backend/package.json)
- fi
- TAG="v${VERSION}-${SHORT_SHA}"
- echo "TAG=${TAG}" >> $GITHUB_ENV
- echo "tag=${TAG}" >> $GITHUB_OUTPUT
-
- name: Show set environment variables
run: |
printf " TAG: %s\n" "$TAG"
@@ -338,8 +360,8 @@ jobs:
tag-latest:
needs: [build]
- # Only for successful tag pushes (not PRs with docker-push label) and only for "plain" versions (no '-')
- if: ${{ needs.build.result == 'success' && github.event_name == 'push' && !contains(github.ref_name, '-') }}
+ # Only when the same dispatch asked for it.
+ if: ${{ needs.build.result == 'success' && github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' && github.event.inputs.latest == 'true' }}
runs-on: [self-hosted, linux, x64, universe-super]
timeout-minutes: 30
name: Tag release build as latest
@@ -350,7 +372,13 @@ jobs:
- backend
steps:
- name: Set env variables
- run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
+ env:
+ DISPATCH_TAG: ${{ github.event.inputs.tag }}
+ run: |
+ case "$DISPATCH_TAG" in
+ ''|*[!A-Za-z0-9._-]*) echo "Error: tag must be a plain image tag"; exit 1 ;;
+ esac
+ echo "TAG=${DISPATCH_TAG}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
diff --git a/.github/workflows/e2e_parameterized.yml b/.github/workflows/e2e_parameterized.yml
index 5efdfcfd80d..86afe5efb58 100644
--- a/.github/workflows/e2e_parameterized.yml
+++ b/.github/workflows/e2e_parameterized.yml
@@ -6,7 +6,7 @@ on:
mempool_hostname:
description: 'Mempool Hostname'
required: true
- default: 'mempool.space'
+ default: 'explorer.bitcoinuniverse.io'
type: string
liquid_hostname:
description: 'Liquid Hostname'
diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json
index c8d811f86df..c1250f44668 100644
--- a/backend/mempool-config.sample.json
+++ b/backend/mempool-config.sample.json
@@ -26,8 +26,8 @@
"USER_AGENT": "mempool",
"STDOUT_LOG_MIN_PRIORITY": "debug",
"AUTOMATIC_POOLS_UPDATE": false,
- "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
- "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
+ "POOLS_JSON_URL": "",
+ "POOLS_JSON_TREE_URL": "",
"POOLS_JSON_FILE": "tasks/pools/pools-v2.json",
"POOLS_UPDATE_DELAY": 604800,
"AUDIT": false,
@@ -131,7 +131,7 @@
},
"EXTERNAL_DATA_SERVER": {
"MEMPOOL_API": "",
- "MEMPOOL_ONION": "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1",
+ "MEMPOOL_ONION": "",
"LIQUID_API": "https://liquid.network/api/v1",
"LIQUID_ONION": "http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1"
},
diff --git a/backend/mempool-config.test.json b/backend/mempool-config.test.json
index 4b69da9b0f6..cb72666b73c 100644
--- a/backend/mempool-config.test.json
+++ b/backend/mempool-config.test.json
@@ -130,7 +130,7 @@
},
"EXTERNAL_DATA_SERVER": {
"MEMPOOL_API": "",
- "MEMPOOL_ONION": "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1",
+ "MEMPOOL_ONION": "",
"LIQUID_API": "https://liquid.network/api/v1",
"LIQUID_ONION": "http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1"
},
diff --git a/docker/README.md b/docker/README.md
index 2658914eb60..de758221ee7 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -7,10 +7,44 @@ If you are looking to use these Docker images to deploy your own instance of Mem
See a video guide of this installation method by k3tan [on BitcoinTV.com](https://bitcointv.com/w/8fpAx6rf5CQ16mMhospwjg).
Jump to a section in this doc:
+- [Owned endpoint prerequisites](#owned-endpoint-prerequisites)
- [Configure with Bitcoin Core Only](#configure-with-bitcoin-core-only)
- [Configure with Bitcoin Core + Electrum Server](#configure-with-bitcoin-core--electrum-server)
- [Further Configuration](#further-configuration)
+## Owned endpoint prerequisites
+
+The images never contact a third-party blockchain service on their own. Every
+endpoint default is empty or same-origin, so an unconfigured feature fails
+visibly with the name of the prerequisite instead of quietly reading from
+somewhere else. The start scripts (`backend/start.sh`, `frontend/entrypoint.sh`)
+accept an endpoint only when it is empty, a `/path` on the same origin, or an
+`http(s)` URL without credentials whose host is loopback, a single-label
+container name, or under `bitcoinuniverse.io`; anything else stops the
+container with exit code 78, naming the field and the host but never the value.
+
+| Feature | Backend variable | Frontend variable | Default and unconfigured behaviour |
+| --- | --- | --- | --- |
+| About page (contributors, donations, translators) | `EXTERNAL_DATA_SERVER_MEMPOOL_API` | | empty; the routes are not mounted |
+| Accounts, accelerations, invoices, faucet, Lightning metadata, proofs | `MEMPOOL_SERVICES_API` | `SERVICES_API` | empty; capabilities report the dependency as not configured and the frontend stays on its own origin |
+| The same over Tor | `EXTERNAL_DATA_SERVER_MEMPOOL_ONION` | `ONION_SERVICES_API` | empty; both must name this deployment's own `.onion` host, and the page hostname alone never switches providers |
+| Services proxied through nginx | | `PROXIED_SERVICES=true` with `PROXIED_SERVICES_HOST` | off; `true` without a host refuses to start |
+| Fiat prices | `FIAT_PRICE_ENABLED` | | `false`; `/api/v1/prices` answers `-1` until an owned price source is configured |
+| Mining pool table refresh | `MEMPOOL_AUTOMATIC_POOLS_UPDATE` with `MEMPOOL_POOLS_JSON_URL` and `MEMPOOL_POOLS_JSON_TREE_URL` | | `false` and empty; the bundled `pools-v2.json` is used |
+| Cross-network links | | `MEMPOOL_WEBSITE_URL` | `https://explorer.bitcoinuniverse.io` |
+
+The one permitted external runtime category is **Liquid asset catalogue
+metadata**, read from `EXTERNAL_DATA_SERVER_LIQUID_API` and
+`EXTERNAL_DATA_SERVER_LIQUID_ONION` (defaults: `liquid.network` and its onion).
+It covers asset names, tickers, precision, icons and issuer details only.
+Balances, ownership, transfers and history come from the owned Elements node and
+indexer; those two fields accept no other host than the catalogue or an owned
+one.
+
+`scripts/universe/docker-runtime-defaults.test.mjs` runs both start scripts
+against the shipped templates and proves the rendered configuration for a
+minimal environment, for explicit owned endpoints, and for rejected input.
+
## Configure with Bitcoin Core Only
_Note: address lookups require an Electrum Server and will not work with this configuration. [Add an Electrum Server](#configure-with-bitcoin-core--electrum-server) to your backend for full functionality._
@@ -107,8 +141,8 @@ Below we list all settings from `mempool-config.json` and the corresponding over
"STDOUT_LOG_MIN_PRIORITY": "info",
"INDEXING_BLOCKS_AMOUNT": false,
"AUTOMATIC_POOLS_UPDATE": false,
- "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
- "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
+ "POOLS_JSON_URL": "",
+ "POOLS_JSON_TREE_URL": "",
"POOLS_UPDATE_DELAY": 604800,
"CPFP_INDEXING": false,
"MAX_BLOCKS_BULK_QUERY": 0,
diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile
index 049f13f6fbb..8ee1e1de470 100644
--- a/docker/backend/Dockerfile
+++ b/docker/backend/Dockerfile
@@ -1,4 +1,4 @@
-FROM rust:1.84-bookworm AS builder
+FROM rust:1.89.0-bookworm AS builder
ARG commitHash
ENV MEMPOOL_COMMIT_HASH=${commitHash}
@@ -8,7 +8,7 @@ WORKDIR /build
RUN apt-get update && \
apt-get install -y curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
- apt-get install -y nodejs=24.13.0-1nodesource1 build-essential python3 pkg-config && \
+ apt-get install -y nodejs=24.19.0-1nodesource1 build-essential python3 pkg-config && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -19,20 +19,27 @@ ENV PATH="/usr/local/cargo/bin:$PATH"
COPY --from=backend . .
COPY --from=rustgbt . ../rust/
ENV FD=/build/rust-gbt
-RUN npm install --omit=dev --omit=optional
+# The pinned runtime pair and the toolchain that builds the native engines;
+# a base image that drifts fails here, not later.
+RUN node --version | grep -x v24.19.0 && npm --version | grep -x 11.17.0 && cargo --version | grep -F 1.89.0
+# Locked install (preinstall builds rust-gbt with this toolchain); the
+# vendored first-party tarball in backend/vendor is part of the build context.
+RUN npm ci --omit=dev --omit=optional
WORKDIR /build
RUN npm run package
-FROM rust:1.84-bookworm AS runtime
+FROM rust:1.89.0-bookworm AS runtime
RUN apt-get update && \
apt-get install -y curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
- apt-get install -y nodejs=24.13.0-1nodesource1 && \
+ apt-get install -y nodejs=24.19.0-1nodesource1 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
+RUN node --version | grep -x v24.19.0 && npm --version | grep -x 11.17.0
+
WORKDIR /backend
RUN chown 1000:1000 ./
diff --git a/docker/backend/start.sh b/docker/backend/start.sh
index cfd782977d9..f996626a6c7 100755
--- a/docker/backend/start.sh
+++ b/docker/backend/start.sh
@@ -27,9 +27,11 @@ __MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1}
__MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0}
__MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool}
__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
-__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=true}
-__MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json}
-__MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master}
+# The mining pool table ships with the backend (POOLS_JSON_FILE). Fetching a
+# newer table at runtime is opt-in and needs an explicit source.
+__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=false}
+__MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=""}
+__MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=""}
__MEMPOOL_POOLS_JSON_FILE__=${MEMPOOL_POOLS_JSON_FILE:=tasks/pools/pools-v2.json}
__MEMPOOL_POOLS_UPDATE_DELAY__=${MEMPOOL_POOLS_UPDATE_DELAY:=604800}
__MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false}
@@ -109,8 +111,11 @@ __SOCKS5PROXY_USERNAME__=${SOCKS5PROXY_USERNAME:=""}
__SOCKS5PROXY_PASSWORD__=${SOCKS5PROXY_PASSWORD:=""}
# EXTERNAL_DATA_SERVER
-__EXTERNAL_DATA_SERVER_MEMPOOL_API__=${EXTERNAL_DATA_SERVER_MEMPOOL_API:=https://mempool.space/api/v1}
-__EXTERNAL_DATA_SERVER_MEMPOOL_ONION__=${EXTERNAL_DATA_SERVER_MEMPOOL_ONION:=http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1}
+# Unset by default: the about-page and acceleration routes that read these
+# stay unmounted until an operator names a Bitcoin Universe endpoint. The
+# Liquid asset catalogue is the one documented external metadata source.
+__EXTERNAL_DATA_SERVER_MEMPOOL_API__=${EXTERNAL_DATA_SERVER_MEMPOOL_API:=""}
+__EXTERNAL_DATA_SERVER_MEMPOOL_ONION__=${EXTERNAL_DATA_SERVER_MEMPOOL_ONION:=""}
__EXTERNAL_DATA_SERVER_LIQUID_API__=${EXTERNAL_DATA_SERVER_LIQUID_API:=https://liquid.network/api/v1}
__EXTERNAL_DATA_SERVER_LIQUID_ONION__=${EXTERNAL_DATA_SERVER_LIQUID_ONION:=http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1}
@@ -148,7 +153,9 @@ __REPLICATION_STATISTICS_START_TIME__=${REPLICATION_STATISTICS_START_TIME:=14819
__REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
# MEMPOOL_SERVICES
-__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
+# Unset by default: accounts, accelerations and the other services routes
+# report "unconfigured" until an operator names a Bitcoin Universe endpoint.
+__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:=""}
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
# STRATUM
@@ -161,10 +168,69 @@ __REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""}
__REDIS_BATCH_QUERY_BASE_SIZE__=${REDIS_BATCH_QUERY_BASE_SIZE:=5000}
# FIAT_PRICE
-__FIAT_PRICE_ENABLED__=${FIAT_PRICE_ENABLED:=true}
+# Off by default: the price task calls public exchange APIs from the backend.
+# Turn it on only for a deployment whose price source is its own.
+__FIAT_PRICE_ENABLED__=${FIAT_PRICE_ENABLED:=false}
__FIAT_PRICE_PAID__=${FIAT_PRICE_PAID:=false}
__FIAT_PRICE_API_KEY__=${FIAT_PRICE_API_KEY:=""}
+# Runtime data comes from Bitcoin Universe infrastructure only. An endpoint is
+# empty, a same-origin /path, or an http(s) URL without credentials whose host
+# is loopback, a single-label container name, or under bitcoinuniverse.io.
+# Onion fields may name a .onion host, never the upstream explorer's. Liquid
+# fields may name the Liquid asset catalogue (metadata only). Anything else
+# stops the container before configuration is rendered, and the value is not
+# echoed: only the field name and, when it is not a credential problem, the
+# host.
+universe_endpoint_check() {
+ name="$1"
+ value="$2"
+ extra="$3"
+ [ -z "${value}" ] && return 0
+ case "${value}" in
+ /*) return 0 ;;
+ http://*|https://*) ;;
+ *)
+ echo "start.sh: ${name} must be empty, a /path, or an http(s) URL; refusing to start" >&2
+ return 1 ;;
+ esac
+ case "${value}" in
+ *[\"\'\\\!\|\ \ ]*)
+ echo "start.sh: ${name} contains a character no endpoint URL uses (quote, backslash, space, ! or |); refusing to start" >&2
+ return 1 ;;
+ esac
+ hostport=${value#*://}
+ hostport=${hostport%%/*}
+ case "${hostport}" in
+ *@*)
+ echo "start.sh: ${name} must not carry credentials in the URL; refusing to start" >&2
+ return 1 ;;
+ esac
+ host=$(printf '%s' "${hostport%%:*}" | tr 'A-Z' 'a-z' | sed 's/\.*$//')
+ case "${host}" in
+ ""|*[!a-z0-9.-]*)
+ echo "start.sh: ${name} has a malformed host; refusing to start" >&2
+ return 1 ;;
+ localhost|127.*|bitcoinuniverse.io|*.bitcoinuniverse.io) return 0 ;;
+ mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion) ;; # origin-gate:deny
+ liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion) [ "${extra}" = "liquid" ] && return 0 ;;
+ *.onion) case "${extra}" in onion|liquid) return 0 ;; esac ;;
+ liquid.network|*.liquid.network) [ "${extra}" = "liquid" ] && return 0 ;;
+ *.*) ;;
+ *) return 0 ;;
+ esac
+ echo "start.sh: ${name} points outside Bitcoin Universe infrastructure (host: ${host}); refusing to start" >&2
+ return 1
+}
+
+universe_endpoint_check EXTERNAL_DATA_SERVER_MEMPOOL_API "${__EXTERNAL_DATA_SERVER_MEMPOOL_API__}" || exit 78
+universe_endpoint_check EXTERNAL_DATA_SERVER_MEMPOOL_ONION "${__EXTERNAL_DATA_SERVER_MEMPOOL_ONION__}" onion || exit 78
+universe_endpoint_check EXTERNAL_DATA_SERVER_LIQUID_API "${__EXTERNAL_DATA_SERVER_LIQUID_API__}" liquid || exit 78
+universe_endpoint_check EXTERNAL_DATA_SERVER_LIQUID_ONION "${__EXTERNAL_DATA_SERVER_LIQUID_ONION__}" liquid || exit 78
+universe_endpoint_check MEMPOOL_SERVICES_API "${__MEMPOOL_SERVICES_API__}" || exit 78
+universe_endpoint_check MEMPOOL_POOLS_JSON_URL "${__MEMPOOL_POOLS_JSON_URL__}" || exit 78
+universe_endpoint_check MEMPOOL_POOLS_JSON_TREE_URL "${__MEMPOOL_POOLS_JSON_TREE_URL__}" || exit 78
+
mkdir -p "${__MEMPOOL_CACHE_DIR__}"
sed -i "s!__MEMPOOL_NETWORK__!${__MEMPOOL_NETWORK__}!g" mempool-config.json
diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile
index 6bbb2d5f326..3f937cb2ddb 100644
--- a/docker/frontend/Dockerfile
+++ b/docker/frontend/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:24.13-bookworm-slim AS builder
+FROM node:24.19.0-bookworm-slim AS builder
ARG commitHash
ENV DOCKER_COMMIT_HASH=${commitHash}
@@ -8,10 +8,16 @@ WORKDIR /build
COPY . .
RUN apt-get update
RUN apt-get install -y build-essential rsync
+# The pinned runtime pair; a base image that drifts fails here, not later.
+RUN node --version | grep -x v24.19.0 && npm --version | grep -x 11.17.0
RUN cp mempool-frontend-config.sample.json mempool-frontend-config.json
-RUN npm install --omit=dev --omit=optional
+# Locked install; the build tools are dev dependencies and nothing from this
+# stage but dist/ reaches the nginx image.
+RUN npm ci
-RUN npm run build
+# The fork's production build: English only, and no asset synchronization
+# from third parties at build time.
+RUN npm run build:universe
FROM nginx:1.27.0-alpine
diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh
index c1a99944f2a..1774103700d 100644
--- a/docker/frontend/entrypoint.sh
+++ b/docker/frontend/entrypoint.sh
@@ -3,19 +3,77 @@ __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
+# File locations, overridable so the rendering can be exercised outside the image.
+__NGINX_ETC__=${NGINX_ETC:=/etc/nginx}
+__PATCH_DIR__=${PATCH_DIR:=/patch}
+__MEMPOOL_WWW__=${MEMPOOL_WWW:=/var/www/mempool}
+
+# Runtime data comes from Bitcoin Universe infrastructure only. A services
+# endpoint is empty, a same-origin /path, or an http(s) URL without
+# credentials whose host is loopback, a single-label container name, or under
+# bitcoinuniverse.io. Anything else stops the container before configuration
+# is rendered; the value is not echoed, only the field name and, when it is not
+# a credential problem, the host.
+universe_endpoint_check() {
+ name="$1"
+ value="$2"
+ extra="$3"
+ [ -z "${value}" ] && return 0
+ case "${value}" in
+ /*) return 0 ;;
+ http://*|https://*) ;;
+ *)
+ echo "entrypoint.sh: ${name} must be empty, a /path, or an http(s) URL; refusing to start" >&2
+ return 1 ;;
+ esac
+ case "${value}" in
+ *[\"\'\\\!\|\ \ ]*)
+ echo "entrypoint.sh: ${name} contains a character no endpoint URL uses (quote, backslash, space, ! or |); refusing to start" >&2
+ return 1 ;;
+ esac
+ hostport=${value#*://}
+ hostport=${hostport%%/*}
+ case "${hostport}" in
+ *@*)
+ echo "entrypoint.sh: ${name} must not carry credentials in the URL; refusing to start" >&2
+ return 1 ;;
+ esac
+ host=$(printf '%s' "${hostport%%:*}" | tr 'A-Z' 'a-z' | sed 's/\.*$//')
+ case "${host}" in
+ ""|*[!a-z0-9.-]*)
+ echo "entrypoint.sh: ${name} has a malformed host; refusing to start" >&2
+ return 1 ;;
+ localhost|127.*|bitcoinuniverse.io|*.bitcoinuniverse.io) return 0 ;;
+ mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion) ;; # origin-gate:deny
+ *.onion) [ "${extra}" = "onion" ] && return 0 ;;
+ *.*) ;;
+ *) return 0 ;;
+ esac
+ echo "entrypoint.sh: ${name} points outside Bitcoin Universe infrastructure (host: ${host}); refusing to start" >&2
+ return 1
+}
+
+# Proxying the services API through nginx needs an explicit owned host; the
+# nginx template carries a __PROXIED_SERVICES_HOST__ placeholder where it
+# offers that route.
__PROXIED_SERVICES__=${PROXIED_SERVICES:=false}
-__PROXIED_SERVICES_HOST__=${PROXIED_SERVICES_HOST:=https://mempool.space}
+__PROXIED_SERVICES_HOST__=${PROXIED_SERVICES_HOST:=""}
if [ "${__PROXIED_SERVICES__}" = "true" ]; then
- sed -i "s|proxy_pass https://mempool.space;|proxy_pass ${__PROXIED_SERVICES_HOST__};|g" /etc/nginx/conf.d/nginx-mempool.conf
+ if [ -z "${__PROXIED_SERVICES_HOST__}" ]; then
+ echo "entrypoint.sh: PROXIED_SERVICES=true needs PROXIED_SERVICES_HOST (a Bitcoin Universe services origin); refusing to start" >&2
+ exit 78
+ fi
+ universe_endpoint_check PROXIED_SERVICES_HOST "${__PROXIED_SERVICES_HOST__}" || exit 78
+ sed -i "s|__PROXIED_SERVICES_HOST__|${__PROXIED_SERVICES_HOST__}|g" "${__NGINX_ETC__}/conf.d/nginx-mempool.conf"
fi
-sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
-sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
+sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" "${__NGINX_ETC__}/conf.d/nginx-mempool.conf"
+sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" "${__NGINX_ETC__}/conf.d/nginx-mempool.conf"
-cp /etc/nginx/nginx.conf /patch/nginx.conf
-sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
-cat /patch/nginx.conf > /etc/nginx/nginx.conf
+cp "${__NGINX_ETC__}/nginx.conf" "${__PATCH_DIR__}/nginx.conf"
+sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" "${__PATCH_DIR__}/nginx.conf"
+cat "${__PATCH_DIR__}/nginx.conf" > "${__NGINX_ETC__}/nginx.conf"
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
export LIGHTNING=true
@@ -39,7 +97,7 @@ __BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__ROOT_NETWORK__=${ROOT_NETWORK:=}
-__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
+__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://explorer.bitcoinuniverse.io}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
@@ -56,7 +114,16 @@ __SIGNET_TX_FIRST_SEEN_START_HEIGHT__=${SIGNET_TX_FIRST_SEEN_START_HEIGHT:=0}
__REGTEST_TX_FIRST_SEEN_START_HEIGHT__=${REGTEST_TX_FIRST_SEEN_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
-__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
+# Unset by default, matching the frontend: account and acceleration calls
+# then never leave this origin. Name a Bitcoin Universe services endpoint to
+# turn them on.
+__SERVICES_API__=${SERVICES_API:=""}
+universe_endpoint_check SERVICES_API "${__SERVICES_API__}" || exit 78
+# The services endpoint used when this deployment is reached over Tor. Only
+# an explicitly configured .onion host is used; the page hostname alone never
+# switches providers.
+__ONION_SERVICES_API__=${ONION_SERVICES_API:=""}
+universe_endpoint_check ONION_SERVICES_API "${__ONION_SERVICES_API__}" onion || exit 78
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
@@ -97,12 +164,13 @@ export __REGTEST_TX_FIRST_SEEN_START_HEIGHT__
export __ACCELERATOR__
export __ACCELERATOR_BUTTON__
export __SERVICES_API__
+export __ONION_SERVICES_API__
export __PUBLIC_ACCELERATIONS__
export __HISTORICAL_PRICE__
export __ADDITIONAL_CURRENCIES__
export __STRATUM_ENABLED__
-folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
+folder=$(find "${__MEMPOOL_WWW__}" -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
diff --git a/docs/operations/CONFIGURATION.md b/docs/operations/CONFIGURATION.md
index f3e83fc3301..80afbde792b 100644
--- a/docs/operations/CONFIGURATION.md
+++ b/docs/operations/CONFIGURATION.md
@@ -135,7 +135,7 @@ purpose: the `all` range is the whole series.
### `FIAT_PRICE`
-`ENABLED` (default `true`), `PAID`, `API_KEY`.
+`ENABLED` (backend default `true`, Docker default `false`), `PAID`, `API_KEY`.
Worth understanding before you turn it on: the price task calls public exchange
APIs directly from the backend. Those are market data sources, not blockchain
@@ -163,12 +163,38 @@ credentials. Routes the backend's outbound requests through a SOCKS5 proxy.
| Section | State | Why |
| --- | --- | --- |
| `LIGHTNING`, `LND`, `CLIGHTNING` | off, and off by default | The inherited Lightning product is not part of the Universe explorer. `/api/v1/backend-info` reports `lightning: false` on the public deployment |
-| `MEMPOOL_SERVICES` | `API` empty, `ACCELERATIONS` false | Upstream's hosted services. Leave both unset |
-| `EXTERNAL_DATA_SERVER` | `MEMPOOL_API` empty | **Universe change.** Upstream ships a hosted endpoint here. The remaining onion and Liquid entries are upstream defaults this fork has not cleared; they are not read by a mainnet Bitcoin deployment, and nothing in the Universe deployment path calls them. Do not enable them |
+| `MEMPOOL_SERVICES` | `API` empty, `ACCELERATIONS` false | Accounts, accelerations, invoices and the other services routes report `unconfigured` until `API` names a Bitcoin Universe services endpoint. The Docker start script refuses any other host |
+| `EXTERNAL_DATA_SERVER` | `MEMPOOL_API` and `MEMPOOL_ONION` empty | **Universe change.** Upstream ships hosted endpoints here; the about-page routes stay unmounted until `MEMPOOL_API` names a Bitcoin Universe endpoint, and `MEMPOOL_ONION` is the onion host of that same deployment, never a provider switch. `LIQUID_API` / `LIQUID_ONION` keep the Liquid asset catalogue (see the metadata exception below) |
| `REPLICATION` | off | Replicates audit and statistics data from other mempool servers |
| `MAXMIND` | off | GeoIP databases, used only by the Lightning product |
| `WALLETS`, `STRATUM` | off | Upstream features this fork does not use |
+### Owned endpoint prerequisites and the metadata exception
+
+Every runtime endpoint the backend or the frontend calls is either a relative
+path on its own origin, loopback, a container name, or a host under
+`bitcoinuniverse.io`. `scripts/universe/check-origins.mjs` enforces that on the
+source tree, on the built bundle, and (through
+`scripts/universe/docker-runtime-defaults.test.mjs`) on the configuration the
+container start scripts render. The scripts themselves refuse to start on any
+other host, on a URL carrying credentials, or on a malformed value, and they
+never echo the value they refused, only the field name and the host.
+
+| Feature | Prerequisite | Unconfigured behaviour |
+| --- | --- | --- |
+| About-page contributors, donations, translators | `EXTERNAL_DATA_SERVER.MEMPOOL_API` (Docker: `EXTERNAL_DATA_SERVER_MEMPOOL_API`) | routes not mounted, `404` |
+| Accounts, accelerations, invoices, faucet, Lightning metadata, proofs | `MEMPOOL_SERVICES.API` (Docker: `MEMPOOL_SERVICES_API`) and the frontend `SERVICES_API` (Docker frontend: `SERVICES_API`) | capabilities report the dependency as not configured; the frontend stays on its own origin |
+| The same over Tor | `EXTERNAL_DATA_SERVER.MEMPOOL_ONION` and the frontend `ONION_SERVICES_API`, both naming this deployment's own `.onion` host | clearnet endpoints are used; the page hostname alone never switches providers |
+| Fiat prices | `FIAT_PRICE.ENABLED` (Docker: `FIAT_PRICE_ENABLED`, default `false`) | `/api/v1/prices` answers `-1` for every currency |
+| Mining pool table refresh | `MEMPOOL.AUTOMATIC_POOLS_UPDATE` with an explicit `POOLS_JSON_URL` and `POOLS_JSON_TREE_URL` (Docker defaults are empty) | the bundled `pools-v2.json` is used |
+
+The one permitted external runtime category is **Liquid asset catalogue
+metadata**: asset names, tickers, precision, icons and issuer details read from
+`EXTERNAL_DATA_SERVER.LIQUID_API` / `LIQUID_ONION` (`liquid.network` and its
+onion). It covers collection metadata only. Balances, ownership, transfers,
+history and every other chain fact come from the owned Elements node and
+indexer, and the origin gate rejects any attempt to widen the exception.
+
## Gateway
`scripts/universe/gateway.mjs`. Every variable is optional and every default is
diff --git a/docs/operations/INSTALL.md b/docs/operations/INSTALL.md
index 07ac9ba9eb9..b09f84e1576 100644
--- a/docs/operations/INSTALL.md
+++ b/docs/operations/INSTALL.md
@@ -19,7 +19,7 @@ them. This page covers what is different, in order.
| --- | --- | --- |
| Node.js | 24.19.0 | pinned in `.nvmrc`; the toolchain the CI fleet and the release artifact use |
| npm | 11.17.0 | pinned alongside Node |
-| Rust and `cargo` | 1.84 | `rust/gbt/rust-toolchain`. The backend's `preinstall` script builds `rust/gbt` before anything else, so `npm ci` in `backend/` fails without `cargo` on the path |
+| Rust and `cargo` | 1.89.0 | `rust/gbt/rust-toolchain`. The locked engine dependencies need edition 2024, which 1.84 lacks. The backend's `preinstall` script builds `rust/gbt` before anything else, so `npm ci` in `backend/` fails without `cargo` on the path |
| Bitcoin Core | a `txindex=1` node with RPC enabled | the only source of Bitcoin chain data |
| MariaDB or MySQL | MariaDB 10.5 or later; the Universe fleet standard is MySQL 8.4 LTS | blocks, mining, statistics, and price history |
| An address index | optional | without one, address and unspent-output lookups report that they cannot be served |
diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json
index 069d0ae64f6..b881195f083 100644
--- a/frontend/mempool-frontend-config.sample.json
+++ b/frontend/mempool-frontend-config.sample.json
@@ -36,5 +36,6 @@
"ACCELERATOR_BUTTON": true,
"PUBLIC_ACCELERATIONS": false,
"STRATUM_ENABLED": false,
- "SERVICES_API": "/services"
+ "SERVICES_API": "/services",
+ "ONION_SERVICES_API": ""
}
diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts
index eaf4ce54fd7..b4ebb9a359d 100644
--- a/frontend/src/app/services/state.service.ts
+++ b/frontend/src/app/services/state.service.ts
@@ -100,6 +100,7 @@ export interface Env {
PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string;
STRATUM_ENABLED: boolean;
SERVICES_API?: string;
+ ONION_SERVICES_API?: string;
customize?: Customization;
PROD_DOMAINS: string[];
}
@@ -151,6 +152,8 @@ const defaultEnv: Env = {
// The hosted services API is an upstream product. It is unset here, so the
// account and acceleration calls that use it never leave this origin.
'SERVICES_API': '',
+ // The onion services endpoint a Tor-served deployment names for itself.
+ 'ONION_SERVICES_API': '',
'PROD_DOMAINS': [],
};
@@ -273,8 +276,11 @@ export class StateService {
this.env.MINING_DASHBOARD = false;
}
- if (document.location.hostname.endsWith('.onion')) {
- this.env.SERVICES_API = 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/services';
+ // Onion transport is explicit configuration: a deployment served over Tor
+ // names its own onion services endpoint. The page hostname alone never
+ // switches providers.
+ if (document.location.hostname.endsWith('.onion') && this.env.ONION_SERVICES_API) {
+ this.env.SERVICES_API = this.env.ONION_SERVICES_API;
}
if (this.isBrowser) {
diff --git a/production/mempool-config.liquid.json b/production/mempool-config.liquid.json
index 316f99305f6..90ee8d7a70d 100644
--- a/production/mempool-config.liquid.json
+++ b/production/mempool-config.liquid.json
@@ -25,50 +25,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/elements/socket/esplora-elements-liquid",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3001",
- "http://node202.hnl.mempool.space:3001",
- "http://node203.hnl.mempool.space:3001",
- "http://node204.hnl.mempool.space:3001",
- "http://node201.sg1.mempool.space:3001",
- "http://node202.sg1.mempool.space:3001",
- "http://node203.sg1.mempool.space:3001",
- "http://node204.sg1.mempool.space:3001",
- "http://node201.va1.mempool.space:3001",
- "http://node202.va1.mempool.space:3001",
- "http://node203.va1.mempool.space:3001",
- "http://node204.va1.mempool.space:3001",
- "http://node205.va1.mempool.space:3001",
- "http://node206.va1.mempool.space:3001",
- "http://node207.va1.mempool.space:3001",
- "http://node208.va1.mempool.space:3001",
- "http://node209.va1.mempool.space:3001",
- "http://node210.va1.mempool.space:3001",
- "http://node211.va1.mempool.space:3001",
- "http://node212.va1.mempool.space:3001",
- "http://node213.va1.mempool.space:3001",
- "http://node214.va1.mempool.space:3001",
- "http://node201.fra.mempool.space:3001",
- "http://node202.fra.mempool.space:3001",
- "http://node203.fra.mempool.space:3001",
- "http://node204.fra.mempool.space:3001",
- "http://node205.fra.mempool.space:3001",
- "http://node206.fra.mempool.space:3001",
- "http://node207.fra.mempool.space:3001",
- "http://node208.fra.mempool.space:3001",
- "http://node209.fra.mempool.space:3001",
- "http://node210.fra.mempool.space:3001",
- "http://node211.fra.mempool.space:3001",
- "http://node212.fra.mempool.space:3001",
- "http://node213.fra.mempool.space:3001",
- "http://node214.fra.mempool.space:3001",
- "http://node201.tk7.mempool.space:3001",
- "http://node202.tk7.mempool.space:3001",
- "http://node203.tk7.mempool.space:3001",
- "http://node204.tk7.mempool.space:3001",
- "http://node205.tk7.mempool.space:3001",
- "http://node206.tk7.mempool.space:3001"
- ]
+ "FALLBACK": []
},
"DATABASE": {
"ENABLED": true,
diff --git a/production/mempool-config.liquidtestnet.json b/production/mempool-config.liquidtestnet.json
index e42bbadd6fa..482e57c4ced 100644
--- a/production/mempool-config.liquidtestnet.json
+++ b/production/mempool-config.liquidtestnet.json
@@ -25,50 +25,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/elements/socket/esplora-elements-liquidtestnet",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3004",
- "http://node202.hnl.mempool.space:3004",
- "http://node203.hnl.mempool.space:3004",
- "http://node204.hnl.mempool.space:3004",
- "http://node201.sg1.mempool.space:3004",
- "http://node202.sg1.mempool.space:3004",
- "http://node203.sg1.mempool.space:3004",
- "http://node204.sg1.mempool.space:3004",
- "http://node201.va1.mempool.space:3004",
- "http://node202.va1.mempool.space:3004",
- "http://node203.va1.mempool.space:3004",
- "http://node204.va1.mempool.space:3004",
- "http://node205.va1.mempool.space:3004",
- "http://node206.va1.mempool.space:3004",
- "http://node207.va1.mempool.space:3004",
- "http://node208.va1.mempool.space:3004",
- "http://node209.va1.mempool.space:3004",
- "http://node210.va1.mempool.space:3004",
- "http://node211.va1.mempool.space:3004",
- "http://node212.va1.mempool.space:3004",
- "http://node213.va1.mempool.space:3004",
- "http://node214.va1.mempool.space:3004",
- "http://node201.fra.mempool.space:3004",
- "http://node202.fra.mempool.space:3004",
- "http://node203.fra.mempool.space:3004",
- "http://node204.fra.mempool.space:3004",
- "http://node205.fra.mempool.space:3004",
- "http://node206.fra.mempool.space:3004",
- "http://node207.fra.mempool.space:3004",
- "http://node208.fra.mempool.space:3004",
- "http://node209.fra.mempool.space:3004",
- "http://node210.fra.mempool.space:3004",
- "http://node211.fra.mempool.space:3004",
- "http://node212.fra.mempool.space:3004",
- "http://node213.fra.mempool.space:3004",
- "http://node214.fra.mempool.space:3004",
- "http://node201.tk7.mempool.space:3004",
- "http://node202.tk7.mempool.space:3004",
- "http://node203.tk7.mempool.space:3004",
- "http://node204.tk7.mempool.space:3004",
- "http://node205.tk7.mempool.space:3004",
- "http://node206.tk7.mempool.space:3004"
- ]
+ "FALLBACK": []
},
"DATABASE": {
"ENABLED": true,
diff --git a/production/mempool-config.mainnet-lightning.json b/production/mempool-config.mainnet-lightning.json
index 0ec117691ad..4d5eed631c4 100644
--- a/production/mempool-config.mainnet-lightning.json
+++ b/production/mempool-config.mainnet-lightning.json
@@ -18,50 +18,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3000",
- "http://node202.hnl.mempool.space:3000",
- "http://node203.hnl.mempool.space:3000",
- "http://node204.hnl.mempool.space:3000",
- "http://node201.sg1.mempool.space:3000",
- "http://node202.sg1.mempool.space:3000",
- "http://node203.sg1.mempool.space:3000",
- "http://node204.sg1.mempool.space:3000",
- "http://node201.va1.mempool.space:3000",
- "http://node202.va1.mempool.space:3000",
- "http://node203.va1.mempool.space:3000",
- "http://node204.va1.mempool.space:3000",
- "http://node205.va1.mempool.space:3000",
- "http://node206.va1.mempool.space:3000",
- "http://node207.va1.mempool.space:3000",
- "http://node208.va1.mempool.space:3000",
- "http://node209.va1.mempool.space:3000",
- "http://node210.va1.mempool.space:3000",
- "http://node211.va1.mempool.space:3000",
- "http://node212.va1.mempool.space:3000",
- "http://node213.va1.mempool.space:3000",
- "http://node214.va1.mempool.space:3000",
- "http://node201.fra.mempool.space:3000",
- "http://node202.fra.mempool.space:3000",
- "http://node203.fra.mempool.space:3000",
- "http://node204.fra.mempool.space:3000",
- "http://node205.fra.mempool.space:3000",
- "http://node206.fra.mempool.space:3000",
- "http://node207.fra.mempool.space:3000",
- "http://node208.fra.mempool.space:3000",
- "http://node209.fra.mempool.space:3000",
- "http://node210.fra.mempool.space:3000",
- "http://node211.fra.mempool.space:3000",
- "http://node212.fra.mempool.space:3000",
- "http://node213.fra.mempool.space:3000",
- "http://node214.fra.mempool.space:3000",
- "http://node201.tk7.mempool.space:3000",
- "http://node202.tk7.mempool.space:3000",
- "http://node203.tk7.mempool.space:3000",
- "http://node204.tk7.mempool.space:3000",
- "http://node205.tk7.mempool.space:3000",
- "http://node206.tk7.mempool.space:3000"
- ]
+ "FALLBACK": []
},
"LIGHTNING": {
"ENABLED": true,
diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json
index b69c0f2c9eb..697ed847cd0 100644
--- a/production/mempool-config.mainnet.json
+++ b/production/mempool-config.mainnet.json
@@ -41,56 +41,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-mainnet",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3000",
- "http://node202.hnl.mempool.space:3000",
- "http://node203.hnl.mempool.space:3000",
- "http://node204.hnl.mempool.space:3000",
- "http://node201.sv1.mempool.space:3000",
- "http://node202.sv1.mempool.space:3000",
- "http://node203.sv1.mempool.space:3000",
- "http://node204.sv1.mempool.space:3000",
- "http://node205.sv1.mempool.space:3000",
- "http://node206.sv1.mempool.space:3000",
- "http://node201.fra.mempool.space:3000",
- "http://node202.fra.mempool.space:3000",
- "http://node203.fra.mempool.space:3000",
- "http://node204.fra.mempool.space:3000",
- "http://node205.fra.mempool.space:3000",
- "http://node206.fra.mempool.space:3000",
- "http://node207.fra.mempool.space:3000",
- "http://node208.fra.mempool.space:3000",
- "http://node209.fra.mempool.space:3000",
- "http://node210.fra.mempool.space:3000",
- "http://node211.fra.mempool.space:3000",
- "http://node212.fra.mempool.space:3000",
- "http://node213.fra.mempool.space:3000",
- "http://node214.fra.mempool.space:3000",
- "http://node201.tk7.mempool.space:3000",
- "http://node202.tk7.mempool.space:3000",
- "http://node203.tk7.mempool.space:3000",
- "http://node204.tk7.mempool.space:3000",
- "http://node205.tk7.mempool.space:3000",
- "http://node206.tk7.mempool.space:3000",
- "http://node201.va1.mempool.space:3000",
- "http://node202.va1.mempool.space:3000",
- "http://node203.va1.mempool.space:3000",
- "http://node204.va1.mempool.space:3000",
- "http://node205.va1.mempool.space:3000",
- "http://node206.va1.mempool.space:3000",
- "http://node207.va1.mempool.space:3000",
- "http://node208.va1.mempool.space:3000",
- "http://node209.va1.mempool.space:3000",
- "http://node210.va1.mempool.space:3000",
- "http://node211.va1.mempool.space:3000",
- "http://node212.va1.mempool.space:3000",
- "http://node213.va1.mempool.space:3000",
- "http://node214.va1.mempool.space:3000",
- "http://node201.sg1.mempool.space:3000",
- "http://node202.sg1.mempool.space:3000",
- "http://node203.sg1.mempool.space:3000",
- "http://node204.sg1.mempool.space:3000"
- ]
+ "FALLBACK": []
},
"DATABASE": {
"ENABLED": true,
@@ -110,56 +61,7 @@
"AUDIT_START_HEIGHT": 774000,
"STATISTICS": true,
"STATISTICS_START_TIME": "24h",
- "SERVERS": [
- "node201.hnl.mempool.space",
- "node202.hnl.mempool.space",
- "node203.hnl.mempool.space",
- "node204.hnl.mempool.space",
- "node201.sv1.mempool.space",
- "node202.sv1.mempool.space",
- "node203.sv1.mempool.space",
- "node204.sv1.mempool.space",
- "node205.sv1.mempool.space",
- "node206.sv1.mempool.space",
- "node201.fra.mempool.space",
- "node202.fra.mempool.space",
- "node203.fra.mempool.space",
- "node204.fra.mempool.space",
- "node205.fra.mempool.space",
- "node206.fra.mempool.space",
- "node207.fra.mempool.space",
- "node208.fra.mempool.space",
- "node209.fra.mempool.space",
- "node210.fra.mempool.space",
- "node211.fra.mempool.space",
- "node212.fra.mempool.space",
- "node213.fra.mempool.space",
- "node214.fra.mempool.space",
- "node201.tk7.mempool.space",
- "node202.tk7.mempool.space",
- "node203.tk7.mempool.space",
- "node204.tk7.mempool.space",
- "node205.tk7.mempool.space",
- "node206.tk7.mempool.space",
- "node201.va1.mempool.space",
- "node202.va1.mempool.space",
- "node203.va1.mempool.space",
- "node204.va1.mempool.space",
- "node205.va1.mempool.space",
- "node206.va1.mempool.space",
- "node207.va1.mempool.space",
- "node208.va1.mempool.space",
- "node209.va1.mempool.space",
- "node210.va1.mempool.space",
- "node211.va1.mempool.space",
- "node212.va1.mempool.space",
- "node213.va1.mempool.space",
- "node214.va1.mempool.space",
- "node201.sg1.mempool.space",
- "node202.sg1.mempool.space",
- "node203.sg1.mempool.space",
- "node204.sg1.mempool.space"
- ]
+ "SERVERS": []
},
"REDIS": {
"ENABLED": true,
@@ -167,8 +69,8 @@
"BATCH_QUERY_BASE_SIZE": 5000
},
"MEMPOOL_SERVICES": {
- "API": "https://mempool.space/api/v1/services",
- "ACCELERATIONS": true
+ "API": "",
+ "ACCELERATIONS": false
},
"WALLETS": {
"ENABLED": true,
diff --git a/production/mempool-config.signet-lightning.json b/production/mempool-config.signet-lightning.json
index 01ea9c39a88..1a4fee8c18b 100644
--- a/production/mempool-config.signet-lightning.json
+++ b/production/mempool-config.signet-lightning.json
@@ -18,50 +18,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-signet",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3003",
- "http://node202.hnl.mempool.space:3003",
- "http://node203.hnl.mempool.space:3003",
- "http://node204.hnl.mempool.space:3003",
- "http://node201.sg1.mempool.space:3003",
- "http://node202.sg1.mempool.space:3003",
- "http://node203.sg1.mempool.space:3003",
- "http://node204.sg1.mempool.space:3003",
- "http://node201.va1.mempool.space:3003",
- "http://node202.va1.mempool.space:3003",
- "http://node203.va1.mempool.space:3003",
- "http://node204.va1.mempool.space:3003",
- "http://node205.va1.mempool.space:3003",
- "http://node206.va1.mempool.space:3003",
- "http://node207.va1.mempool.space:3003",
- "http://node208.va1.mempool.space:3003",
- "http://node209.va1.mempool.space:3003",
- "http://node210.va1.mempool.space:3003",
- "http://node211.va1.mempool.space:3003",
- "http://node212.va1.mempool.space:3003",
- "http://node213.va1.mempool.space:3003",
- "http://node214.va1.mempool.space:3003",
- "http://node201.fra.mempool.space:3003",
- "http://node202.fra.mempool.space:3003",
- "http://node203.fra.mempool.space:3003",
- "http://node204.fra.mempool.space:3003",
- "http://node205.fra.mempool.space:3003",
- "http://node206.fra.mempool.space:3003",
- "http://node207.fra.mempool.space:3003",
- "http://node208.fra.mempool.space:3003",
- "http://node209.fra.mempool.space:3003",
- "http://node210.fra.mempool.space:3003",
- "http://node211.fra.mempool.space:3003",
- "http://node212.fra.mempool.space:3003",
- "http://node213.fra.mempool.space:3003",
- "http://node214.fra.mempool.space:3003",
- "http://node201.tk7.mempool.space:3003",
- "http://node202.tk7.mempool.space:3003",
- "http://node203.tk7.mempool.space:3003",
- "http://node204.tk7.mempool.space:3003",
- "http://node205.tk7.mempool.space:3003",
- "http://node206.tk7.mempool.space:3003"
- ]
+ "FALLBACK": []
},
"LIGHTNING": {
"ENABLED": true,
diff --git a/production/mempool-config.testnet-lightning.json b/production/mempool-config.testnet-lightning.json
index 02f7451db2e..ec1bbed63b6 100644
--- a/production/mempool-config.testnet-lightning.json
+++ b/production/mempool-config.testnet-lightning.json
@@ -18,50 +18,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3002",
- "http://node202.hnl.mempool.space:3002",
- "http://node203.hnl.mempool.space:3002",
- "http://node204.hnl.mempool.space:3002",
- "http://node201.sg1.mempool.space:3002",
- "http://node202.sg1.mempool.space:3002",
- "http://node203.sg1.mempool.space:3002",
- "http://node204.sg1.mempool.space:3002",
- "http://node201.va1.mempool.space:3002",
- "http://node202.va1.mempool.space:3002",
- "http://node203.va1.mempool.space:3002",
- "http://node204.va1.mempool.space:3002",
- "http://node205.va1.mempool.space:3002",
- "http://node206.va1.mempool.space:3002",
- "http://node207.va1.mempool.space:3002",
- "http://node208.va1.mempool.space:3002",
- "http://node209.va1.mempool.space:3002",
- "http://node210.va1.mempool.space:3002",
- "http://node211.va1.mempool.space:3002",
- "http://node212.va1.mempool.space:3002",
- "http://node213.va1.mempool.space:3002",
- "http://node214.va1.mempool.space:3002",
- "http://node201.fra.mempool.space:3002",
- "http://node202.fra.mempool.space:3002",
- "http://node203.fra.mempool.space:3002",
- "http://node204.fra.mempool.space:3002",
- "http://node205.fra.mempool.space:3002",
- "http://node206.fra.mempool.space:3002",
- "http://node207.fra.mempool.space:3002",
- "http://node208.fra.mempool.space:3002",
- "http://node209.fra.mempool.space:3002",
- "http://node210.fra.mempool.space:3002",
- "http://node211.fra.mempool.space:3002",
- "http://node212.fra.mempool.space:3002",
- "http://node213.fra.mempool.space:3002",
- "http://node214.fra.mempool.space:3002",
- "http://node201.tk7.mempool.space:3002",
- "http://node202.tk7.mempool.space:3002",
- "http://node203.tk7.mempool.space:3002",
- "http://node204.tk7.mempool.space:3002",
- "http://node205.tk7.mempool.space:3002",
- "http://node206.tk7.mempool.space:3002"
- ]
+ "FALLBACK": []
},
"LIGHTNING": {
"ENABLED": true,
diff --git a/production/mempool-config.testnet.json b/production/mempool-config.testnet.json
index 67a570e9e48..6021d8c579a 100644
--- a/production/mempool-config.testnet.json
+++ b/production/mempool-config.testnet.json
@@ -28,50 +28,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3002",
- "http://node202.hnl.mempool.space:3002",
- "http://node203.hnl.mempool.space:3002",
- "http://node204.hnl.mempool.space:3002",
- "http://node201.sg1.mempool.space:3002",
- "http://node202.sg1.mempool.space:3002",
- "http://node203.sg1.mempool.space:3002",
- "http://node204.sg1.mempool.space:3002",
- "http://node201.va1.mempool.space:3002",
- "http://node202.va1.mempool.space:3002",
- "http://node203.va1.mempool.space:3002",
- "http://node204.va1.mempool.space:3002",
- "http://node205.va1.mempool.space:3002",
- "http://node206.va1.mempool.space:3002",
- "http://node207.va1.mempool.space:3002",
- "http://node208.va1.mempool.space:3002",
- "http://node209.va1.mempool.space:3002",
- "http://node210.va1.mempool.space:3002",
- "http://node211.va1.mempool.space:3002",
- "http://node212.va1.mempool.space:3002",
- "http://node213.va1.mempool.space:3002",
- "http://node214.va1.mempool.space:3002",
- "http://node201.fra.mempool.space:3002",
- "http://node202.fra.mempool.space:3002",
- "http://node203.fra.mempool.space:3002",
- "http://node204.fra.mempool.space:3002",
- "http://node205.fra.mempool.space:3002",
- "http://node206.fra.mempool.space:3002",
- "http://node207.fra.mempool.space:3002",
- "http://node208.fra.mempool.space:3002",
- "http://node209.fra.mempool.space:3002",
- "http://node210.fra.mempool.space:3002",
- "http://node211.fra.mempool.space:3002",
- "http://node212.fra.mempool.space:3002",
- "http://node213.fra.mempool.space:3002",
- "http://node214.fra.mempool.space:3002",
- "http://node201.tk7.mempool.space:3002",
- "http://node202.tk7.mempool.space:3002",
- "http://node203.tk7.mempool.space:3002",
- "http://node204.tk7.mempool.space:3002",
- "http://node205.tk7.mempool.space:3002",
- "http://node206.tk7.mempool.space:3002"
- ]
+ "FALLBACK": []
},
"DATABASE": {
"ENABLED": true,
diff --git a/production/mempool-config.testnet4.json b/production/mempool-config.testnet4.json
index 29c1591fc4f..11df3e4898d 100644
--- a/production/mempool-config.testnet4.json
+++ b/production/mempool-config.testnet4.json
@@ -28,50 +28,7 @@
},
"ESPLORA": {
"UNIX_SOCKET_PATH": "/bitcoin/socket/esplora-bitcoin-testnet4",
- "FALLBACK": [
- "http://node201.hnl.mempool.space:3005",
- "http://node202.hnl.mempool.space:3005",
- "http://node203.hnl.mempool.space:3005",
- "http://node204.hnl.mempool.space:3005",
- "http://node201.sg1.mempool.space:3005",
- "http://node202.sg1.mempool.space:3005",
- "http://node203.sg1.mempool.space:3005",
- "http://node204.sg1.mempool.space:3005",
- "http://node201.va1.mempool.space:3005",
- "http://node202.va1.mempool.space:3005",
- "http://node203.va1.mempool.space:3005",
- "http://node204.va1.mempool.space:3005",
- "http://node205.va1.mempool.space:3005",
- "http://node206.va1.mempool.space:3005",
- "http://node207.va1.mempool.space:3005",
- "http://node208.va1.mempool.space:3005",
- "http://node209.va1.mempool.space:3005",
- "http://node210.va1.mempool.space:3005",
- "http://node211.va1.mempool.space:3005",
- "http://node212.va1.mempool.space:3005",
- "http://node213.va1.mempool.space:3005",
- "http://node214.va1.mempool.space:3005",
- "http://node201.fra.mempool.space:3005",
- "http://node202.fra.mempool.space:3005",
- "http://node203.fra.mempool.space:3005",
- "http://node204.fra.mempool.space:3005",
- "http://node205.fra.mempool.space:3005",
- "http://node206.fra.mempool.space:3005",
- "http://node207.fra.mempool.space:3005",
- "http://node208.fra.mempool.space:3005",
- "http://node209.fra.mempool.space:3005",
- "http://node210.fra.mempool.space:3005",
- "http://node211.fra.mempool.space:3005",
- "http://node212.fra.mempool.space:3005",
- "http://node213.fra.mempool.space:3005",
- "http://node214.fra.mempool.space:3005",
- "http://node201.tk7.mempool.space:3005",
- "http://node202.tk7.mempool.space:3005",
- "http://node203.tk7.mempool.space:3005",
- "http://node204.tk7.mempool.space:3005",
- "http://node205.tk7.mempool.space:3005",
- "http://node206.tk7.mempool.space:3005"
- ]
+ "FALLBACK": []
},
"DATABASE": {
"ENABLED": true,
diff --git a/production/mempool-frontend-config.bitb.json b/production/mempool-frontend-config.bitb.json
index 7a9d0312aeb..297902171ae 100644
--- a/production/mempool-frontend-config.bitb.json
+++ b/production/mempool-frontend-config.bitb.json
@@ -5,7 +5,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"LIGHTNING": true,
diff --git a/production/mempool-frontend-config.liquid.json b/production/mempool-frontend-config.liquid.json
index c807a76b1da..11b0956043e 100644
--- a/production/mempool-frontend-config.liquid.json
+++ b/production/mempool-frontend-config.liquid.json
@@ -6,7 +6,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"MEMPOOL_BLOCKS_AMOUNT": 2,
diff --git a/production/mempool-frontend-config.mainnet.json b/production/mempool-frontend-config.mainnet.json
index 45e30072a9f..d6c6e7692df 100644
--- a/production/mempool-frontend-config.mainnet.json
+++ b/production/mempool-frontend-config.mainnet.json
@@ -6,7 +6,7 @@
"LIQUID_TESTNET_ENABLED": false,
"STRATUM_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"MAINNET_BLOCK_AUDIT_START_HEIGHT": 773911,
"TESTNET_BLOCK_AUDIT_START_HEIGHT": 2417829,
@@ -18,5 +18,5 @@
"ACCELERATOR_BUTTON": true,
"PUBLIC_ACCELERATIONS": true,
"AUDIT": true,
- "PROD_DOMAINS": "mempool.space"
+ "PROD_DOMAINS": "explorer.bitcoinuniverse.io"
}
diff --git a/production/mempool-frontend-config.meta.json b/production/mempool-frontend-config.meta.json
index 67bd7374560..74a780a3ab9 100644
--- a/production/mempool-frontend-config.meta.json
+++ b/production/mempool-frontend-config.meta.json
@@ -5,7 +5,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"LIGHTNING": true,
diff --git a/production/mempool-frontend-config.onbtc.json b/production/mempool-frontend-config.onbtc.json
index 102828ad442..98e1227b436 100644
--- a/production/mempool-frontend-config.onbtc.json
+++ b/production/mempool-frontend-config.onbtc.json
@@ -5,7 +5,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"LIGHTNING": true,
diff --git a/production/mempool-frontend-config.river.json b/production/mempool-frontend-config.river.json
index c90cc3e3102..c28a745c88a 100644
--- a/production/mempool-frontend-config.river.json
+++ b/production/mempool-frontend-config.river.json
@@ -5,7 +5,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"LIGHTNING": true,
diff --git a/production/mempool-frontend-config.strategy.json b/production/mempool-frontend-config.strategy.json
index f15e3752a44..ecf474f8f96 100644
--- a/production/mempool-frontend-config.strategy.json
+++ b/production/mempool-frontend-config.strategy.json
@@ -5,7 +5,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"LIGHTNING": true,
diff --git a/production/mempool-frontend-config.xxi.json b/production/mempool-frontend-config.xxi.json
index 29f1a60d2e8..c889363c2e0 100644
--- a/production/mempool-frontend-config.xxi.json
+++ b/production/mempool-frontend-config.xxi.json
@@ -5,7 +5,7 @@
"LIQUID_ENABLED": true,
"LIQUID_TESTNET_ENABLED": true,
"SIGNET_ENABLED": true,
- "MEMPOOL_WEBSITE_URL": "https://mempool.space",
+ "MEMPOOL_WEBSITE_URL": "https://explorer.bitcoinuniverse.io",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"ITEMS_PER_PAGE": 25,
"LIGHTNING": true,
diff --git a/rust/gbt/rust-toolchain b/rust/gbt/rust-toolchain
index 40671b90850..636ea711ad9 100644
--- a/rust/gbt/rust-toolchain
+++ b/rust/gbt/rust-toolchain
@@ -1 +1 @@
-1.84
+1.89.0
diff --git a/scripts/universe/check-branding.mjs b/scripts/universe/check-branding.mjs
index 01985bc2c3d..8c4425a592f 100644
--- a/scripts/universe/check-branding.mjs
+++ b/scripts/universe/check-branding.mjs
@@ -74,6 +74,8 @@ const ALLOWED = [
{ prefix: 'SUPPORT.md', reason: 'fork attribution required by the licence' },
{ prefix: 'docs.manifest.json', reason: 'the upstream block attributes the upstream project to the documentation portal' },
{ prefix: 'scripts/universe/check-origins.mjs', reason: 'the origin gate lists the hosts it bans' },
+ { prefix: 'scripts/universe/check-origins.test.mjs', reason: 'the origin gate tests name the hosts it must reject' },
+ { prefix: 'scripts/universe/docker-runtime-defaults.test.mjs', reason: 'the rendered-config gate proves the hosts the start scripts refuse' },
{ prefix: 'frontend/cypress/', reason: 'inherited upstream end-to-end suite and recorded fixtures, not shipped' },
{ prefix: '.github/workflows/', reason: 'inherited upstream CI definitions; Universe CI is separate' },
];
diff --git a/scripts/universe/check-origins.mjs b/scripts/universe/check-origins.mjs
index 4f8bd166c02..582b739ba88 100644
--- a/scripts/universe/check-origins.mjs
+++ b/scripts/universe/check-origins.mjs
@@ -8,18 +8,43 @@
* in through a well-meaning fallback. So it is checked.
*
* Usage:
- * node scripts/universe/check-origins.mjs [path ...]
+ * node scripts/universe/check-origins.mjs [--json] [path ...]
*
- * The release workflow runs it over the source tree and over `frontend/dist`.
+ * With no path the repository source tree is scanned and build output is
+ * skipped. A named directory (`frontend/dist`) is scanned as a built bundle:
+ * every reference in it is runtime, comments do not exist any more, and no
+ * path exemption applies.
+ *
+ * Policy (the exported lists are the policy; the tests pin them):
+ *
+ * 1. Hosts are read from parsed URLs and from bare host tokens, lower-cased
+ * with trailing dots removed. A forbidden provider matches by host equality
+ * or dot-suffix (`api.hiro.so` is `hiro.so`). A host that merely contains a
+ * forbidden provider between dots (`mempool.space.evil.example`) is a
+ * lookalike and is rejected the same way. `notmempool.space` shares no label
+ * boundary with `mempool.space`, so it is an unknown host, judged by the
+ * runtime policy below rather than by the denylist.
+ * 2. Runtime call sites (fetch/axios/WebSocket/proxy_pass/curl lines) and
+ * runtime configuration values (docker templates, production configs, the
+ * sample configs) may only reference relative paths, loopback, single-label
+ * container names, Universe-operated hosts, or a documented metadata
+ * exemption. Anything else is an unapproved runtime origin.
+ * 3. Build inputs (package registries, release downloads, distribution
+ * package repositories) are allowed in build-context files only.
+ * 4. Documentation cites hosts without calling them. Citation files may name
+ * any host; a comment line or specification field in code may cite the
+ * protocol specification hosts listed below, never a runtime call.
+ * 5. Credentials embedded in a URL are a finding wherever the host is not
+ * loopback. The credential is never printed, only the host.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
-import { join, relative, resolve, sep } from 'node:path';
+import { basename, join, relative, resolve, sep } from 'node:path';
-const REPOSITORY_ROOT = resolve(import.meta.dirname, '..', '..');
+export const REPOSITORY_ROOT = resolve(import.meta.dirname, '..', '..');
const SKIPPED_DIRECTORIES = new Set([
- '.git', 'node_modules', 'target', 'coverage', '.angular', '.cache',
+ '.git', 'node_modules', 'target', 'coverage', '.angular', '.cache', '.worktrees',
]);
/**
@@ -30,28 +55,57 @@ const SKIPPED_DIRECTORIES = new Set([
const SKIPPED_WHEN_WALKING_ROOT = new Set(['dist', 'build']);
/**
- * Hosts that must never appear as a runtime data source.
- *
- * Public blockchain APIs and explorers, hosted protocol indexers, analytics,
- * and remote font and script CDNs.
+ * Hosts that must never appear as a runtime data source: public blockchain
+ * APIs and explorers across every chain this explorer serves, hosted protocol
+ * indexers, Lightning graph services, analytics, and remote font and script
+ * CDNs. Subdomains match too.
*/
-const FORBIDDEN_HOSTS = [
+export const FORBIDDEN_HOSTS = [
+ // Bitcoin explorers and hosted APIs
'mempool.space',
+ 'mempool.ninja',
+ 'mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion',
'blockstream.info',
+ 'esplora.blockstream.com',
'blockchain.info',
+ 'blockchain.com',
'blockchair.com',
+ 'blockcypher.com',
'btc.com',
+ 'btcscan.org',
+ 'bitaps.com',
+ 'sochain.com',
+ 'chain.so',
+ 'smartbit.com.au',
'oklink.com',
+ 'tokenview.io',
+ 'bitcoinexplorer.org',
+ 'trezor.io',
+ '3xpl.com',
+ // Ordinals, Runes, Stamps, BRC-20 indexers and marketplaces
'unisat.io',
'ordiscan.com',
'ordinals.com',
+ 'ord.io',
'hiro.so',
'geniidata.com',
'bestinslot.xyz',
'openstamp.io',
'stampchain.io',
'magiceden.io',
- 'esplora.blockstream.com',
+ 'xverse.app',
+ 'ordinalswallet.com',
+ 'simplehash.com',
+ 'luminex.io',
+ // Other chains served here
+ 'dogechain.info',
+ 'zcha.in',
+ 'zecblockexplorer.com',
+ 'zcashblockexplorer.com',
+ // Lightning graph services
+ '1ml.com',
+ 'amboss.space',
+ // Analytics and remote asset CDNs
'google-analytics.com',
'googletagmanager.com',
'fonts.googleapis.com',
@@ -61,11 +115,70 @@ const FORBIDDEN_HOSTS = [
'unpkg.com',
];
+/** Universe-operated authorities. Subdomains match. */
+export const APPROVED_RUNTIME_HOSTS = [
+ 'bitcoinuniverse.io',
+];
+
+export const LOOPBACK_HOSTS = new Set([
+ 'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0', '[::]',
+]);
+
+/**
+ * The one external runtime category with a documented exception: the Liquid
+ * asset catalogue (asset names, tickers, icons, issuer metadata) read through
+ * `EXTERNAL_DATA_SERVER.LIQUID_API` / `LIQUID_ONION`. Collection metadata
+ * only; balances, ownership, transfers and history never come from it.
+ */
+export const METADATA_EXEMPT_HOSTS = [
+ { host: 'liquid.network', reason: 'Liquid asset catalogue metadata (EXTERNAL_DATA_SERVER.LIQUID_API)' },
+ { host: 'liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion', reason: 'Liquid asset catalogue metadata over Tor (EXTERNAL_DATA_SERVER.LIQUID_ONION)' },
+];
+
/**
- * Places a host name may legitimately appear: research that names competitors,
- * fork provenance, this gate's own list, and documentation about the policy.
+ * Protocol specification hosts a comment or a `specification_url` field may
+ * cite. A citation is a link to spec text; it is never a fetch target, so the
+ * exemption does not apply on a call-site line.
*/
-const ALLOWED = [
+export const CITATION_HOSTS = [
+ { host: 'docs.ordinals.com', reason: 'Ordinals and Runes protocol specification text' },
+];
+
+/**
+ * Build inputs. Allowed only in build-context files (Dockerfiles, workflows,
+ * package manifests, the Docker init script): they are where the software
+ * comes from, not where its data comes from.
+ */
+export const BUILD_DEPENDENCY_HOSTS = [
+ 'registry.npmjs.org',
+ 'npmjs.com',
+ 'crates.io',
+ 'github.com',
+ 'raw.githubusercontent.com',
+ 'objects.githubusercontent.com',
+ 'api.github.com',
+ 'ghcr.io',
+ 'docker.io',
+ 'hub.docker.com',
+ 'deb.nodesource.com',
+ 'deb.debian.org',
+ 'security.debian.org',
+ 'dl-cdn.alpinelinux.org',
+ 'nodejs.org',
+ 'sh.rustup.rs',
+ 'static.rust-lang.org',
+ 'pypi.org',
+ 'files.pythonhosted.org',
+ 'playwright.azureedge.net',
+ 'cdn.playwright.dev',
+];
+
+/**
+ * Files that cite hosts rather than call them: research that names
+ * competitors, fork provenance, licences, upstream documentation, translations
+ * of that documentation, and the policy documents themselves.
+ */
+export const CITATION_PATHS = [
'frontend/src/app/docs/api-docs/api-docs.component.html',
'docs/research/',
'docs/legal/',
@@ -78,27 +191,80 @@ const ALLOWED = [
'README.md',
'contributors/',
'audits/',
- 'scripts/universe/check-origins.mjs',
- 'scripts/universe/check-branding.mjs',
'frontend/src/locale/',
'backend/README.md',
'frontend/README.md',
- 'production/',
- 'docker/',
- 'rust/',
- // Upstream test fixtures and sample configuration, never a runtime path.
+ 'docker/README.md',
+ 'production/README.md',
+ // The mining pool identification table: names, tags and website links of
+ // pools, never a data source.
+ 'backend/src/tasks/pools/',
+ // Inherited upstream mempool.space nginx deployment for its own hosts. The
+ // Universe release ships only production/linux (see the release workflow).
+ 'production/nginx/',
+ 'production/freebsd/',
+];
+
+/** The gate and its tests name forbidden hosts in order to reject them. */
+export const GATE_PATHS = [
+ 'scripts/universe/check-origins.mjs',
+ 'scripts/universe/check-origins.test.mjs',
+ 'scripts/universe/docker-runtime-defaults.test.mjs',
+ 'scripts/universe/check-branding.mjs',
+];
+
+/** Upstream test fixtures and sample data, never a runtime path. */
+export const TEST_FIXTURE_PATHS = [
'backend/src/__fixtures__/',
'frontend/cypress/',
+];
+
+/** Build-context files, where build-dependency hosts are expected. */
+export const BUILD_CONTEXT_PATHS = [
'.github/workflows/',
+ 'docker/init.sh',
+];
+const BUILD_CONTEXT_BASENAMES = new Set([
+ 'Dockerfile', 'package.json', 'package-lock.json', 'Cargo.toml', 'Cargo.lock',
+]);
+
+/**
+ * Runtime configuration: every absolute URL in these files is a value the
+ * process will use, so each one is judged by the runtime policy.
+ */
+export const RUNTIME_CONFIG_PATHS = [
+ 'docker/backend/start.sh',
+ 'docker/backend/mempool-config.json',
+ 'docker/frontend/entrypoint.sh',
+ 'docker/docker-compose.yml',
+ 'production/mempool-config.',
+ 'production/mempool-frontend-config.',
+ 'backend/mempool-config.sample.json',
+ 'frontend/mempool-frontend-config.sample.json',
+ 'frontend/proxy.conf',
'nginx.conf',
'nginx-mempool.conf',
];
+/** Tokens that mark a line as a runtime request site. */
+export const CALL_SITE_TOKENS = [
+ 'fetch(', 'axios', '.get(', '.post(', '.put(', '.request(', 'http.get', 'https.get',
+ 'http.request', 'https.request', 'WebSocket(', 'httpClient', 'this.http', 'got(',
+ 'proxy_pass', 'curl ', 'wget ', 'EventSource(', 'XMLHttpRequest',
+];
+
/**
* Sentences that name a host in order to disclaim it rather than call it.
* Recognised as whole phrases so a bundle scan still sees them after
* minification.
*/
+/**
+ * A script that refuses a forbidden host has to name it. The line carries this
+ * marker and the gate records nothing for the hosts on it. Bundles never
+ * carry the marker: they are scanned as-is.
+ */
+export const DENY_MARKER = 'origin-gate:deny';
+
const DISCLAIMER_PHRASES = [
'It is not affiliated with or endorsed by mempool.space.',
];
@@ -108,7 +274,64 @@ const TEXT_EXTENSIONS = new Set([
'.md', '.yml', '.yaml', '.txt', '.sh', '.rs', '.sql', '.conf', '.map',
]);
-/** Character ranges covered by a disclaimer sentence in this file. */
+const URL_PATTERN = /\b(?:https?|wss?):\/\/[^\s"'`<>()[\]{},;\\$]+/gi;
+const HOST_TOKEN_PATTERN = /(? 0 && TEXT_EXTENSIONS.has(name.slice(dot));
+function lineAt(contents, index) {
+ const start = contents.lastIndexOf('\n', index - 1) + 1;
+ let end = contents.indexOf('\n', index);
+ if (end === -1) end = contents.length;
+ return { number: contents.slice(0, start).split('\n').length, text: contents.slice(start, end) };
+}
+
+/** The configuration key or shell variable a reference is assigned to, if any. */
+function fieldOf(lineText) {
+ const json = lineText.match(/"([A-Za-z0-9_.-]+)"\s*:/);
+ if (json) return json[1];
+ const shell = lineText.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/);
+ if (shell) return shell[1];
+ const assignment = lineText.match(/^\s*(?:const|let|var|private|public|readonly|static)?\s*([A-Za-z_$][A-Za-z0-9_$.]*)\s*[:=]/);
+ if (assignment) return assignment[1];
+ const directive = lineText.match(/^\s*(proxy_pass|set|rewrite|return)\b/);
+ if (directive) return directive[1];
+ return undefined;
}
-function isAllowed(relativePath) {
+export function isCallSite(lineText) {
+ return CALL_SITE_TOKENS.some((token) => lineText.includes(token));
+}
+
+/**
+ * Every host reference in a text: parsed absolute URLs first, then bare host
+ * tokens that no URL already covered. Credentials are detected, never kept.
+ */
+export function extractReferences(contents) {
+ const references = [];
+ const covered = [];
+ for (const match of contents.matchAll(URL_PATTERN)) {
+ let url;
+ try {
+ url = new URL(match[0]);
+ } catch {
+ continue;
+ }
+ if (!url.hostname) continue;
+ covered.push([match.index, match.index + match[0].length]);
+ references.push({
+ index: match.index,
+ raw: match[0],
+ origin: `${url.protocol}//${url.host}`,
+ host: normalizeHost(url.hostname),
+ port: url.port || '',
+ protocol: url.protocol.replace(':', ''),
+ hasCredentials: url.username !== '' || url.password !== '',
+ absolute: true,
+ });
+ }
+ for (const match of contents.matchAll(HOST_TOKEN_PATTERN)) {
+ const start = match.index;
+ if (covered.some(([from, to]) => start >= from && start < to)) continue;
+ const host = normalizeHost(match[0]);
+ // Only classified hosts are worth a bare-token finding; every dotted
+ // identifier in a code base would otherwise be a host.
+ const { category } = classifyHost(host);
+ if (category !== 'forbidden' && category !== 'lookalike') continue;
+ references.push({
+ index: start, raw: match[0], origin: host, host, port: '', protocol: '',
+ hasCredentials: false, absolute: false,
+ });
+ }
+ return references.sort((a, b) => a.index - b.index);
+}
+
+function startsWithAny(posixPath, prefixes) {
+ return prefixes.some((prefix) => posixPath.startsWith(prefix));
+}
+
+/** The scanning context of a repository-relative path. */
+export function contextOf(relativePath) {
const posix = relativePath.split(sep).join('/');
- return ALLOWED.some((prefix) => posix.startsWith(prefix));
+ const name = basename(posix);
+ if (startsWithAny(posix, GATE_PATHS)) return 'gate';
+ if (startsWithAny(posix, TEST_FIXTURE_PATHS)) return 'fixture';
+ if (startsWithAny(posix, CITATION_PATHS)) return 'citation';
+ if (startsWithAny(posix, BUILD_CONTEXT_PATHS) || BUILD_CONTEXT_BASENAMES.has(name)) return 'build';
+ if (startsWithAny(posix, RUNTIME_CONFIG_PATHS)) return 'runtime-config';
+ if (posix.endsWith('.md')) return 'documentation';
+ if (TEST_FILE_PATTERN.test(posix)) return 'test';
+ return 'source';
+}
+
+/**
+ * Findings for one text. `context` is one of the values `contextOf` returns,
+ * or `bundle` for built output, or `rendered-config` for configuration a
+ * container start script produced.
+ */
+export function scanText(contents, { file = '', context = 'source' } = {}) {
+ if (context === 'gate' || context === 'fixture' || context === 'citation') return [];
+ const spans = disclaimerSpans(contents);
+ const findings = [];
+ const strict = context === 'bundle' || context === 'rendered-config' || context === 'runtime-config';
+ for (const reference of extractReferences(contents)) {
+ if (spans.some(([start, end]) => reference.index >= start && reference.index < end)) continue;
+ const line = lineAt(contents, reference.index);
+ const comment = !strict && COMMENT_LINE_PATTERN.test(line.text);
+ const callSite = isCallSite(line.text);
+ const verdict = classifyHost(reference.host);
+ const base = {
+ file, line: line.number, field: fieldOf(line.text), origin: reference.origin, host: reference.host,
+ port: reference.port, category: verdict.category, matched: verdict.matched,
+ };
+ if (verdict.category === 'forbidden' || verdict.category === 'lookalike') {
+ if (context === 'documentation' && !callSite) continue;
+ if (context !== 'bundle' && line.text.includes(DENY_MARKER)) continue;
+ findings.push({ ...base, kind: verdict.category === 'forbidden' ? 'forbidden-origin' : 'lookalike-origin' });
+ continue;
+ }
+ if (reference.hasCredentials && verdict.category !== 'loopback' && context !== 'documentation' && context !== 'test') {
+ findings.push({ ...base, kind: 'credential-in-url' });
+ }
+ if (!reference.absolute || context === 'documentation' || context === 'test') continue;
+ if (verdict.category === 'loopback' || verdict.category === 'local' || verdict.category === 'approved') continue;
+ if (verdict.category === 'metadata') continue;
+ // A .onion host cannot be verified as owned by its name. It is accepted
+ // only where an operator configured it as this deployment's onion
+ // transport, which is a field named for that purpose.
+ if (verdict.category === 'onion' && /ONION/i.test(base.field ?? '')) continue;
+ if (verdict.category === 'citation') {
+ if (callSite || strict) findings.push({ ...base, kind: 'citation-host-called' });
+ continue;
+ }
+ if (verdict.category === 'build' && context === 'build') continue;
+ if (context === 'bundle') continue;
+ if (comment && !callSite) continue;
+ if (strict || callSite) {
+ findings.push({ ...base, kind: 'unapproved-runtime-origin' });
+ }
+ if (findings.length > 40) break;
+ }
+ return findings;
+}
+
+function hasTextExtension(name) {
+ const dot = name.lastIndexOf('.');
+ return dot > 0 && TEXT_EXTENSIONS.has(name.slice(dot));
}
function* walk(directory, skipBuildOutput) {
@@ -138,73 +492,72 @@ function* walk(directory, skipBuildOutput) {
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
if (skipBuildOutput && SKIPPED_WHEN_WALKING_ROOT.has(entry.name)) continue;
yield* walk(full, skipBuildOutput);
- } else if (entry.isFile() && hasTextExtension(entry.name)) {
+ } else if (entry.isFile() && (hasTextExtension(entry.name) || BUILD_CONTEXT_BASENAMES.has(entry.name))) {
yield full;
}
}
}
-function findings(file) {
- const relativePath = relative(REPOSITORY_ROOT, file);
- if (isAllowed(relativePath)) return [];
+export function scanFile(file, { bundle = false, root = REPOSITORY_ROOT } = {}) {
+ const relativePath = relative(root, file).split(sep).join('/');
let contents;
try {
contents = readFileSync(file, 'utf8');
} catch {
return [];
}
- const spans = disclaimerSpans(contents);
- const hits = [];
- for (const host of FORBIDDEN_HOSTS) {
- // A host only counts when it stands on its own. Without the boundaries a
- // filename like `btc.component.html` reads as the host `btc.com`, and a
- // spec citation like `docs.ordinals.com` reads as `ordinals.com`.
- const pattern = new RegExp(
- `(? match.index >= start && match.index < end)) {
- continue;
- }
- const line = contents.slice(0, match.index).split('\n').length;
- hits.push({ file: relativePath, line, host });
- if (hits.length > 20) return hits;
+ const context = bundle ? 'bundle' : contextOf(relativePath);
+ return scanText(contents, { file: relativePath, context });
+}
+
+/**
+ * Scan the given targets. No target: the source tree, build output skipped.
+ * A directory target: a built bundle. A file target: judged by its path.
+ */
+export function scanTargets(argv, { root = REPOSITORY_ROOT } = {}) {
+ const targets = argv.length === 0 ? [root] : argv.map((entry) => resolve(root, entry));
+ const findings = [];
+ for (const target of targets) {
+ const stats = statSync(target);
+ if (stats.isDirectory()) {
+ const bundle = argv.length > 0;
+ for (const file of walk(target, argv.length === 0)) findings.push(...scanFile(file, { bundle, root }));
+ } else {
+ findings.push(...scanFile(target, { root }));
}
}
- return hits;
+ return findings;
}
-const explicitTargets = process.argv.slice(2);
-
-function targets(argv) {
- if (argv.length === 0) return [REPOSITORY_ROOT];
- return argv.map((entry) => resolve(REPOSITORY_ROOT, entry));
+export function formatFinding(finding) {
+ const field = finding.field ? ` [${finding.field}]` : '';
+ return ` ${finding.file}:${finding.line}: ${finding.kind} ${finding.origin}${field}`;
}
-const problems = [];
-for (const target of targets(explicitTargets)) {
- let stats;
+function main(argv) {
+ const json = argv.includes('--json');
+ const paths = argv.filter((entry) => entry !== '--json');
+ let findings;
try {
- stats = statSync(target);
- } catch {
- console.error(`Origin gate: ${target} does not exist.`);
- process.exit(1);
+ findings = scanTargets(paths);
+ } catch (error) {
+ console.error(`Origin gate: ${error.message}`);
+ return 1;
}
- if (stats.isDirectory()) {
- for (const file of walk(target, explicitTargets.length === 0)) problems.push(...findings(file));
- } else {
- problems.push(...findings(target));
+ if (json) {
+ console.log(JSON.stringify({ findings }, null, 2));
+ return findings.length > 0 ? 1 : 0;
}
-}
-
-if (problems.length > 0) {
- console.error(`Third-party origins found in ${problems.length} place(s):`);
- for (const problem of problems.slice(0, 50)) {
- console.error(` ${problem.file}:${problem.line}: ${problem.host}`);
+ if (findings.length > 0) {
+ console.error(`Third-party or unapproved origins found in ${findings.length} place(s):`);
+ for (const finding of findings.slice(0, 80)) console.error(formatFinding(finding));
+ console.error('\nRuntime data must come from Bitcoin Universe infrastructure only.');
+ return 1;
}
- console.error('\nRuntime data must come from Bitcoin Universe infrastructure only.');
- process.exit(1);
+ console.log('Origin gate passed: no third-party data origins found.');
+ return 0;
}
-console.log('Origin gate passed: no third-party data origins found.');
+if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
+ process.exitCode = main(process.argv.slice(2));
+}
diff --git a/scripts/universe/check-origins.test.mjs b/scripts/universe/check-origins.test.mjs
new file mode 100644
index 00000000000..7d1651622fc
--- /dev/null
+++ b/scripts/universe/check-origins.test.mjs
@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+import {
+ classifyHost, contextOf, extractReferences, formatFinding, isCallSite, normalizeHost, scanText,
+ FORBIDDEN_HOSTS, DENY_MARKER,
+} from './check-origins.mjs';
+
+const gate = new URL('./check-origins.mjs', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
+
+/**
+ * The six inert fixtures of the 2026-09-16 audit (evidence/origin-gate-
+ * reproduction.json). Each is one fetch expression in a file; the text is
+ * scanned, never executed.
+ */
+const AUDIT_FIXTURES = [
+ { name: 'root-domain', text: 'fetch("https://mempool.space/api/blocks/tip/height");', reject: true },
+ { name: 'forbidden-subdomain', text: 'fetch("https://api.hiro.so/extended/v1/tx");', reject: true },
+ { name: 'explorer-subdomain', text: 'fetch("https://api.mempool.space/api/blocks/tip/height");', reject: true },
+ { name: 'uppercase-forbidden', text: 'fetch("https://MEMPOOL.SPACE/api/blocks/tip/height");', reject: true },
+ { name: 'unlisted-provider', text: 'fetch("https://api.blockcypher.com/v1/btc/main");', reject: true },
+ { name: 'owned-control', text: 'fetch("https://explorer.bitcoinuniverse.io/api/v1/backend-info");', reject: false },
+];
+
+function kinds(findings) {
+ return findings.map((finding) => finding.kind);
+}
+
+test('the audit fixtures: five rejected, the owned control permitted by the approved policy only', () => {
+ for (const fixture of AUDIT_FIXTURES) {
+ const findings = scanText(fixture.text, { file: `${fixture.name}.js`, context: 'source' });
+ if (fixture.reject) {
+ assert.deepEqual(kinds(findings), ['forbidden-origin'], fixture.name);
+ } else {
+ assert.deepEqual(findings, [], fixture.name);
+ assert.equal(classifyHost('explorer.bitcoinuniverse.io').category, 'approved');
+ }
+ }
+ // The control is allowed because bitcoinuniverse.io is approved, not because
+ // it is merely absent from the denylist: an equally unknown host at the same
+ // call site is refused.
+ const unknown = scanText('fetch("https://explorer.someone-else.example/api/v1/backend-info");', { context: 'source' });
+ assert.deepEqual(kinds(unknown), ['unapproved-runtime-origin']);
+});
+
+test('the gate rejects the fixtures from the command line too, without executing them', () => {
+ const directory = mkdtempSync(join(tmpdir(), 'origin-fixtures-'));
+ try {
+ for (const fixture of AUDIT_FIXTURES) {
+ const file = join(directory, `${fixture.name}.js`);
+ writeFileSync(file, fixture.text);
+ const result = spawnSync(process.execPath, [gate, file], { encoding: 'utf8', timeout: 20_000 });
+ assert.equal(result.status, fixture.reject ? 1 : 0, `${fixture.name}: ${result.stdout}${result.stderr}`);
+ if (fixture.reject) assert.match(result.stderr, /forbidden-origin/);
+ const json = spawnSync(process.execPath, [gate, '--json', file], { encoding: 'utf8', timeout: 20_000 });
+ const { findings } = JSON.parse(json.stdout);
+ assert.equal(findings.length, fixture.reject ? 1 : 0);
+ if (fixture.reject) {
+ assert.ok(findings[0].file.endsWith(`${fixture.name}.js`));
+ assert.equal(findings[0].line, 1);
+ assert.ok(findings[0].origin);
+ }
+ }
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test('hosts are compared after WHATWG parsing, lower-casing and trailing-dot removal', () => {
+ assert.equal(normalizeHost('MEMPOOL.SPACE.'), 'mempool.space');
+ assert.equal(normalizeHost('[::1]'), '::1');
+ assert.equal(classifyHost('Api.Mempool.Space').category, 'forbidden');
+ assert.equal(classifyHost('mempool.space.').category, 'forbidden');
+ assert.equal(classifyHost('api.hiro.so').matched, 'hiro.so');
+ const [reference] = extractReferences('const u = "HTTPS://API.BLOCKCYPHER.COM:443/v1";');
+ assert.equal(reference.host, 'api.blockcypher.com');
+ assert.equal(reference.port, '');
+ assert.equal(reference.origin, 'https://api.blockcypher.com');
+});
+
+test('lookalike suffixes: an infix provider is rejected, a shared substring is not a match', () => {
+ // `mempool.space.evil.example` is not a subdomain of mempool.space; it is
+ // rejected as a lookalike so a registrable-domain trick never passes.
+ assert.equal(classifyHost('mempool.space.evil.example').category, 'lookalike');
+ assert.deepEqual(kinds(scanText('fetch("https://mempool.space.evil.example/api");')), ['lookalike-origin']);
+ // `notmempool.space` shares no label boundary with mempool.space. It is an
+ // unknown host, refused by the runtime policy rather than by the denylist,
+ // and never reported as mempool.space.
+ assert.equal(classifyHost('notmempool.space').category, 'unknown');
+ const findings = scanText('fetch("https://notmempool.space/api");');
+ assert.deepEqual(kinds(findings), ['unapproved-runtime-origin']);
+ assert.equal(findings[0].matched, undefined);
+ // A file name is not a host.
+ assert.deepEqual(scanText("import { BtcComponent } from './btc.component';"), []);
+});
+
+test('credentials in a URL are a finding and are never printed', () => {
+ const text = 'const r = await fetch("https://operator:hunter2-secret@services.example.net/api/v1");';
+ const findings = scanText(text, { file: 'src/x.ts', context: 'source' });
+ assert.deepEqual(kinds(findings), ['credential-in-url', 'unapproved-runtime-origin']);
+ for (const finding of findings) {
+ const printed = formatFinding(finding) + JSON.stringify(finding);
+ assert.doesNotMatch(printed, /hunter2|operator/);
+ assert.equal(finding.host, 'services.example.net');
+ }
+ // Credentials on an owned host are still a finding: a bearer in a URL is a
+ // secret in configuration.
+ const owned = scanText('SERVICES_API=https://token:abc@explorer.bitcoinuniverse.io/api', { context: 'runtime-config' });
+ assert.deepEqual(kinds(owned), ['credential-in-url']);
+ assert.doesNotMatch(JSON.stringify(owned), /abc/);
+ // Loopback credentials are local (Core RPC style) and not reported.
+ assert.deepEqual(scanText('const rpc = "http://user:pass@127.0.0.1:8332";'), []);
+});
+
+test('ports do not change the verdict', () => {
+ assert.deepEqual(scanText('fetch("https://explorer.bitcoinuniverse.io:8443/api/v1/backend-info");'), []);
+ const findings = scanText('fetch("https://mempool.space:443/api/v1/fees");');
+ assert.deepEqual(kinds(findings), ['forbidden-origin']);
+ assert.equal(findings[0].port, '');
+ const odd = scanText('proxy_pass http://node201.hnl.mempool.space:3000;', { context: 'runtime-config' });
+ assert.equal(odd[0].port, '3000');
+ assert.equal(odd[0].origin, 'http://node201.hnl.mempool.space:3000');
+});
+
+test('redirects to a forbidden provider are rejected', () => {
+ assert.deepEqual(kinds(scanText('return 301 https://mempool.space$request_uri;', { context: 'runtime-config' })), ['forbidden-origin']);
+ assert.deepEqual(kinds(scanText("res.redirect('https://api.blockcypher.com/v1/btc/main');")), ['forbidden-origin']);
+ assert.deepEqual(kinds(scanText('rewrite ^/liquid/(.*) https://liquid.network/$1;', { context: 'runtime-config' })), []);
+});
+
+test('approved metadata and citation contexts', () => {
+ // The Liquid asset catalogue is the documented metadata exception.
+ assert.deepEqual(scanText('"LIQUID_API": "https://liquid.network/api/v1",', { context: 'runtime-config' }), []);
+ // A specification citation in a comment or a specification field is not a fetch.
+ assert.deepEqual(scanText(' // see pointer docs: https://docs.ordinals.com/inscriptions/pointer.html'), []);
+ assert.deepEqual(scanText(" specification_url: 'https://docs.ordinals.com/runes.html',"), []);
+ // The same host at a call site is a call, not a citation.
+ assert.deepEqual(kinds(scanText("fetch('https://docs.ordinals.com/runes.html')")), ['citation-host-called']);
+ // Documentation may cite any host; a bundle may cite none.
+ assert.deepEqual(scanText('Compare with https://mempool.space/docs/api', { context: 'documentation' }), []);
+ assert.deepEqual(kinds(scanText('x="https://docs.ordinals.com/"', { context: 'bundle' })), ['citation-host-called']);
+ assert.deepEqual(kinds(scanText('x="https://mempool.space/"', { context: 'bundle' })), ['forbidden-origin']);
+ // A comment naming a forbidden provider in code is still a finding: comments
+ // are stripped from bundles, so the source scan is the only place it shows.
+ assert.deepEqual(kinds(scanText('// fallback: https://mempool.space/api')), ['forbidden-origin']);
+ // An unknown host in a code comment is a citation, not a call.
+ assert.deepEqual(scanText('// adapted from https://stackoverflow.example/q/1'), []);
+});
+
+test('build inputs are allowed in build context only', () => {
+ const line = 'RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash -';
+ assert.deepEqual(scanText(line, { context: 'build' }), []);
+ assert.deepEqual(kinds(scanText(line, { context: 'source' })), ['unapproved-runtime-origin']);
+ assert.deepEqual(kinds(scanText('"POOLS_JSON_URL": "https://raw.githubusercontent.com/x/y/pools.json",', { context: 'runtime-config' })), ['unapproved-runtime-origin']);
+ assert.equal(contextOf('docker/backend/Dockerfile'), 'build');
+ assert.equal(contextOf('.github/workflows/docker.yml'), 'build');
+ assert.equal(contextOf('docker/init.sh'), 'build');
+});
+
+test('runtime configuration values are all judged, call sites in code are judged, plain links are not', () => {
+ assert.equal(contextOf('docker/backend/start.sh'), 'runtime-config');
+ assert.equal(contextOf('docker/frontend/entrypoint.sh'), 'runtime-config');
+ assert.equal(contextOf('production/mempool-config.mainnet.json'), 'runtime-config');
+ assert.equal(contextOf('backend/mempool-config.sample.json'), 'runtime-config');
+ assert.equal(contextOf('backend/src/api/about.routes.ts'), 'source');
+ assert.equal(contextOf('backend/src/__tests__/config.test.ts'), 'test');
+ assert.equal(contextOf('docs/operations/INSTALL.md'), 'documentation');
+ assert.equal(contextOf('docs/research/competitors.md'), 'citation');
+ assert.equal(contextOf('scripts/universe/check-origins.test.mjs'), 'gate');
+ // Configuration: an unknown host is a finding even without a call token.
+ assert.deepEqual(kinds(scanText('__X_API__=${X_API:=https://api.partner.example/v1}', { context: 'runtime-config' })), ['unapproved-runtime-origin']);
+ // Code: an unknown host is a finding only where a request is made.
+ assert.ok(isCallSite('axios.get(url)'));
+ assert.deepEqual(kinds(scanText("axios.get('https://api.partner.example/v1/tx')")), ['unapproved-runtime-origin']);
+ assert.deepEqual(scanText("link: 'https://pool.example/about'"), []);
+ // Same-origin, loopback and container names are fine everywhere.
+ assert.deepEqual(scanText("fetch('/api/v1/blocks'); fetch('http://127.0.0.1:8999/x'); fetch('http://api:8999/x'); fetch('ws://localhost:8999/ws');", { context: 'runtime-config' }), []);
+ // Unit tests keep the denylist and nothing else.
+ assert.deepEqual(kinds(scanText("fetch('https://user:pw@partner.example/x'); fetch('https://mempool.space/x');", { context: 'test' })), ['forbidden-origin']);
+});
+
+test('a script that refuses a forbidden host may name it on a marked line', () => {
+ const line = ` mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion) ;; # ${DENY_MARKER}`;
+ assert.deepEqual(scanText(line, { context: 'runtime-config' }), []);
+ assert.deepEqual(kinds(scanText(line.replace(` # ${DENY_MARKER}`, ''), { context: 'runtime-config' })), ['forbidden-origin']);
+ assert.deepEqual(kinds(scanText(line, { context: 'bundle' })), ['forbidden-origin']);
+});
+
+test('findings are structured: file, line, field, origin', () => {
+ const [finding] = scanText('__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}', { file: 'docker/backend/start.sh', context: 'runtime-config' });
+ assert.equal(finding.file, 'docker/backend/start.sh');
+ assert.equal(finding.line, 1);
+ assert.equal(finding.field, '__MEMPOOL_SERVICES_API__');
+ assert.equal(finding.origin, 'https://mempool.space');
+ assert.equal(formatFinding(finding), ' docker/backend/start.sh:1: forbidden-origin https://mempool.space [__MEMPOOL_SERVICES_API__]');
+ const [json] = scanText(' "API": "https://mempool.space/api/v1/services",', { file: 'c.json', context: 'runtime-config' });
+ assert.equal(json.field, 'API');
+});
+
+test('the denylist covers the providers the audit named', () => {
+ for (const host of ['mempool.space', 'api.mempool.space', 'hiro.so', 'unisat.io', 'blockcypher.com', 'ordinals.com', 'blockstream.info']) {
+ assert.equal(classifyHost(host).category, 'forbidden', host);
+ }
+ assert.ok(FORBIDDEN_HOSTS.every((host) => host === normalizeHost(host)));
+});
diff --git a/scripts/universe/docker-runtime-defaults.test.mjs b/scripts/universe/docker-runtime-defaults.test.mjs
new file mode 100644
index 00000000000..b5d99d351eb
--- /dev/null
+++ b/scripts/universe/docker-runtime-defaults.test.mjs
@@ -0,0 +1,217 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { delimiter, join, sep } from 'node:path';
+import { tmpdir } from 'node:os';
+import test from 'node:test';
+import { scanText } from './check-origins.mjs';
+
+/**
+ * The container start scripts render the runtime configuration that the
+ * images actually run with. These tests run the real scripts against copies
+ * of the real templates and judge the rendered result with the origin gate:
+ * an unconfigured service must fail visibly rather than reach a third party.
+ */
+
+const root = new URL('../../', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
+const repo = (...parts) => join(root, ...parts);
+const posix = (path) => path.split(sep).join('/');
+const startSh = readFileSync(repo('docker', 'backend', 'start.sh'), 'utf8');
+const entrypointSh = readFileSync(repo('docker', 'frontend', 'entrypoint.sh'), 'utf8');
+const backendTemplate = readFileSync(repo('docker', 'backend', 'mempool-config.json'), 'utf8');
+
+/** On Windows the POSIX tools the scripts use (find, sed, envsubst) live in Git's own bin directories. */
+const posixBins = process.platform === 'win32'
+ ? spawnSync('bash', ['-c', 'cygpath -w /usr/bin; cygpath -w /mingw64/bin'], { encoding: 'utf8' }).stdout.split(/\r?\n/).filter(Boolean)
+ : [];
+
+/** A shell environment with nothing of this process's configuration in it. */
+function bareEnvironment(extra = {}, binDirectory) {
+ const environment = {
+ PATH: [binDirectory, ...posixBins, process.env.PATH].filter(Boolean).join(delimiter),
+ HOME: process.env.HOME ?? process.env.USERPROFILE ?? tmpdir(),
+ TEMP: tmpdir(), TMP: tmpdir(),
+ };
+ if (process.env.SYSTEMROOT) environment.SYSTEMROOT = process.env.SYSTEMROOT;
+ if (process.env.SystemRoot) environment.SystemRoot = process.env.SystemRoot;
+ return { ...environment, ...extra };
+}
+
+/** Run docker/backend/start.sh in a scratch directory; `node` is a stub. */
+function renderBackend(extra = {}) {
+ const directory = mkdtempSync(join(tmpdir(), 'backend-render-'));
+ try {
+ const bin = join(directory, 'bin');
+ mkdirSync(bin);
+ writeFileSync(join(bin, 'node'), '#!/bin/sh\nexit 0\n');
+ chmodSync(join(bin, 'node'), 0o755);
+ writeFileSync(join(directory, 'start.sh'), startSh.replaceAll('\r\n', '\n'));
+ writeFileSync(join(directory, 'mempool-config.json'), backendTemplate);
+ const result = spawnSync('bash', ['start.sh'], {
+ cwd: directory, encoding: 'utf8', env: bareEnvironment(extra, bin), timeout: 30_000,
+ });
+ assert.equal(result.error, undefined);
+ const rendered = readFileSync(join(directory, 'mempool-config.json'), 'utf8');
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr, rendered };
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+/** Run docker/frontend/entrypoint.sh against scratch nginx and web roots. */
+function renderFrontend(extra = {}) {
+ const directory = mkdtempSync(join(tmpdir(), 'frontend-render-'));
+ try {
+ const etc = join(directory, 'etc', 'nginx');
+ const patch = join(directory, 'patch');
+ const www = join(directory, 'www', 'resources');
+ mkdirSync(join(etc, 'conf.d'), { recursive: true });
+ mkdirSync(patch);
+ mkdirSync(www, { recursive: true });
+ copyFileSync(repo('nginx.conf'), join(etc, 'nginx.conf'));
+ copyFileSync(repo('nginx-mempool.conf'), join(etc, 'conf.d', 'nginx-mempool.conf'));
+ // The shape frontend/generate-config.js writes for string settings.
+ writeFileSync(join(www, 'config.template.js'), [
+ '(function (window) {',
+ ' window.__env = window.__env || {};',
+ " window.__env.MEMPOOL_WEBSITE_URL = '${__MEMPOOL_WEBSITE_URL__}';",
+ " window.__env.SERVICES_API = '${__SERVICES_API__}';",
+ " window.__env.ONION_SERVICES_API = '${__ONION_SERVICES_API__}';",
+ ' }(this));',
+ ].join('\n'));
+ writeFileSync(join(www, 'config.js'), '');
+ writeFileSync(join(directory, 'entrypoint.sh'), entrypointSh.replaceAll('\r\n', '\n'));
+ const result = spawnSync('bash', ['entrypoint.sh', 'true'], {
+ cwd: directory, encoding: 'utf8', timeout: 30_000,
+ // Forward slashes: the script hands these paths to find and xargs.
+ env: bareEnvironment({ NGINX_ETC: posix(etc), PATCH_DIR: posix(patch), MEMPOOL_WWW: posix(join(directory, 'www')), ...extra }),
+ });
+ assert.equal(result.error, undefined);
+ const rendered = readFileSync(join(www, 'config.js'), 'utf8');
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr, rendered };
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+test('the shipped templates carry no third-party or unapproved runtime origin', () => {
+ for (const [file, text] of [
+ ['docker/backend/start.sh', startSh],
+ ['docker/frontend/entrypoint.sh', entrypointSh],
+ ['docker/backend/mempool-config.json', backendTemplate],
+ ]) {
+ assert.deepEqual(scanText(text, { file, context: 'runtime-config' }), [], file);
+ }
+});
+
+test('backend: a minimal environment renders valid JSON with no external data source', () => {
+ const { status, stderr, rendered } = renderBackend();
+ assert.equal(status, 0, stderr);
+ const config = JSON.parse(rendered);
+ assert.deepEqual(scanText(rendered, { file: 'mempool-config.json', context: 'rendered-config' }), []);
+ assert.equal(config.EXTERNAL_DATA_SERVER.MEMPOOL_API, '');
+ assert.equal(config.EXTERNAL_DATA_SERVER.MEMPOOL_ONION, '');
+ assert.equal(config.MEMPOOL_SERVICES.API, '');
+ assert.equal(config.MEMPOOL_SERVICES.ACCELERATIONS, false);
+ assert.equal(config.FIAT_PRICE.ENABLED, false);
+ assert.equal(config.MEMPOOL.AUTOMATIC_POOLS_UPDATE, false);
+ assert.equal(config.MEMPOOL.POOLS_JSON_URL, '');
+ assert.equal(config.MEMPOOL.POOLS_JSON_TREE_URL, '');
+ assert.deepEqual(config.ESPLORA.FALLBACK, []);
+ // The one documented external metadata source stays: the Liquid asset catalogue.
+ assert.equal(config.EXTERNAL_DATA_SERVER.LIQUID_API, 'https://liquid.network/api/v1');
+ assert.doesNotMatch(rendered, /mempool\.space|mempoolhqx4|blockstream|__[A-Z0-9_]+__/);
+});
+
+test('backend: explicit owned endpoints are preserved exactly', () => {
+ const owned = {
+ EXTERNAL_DATA_SERVER_MEMPOOL_API: 'https://explorer.bitcoinuniverse.io/api/v1',
+ EXTERNAL_DATA_SERVER_MEMPOOL_ONION: 'http://universe3xample2onion4address5for6tests7only8abcdefg.onion/api/v1',
+ MEMPOOL_SERVICES_API: 'https://Services.BitcoinUniverse.io:8443/api/v1/services',
+ MEMPOOL_POOLS_JSON_URL: 'http://api:8999/pools/pools-v2.json',
+ MEMPOOL_POOLS_JSON_TREE_URL: '/pools/tree',
+ FIAT_PRICE_ENABLED: 'true',
+ };
+ const { status, stderr, rendered } = renderBackend(owned);
+ assert.equal(status, 0, stderr);
+ const config = JSON.parse(rendered);
+ assert.equal(config.EXTERNAL_DATA_SERVER.MEMPOOL_API, owned.EXTERNAL_DATA_SERVER_MEMPOOL_API);
+ assert.equal(config.EXTERNAL_DATA_SERVER.MEMPOOL_ONION, owned.EXTERNAL_DATA_SERVER_MEMPOOL_ONION);
+ assert.equal(config.MEMPOOL_SERVICES.API, owned.MEMPOOL_SERVICES_API);
+ assert.equal(config.MEMPOOL.POOLS_JSON_URL, owned.MEMPOOL_POOLS_JSON_URL);
+ assert.equal(config.MEMPOOL.POOLS_JSON_TREE_URL, owned.MEMPOOL_POOLS_JSON_TREE_URL);
+ assert.equal(config.FIAT_PRICE.ENABLED, true);
+ assert.deepEqual(scanText(rendered, { file: 'mempool-config.json', context: 'rendered-config' }), []);
+});
+
+test('backend: disallowed, malformed and credential-bearing endpoints stop the container without echoing secrets', () => {
+ const cases = [
+ { env: { MEMPOOL_SERVICES_API: 'https://mempool.space/api/v1/services' }, message: /MEMPOOL_SERVICES_API .*host: mempool\.space/ },
+ { env: { MEMPOOL_SERVICES_API: 'https://MEMPOOL.SPACE./api/v1/services' }, message: /host: mempool\.space\)/ },
+ { env: { EXTERNAL_DATA_SERVER_MEMPOOL_API: 'https://api.mempool.space/api/v1' }, message: /EXTERNAL_DATA_SERVER_MEMPOOL_API .*host: api\.mempool\.space/ },
+ { env: { EXTERNAL_DATA_SERVER_MEMPOOL_API: 'https://explorer.bitcoinuniverse.io.evil.example/api/v1' }, message: /host: explorer\.bitcoinuniverse\.io\.evil\.example/ },
+ { env: { EXTERNAL_DATA_SERVER_MEMPOOL_ONION: 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1' }, message: /EXTERNAL_DATA_SERVER_MEMPOOL_ONION .*host: mempoolhqx4/ },
+ { env: { EXTERNAL_DATA_SERVER_MEMPOOL_API: 'http://universe3xample2onion4address5for6tests7only8abcdefg.onion/api/v1' }, message: /EXTERNAL_DATA_SERVER_MEMPOOL_API .*\.onion/ },
+ { env: { EXTERNAL_DATA_SERVER_LIQUID_API: 'https://blockstream.info/liquid/api' }, message: /EXTERNAL_DATA_SERVER_LIQUID_API .*host: blockstream\.info/ },
+ { env: { MEMPOOL_POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json' }, message: /MEMPOOL_POOLS_JSON_URL .*host: raw\.githubusercontent\.com/ },
+ { env: { MEMPOOL_SERVICES_API: 'ftp://explorer.bitcoinuniverse.io/services' }, message: /MEMPOOL_SERVICES_API must be empty, a \/path, or an http\(s\) URL/ },
+ { env: { MEMPOOL_SERVICES_API: 'explorer.bitcoinuniverse.io/services' }, message: /MEMPOOL_SERVICES_API must be empty/ },
+ { env: { MEMPOOL_SERVICES_API: 'https://explorer.bitcoinuniverse.io/api"; rm -rf /' }, message: /MEMPOOL_SERVICES_API/ },
+ { env: { MEMPOOL_SERVICES_API: 'https://svc:s3cretpass@explorer.bitcoinuniverse.io/api/v1/services' }, message: /MEMPOOL_SERVICES_API must not carry credentials/, secret: 's3cretpass' },
+ { env: { EXTERNAL_DATA_SERVER_MEMPOOL_API: 'https://token-abc123@mempool.space/api/v1' }, message: /must not carry credentials/, secret: 'token-abc123' },
+ ];
+ for (const { env, message, secret } of cases) {
+ const { status, stdout, stderr, rendered } = renderBackend(env);
+ assert.equal(status, 78, `${JSON.stringify(env)} ${stdout}${stderr}`);
+ assert.match(stderr, message, JSON.stringify(env));
+ assert.match(stderr, /refusing to start/);
+ // Configuration is not rendered on refusal: the template is untouched.
+ assert.match(rendered, /__MEMPOOL_SERVICES_API__/);
+ if (secret) {
+ assert.doesNotMatch(stdout + stderr, new RegExp(secret));
+ }
+ }
+});
+
+test('frontend: a minimal environment renders no services endpoint and no third-party link', () => {
+ const { status, stderr, rendered } = renderFrontend();
+ assert.equal(status, 0, stderr);
+ assert.deepEqual(scanText(rendered, { file: 'config.js', context: 'rendered-config' }), []);
+ assert.match(rendered, /window\.__env\.SERVICES_API = '';/);
+ assert.match(rendered, /window\.__env\.ONION_SERVICES_API = '';/);
+ assert.match(rendered, /window\.__env\.MEMPOOL_WEBSITE_URL = 'https:\/\/explorer\.bitcoinuniverse\.io';/);
+ assert.doesNotMatch(rendered, /mempool\.space|mempoolhqx4|\$\{__/);
+});
+
+test('frontend: owned endpoints are preserved exactly and onion transport is explicit configuration', () => {
+ const owned = {
+ SERVICES_API: 'https://services.bitcoinuniverse.io/api/v1/services',
+ ONION_SERVICES_API: 'http://universe3xample2onion4address5for6tests7only8abcdefg.onion/api/v1/services',
+ };
+ const { status, stderr, rendered } = renderFrontend(owned);
+ assert.equal(status, 0, stderr);
+ assert.match(rendered, new RegExp(`SERVICES_API = '${owned.SERVICES_API}';`));
+ assert.match(rendered, new RegExp(`ONION_SERVICES_API = '${owned.ONION_SERVICES_API}';`));
+ assert.deepEqual(scanText(rendered, { file: 'config.js', context: 'rendered-config' }), []);
+});
+
+test('frontend: disallowed, malformed and credential-bearing endpoints stop the container without echoing secrets', () => {
+ const cases = [
+ { env: { SERVICES_API: 'https://mempool.space/api/v1/services' }, message: /SERVICES_API .*host: mempool\.space/ },
+ { env: { SERVICES_API: 'https://Api.Mempool.Space/api/v1/services' }, message: /host: api\.mempool\.space/ },
+ { env: { SERVICES_API: 'http://universe3xample2onion4address5for6tests7only8abcdefg.onion/api/v1/services' }, message: /SERVICES_API .*\.onion/ },
+ { env: { ONION_SERVICES_API: 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/services' }, message: /ONION_SERVICES_API .*mempoolhqx4/ },
+ { env: { ONION_SERVICES_API: 'https://services.example.net/api' }, message: /ONION_SERVICES_API .*host: services\.example\.net/ },
+ { env: { SERVICES_API: 'wss://services.bitcoinuniverse.io/api' }, message: /SERVICES_API must be empty, a \/path, or an http\(s\) URL/ },
+ { env: { SERVICES_API: 'https://svc:s3cretpass@services.bitcoinuniverse.io/api/v1/services' }, message: /must not carry credentials/, secret: 's3cretpass' },
+ { env: { PROXIED_SERVICES: 'true' }, message: /PROXIED_SERVICES=true needs PROXIED_SERVICES_HOST/ },
+ { env: { PROXIED_SERVICES: 'true', PROXIED_SERVICES_HOST: 'https://mempool.space' }, message: /PROXIED_SERVICES_HOST .*host: mempool\.space/ },
+ ];
+ for (const { env, message, secret } of cases) {
+ const { status, stdout, stderr, rendered } = renderFrontend(env);
+ assert.equal(status, 78, `${JSON.stringify(env)} ${stdout}${stderr}`);
+ assert.match(stderr, message, JSON.stringify(env));
+ assert.equal(rendered, '', 'config.js is not rendered on refusal');
+ if (secret) assert.doesNotMatch(stdout + stderr, new RegExp(secret));
+ }
+});
diff --git a/scripts/universe/workflow-safety.test.mjs b/scripts/universe/workflow-safety.test.mjs
index 5a80ce2ba57..da73f281c56 100644
--- a/scripts/universe/workflow-safety.test.mjs
+++ b/scripts/universe/workflow-safety.test.mjs
@@ -78,6 +78,38 @@ test('actual Docker compose preparation assigns database tmpfs to the database a
} finally { rmSync(directory, { recursive: true, force: true }); }
});
+test('container gate: develop and main pull requests touching the templates build without publishing', () => {
+ const source = workflow('docker');
+ const [before, after] = source.split('\njobs:\n');
+ // Triggers: pull requests to the fork's integration branches, scoped to the
+ // container templates and the runtime configuration they render, plus an
+ // explicit dispatch. No tag push, no upstream master, no label shortcut.
+ assert.match(before, /pull_request:\n branches: \[develop, main\]/);
+ assert.doesNotMatch(before, /^\s+push:\n\s+tags:/m);
+ assert.doesNotMatch(source, /master|docker-push|labeled/);
+ for (const path of ["'docker/**'", "'backend/mempool-config.sample.json'", "'frontend/mempool-frontend-config.sample.json'", "'production/mempool-config.*.json'", "'rust/gbt/rust-toolchain'"]) {
+ assert.ok(before.includes(` - ${path}`), path);
+ }
+ assert.match(before, /workflow_dispatch:\n inputs:\n tag:/);
+ assert.match(before, /publish:\n[\s\S]*?type: boolean\n\s+default: false/);
+ // The gate job builds locally and never pushes; only the dispatched
+ // publication job does, and only when asked.
+ const gate = after.split('\n build:\n')[0];
+ const publish = after.split('\n build:\n')[1].split('\n tag-latest:\n')[0];
+ const latest = after.split('\n tag-latest:\n')[1];
+ assert.match(gate, /--load/);
+ assert.doesNotMatch(gate, /push=true|login-action|DOCKER_PASSWORD/);
+ assert.match(publish, /if: \|\n\s+needs\.test-images\.result == 'success' &&\n\s+github\.event_name == 'workflow_dispatch' &&\n\s+github\.event\.inputs\.publish == 'true'/);
+ assert.match(latest, /github\.event\.inputs\.publish == 'true' && github\.event\.inputs\.latest == 'true'/);
+ assert.doesNotMatch(source, /github\.ref_name|GITHUB_REF\//);
+ // Every job keeps the shared runner labels used elsewhere.
+ assert.equal((source.match(/runs-on: \[self-hosted, linux, x64, universe-super\]/g) ?? []).length, 3);
+ // The dispatched tag is validated before it reaches a shell or a registry.
+ for (const block of [gate, publish, latest]) {
+ assert.match(block, /case "\$DISPATCH_TAG" in\n\s+''\|\*\[!A-Za-z0-9\._-\]\*\)/);
+ }
+});
+
test('Docker build cannot mutate global swap or restart the shared daemon', () => {
const source = workflow('docker');
assert.doesNotMatch(source, /sudo\s+(?:swapoff|swapon|mkswap|mount)|sudo\s+systemctl\s+restart\s+docker|\/mnt\/swapfile/);
From 4829ac2828261845cdd123c223cce139953450f8 Mon Sep 17 00:00:00 2001
From: Bitcoin Universe
Date: Thu, 17 Sep 2026 05:41:30 +0000
Subject: [PATCH 08/19] fix(config): no upstream onion default for the external
data server
The origin gate now refuses the inherited mempool.space onion as a backend
default; the field starts empty and the deny fixture in the relay test is
marked as such.
Co-Authored-By: Claude Opus 5
---
backend/src/__tests__/config.test.ts | 2 +-
.../api/intelligence/private-submission/private-relay.test.ts | 2 +-
backend/src/config.ts | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts
index d9ed56321a2..e88b738f163 100644
--- a/backend/src/__tests__/config.test.ts
+++ b/backend/src/__tests__/config.test.ts
@@ -124,7 +124,7 @@ describe('Mempool Backend Config', () => {
expect(config.EXTERNAL_DATA_SERVER).toStrictEqual({
MEMPOOL_API: '',
- MEMPOOL_ONION: 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
+ MEMPOOL_ONION: '',
LIQUID_API: 'https://liquid.network/api/v1',
LIQUID_ONION: 'http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1'
});
diff --git a/backend/src/api/intelligence/private-submission/private-relay.test.ts b/backend/src/api/intelligence/private-submission/private-relay.test.ts
index a1d785cf951..8c65ea40ff9 100644
--- a/backend/src/api/intelligence/private-submission/private-relay.test.ts
+++ b/backend/src/api/intelligence/private-submission/private-relay.test.ts
@@ -193,7 +193,7 @@ describe('private relay endpoint configuration', () => {
it('rejects endpoints on another network, clearnet submit hosts, bad proxies and duplicates by id', () => {
const parsed = parsePrivateRelayEndpoints(JSON.stringify([
{ id: 'mainnet-tor', transport: 'tor', proxy: 'socks5h://127.0.0.1:9050', submitUrl: 'http://abc.onion/api/tx', network: 'mainnet' },
- { id: 'clearnet', transport: 'tor', proxy: 'socks5h://127.0.0.1:9050', submitUrl: 'https://mempool.space/api/tx', network: NETWORK },
+ { id: 'clearnet', transport: 'tor', proxy: 'socks5h://127.0.0.1:9050', submitUrl: 'https://example.com/api/tx', network: NETWORK }, // a clearnet submit host (not .onion) must be refused
{ id: 'http-proxy', transport: 'tor', proxy: 'http://127.0.0.1:8118', submitUrl: 'http://abc.onion/api/tx', network: NETWORK },
{ id: 'ok', transport: 'i2p', proxy: 'socks5h://127.0.0.1:4447', submitUrl: 'http://abc.b32.i2p/api/tx', network: NETWORK },
{ id: 'ok', transport: 'i2p', proxy: 'socks5h://127.0.0.1:4447', submitUrl: 'http://def.b32.i2p/api/tx', network: NETWORK },
diff --git a/backend/src/config.ts b/backend/src/config.ts
index 2f31e694903..87d60912283 100644
--- a/backend/src/config.ts
+++ b/backend/src/config.ts
@@ -309,7 +309,7 @@ const defaults: IConfig = {
'EXTERNAL_DATA_SERVER': {
// Unset by default: this deployment never calls a hosted third-party API.
'MEMPOOL_API': '',
- 'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
+ 'MEMPOOL_ONION': '',
'LIQUID_API': 'https://liquid.network/api/v1',
'LIQUID_ONION': 'http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1'
},
From 1cd0da2ec0421c2c37bd73553cb24d20d26a63c3 Mon Sep 17 00:00:00 2001
From: Bitcoin Universe
Date: Thu, 17 Sep 2026 05:46:25 +0000
Subject: [PATCH 09/19] feat(bootstrap): trusted AssumeUTXO catalogue, byte
verification, measured plans and authorized operator jobs
BE-DEF-018: a signed catalogue (UNIVERSE_BOOTSTRAP_SNAPSHOT_CATALOGUE) with
ed25519 producer keys and operator-pinned Bitcoin Core commitments backs
listSnapshots/getSnapshot; verifySnapshot streams the actual bytes from an
allowlisted source under a size and deadline bound, checks sha256, manifest
signature, network magic, base block and height against the owned node, and
reproduces Core's hash_serialized_3 while streaming (proved against a real
Core 28.0 regtest dumptxoutset fixture). Runs persist in
universe_bootstrap_verifications; a missing run is 404, a missing store 503.
BE-DEF-019: createBootstrapPlan uses the measured node capabilities and
chainstate, a statfs or operator-measured capacity, and a verified pinned
snapshot compatible with the node's exact release; it rejects unsupported
network/version, missing capability, unmeasured or stale capacity,
insufficient space, unverified snapshots and ineligible chainstates.
BE-DEF-020: operator POST routes sit behind the admin adapter guard (private
origin plus signed request) and loads need signed elevation and a typed
confirmation; jobs are durable (universe_bootstrap_jobs) with idempotency
keys and leases, executed by a bounded worker that issues dumptxoutset or
loadtxoutset with a configured path only and records Core's own result and
the observed chainstate; a timeout or a crash after the RPC ends in
needs-review, never a re-issued RPC.
Co-Authored-By: Claude Opus 5
---
backend/src/api/database-migration.ts | 36 +-
.../bootstrap/__fixtures__/README.md | 13 +
.../__fixtures__/regtest-105.snapshot.base64 | 1 +
.../bootstrap/__fixtures__/support.ts | 149 ++++
.../bootstrap/bootstrap-capacity.ts | 70 ++
.../bootstrap/bootstrap-config.ts | 55 ++
.../bootstrap/bootstrap-errors.ts | 16 +
.../bootstrap/bootstrap-executor.test.ts | 220 ++++++
.../bootstrap/bootstrap-executor.ts | 397 +++++++++++
.../bootstrap/bootstrap-preconditions.ts | 132 ++++
.../bootstrap/bootstrap-store.test.ts | 70 ++
.../intelligence/bootstrap/bootstrap-store.ts | 236 +++++++
.../bootstrap/bootstrap.models.ts | 279 +++++++-
.../bootstrap/bootstrap.routes.test.ts | 144 ++++
.../bootstrap/bootstrap.routes.ts | 71 +-
.../bootstrap/bootstrap.service.ts | 656 +++++++++++++++---
.../intelligence/bootstrap/bootstrap.test.ts | 313 ++++++++-
.../bootstrap/node-reader.test.ts | 2 +-
.../bootstrap/snapshot-catalogue.ts | 229 ++++++
.../bootstrap/snapshot-format.test.ts | 132 ++++
.../intelligence/bootstrap/snapshot-format.ts | 457 ++++++++++++
.../bootstrap/snapshot-verifier.ts | 344 +++++++++
.../verification-integrity.test.ts | 14 +-
23 files changed, 3872 insertions(+), 164 deletions(-)
create mode 100644 backend/src/api/intelligence/bootstrap/__fixtures__/README.md
create mode 100644 backend/src/api/intelligence/bootstrap/__fixtures__/regtest-105.snapshot.base64
create mode 100644 backend/src/api/intelligence/bootstrap/__fixtures__/support.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-capacity.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-config.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-errors.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-executor.test.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-executor.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-preconditions.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-store.test.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap-store.ts
create mode 100644 backend/src/api/intelligence/bootstrap/bootstrap.routes.test.ts
create mode 100644 backend/src/api/intelligence/bootstrap/snapshot-catalogue.ts
create mode 100644 backend/src/api/intelligence/bootstrap/snapshot-format.test.ts
create mode 100644 backend/src/api/intelligence/bootstrap/snapshot-format.ts
create mode 100644 backend/src/api/intelligence/bootstrap/snapshot-verifier.ts
diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts
index 8628a094f50..9547b2ab7ba 100644
--- a/backend/src/api/database-migration.ts
+++ b/backend/src/api/database-migration.ts
@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
- private static currentVersion = 109;
+ private static currentVersion = 110;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@@ -1467,6 +1467,40 @@ class DatabaseMigration {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await this.updateToSchemaVersion(109);
}
+ if (databaseSchemaVersion < 110) {
+ // Bootstrap (AssumeUTXO) verification runs and operator jobs. Each row
+ // carries the backend's own network; the JSON document is the record.
+ await this.$executeQuery(`CREATE TABLE IF NOT EXISTS universe_bootstrap_verifications (
+ verification_id CHAR(36) NOT NULL,
+ snapshot_id VARCHAR(128) NOT NULL,
+ network VARCHAR(16) NOT NULL,
+ state VARCHAR(16) NOT NULL,
+ created_at DATETIME(3) NOT NULL,
+ updated_at DATETIME(3) NOT NULL,
+ document JSON NOT NULL,
+ PRIMARY KEY (verification_id),
+ INDEX universe_bootstrap_verifications_snapshot (snapshot_id, network, created_at),
+ INDEX universe_bootstrap_verifications_state (network, state, updated_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
+ await this.$executeQuery(`CREATE TABLE IF NOT EXISTS universe_bootstrap_jobs (
+ job_id CHAR(36) NOT NULL,
+ network VARCHAR(16) NOT NULL,
+ node_id VARCHAR(64) NOT NULL,
+ job_type VARCHAR(32) NOT NULL,
+ idempotency_key VARCHAR(128) NOT NULL,
+ state VARCHAR(16) NOT NULL,
+ lease_owner VARCHAR(128) NULL,
+ lease_expires_at DATETIME(3) NULL,
+ created_at DATETIME(3) NOT NULL,
+ updated_at DATETIME(3) NOT NULL,
+ document JSON NOT NULL,
+ PRIMARY KEY (job_id),
+ UNIQUE INDEX universe_bootstrap_jobs_idempotency (network, idempotency_key),
+ INDEX universe_bootstrap_jobs_claim (network, state, lease_expires_at, created_at),
+ INDEX universe_bootstrap_jobs_node (network, node_id, state)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
+ await this.updateToSchemaVersion(110);
+ }
if (databaseSchemaVersion < 106 && config.MEMPOOL.NETWORK === 'liquid') {
// In a specific setup it's possible that 3G6neksSBMp51kHJ2if8SeDUrzT8iVETWT and bc1qwnevjp8nsq7adu3hxlvdvslrf242q4vuavfg0y929jp2zntp3vgq7cq6z2
diff --git a/backend/src/api/intelligence/bootstrap/__fixtures__/README.md b/backend/src/api/intelligence/bootstrap/__fixtures__/README.md
new file mode 100644
index 00000000000..cb32bc7f03a
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/__fixtures__/README.md
@@ -0,0 +1,13 @@
+# Bootstrap fixtures
+
+`regtest-105.snapshot.base64` is the exact `dumptxoutset` output of an offline
+Bitcoin Core 28.0 regtest node (no peers, no network) at height 105 with 107
+coins covering P2WPKH, P2PKH, P2SH-P2WPKH, P2TR, compressed P2PK, uncompressed
+P2PK, a raw OP_TRUE script and a three-output transaction. Core reported:
+
+- base_hash `0be30936f2332969e5dd82fe06d0c4d8d25d86351442ec813c2f70aa132c2acb`, base_height 105
+- coins_written 107
+- `gettxoutsetinfo hash_serialized_3` = `2fff9d5b75bf5539b20a99cbae5639819995d0b560d2cfda171aafed870418fc`
+- file sha256 `facbe9af2dede0a3322ec7b9c46328f4f86cfc4764cab11b90b3821104cceb7e` (6349 bytes)
+
+The tests decode it with the streaming parser and must reproduce those values.
diff --git a/backend/src/api/intelligence/bootstrap/__fixtures__/regtest-105.snapshot.base64 b/backend/src/api/intelligence/bootstrap/__fixtures__/regtest-105.snapshot.base64
new file mode 100644
index 00000000000..f192bc4e52e
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/__fixtures__/regtest-105.snapshot.base64
@@ -0,0 +1 @@
+dXR4b/8CAPq/tdrLKiwTqnAvPIHsQhQ1hl3S2MTQBv6C3eVpKTPyNgnjC2sAAAAAAAAAAHXm5YFd622oqbWFIhY57IDbrBgwwsKnv7yKoTTmRy0BAH0yHAAU9sHlbyVoiZooiU5idBL7v62KADgAustdwKLTQDLQ874gLOEcrHD7Dpc0R/QKAr0Ej8gmkgEAbzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOAQbFvLf6lEnqBdOtKOfBJkOcfyMGT7EsvxGiQs0mJmTAQAxMhwAFPbB5W8laImaKIlOYnQS+7+tigA4B2RScrkNGRJ57q3im2fvTh2IxUqcV/23tWqXsFCyFF0BAG0yHAAU9sHlbyVoiZooiU5idBL7v62KADgMNZ2XdyZ9QeNVuXm6RVCiXf+yV/m/EjrcDNBvGQak5wEAOzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOBCIiOxNA7frL5knDH0PywhC93druLhfe0O8+7SJlK1WAQCAKzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOBC39yCWqrpGEx9UIJEqiFAla2/OoXu93rhiIDx2IR6nAQCAPzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOBSvcHD6yi82l9gryUuOVB51arsFkwxrib2tofLh5L1MAQANMhwAFPbB5W8laImaKIlOYnQS+7+tigA4Fu+QyxroM435qIl6Gx3s5V2NEZ8WqA+YN2eZw3YlGvcBAF0yHAAU9sHlbyVoiZooiU5idBL7v62KADgX8LbMydlUsKTnTNaFYMEh2Vpzv3CyJ8V1JqZ6dBX/3wEAgAEyHAAU9sHlbyVoiZooiU5idBL7v62KADgdfSLuGDK3W5Lzg4N2R7lQqIezCRi6g+Qcf9cvqGXthQEAETIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOCIDpzknwoDO1zcf0SgY/Kqn4s6oyEh0kC+Lde5+UqSRAQCAETIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOCM4VzvqhsVLkNxcLbHU4Ik7u+38jnoJAqdoWD+TWID8AQB3MhwAFPbB5W8laImaKIlOYnQS+7+tigA4JEm8RTrhMHslRPdRia5ee53rzZM6VJ7JrSlU78w8FjkBAIA7MhwAFPbB5W8laImaKIlOYnQS+7+tigA4JIv5+/EJ5n8JXTVs9Ds07/ez/R1AYD5smLMnBaNDq4oBAB8yHAAU9sHlbyVoiZooiU5idBL7v62KADgkr3nKApIDM2C/7upZBrN8A30rOM/1/h+QX+GUIQty9wEAgAsyHAAU9sHlbyVoiZooiU5idBL7v62KADglQcfc+CWKmMIQXF0PDi17p9GZOIRCfVkmzjR+/+HjjAEASTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOCcHwqgeNFvQueO8kPm8lcmiZhogNT2wIa/2G+d1GQ0tAQCAPTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOCwyzddQUC2KQSBCdwjPOwoTm5fri4DpO05ws/qhrlL9AQATMhwAFPbB5W8laImaKIlOYnQS+7+tigA4L2IHvaHZHOEIi6GLZInDUbtFfQfklGNG00lp4XhbEMoBAHMyHAAU9sHlbyVoiZooiU5idBL7v62KADg/1Jw6k+VJPyZ/Vygf4vtHGeVYYVgorryI1DgcOTSENAEAUTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOEBcWiLvFqvs7m+b0IgI1kQfTBMQagXE9lr7qVTmsn73AQA3MhwAFPbB5W8laImaKIlOYnQS+7+tigA4QKIimiw6xdaLrP++9T+8q84NPOGElJK4mOSd1LYUW7UBAIAzMhwAFPbB5W8laImaKIlOYnQS+7+tigA4Q176yjCJGK9fIlbiSC5HGSXOXFyzCl8/TnmqO3DHCesBABkyHAAU9sHlbyVoiZooiU5idBL7v62KADhGB9kw0apT4lJFcNr0Xiac9gGsYaxYiMGVc5fn1Ys0WgEAIzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOEbqezFAm0tEZBKNcNHoeErXj3aVbRnKTNev8VMKml9vAQAvMhwAFPbB5W8laImaKIlOYnQS+7+tigA4SHX3N16PQmuBZPu/TMsUSY8jT44F7LmRJODqsGcN3vMBAIBRMgdRSi+m3kLS7wEjHBjCnxOev34lu4sCpvKD6htu13MlAV8BAHsyHAAU9sHlbyVoiZooiU5idBL7v62KADhUAHVO4CTOeJfBiOiiCL3VcLBh6tyeWflPOqBK8HhoOwEAYzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOFdts/LV1PyQwUXqaZ3AScTtkwm06VLGtUokJ99JhQRDAQBVMhwAFPbB5W8laImaKIlOYnQS+7+tigA4WT4Vcnv+DbXlQKELNNDGbdpUuiZ8OISS04Cgd9Jl8EUBAIAZMhwAFPbB5W8laImaKIlOYnQS+7+tigA4WnqMM2+8B38FvW25jWpHlxrAZlys+jOQpqLpipSQ5mQBAIAVMhwAFPbB5W8laImaKIlOYnQS+7+tigA4XO/DSXZ8eA5Rp30DUsYk7q0EmRoGPPePQLybENQI8K0BAGkyHAAU9sHlbyVoiZooiU5idBL7v62KADhfFxmCT3vxxPR8XcjTGF9FvEiitxPcR31QiLmNnSxm7AEAgDcyHAAU9sHlbyVoiZooiU5idBL7v62KADhfjKtpflVidrtInDiJp2SXs9oR0RQu92oq+I2m119OAwEAgEkyHAAU9sHlbyVoiZooiU5idBL7v62KADhhM/3sXYf4d/TTVwXcrqqIZnMcpflbhXgJup/N3pbItAQAgFKAor6zzlcoUSCmUvLoTIOzCEy5JvSCcU1zueioowUASj4/ArSVz9z5OQGAUoOQ5+o9AK04LR2/zouRwJBvVEyFBxc8WblwAoBSMihRIPTjmvgPf89QercQJ63ylT6+kqvXYk+QgT8LatX+Hz2bA4BSBgGo0wD45z/+WUc55akW2ORzoN92zGMJ8AJhCLRd6DCuDbI3xWxRUDhTgFwZaWvff019B2SSAQBFMhwAFPbB5W8laImaKIlOYnQS+7+tigA4Yz5t2XOyMa7pGVdQtJ/DANF5EdSS96P7gSGve5g2CBgBAB0yHAAU9sHlbyVoiZooiU5idBL7v62KADhjXZWpnYcMCVgX7cU/g5o/eC5mf1Wr/SVFWZUUiLNKBgEAazIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOGaKuKV2Mwl5r/bs7o/1+JmbemBA0i/BC/ykSS67/jchAQCARzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOGmsa1leXuzfsVx8gkjoyeIWLte+/DJHAlheBQLfAhBWAQArMhwAFPbB5W8laImaKIlOYnQS+7+tigA4aiHOeBv+OlwcG2clKa6MqLYVcvAvSyMcw8RfdgfRUL0BAAUyHAAU9sHlbyVoiZooiU5idBL7v62KADhr0TdKF6wtY2wCJzXIHzaFbAbYkN/xz0MBd7MqUwQeAAEAgAMyHAAU9sHlbyVoiZooiU5idBL7v62KADhuGcWDpv3UOrU9kT0F1/MUqUUC54kjEPvatOBS8m2VqAEALTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOG80WJRSNpFyTLfp9RhOEsz/Hj9eyFUBN+ZF8l/yak9GAQCAIzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOHMBHCgTIIwi5oKSXPBwLoDXP2lE5sQlyBgtp9sHiRFJAQBLMhwAFPbB5W8laImaKIlOYnQS+7+tigA4eq75k/uumoBGfVlPcExATrhEFdY9q+Vqg9UeIamUYqkBAH8yHAAU9sHlbyVoiZooiU5idBL7v62KADh8+/pSr3mzueEfCWASv5U+d/W5NLAbazbs2Kk1zt1uVQEAgB8yHAAU9sHlbyVoiZooiU5idBL7v62KADh9ddhWCL/Xzeo5BhzDD9a5vfL80dhO0WAKKTUf/7Hl4AEAgAUyHAAU9sHlbyVoiZooiU5idBL7v62KADh+vI8u4D4ofKPUpn56zyxN4CTEumZoHOyjsWkpYwumCwEAJzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOH9FMc/HQHA5xhEOnrx0IA6CWeRMZcAxDC8DFDse2VdvAQCADTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOIP50TW3A7LuXpaWF+DU5JMtcjvmWl+XkXk8FOODlJ7/AQAXMhwAFPbB5W8laImaKIlOYnQS+7+tigA4hQY11faLiwIEerbYU94estMJNPJ3w3iZvglJpTD49mEBAIAXMhwAFPbB5W8laImaKIlOYnQS+7+tigA4hifoh7hARyC8d0yyerozak7rOn70BTssagIcwHfgNgsBAD0yHAAU9sHlbyVoiZooiU5idBL7v62KADiGhKXkobgVz4+sNyqsXAPfZYKgzLW9oWjun+p1GpGHjAEAgCkyHAAU9sHlbyVoiZooiU5idBL7v62KADiIah9OGe3pNMJmONFjxk3yUIlVev1T1QgTw9T9xcSFJAEAPzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOInDNpr4KF6ZBbkuajaLOksYBlTpkw2Fa30pCSnxbfw3AQCATzIEeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5iOW/6LmMpE4fJHCSWSLvZuaNPp9TGI1VcOycOwmh95QAEAgCcyHAAU9sHlbyVoiZooiU5idBL7v62KADiQLX2jGtDkIYd/fBwWp0lLxoKOSHYSrb7zvKybCHSXMAEAFTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOJAzs2QSmOyr3ES10ngZTtn2YinA71fSoB0MH1JcCYBbAQBPMhwAFPbB5W8laImaKIlOYnQS+7+tigA4kLfS11HjDMtqspYqsG2v6nWVL6sVOSj5N8xnblSzeD8BAGUyHAAU9sHlbyVoiZooiU5idBL7v62KADiSd1DEUOzzTxBZhUBJlyTurtHHPEXXbVZL7TUvPdDjSAEAWzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOJPw4uySptcjUnee2QRX1FZDGRypFNRiN+gYn91btS6eAQBZMhwAFPbB5W8laImaKIlOYnQS+7+tigA4ljH/aUHJ2xRW2GmTC712gC519p/RVJbOCNNxvUee8jkBAIATMhwAFPbB5W8laImaKIlOYnQS+7+tigA4lvjW49yRnACWRdNaH+aaQ3b+7dPP/f0wK2iPKT6nJ64BAIA1MhwAFPbB5W8laImaKIlOYnQS+7+tigA4mEEb01o3bdPYrG19CUkWdlzA8Bb2E6g5msiGTxyJ+NsBADMyHAAU9sHlbyVoiZooiU5idBL7v62KADieToYAwfSyTDbZeM5jGGBJrGzdm9/rQ0J0mlt48SVQygEAgC8yHAAU9sHlbyVoiZooiU5idBL7v62KADiexAlW9ttZT/2wDTRCcpq1tYN2rIM49QQQv55fAZz+kgEAXzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOJ7sfzULuL1JyjX6oSJY9na6vTxYhjw7eJ+HXuIPCuqtAQBBMhwAFPbB5W8laImaKIlOYnQS+7+tigA4n35I/T4InhwgaH197MUfaWgT3KuThFp8BW1wXVyUQ9QBAHUyHAAU9sHlbyVoiZooiU5idBL7v62KADiiTqffO1EzzveJqFr8q7C5n/HA09o37JtHovLr9iv9xwEADzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOKRHCW2KVirQ/Hpu4OB6I7XsX3x04yr6Ug4HDbCqNoxjAQCAOTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOKUyZEYoQdFVtGbj/3ktfzOuu1bOnekM/foGQZiSQzrqAQBXMhwAFPbB5W8laImaKIlOYnQS+7+tigA4p4EhZD5tcQDKzcXEPnKNfdGNRsTzyVJkO8KaBxkn894BAHkyHAAU9sHlbyVoiZooiU5idBL7v62KADipXS+idvDdrRq+mOHXCl8le60b08STm9Rgt8wucmGGrQEACzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOKnLGuyd8RWdWnqYQNfuPBC/u4W7JEKPjuUdLQXCDas/AQAlMhwAFPbB5W8laImaKIlOYnQS+7+tigA4sClqH+5tASzOPYVUwjVtM4CkkxsAJd88Sj2A2Dj3qGwBAAcyHAAU9sHlbyVoiZooiU5idBL7v62KADiyLZ8Ld8JAboV2Z0ZvfgMf4iyGjRraI55yaNQzoItEOAEAgBsyHAAU9sHlbyVoiZooiU5idBL7v62KADi4U1bdPijm4/QgiayMZbOUSilPcynxYsTI30mMMerdUQEAYTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOLp4o+4rZJacf1xG7GsEgvsaPJKt+jtQdJq8h3nS26dOAQAhMhwAFPbB5W8laImaKIlOYnQS+7+tigA4vC+wqzsZl6ma3X8ek6jTNasZmHN/V+0KfT3Mi8vEUCcBAEcyHAAU9sHlbyVoiZooiU5idBL7v62KADjAV4fBuQVYzCcD+GxXgnjZSRqAC2+F6nyE/D4+o+rwGQEAgEMyHAAU9sHlbyVoiZooiU5idBL7v62KADjBSrx7ic4B6Hs8jwTfO31ZeXLYVPPJbTFHOwWTo+s3DgEAgDEyHAAU9sHlbyVoiZooiU5idBL7v62KADjB+APhb9vv4IpvkKgTLQSEYWGzkcJjI5Ai2zyg2tgSfgEAgAkyHAAU9sHlbyVoiZooiU5idBL7v62KADjEm3Q+EsumoQvStfo4hPDe5IeGh292epckDqvxHvjyswEAgEsyHAAU9sHlbyVoiZooiU5idBL7v62KADjF2vIdR9zYKP6TRc/e/lJzteRVMIAIREyFD4JGziZKkwEAGzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOMhW2/i6IUptmyriA1ccuOwZi/p2AbsKpIcyd5XOhYZjAQApMhwAFPbB5W8laImaKIlOYnQS+7+tigA4yXDl3P0yR/10U/xvCFOrQcLfV/qyoUoWX7aOEkjhtO4BAHEyHAAU9sHlbyVoiZooiU5idBL7v62KADjKBdAPkJzMfbIryTl+c9zWhfkwxozUI44yCO5qp2T8MgEAgAcyHAAU9sHlbyVoiZooiU5idBL7v62KADjKP1lqgeO+iW3YeTtKsoUmkQb1R8/K8W2Svz6d1OrbmwEAgA8yHAAU9sHlbyVoiZooiU5idBL7v62KADjLf+2IWWvx6n6CqBPd0v07A7laGEgtLClmAObLAgrECgEAgC0yHAAU9sHlbyVoiZooiU5idBL7v62KADjPot85utqo8C6+QnCanHYyuLsB3uSwhqe/Cyh9r52aoQEAgB0yHAAU9sHlbyVoiZooiU5idBL7v62KADjSPYqnNlPRQAo3JHuJVW9yRMxafIBUg+IDxll2TsM0mgEAgE0yAnm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeY2ZKd/P721gVcWbuW09LAnYdXk9Jobk9zJDZXrgr+2YYBAGcyHAAU9sHlbyVoiZooiU5idBL7v62KADjdnQjyD/8FL/Uzy17Fd4Rbmqu1Elj9FTMgGGvCtFDZHQEAQzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOOlTnj6SL3RKF+EI7WweJYCEWaTqImsuLP1G9+6CtzJwAQA1MhwAFPbB5W8laImaKIlOYnQS+7+tigA47kbtkioLGuxRoZSOC86phRM8caDbuqLkwuqttDOXro8BADkyHAAU9sHlbyVoiZooiU5idBL7v62KADjwPVJhD9uGaCHDcgiooARPe+qo9We7hBHC5pgx6hMQ9wEAgEUyHAAU9sHlbyVoiZooiU5idBL7v62KADjxFc+WicbKG+pS/9hdAy7+fk2Dh0QLjfNgoxPitydqoQEAgCEyHAAU9sHlbyVoiZooiU5idBL7v62KADj1hsiYResWD3RsKDHrewiOfYJqMRwwAvu3Fw28nMi27AEATTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOPddwd4JKkpi6e9oYxVMhSHa4re+SOeH2iyMaOgkWnzMAQCAUzICeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5j5g4v1F4jj4l96gohQtkCXHV/97fQDmciXIOvTu7hDhgEAgEEyHAAU9sHlbyVoiZooiU5idBL7v62KADj7Sjj6qyKLliVNBqsqM8urWD+aCrGC6ZRZn2ABCgwKggEAUzIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOP4pmIVY0gfU74sr1mX1zy17jUuDTWpGZqA3H05WK0Z7AQCAJTIcABT2weVvJWiJmiiJTmJ0Evu/rYoAOA==
diff --git a/backend/src/api/intelligence/bootstrap/__fixtures__/support.ts b/backend/src/api/intelligence/bootstrap/__fixtures__/support.ts
new file mode 100644
index 00000000000..23809b6f532
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/__fixtures__/support.ts
@@ -0,0 +1,149 @@
+import { createHash, generateKeyPairSync, KeyObject, sign } from 'crypto';
+import { readFileSync } from 'fs';
+import { join } from 'path';
+import { BootstrapEnvironment } from '../bootstrap-config';
+import { BootstrapCatalogue, BootstrapCatalogueSnapshot } from '../bootstrap.models';
+import { deterministicSnapshotJson } from '../snapshot-catalogue';
+import { SnapshotByteSource } from '../snapshot-verifier';
+
+/** Test doubles shared by the bootstrap suites. Nothing here runs in production. */
+
+export const FIXTURE_BYTES = Buffer.from(readFileSync(join(__dirname, 'regtest-105.snapshot.base64'), 'utf8'), 'base64');
+export const FIXTURE = {
+ baseHash: '0be30936f2332969e5dd82fe06d0c4d8d25d86351442ec813c2f70aa132c2acb',
+ height: 105,
+ coins: 107,
+ hashSerialized3: '2fff9d5b75bf5539b20a99cbae5639819995d0b560d2cfda171aafed870418fc',
+ sha256: createHash('sha256').update(FIXTURE_BYTES).digest('hex'),
+ genesis: '0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206',
+};
+
+export function producerKeys(): { privateKey: KeyObject; publicKeyHex: string } {
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
+ const jwk = publicKey.export({ format: 'jwk' }) as { x: string };
+ return { privateKey, publicKeyHex: Buffer.from(jwk.x, 'base64url').toString('hex') };
+}
+
+export function signSnapshot(snapshot: BootstrapCatalogueSnapshot, privateKey: KeyObject): string {
+ return sign(null, deterministicSnapshotJson(snapshot), privateKey).toString('hex');
+}
+
+export function catalogueFor(
+ privateKey: KeyObject,
+ publicKeyHex: string,
+ overrides: Partial = {},
+ pinned: Partial = {}
+): BootstrapCatalogue {
+ const snapshot: BootstrapCatalogueSnapshot = {
+ id: 'regtest-105',
+ network: 'regtest',
+ coreVersion: '28.0.0',
+ height: FIXTURE.height,
+ blockHash: FIXTURE.baseHash,
+ sizeBytes: FIXTURE_BYTES.length,
+ sha256: FIXTURE.sha256,
+ source: '/allowed/regtest-105.dat',
+ manifest: { producerId: 'lab', signature: '' },
+ ...overrides,
+ };
+ snapshot.manifest = { producerId: snapshot.manifest.producerId, signature: snapshot.manifest.signature || signSnapshot(snapshot, privateKey) };
+ return {
+ producers: [{ id: 'lab', algorithm: 'ed25519', publicKey: publicKeyHex }],
+ pinnedCommitments: [{ network: 'regtest', coreVersion: '28.0.0', height: FIXTURE.height, blockHash: FIXTURE.baseHash, utxoCommitment: FIXTURE.hashSerialized3, coinCount: FIXTURE.coins, ...pinned }],
+ snapshots: [snapshot],
+ };
+}
+
+export function testEnvironment(overrides: Partial = {}): BootstrapEnvironment {
+ return {
+ catalogueFile: undefined,
+ sourceAllowlist: ['/allowed'],
+ maxSnapshotBytes: 1 << 20,
+ verifyDeadlineMs: 5000,
+ snapshotDir: '/var/lib/bitcoin/snapshots',
+ datadir: '/var/lib/bitcoin',
+ capacityBytes: undefined,
+ capacityMeasuredAt: undefined,
+ capacityMaxAgeMs: 24 * 3600 * 1000,
+ jobTimeoutMs: 5000,
+ jobLeaseMs: 60000,
+ jobPollMs: 60000,
+ ...overrides,
+ };
+}
+
+/** Serves a buffer in chunks; `stall` never ends, for deadline tests. */
+export function byteSource(bytes: Buffer, options: { chunk?: number; stall?: boolean } = {}): SnapshotByteSource & { opened: string[] } {
+ const opened: string[] = [];
+ return {
+ opened,
+ async open(source): Promise & { destroy?: () => void }> {
+ opened.push(source);
+ let destroyed = false;
+ const chunk = options.chunk ?? 512;
+ /** @asyncUnsafe Test double; the consumer owns the iteration. */
+ async function* generate(): AsyncGenerator {
+ for (let i = 0; i < bytes.length; i += chunk) {
+ if (destroyed) {
+ return;
+ }
+ yield bytes.subarray(i, Math.min(bytes.length, i + chunk));
+ }
+ if (options.stall) {
+ while (!destroyed) {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ }
+ }
+ const iterable = generate() as AsyncGenerator & { destroy?: () => void };
+ iterable.destroy = () => {
+ destroyed = true;
+ };
+ return iterable;
+ },
+ };
+}
+
+export type CoreCall = (method: string, params: unknown[]) => Promise;
+
+/** A regtest Core 28.0 at the fixture height, fully validated, reachable through the reader RPCs. */
+export function fakeCore(overrides: { blocks?: number; headers?: number; ibd?: boolean; subversion?: string; help?: string; chainstates?: any[]; calls?: string[] } = {}): { network: string; call: CoreCall; calls: string[] } {
+ const calls = overrides.calls ?? [];
+ const blocks = overrides.blocks ?? FIXTURE.height;
+ const headers = overrides.headers ?? blocks;
+ const hashes: Record = { 0: FIXTURE.genesis, [FIXTURE.height]: FIXTURE.baseHash, 104: '0330fd86eb1429db0fd4dec8c659556aeb265f3c98ac9bd75d0f18c0204b10a3' };
+ const hashAt = (h: number): string => hashes[h] ?? createHash('sha256').update('block' + h).digest('hex');
+ const tip = hashAt(blocks);
+ const chainstates = overrides.chainstates ?? [{ blocks, bestblockhash: tip, verificationprogress: 1, validated: true, coins_db_cache_bytes: 8388608, coins_tip_cache_bytes: 461373440 }];
+ return {
+ network: 'regtest',
+ calls,
+ async call(method, params) {
+ calls.push(method);
+ switch (method) {
+ case 'getblockhash':
+ return hashAt(Number(params[0]));
+ case 'getblockheader': {
+ const height = Object.keys(hashes).map(Number).find((h) => hashes[h] === params[0]);
+ const found = height ?? [...Array(headers + 1).keys()].find((h) => hashAt(h) === params[0]);
+ if (found === undefined) {
+ throw new Error('Block not found');
+ }
+ return { hash: params[0], height: found, confirmations: blocks - found + 1 };
+ }
+ case 'getblockchaininfo':
+ return { chain: 'regtest', blocks, headers, bestblockhash: tip, initialblockdownload: overrides.ibd ?? false, size_on_disk: 12345678 };
+ case 'getnetworkinfo':
+ return { version: 280000, subversion: overrides.subversion ?? '/Satoshi:28.0.0/' };
+ case 'getchainstates':
+ return { headers, chainstates };
+ case 'help':
+ return overrides.help ?? 'dumptxoutset "path"\ngetchainstates\nloadtxoutset "path"\n';
+ case 'getbestblockhash':
+ return tip;
+ default:
+ throw new Error('unexpected RPC ' + method);
+ }
+ },
+ };
+}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-capacity.ts b/backend/src/api/intelligence/bootstrap/bootstrap-capacity.ts
new file mode 100644
index 00000000000..a55cd470e24
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-capacity.ts
@@ -0,0 +1,70 @@
+import { promises as fs } from 'fs';
+import { BootstrapEnvironment } from './bootstrap-config';
+import { BootstrapEvidenceError } from './bootstrap-errors';
+
+export interface CapacityMeasurement {
+ method: 'statfs' | 'configured';
+ measured_at: string;
+ free_bytes: number;
+ total_bytes: number | null;
+}
+
+export type StatfsReader = (path: string) => Promise<{ bsize: number; bavail: number; blocks: number }>;
+
+/**
+ * The node's disk capacity as this backend can actually measure it: a statfs
+ * of the configured datadir when it is on this host, otherwise the operator's
+ * own measurement (UNIVERSE_BOOTSTRAP_CAPACITY_BYTES with the time it was
+ * taken) minus the block data Core reports. A caller's declared free disk is
+ * never a substitute for either.
+ */
+/** @asyncUnsafe Rejections are typed BootstrapEvidenceErrors for the caller. */
+export async function measureCapacity(
+ env: BootstrapEnvironment,
+ blocksOnDiskBytes: number,
+ now: number = Date.now(),
+ statfs: StatfsReader = (path) => fs.statfs(path) as Promise<{ bsize: number; bavail: number; blocks: number }>
+): Promise {
+ if (env.datadir) {
+ try {
+ const stat = await statfs(env.datadir);
+ return {
+ method: 'statfs',
+ measured_at: new Date(now).toISOString(),
+ free_bytes: Number(stat.bavail) * Number(stat.bsize),
+ total_bytes: Number(stat.blocks) * Number(stat.bsize),
+ };
+ } catch (e) {
+ throw new BootstrapEvidenceError(
+ 'capacity-not-measured',
+ 'The configured datadir could not be measured: ' + (e instanceof Error ? e.message : String(e))
+ );
+ }
+ }
+ if (env.capacityBytes === undefined) {
+ throw new BootstrapEvidenceError(
+ 'capacity-not-measured',
+ 'No owned capacity measurement is configured (UNIVERSE_BOOTSTRAP_DATADIR or UNIVERSE_BOOTSTRAP_CAPACITY_BYTES with UNIVERSE_BOOTSTRAP_CAPACITY_MEASURED_AT).'
+ );
+ }
+ const measuredAt = Date.parse(env.capacityMeasuredAt ?? '');
+ if (!Number.isFinite(measuredAt)) {
+ throw new BootstrapEvidenceError(
+ 'capacity-not-measured',
+ 'The configured capacity carries no measurement time (UNIVERSE_BOOTSTRAP_CAPACITY_MEASURED_AT).'
+ );
+ }
+ if (now - measuredAt > env.capacityMaxAgeMs || measuredAt > now) {
+ throw new BootstrapEvidenceError(
+ 'stale-measurement',
+ `The configured capacity was measured at ${new Date(measuredAt).toISOString()}, outside the accepted age of ${env.capacityMaxAgeMs} ms.`,
+ 409
+ );
+ }
+ return {
+ method: 'configured',
+ measured_at: new Date(measuredAt).toISOString(),
+ free_bytes: Math.max(0, env.capacityBytes - blocksOnDiskBytes),
+ total_bytes: env.capacityBytes,
+ };
+}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-config.ts b/backend/src/api/intelligence/bootstrap/bootstrap-config.ts
new file mode 100644
index 00000000000..541115c0e70
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-config.ts
@@ -0,0 +1,55 @@
+/**
+ * Additive environment configuration for the bootstrap surface. Every key is
+ * optional; an absent key leaves the matching subfeature unavailable and the
+ * read-only node observation untouched.
+ */
+export interface BootstrapEnvironment {
+ /** Path to the trusted catalogue JSON (producers, pinnedCommitments, snapshots). */
+ catalogueFile?: string;
+ /** Comma separated https origins and local directory prefixes a snapshot source may live under. */
+ sourceAllowlist: string[];
+ /** Upper bound on snapshot bytes a verification will stream. */
+ maxSnapshotBytes: number;
+ /** Wall clock deadline for one verification run. */
+ verifyDeadlineMs: number;
+ /** Directory on the Core host that generated snapshots are written to. */
+ snapshotDir?: string;
+ /** Core datadir path for a statfs capacity measurement. */
+ datadir?: string;
+ /** Operator measured volume capacity in bytes, with the time it was measured. */
+ capacityBytes?: number;
+ capacityMeasuredAt?: string;
+ capacityMaxAgeMs: number;
+ /** Deadline for one operator RPC (dumptxoutset or loadtxoutset). */
+ jobTimeoutMs: number;
+ /** Lease a worker holds on a running job; a crashed worker's job is reclaimed after it lapses. */
+ jobLeaseMs: number;
+ /** How often the worker looks for queued or lapsed jobs. */
+ jobPollMs: number;
+}
+
+const integer = (value: string | undefined, fallback: number): number => {
+ const n = Number(value);
+ return value !== undefined && Number.isSafeInteger(n) && n > 0 ? n : fallback;
+};
+
+export function readBootstrapEnvironment(env: NodeJS.ProcessEnv = process.env): BootstrapEnvironment {
+ const capacity = Number(env.UNIVERSE_BOOTSTRAP_CAPACITY_BYTES);
+ return {
+ catalogueFile: env.UNIVERSE_BOOTSTRAP_SNAPSHOT_CATALOGUE || undefined,
+ sourceAllowlist: String(env.UNIVERSE_BOOTSTRAP_SOURCE_ALLOWLIST || '')
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean),
+ maxSnapshotBytes: integer(env.UNIVERSE_BOOTSTRAP_MAX_SNAPSHOT_BYTES, 32 * 1024 ** 3),
+ verifyDeadlineMs: integer(env.UNIVERSE_BOOTSTRAP_VERIFY_DEADLINE_MS, 60 * 60 * 1000),
+ snapshotDir: env.UNIVERSE_BOOTSTRAP_SNAPSHOT_DIR || undefined,
+ datadir: env.UNIVERSE_BOOTSTRAP_DATADIR || undefined,
+ capacityBytes: Number.isSafeInteger(capacity) && capacity > 0 ? capacity : undefined,
+ capacityMeasuredAt: env.UNIVERSE_BOOTSTRAP_CAPACITY_MEASURED_AT || undefined,
+ capacityMaxAgeMs: integer(env.UNIVERSE_BOOTSTRAP_CAPACITY_MAX_AGE_MS, 24 * 60 * 60 * 1000),
+ jobTimeoutMs: integer(env.UNIVERSE_BOOTSTRAP_JOB_TIMEOUT_MS, 6 * 60 * 60 * 1000),
+ jobLeaseMs: integer(env.UNIVERSE_BOOTSTRAP_JOB_LEASE_MS, 5 * 60 * 1000),
+ jobPollMs: integer(env.UNIVERSE_BOOTSTRAP_JOB_POLL_MS, 15 * 1000),
+ };
+}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-errors.ts b/backend/src/api/intelligence/bootstrap/bootstrap-errors.ts
new file mode 100644
index 00000000000..7ae987eb584
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-errors.ts
@@ -0,0 +1,16 @@
+export class BootstrapEvidenceError extends Error {
+ constructor(
+ public readonly code: string,
+ message: string,
+ public readonly status = 503
+ ) {
+ super(message);
+ }
+}
+
+export const unavailable = (code: string, prerequisite: string): never => {
+ throw new BootstrapEvidenceError(
+ code,
+ 'Bootstrap evidence is unavailable. ' + prerequisite
+ );
+};
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-executor.test.ts b/backend/src/api/intelligence/bootstrap/bootstrap-executor.test.ts
new file mode 100644
index 00000000000..b131c3e4437
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-executor.test.ts
@@ -0,0 +1,220 @@
+import { BootstrapJobExecutor } from './bootstrap-executor';
+import { MemoryBootstrapStore } from './bootstrap-store';
+import { BootstrapService } from './bootstrap.service';
+import { BootstrapNodeReader } from './node-reader';
+import { NodeBootstrapJob } from './bootstrap.models';
+import { byteSource, catalogueFor, fakeCore, FIXTURE, FIXTURE_BYTES, producerKeys, testEnvironment } from './__fixtures__/support';
+
+const keys = producerKeys();
+const catalogue = catalogueFor(keys.privateKey, keys.publicKeyHex);
+const statfsPlenty = async (): Promise<{ bsize: number; bavail: number; blocks: number }> => ({ bsize: 4096, bavail: 1 << 20, blocks: 1 << 21 });
+const auth = { keyId: 'control-plane', elevated: true };
+const key = (suffix: string): string => 'idem-' + suffix + '-' + Math.random().toString(16).slice(2, 10);
+
+/** A Core whose chainstates can be swapped after loadtxoutset, as the real node does. */
+function switchableCore(): { current: ReturnType; reader: BootstrapNodeReader; calls: string[]; swap: (next: ReturnType) => void } {
+ const calls: string[] = [];
+ const holder = { current: fakeCore({ blocks: 10, headers: 200, ibd: true, calls }) };
+ const proxy = { network: 'regtest', call: (m: string, p: unknown[]) => holder.current.call(m, p) };
+ return { get current() { return holder.current; }, reader: new BootstrapNodeReader(proxy), calls, swap: (next) => { holder.current = next; } };
+}
+
+function operatorCore(handler: (method: string, params: unknown[]) => Promise): { calls: Array<{ method: string; params: unknown[] }>; call: (method: 'dumptxoutset' | 'loadtxoutset', params: unknown[]) => Promise } {
+ const calls: Array<{ method: string; params: unknown[] }> = [];
+ return { calls, call: (method, params) => { calls.push({ method, params }); return handler(method, params); } };
+}
+
+function build(options: { core?: ReturnType; store?: MemoryBootstrapStore | null; env?: Parameters[0]; handler?: (method: string, params: unknown[]) => Promise } = {}): {
+ service: BootstrapService; store: MemoryBootstrapStore; core: ReturnType; operator: ReturnType; env: ReturnType;
+} {
+ const core = options.core ?? switchableCore();
+ const store = options.store === undefined ? new MemoryBootstrapStore() : (options.store as MemoryBootstrapStore);
+ const env = testEnvironment(options.env);
+ const operator = operatorCore(options.handler ?? (async () => { throw new Error('no handler'); }));
+ const service = new BootstrapService({
+ nodes: core.reader, core: { network: 'regtest', call: (m, p) => core.current.call(m, p) }, env, store, bytes: byteSource(FIXTURE_BYTES), network: 'regtest',
+ statfs: statfsPlenty, catalogue: () => catalogue, operatorCore: operator, workerOwner: 'worker-a',
+ });
+ return { service, store, core, operator, env };
+}
+
+async function verifyFixture(service: BootstrapService): Promise {
+ const pending = await service.verifySnapshot({ snapshot_id: 'regtest-105' });
+ const done = await service.awaitVerification(pending.verification_id);
+ expect(done?.state).toBe('valid');
+}
+
+function executor(built: ReturnType, overrides: Partial[0]> = {}): BootstrapJobExecutor {
+ return new BootstrapJobExecutor({
+ env: built.env, network: 'regtest', store: built.store, nodes: built.core.reader, core: { network: 'regtest', call: (m, p) => built.core.current.call(m, p) },
+ operatorCore: built.operator, catalogue: () => catalogue, owner: 'worker-a', sleep: async () => undefined, statfs: statfsPlenty, loadObservationAttempts: 3, ...overrides,
+ });
+}
+
+const loadedCore = (): ReturnType => fakeCore({
+ blocks: FIXTURE.height, headers: 200, ibd: true,
+ chainstates: [
+ { blocks: 10, bestblockhash: require('crypto').createHash('sha256').update('block10').digest('hex'), verificationprogress: 0.1, validated: true, coins_db_cache_bytes: 1, coins_tip_cache_bytes: 1 },
+ { blocks: FIXTURE.height, bestblockhash: FIXTURE.baseHash, verificationprogress: 0.5, validated: false, coins_db_cache_bytes: 1, coins_tip_cache_bytes: 1, snapshot_blockhash: FIXTURE.baseHash },
+ ],
+});
+
+describe('Bootstrap operator jobs', () => {
+ it('rejects unauthorized and unelevated requests before observing the node or touching the store', async () => {
+ const built = build();
+ await expect(built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('a') }, undefined)).rejects.toThrow(expect.objectContaining({ code: 'UNAUTHORIZED', status: 401 }));
+ await expect(built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'regtest-105', idempotency_key: key('b'), confirm: 'load_snapshot' }, { keyId: 'k', elevated: false })).rejects.toThrow(expect.objectContaining({ code: 'ELEVATION_REQUIRED', status: 403 }));
+ await expect(built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'regtest-105', idempotency_key: key('c') }, auth)).rejects.toThrow(expect.objectContaining({ code: 'invalid-input', status: 400 }));
+ await expect(built.service.createOperatorJob({ job_type: 'generate_snapshot' }, auth)).rejects.toThrow(expect.objectContaining({ code: 'invalid-input' }));
+ await expect(built.service.createOperatorJob({ job_type: 'reindex' as any, idempotency_key: key('d') }, auth)).rejects.toThrow(expect.objectContaining({ code: 'invalid-input' }));
+ expect(built.core.calls).toEqual([]);
+ expect(built.store.jobs.size).toBe(0);
+ expect(built.operator.calls).toEqual([]);
+ });
+
+ it('needs the durable store and an allowlisted output directory', async () => {
+ const noStore = build({ store: null });
+ await expect(noStore.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('e') }, auth)).rejects.toThrow(expect.objectContaining({ code: 'durable-store-unavailable' }));
+ await expect(noStore.service.getJob('x')).rejects.toThrow(expect.objectContaining({ code: 'durable-store-unavailable' }));
+ const noDir = build({ env: { snapshotDir: undefined } });
+ await expect(noDir.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('f') }, auth)).rejects.toThrow(expect.objectContaining({ code: 'unavailable-operator' }));
+ expect(noDir.service.startWorker()).toBeUndefined();
+ expect(await build().service.getJob('missing')).toBeUndefined();
+ });
+
+ it('generates a snapshot through dumptxoutset at a configured path and records what Core answered', async () => {
+ const core = switchableCore();
+ core.swap(fakeCore({ calls: core.calls }));
+ const built = build({
+ core,
+ handler: async (method, params) => ({ coins_written: 107, base_hash: FIXTURE.baseHash, base_height: FIXTURE.height, txoutset_hash: FIXTURE.hashSerialized3, path: params[0], nchaintx: 107 }),
+ });
+ const idem = key('gen');
+ const job = await built.service.createOperatorJob({ job_type: 'generate_snapshot', node_id: 'owned-core-regtest', idempotency_key: idem }, auth);
+ expect(job).toMatchObject({ state: 'queued', status: 'queued', job_type: 'generate_snapshot', node_id: 'owned-core-regtest', requested_by: 'control-plane', attempts: 0, rpc: null });
+ expect(built.operator.calls).toEqual([]);
+ built.service.stopWorker();
+
+ const worker = executor(built);
+ const ran = await worker.runOnce();
+ expect(ran?.job_id).toBe(job.job_id);
+ const stored = await built.service.getJob(job.job_id);
+ expect(stored).toMatchObject({ state: 'completed', attempts: 1, progress_pct: 100, lease: null, rpc: { method: 'dumptxoutset' } });
+ expect(stored?.evidence).toMatchObject({ coins_written: 107, base_hash: FIXTURE.baseHash, base_height: FIXTURE.height, txoutset_hash: FIXTURE.hashSerialized3, core_block_hash_at_base_height: FIXTURE.baseHash });
+ expect(stored?.preconditions).toMatchObject({ core_version: '28.0.0', phase: 'fully_validated', capacity_method: 'statfs' });
+ expect(built.operator.calls).toHaveLength(1);
+ expect(built.operator.calls[0].method).toBe('dumptxoutset');
+ expect(built.operator.calls[0].params).toEqual([`/var/lib/bitcoin/snapshots/utxo-regtest-${FIXTURE.height}-${job.job_id}.dat`]);
+ expect(stored?.checkpoints.map((c) => c.stage)).toEqual(['queued', 'claimed', 'rpc-issued', 'rpc-returned', 'completed']);
+ expect(await worker.runOnce()).toBeNull();
+ });
+
+ it('refuses to generate from a node that is not fully validated', async () => {
+ const built = build();
+ await expect(built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('ibd') }, auth)).rejects.toThrow(expect.objectContaining({ code: 'chainstate-not-eligible', status: 409 }));
+ });
+
+ it('loads a verified snapshot through loadtxoutset and completes only after the node shows the snapshot chainstate', async () => {
+ const core = switchableCore();
+ const built = build({
+ core,
+ handler: async (method) => {
+ if (method === 'loadtxoutset') {
+ core.swap(loadedCore());
+ return { coins_loaded: 107, tip_hash: FIXTURE.baseHash, base_height: FIXTURE.height, path: '/allowed/regtest-105.dat' };
+ }
+ throw new Error('unexpected ' + method);
+ },
+ });
+ await expect(built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'regtest-105', idempotency_key: key('l0'), confirm: 'load_snapshot' }, auth)).rejects.toThrow(expect.objectContaining({ code: 'snapshot-not-verified' }));
+ await verifyFixture(built.service);
+ await expect(built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'unknown', idempotency_key: key('l1'), confirm: 'load_snapshot' }, auth)).rejects.toThrow(expect.objectContaining({ code: 'snapshot-not-in-catalogue', status: 404 }));
+ const job = await built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'regtest-105', idempotency_key: key('l2'), confirm: 'load_snapshot' }, auth);
+ built.service.stopWorker();
+ expect(job.state).toBe('queued');
+ await expect(built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'regtest-105', idempotency_key: key('l3'), confirm: 'load_snapshot' }, auth)).rejects.toThrow(expect.objectContaining({ code: 'job-already-active', status: 409 }));
+
+ await executor(built).runOnce();
+ const stored = await built.service.getJob(job.job_id) as NodeBootstrapJob;
+ expect(stored.state).toBe('completed');
+ expect(stored.evidence).toMatchObject({ coins_loaded: 107, tip_hash: FIXTURE.baseHash, base_height: FIXTURE.height, observed_phase: 'background_validation', active_chainstate_type: 'snapshot', snapshot_chainstate_height: FIXTURE.height, background_ibd_height: 10 });
+ expect(stored.preconditions).toMatchObject({ pinned_commitment: FIXTURE.hashSerialized3, snapshot_height: FIXTURE.height, phase: 'traditional_ibd' });
+ expect(built.operator.calls).toEqual([{ method: 'loadtxoutset', params: ['/allowed/regtest-105.dat'] }]);
+ expect(stored.checkpoints.map((c) => c.stage)).toContain('chainstate-observation');
+ });
+
+ it('leaves a load in needs-review when Core answers but no snapshot chainstate appears', async () => {
+ const built = build({ handler: async () => ({ coins_loaded: 107, tip_hash: FIXTURE.baseHash, base_height: FIXTURE.height }) });
+ await verifyFixture(built.service);
+ const job = await built.service.createOperatorJob({ job_type: 'load_snapshot', snapshot_id: 'regtest-105', idempotency_key: key('nr'), confirm: 'load_snapshot' }, auth);
+ built.service.stopWorker();
+ await executor(built).runOnce();
+ expect(await built.service.getJob(job.job_id)).toMatchObject({ state: 'needs-review', reason: 'chainstate-not-observed' });
+ });
+
+ it('returns the same job for a duplicate idempotency key and never runs Core twice', async () => {
+ const core = switchableCore();
+ core.swap(fakeCore({ calls: core.calls }));
+ const built = build({ core, handler: async (_m, params) => ({ coins_written: 1, base_hash: FIXTURE.baseHash, base_height: FIXTURE.height, txoutset_hash: FIXTURE.hashSerialized3, path: params[0] }) });
+ const idem = key('dup');
+ const first = await built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: idem }, auth);
+ const second = await built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: idem }, auth);
+ built.service.stopWorker();
+ expect(second.job_id).toBe(first.job_id);
+ expect(built.store.jobs.size).toBe(1);
+ const worker = executor(built);
+ await worker.runOnce();
+ await worker.runOnce();
+ expect(built.operator.calls).toHaveLength(1);
+ const again = await built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: idem }, auth);
+ built.service.stopWorker();
+ expect(again).toMatchObject({ job_id: first.job_id, state: 'completed' });
+ });
+
+ it('reclaims a job whose worker died before issuing the RPC, and reconciles one that died after', async () => {
+ const core = switchableCore();
+ core.swap(fakeCore({ calls: core.calls }));
+ const built = build({ core, handler: async (_m, params) => ({ coins_written: 1, base_hash: FIXTURE.baseHash, base_height: FIXTURE.height, txoutset_hash: FIXTURE.hashSerialized3, path: params[0] }) });
+ const crashedBefore = await built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('cb') }, auth);
+ built.service.stopWorker();
+ const past = new Date(Date.now() - 1000).toISOString();
+ const stale = built.store.jobs.get(crashedBefore.job_id) as NodeBootstrapJob;
+ Object.assign(stale, { state: 'running', status: 'running', attempts: 1, lease: { owner: 'worker-dead', expires_at: past } });
+ const other = executor(built, { owner: 'worker-b', clock: () => Date.now() });
+ expect((await other.runOnce())?.job_id).toBe(crashedBefore.job_id);
+ expect(await built.service.getJob(crashedBefore.job_id)).toMatchObject({ state: 'completed', attempts: 2 });
+ expect(built.operator.calls).toHaveLength(1);
+
+ const crashedAfter: NodeBootstrapJob = { ...(await built.service.getJob(crashedBefore.job_id) as NodeBootstrapJob), job_id: 'crashed-after', idempotency_key: key('ca'), state: 'running', status: 'running', attempts: 1, evidence: {}, checkpoints: [], finished_at: undefined, lease: { owner: 'worker-dead', expires_at: past }, rpc: { method: 'dumptxoutset', started_at: past } };
+ built.store.jobs.set(crashedAfter.job_id, crashedAfter);
+ expect((await other.runOnce())?.job_id).toBe('crashed-after');
+ expect(await built.service.getJob('crashed-after')).toMatchObject({ state: 'needs-review', reason: 'rpc-outcome-unknown-after-restart', attempts: 2 });
+ expect(built.operator.calls).toHaveLength(1);
+ const held = built.store.jobs.get(crashedAfter.job_id) as NodeBootstrapJob;
+ Object.assign(held, { state: 'running', lease: { owner: 'worker-live', expires_at: new Date(Date.now() + 60000).toISOString() } });
+ expect(await other.runOnce()).toBeNull();
+ });
+
+ it('marks a job needs-review when Core does not answer within the deadline', async () => {
+ const core = switchableCore();
+ core.swap(fakeCore({ calls: core.calls }));
+ const built = build({ core, env: { jobTimeoutMs: 20 }, handler: () => new Promise(() => undefined) });
+ const job = await built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('to') }, auth);
+ built.service.stopWorker();
+ await executor(built).runOnce();
+ const stored = await built.service.getJob(job.job_id);
+ expect(stored).toMatchObject({ state: 'needs-review', reason: 'executor-timeout', lease: null });
+ expect(stored?.rpc?.finished_at).toBeUndefined();
+ expect(stored?.message).toMatch(/Core may still be running it/);
+ });
+
+ it('records a Core RPC error as a failed job', async () => {
+ const core = switchableCore();
+ core.swap(fakeCore({ calls: core.calls }));
+ const built = build({ core, handler: async () => { throw new Error('already exists. If you are sure this is what you want, move it out of the way first'); } });
+ const job = await built.service.createOperatorJob({ job_type: 'generate_snapshot', idempotency_key: key('err') }, auth);
+ built.service.stopWorker();
+ await executor(built).runOnce();
+ expect(await built.service.getJob(job.job_id)).toMatchObject({ state: 'failed', reason: 'core-rpc-error' });
+ });
+});
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-executor.ts b/backend/src/api/intelligence/bootstrap/bootstrap-executor.ts
new file mode 100644
index 00000000000..fec14849678
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-executor.ts
@@ -0,0 +1,397 @@
+import logger from '../../../logger';
+import { WorkbenchCoreReader } from '../workbench/workbench-core';
+import { BootstrapEnvironment } from './bootstrap-config';
+import { BootstrapEvidenceError } from './bootstrap-errors';
+import { evaluateLoadEligibility, NodeObservation, requireSupportedNode } from './bootstrap-preconditions';
+import { BootstrapStore } from './bootstrap-store';
+import { BootstrapCatalogue, NodeBootstrapJob, NodeBootstrapJobState } from './bootstrap.models';
+import { BootstrapNodeReader } from './node-reader';
+import { measureCapacity, StatfsReader } from './bootstrap-capacity';
+import { classifySource } from './snapshot-catalogue';
+
+/** The owned Core, reached with a timeout long enough for a snapshot RPC. */
+export interface OperatorCore {
+ call(method: 'dumptxoutset' | 'loadtxoutset', params: unknown[]): Promise;
+}
+
+export interface ExecutorDependencies {
+ env: BootstrapEnvironment;
+ network: string;
+ store: BootstrapStore;
+ nodes: BootstrapNodeReader;
+ core: WorkbenchCoreReader;
+ operatorCore: OperatorCore;
+ catalogue: () => BootstrapCatalogue;
+ owner: string;
+ clock?: () => number;
+ sleep?: (ms: number) => Promise;
+ statfs?: StatfsReader;
+ /** How many chainstate observations to take after loadtxoutset returns. */
+ loadObservationAttempts?: number;
+}
+
+const hash = (s: unknown): s is string => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s);
+const integer = (n: unknown): n is number => Number.isSafeInteger(n) && Number(n) >= 0;
+
+/**
+ * Bounded worker for operator jobs. It claims one job at a time under a
+ * lease, re-checks every precondition against the live node, issues exactly
+ * one Core RPC with a fixed path derived from configuration, and records what
+ * Core answered. A lapsed lease (worker crash) lets another worker reclaim
+ * the job; a job whose RPC was already issued is reconciled from the node,
+ * never re-issued.
+ */
+export class BootstrapJobExecutor {
+ private timer?: NodeJS.Timeout;
+ private busy = false;
+ private readonly clock: () => number;
+ private readonly sleep: (ms: number) => Promise;
+
+ constructor(private readonly deps: ExecutorDependencies) {
+ this.clock = deps.clock ?? (() => Date.now());
+ this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
+ }
+
+ public start(): void {
+ if (this.timer) {
+ return;
+ }
+ this.timer = setInterval(() => {
+ this.runOnce().catch((e) => logger.warn('[bootstrap-executor] poll failed: ' + (e instanceof Error ? e.message : e)));
+ }, this.deps.env.jobPollMs);
+ this.timer.unref();
+ }
+
+ public stop(): void {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = undefined;
+ }
+ }
+
+ /** Claims and executes at most one job; returns it, or null when nothing was claimable. */
+ /** @asyncUnsafe The caller logs a rejection; the job itself is written before rethrowing. */
+ public async runOnce(): Promise {
+ if (this.busy) {
+ return null;
+ }
+ this.busy = true;
+ try {
+ const job = await this.deps.store.claimJob(this.deps.network, this.deps.owner, this.iso(), this.deps.env.jobLeaseMs);
+ if (!job) {
+ return null;
+ }
+ await this.execute(job);
+ return job;
+ } finally {
+ this.busy = false;
+ }
+ }
+
+ private iso(): string {
+ return new Date(this.clock()).toISOString();
+ }
+
+ /** @asyncUnsafe Every branch ends by writing the job; a lost lease stops writes. */
+ private async execute(job: NodeBootstrapJob): Promise {
+ const owner = this.deps.owner;
+ let leaseHeld = true;
+ /** @asyncUnsafe A failed write rejects into execute(), which records it. */
+ const write = async (): Promise => {
+ if (!leaseHeld) {
+ return;
+ }
+ job.updated_at = this.iso();
+ job.status = job.state;
+ leaseHeld = await this.deps.store.updateJob(job, owner);
+ };
+ const checkpoint = (stage: string, detail?: string): void => {
+ job.checkpoints.push({ at: this.iso(), stage, ...(detail ? { detail } : {}) });
+ };
+ /** @asyncUnsafe Rejections are typed for execute(). */
+ const finish = async (state: Exclude, message: string, reason?: string): Promise => {
+ job.state = state;
+ job.message = message;
+ job.reason = reason;
+ job.finished_at = this.iso();
+ job.lease = null;
+ checkpoint(state, message);
+ await write();
+ };
+ const heartbeat = setInterval(() => {
+ if (!leaseHeld || !job.lease) {
+ return;
+ }
+ job.lease = { owner, expires_at: new Date(this.clock() + this.deps.env.jobLeaseMs).toISOString() };
+ write().catch((e) => logger.warn('[bootstrap-executor] lease renewal failed: ' + (e instanceof Error ? e.message : e)));
+ }, Math.max(1000, Math.floor(this.deps.env.jobLeaseMs / 3)));
+ heartbeat.unref();
+
+ try {
+ job.attempts += 1;
+ job.started_at = job.started_at ?? this.iso();
+ checkpoint('claimed', `${owner} attempt ${job.attempts}`);
+ await write();
+
+ if (job.rpc?.started_at && !job.rpc.finished_at) {
+ await this.reconcile(job, finish, checkpoint);
+ return;
+ }
+
+ let observation: NodeObservation;
+ try {
+ observation = await this.deps.nodes.read();
+ } catch (e) {
+ await finish('failed', 'The owned node could not be observed before execution: ' + (e instanceof Error ? e.message : String(e)), 'unavailable-node-source');
+ return;
+ }
+ if (observation.capability.node_id !== job.node_id) {
+ await finish('failed', `The observed node is ${observation.capability.node_id}, not ${job.node_id}.`, 'node-not-observed');
+ return;
+ }
+
+ if (job.job_type === 'generate_snapshot') {
+ await this.generate(job, observation, finish, checkpoint, write);
+ } else {
+ await this.load(job, observation, finish, checkpoint, write);
+ }
+ } catch (e) {
+ if (e instanceof BootstrapEvidenceError) {
+ await finish('failed', e.message, e.code);
+ } else {
+ await finish('failed', 'The executor failed: ' + (e instanceof Error ? e.message : String(e)), 'executor-error');
+ }
+ } finally {
+ clearInterval(heartbeat);
+ }
+ }
+
+ /** @asyncUnsafe Rejections are typed for execute(). */
+ private async generate(
+ job: NodeBootstrapJob,
+ observation: NodeObservation,
+ finish: (state: 'completed' | 'failed' | 'needs-review', message: string, reason?: string) => Promise,
+ checkpoint: (stage: string, detail?: string) => void,
+ write: () => Promise
+ ): Promise {
+ const version = requireSupportedNode(observation, this.deps.network);
+ if (!observation.capability.supports_dumptxoutset) {
+ throw new BootstrapEvidenceError('node-capability-missing', 'The owned node does not expose dumptxoutset.', 409);
+ }
+ const chain = observation.observation;
+ if (chain.current_phase !== 'fully_validated') {
+ throw new BootstrapEvidenceError('chainstate-not-eligible', `A snapshot is only generated from a fully validated chainstate; the node is in ${chain.current_phase}.`, 409);
+ }
+ if (!this.deps.env.snapshotDir) {
+ throw new BootstrapEvidenceError('unavailable-operator', 'No allowlisted snapshot output directory is configured (UNIVERSE_BOOTSTRAP_SNAPSHOT_DIR).');
+ }
+ const capacity = await measureCapacity(this.deps.env, Math.round(chain.disk_used_gb * 1073741824), this.clock(), this.deps.statfs);
+ const path = this.deps.env.snapshotDir.replace(/[\\/]+$/, '') + `/utxo-${this.deps.network}-${chain.tip_height}-${job.job_id}.dat`;
+ job.preconditions = {
+ core_version: version.version,
+ phase: chain.current_phase,
+ tip_height: chain.tip_height,
+ tip_hash: chain.active_chainstate.best_block_hash,
+ capacity_method: capacity.method,
+ capacity_free_bytes: capacity.free_bytes,
+ output_path: path,
+ };
+ const params = version.major >= 29 ? [path, 'latest'] : [path];
+ const result = await this.issue(job, 'dumptxoutset', params, checkpoint, write);
+ if (result === undefined) {
+ return;
+ }
+ if (!integer(result?.coins_written) || !hash(result?.base_hash) || !integer(result?.base_height) || !hash(result?.txoutset_hash)) {
+ await finish('needs-review', 'dumptxoutset returned without the expected fields; inspect the node.', 'core-result-malformed');
+ return;
+ }
+ const hashAtHeight = await this.deps.core.call('getblockhash', [result.base_height]);
+ job.evidence = {
+ coins_written: result.coins_written,
+ base_hash: result.base_hash,
+ base_height: result.base_height,
+ txoutset_hash: result.txoutset_hash,
+ path: typeof result.path === 'string' ? result.path : path,
+ core_block_hash_at_base_height: typeof hashAtHeight === 'string' ? hashAtHeight : null,
+ };
+ if (hashAtHeight !== result.base_hash) {
+ await finish('needs-review', 'The snapshot base hash is not on the owned node active chain at its height.', 'base-hash-not-on-chain');
+ return;
+ }
+ job.progress_pct = 100;
+ await finish('completed', `Core wrote ${result.coins_written} coins at height ${result.base_height} (${result.txoutset_hash}). The file is not catalogued or verified by this job.`);
+ }
+
+ /** @asyncUnsafe Rejections are typed for execute(). */
+ private async load(
+ job: NodeBootstrapJob,
+ observation: NodeObservation,
+ finish: (state: 'completed' | 'failed' | 'needs-review', message: string, reason?: string) => Promise,
+ checkpoint: (stage: string, detail?: string) => void,
+ write: () => Promise
+ ): Promise {
+ const catalogue = this.deps.catalogue();
+ const snapshot = catalogue.snapshots.find((s) => s.id === job.snapshot_id);
+ if (!snapshot) {
+ throw new BootstrapEvidenceError('snapshot-not-in-catalogue', `Snapshot ${job.snapshot_id} is not in the trusted catalogue.`, 409);
+ }
+ const eligibility = await evaluateLoadEligibility({
+ observation, network: this.deps.network, snapshot, catalogue, store: this.deps.store, env: this.deps.env, now: this.clock(), statfs: this.deps.statfs,
+ });
+ const source = classifySource(snapshot.source, this.deps.env.sourceAllowlist);
+ if (source.kind !== 'file') {
+ throw new BootstrapEvidenceError('snapshot-source-not-local', 'loadtxoutset reads a path on the Core host; the catalogue source for this snapshot is not a local path.', 409);
+ }
+ job.preconditions = {
+ core_version: eligibility.version.version,
+ phase: observation.observation.current_phase,
+ tip_height: observation.observation.tip_height,
+ snapshot_height: snapshot.height,
+ snapshot_block_hash: snapshot.blockHash,
+ pinned_commitment: eligibility.pinned.utxoCommitment,
+ verification_id: eligibility.verification.verification_id,
+ capacity_method: eligibility.capacity.method,
+ capacity_free_bytes: eligibility.capacity.free_bytes,
+ required_bytes: eligibility.required.required_bytes,
+ };
+ const result = await this.issue(job, 'loadtxoutset', [snapshot.source], checkpoint, write);
+ if (result === undefined) {
+ return;
+ }
+ if (!integer(result?.coins_loaded) || !hash(result?.tip_hash) || !integer(result?.base_height)) {
+ await finish('needs-review', 'loadtxoutset returned without the expected fields; inspect the node.', 'core-result-malformed');
+ return;
+ }
+ job.evidence = { coins_loaded: result.coins_loaded, tip_hash: result.tip_hash, base_height: result.base_height };
+ if (result.tip_hash !== snapshot.blockHash || result.base_height !== snapshot.height) {
+ await finish('needs-review', 'Core activated a snapshot whose base is not the catalogue block.', 'base-mismatch');
+ return;
+ }
+ await this.observeLoadedChainstate(job, snapshot.height, finish, checkpoint, write);
+ }
+
+ /** Issues the single RPC under the job deadline; undefined means the job was already finished. */
+ /** @asyncUnsafe A Core rejection is recorded as the job outcome. */
+ private async issue(
+ job: NodeBootstrapJob,
+ method: 'dumptxoutset' | 'loadtxoutset',
+ params: unknown[],
+ checkpoint: (stage: string, detail?: string) => void,
+ write: () => Promise
+ ): Promise {
+ const rpc: NonNullable = { method, started_at: this.iso() };
+ job.rpc = rpc;
+ checkpoint('rpc-issued', method);
+ await write();
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(new ExecutorTimeout()), this.deps.env.jobTimeoutMs);
+ timer.unref();
+ });
+ try {
+ const result = await Promise.race([this.deps.operatorCore.call(method, params), timeout]);
+ rpc.finished_at = this.iso();
+ checkpoint('rpc-returned', method);
+ return result;
+ } catch (e) {
+ if (e instanceof ExecutorTimeout) {
+ job.state = 'needs-review';
+ job.message = `${method} did not return within ${this.deps.env.jobTimeoutMs} ms; Core may still be running it. Inspect the node before retrying.`;
+ job.reason = 'executor-timeout';
+ } else {
+ rpc.finished_at = this.iso();
+ job.state = 'failed';
+ job.message = `${method} failed: ` + (e instanceof Error ? e.message : String(e));
+ job.reason = 'core-rpc-error';
+ }
+ job.finished_at = this.iso();
+ job.lease = null;
+ checkpoint(job.state, job.message);
+ await write();
+ return undefined;
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+ }
+
+ /** @asyncUnsafe Rejections are typed for execute(). */
+ private async observeLoadedChainstate(
+ job: NodeBootstrapJob,
+ height: number,
+ finish: (state: 'completed' | 'failed' | 'needs-review', message: string, reason?: string) => Promise,
+ checkpoint: (stage: string, detail?: string) => void,
+ write: () => Promise
+ ): Promise {
+ const attempts = this.deps.loadObservationAttempts ?? 20;
+ for (let i = 0; i < attempts; i++) {
+ let observed: NodeObservation | undefined;
+ try {
+ observed = await this.deps.nodes.read();
+ } catch (e) {
+ checkpoint('chainstate-observation', 'unavailable: ' + (e instanceof Error ? e.message : String(e)));
+ }
+ if (observed) {
+ const chain = observed.observation;
+ job.evidence = {
+ ...job.evidence,
+ observed_phase: chain.current_phase,
+ active_chainstate_type: chain.active_chainstate.type,
+ snapshot_chainstate_height: chain.snapshot_chainstate_height,
+ background_ibd_height: chain.background_ibd_height,
+ sync_percent: chain.sync_percent,
+ observed_at: chain.observed_at,
+ };
+ checkpoint('chainstate-observation', `${chain.current_phase} at ${chain.tip_height}`);
+ if (chain.active_chainstate.type === 'snapshot' && chain.snapshot_chainstate_height !== null && chain.snapshot_chainstate_height >= height) {
+ job.progress_pct = 100;
+ await finish('completed', `Core activated the snapshot chainstate; background validation continues (${chain.current_phase}).`);
+ return;
+ }
+ await write();
+ }
+ await this.sleep(this.deps.env.jobPollMs);
+ }
+ await finish('needs-review', 'loadtxoutset returned but no snapshot chainstate was observed on the node afterwards.', 'chainstate-not-observed');
+ }
+
+ /** After a crash mid-RPC the outcome is read from the node, never re-issued. */
+ /** @asyncUnsafe Rejections are typed for execute(). */
+ private async reconcile(
+ job: NodeBootstrapJob,
+ finish: (state: 'completed' | 'failed' | 'needs-review', message: string, reason?: string) => Promise,
+ checkpoint: (stage: string, detail?: string) => void
+ ): Promise {
+ checkpoint('reconcile', `${job.rpc?.method} was issued by a previous attempt`);
+ if (job.job_type === 'generate_snapshot') {
+ await finish('needs-review', 'dumptxoutset was issued before this worker restarted; whether Core finished writing the file is unknown. Inspect the node and the output path before retrying.', 'rpc-outcome-unknown-after-restart');
+ return;
+ }
+ let observed: NodeObservation;
+ try {
+ observed = await this.deps.nodes.read();
+ } catch (e) {
+ await finish('needs-review', 'loadtxoutset was issued before this worker restarted and the node cannot be observed: ' + (e instanceof Error ? e.message : String(e)), 'rpc-outcome-unknown-after-restart');
+ return;
+ }
+ const chain = observed.observation;
+ const height = typeof job.preconditions.snapshot_height === 'number' ? job.preconditions.snapshot_height : -1;
+ job.evidence = {
+ ...job.evidence,
+ observed_phase: chain.current_phase,
+ active_chainstate_type: chain.active_chainstate.type,
+ snapshot_chainstate_height: chain.snapshot_chainstate_height,
+ background_ibd_height: chain.background_ibd_height,
+ observed_at: chain.observed_at,
+ };
+ if (chain.active_chainstate.type === 'snapshot' && chain.snapshot_chainstate_height !== null && chain.snapshot_chainstate_height >= height && height >= 0) {
+ job.progress_pct = 100;
+ await finish('completed', 'The node shows the snapshot chainstate active after a worker restart; background validation continues.');
+ return;
+ }
+ await finish('needs-review', 'loadtxoutset was issued before this worker restarted and the node shows no snapshot chainstate. Inspect the node before retrying.', 'rpc-outcome-unknown-after-restart');
+ }
+}
+
+class ExecutorTimeout extends Error {}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-preconditions.ts b/backend/src/api/intelligence/bootstrap/bootstrap-preconditions.ts
new file mode 100644
index 00000000000..92e4cb00124
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-preconditions.ts
@@ -0,0 +1,132 @@
+import { BootstrapNodeReader } from './node-reader';
+import { BootstrapEnvironment } from './bootstrap-config';
+import { BootstrapEvidenceError } from './bootstrap-errors';
+import { BootstrapStore } from './bootstrap-store';
+import { CapacityMeasurement, measureCapacity, StatfsReader } from './bootstrap-capacity';
+import {
+ BootstrapCatalogue,
+ BootstrapCatalogueSnapshot,
+ BootstrapPinnedCommitment,
+ NodeBootstrapVerification,
+} from './bootstrap.models';
+import { pinnedCommitmentFor } from './snapshot-catalogue';
+import { NETWORK_MAGIC } from './snapshot-format';
+
+export type NodeObservation = Awaited>;
+
+/** assumeutxo shipped in 26.0; the versioned snapshot file format in 28.0. */
+export const MIN_SUPPORTED_CORE_MAJOR = 26;
+export const VERSIONED_SNAPSHOT_MAJOR = 28;
+/** Chainstate on disk is assumed to need as much as the snapshot file; headroom is a tenth on top. */
+export const CHAINSTATE_ESTIMATE_FACTOR = 1;
+export const HEADROOM_FACTOR = 0.1;
+
+export function parseCoreVersion(subversion: string): { version: string; major: number } | null {
+ const match = /^\/Satoshi:(\d+)\.(\d+)\.(\d+)/.exec(subversion);
+ return match ? { version: `${match[1]}.${match[2]}.${match[3]}`, major: Number(match[1]) } : null;
+}
+
+export function requireSupportedNode(observation: NodeObservation, network: string): { version: string; major: number } {
+ const { capability } = observation;
+ if (!NETWORK_MAGIC[capability.network] || capability.network !== network) {
+ throw new BootstrapEvidenceError('unsupported-network', `The owned node serves ${capability.network}, not ${network}.`, 409);
+ }
+ const parsed = parseCoreVersion(capability.exact_version);
+ if (!parsed || parsed.major < MIN_SUPPORTED_CORE_MAJOR) {
+ throw new BootstrapEvidenceError('unsupported-version', `The owned node reports ${capability.exact_version}; AssumeUTXO needs Bitcoin Core ${MIN_SUPPORTED_CORE_MAJOR}.0 or later.`, 409);
+ }
+ return parsed;
+}
+
+export function requireFreshObservation(observation: NodeObservation, now: number, maxAgeMs: number): void {
+ const age = now - Date.parse(observation.observation.observed_at);
+ if (!Number.isFinite(age) || age > maxAgeMs || age < 0) {
+ throw new BootstrapEvidenceError('stale-measurement', `The node observation is ${age} ms old, outside the accepted ${maxAgeMs} ms.`, 409);
+ }
+}
+
+export function requiredBytesForLoad(snapshotBytes: number): { chainstate_estimate_bytes: number; headroom_bytes: number; required_bytes: number } {
+ const chainstate = Math.ceil(snapshotBytes * CHAINSTATE_ESTIMATE_FACTOR);
+ const headroom = Math.ceil((snapshotBytes + chainstate) * HEADROOM_FACTOR);
+ return { chainstate_estimate_bytes: chainstate, headroom_bytes: headroom, required_bytes: snapshotBytes + chainstate + headroom };
+}
+
+export interface LoadEligibility {
+ version: { version: string; major: number };
+ pinned: BootstrapPinnedCommitment;
+ verification: NodeBootstrapVerification;
+ capacity: CapacityMeasurement;
+ required: ReturnType;
+}
+
+/** A snapshot the observed node can load: same format family and a commitment pinned for its exact release. */
+export function compatibleSnapshots(
+ catalogue: BootstrapCatalogue,
+ network: string,
+ version: { version: string; major: number }
+): Array<{ snapshot: BootstrapCatalogueSnapshot; pinned: BootstrapPinnedCommitment }> {
+ const versioned = version.major >= VERSIONED_SNAPSHOT_MAJOR;
+ const out: Array<{ snapshot: BootstrapCatalogueSnapshot; pinned: BootstrapPinnedCommitment }> = [];
+ for (const snapshot of catalogue.snapshots) {
+ const producedBy = parseCoreVersion('/Satoshi:' + snapshot.coreVersion + '/');
+ if (snapshot.network !== network || !producedBy || (producedBy.major >= VERSIONED_SNAPSHOT_MAJOR) !== versioned) {
+ continue;
+ }
+ const pinned = pinnedCommitmentFor(catalogue, { ...snapshot, coreVersion: version.version });
+ if (pinned) {
+ out.push({ snapshot, pinned });
+ }
+ }
+ return out.sort((a, b) => b.snapshot.height - a.snapshot.height);
+}
+
+/**
+ * Everything a load needs, checked against measurements rather than the
+ * request: node version and network, chainstate phase, a commitment pinned
+ * for the node's exact release, a verification run that reached valid over
+ * the bytes, and measured capacity for file, chainstate and headroom.
+ */
+/** @asyncUnsafe Rejections are typed BootstrapEvidenceErrors for the caller. */
+export async function evaluateLoadEligibility(input: {
+ observation: NodeObservation;
+ network: string;
+ snapshot: BootstrapCatalogueSnapshot;
+ catalogue: BootstrapCatalogue;
+ store: BootstrapStore;
+ env: BootstrapEnvironment;
+ now: number;
+ statfs?: StatfsReader;
+}): Promise {
+ const { observation, snapshot, catalogue } = input;
+ const version = requireSupportedNode(observation, input.network);
+ if (!observation.capability.supports_loadtxoutset) {
+ throw new BootstrapEvidenceError('node-capability-missing', 'The owned node does not expose loadtxoutset.', 409);
+ }
+ if (snapshot.network !== input.network) {
+ throw new BootstrapEvidenceError('unsupported-network', `Snapshot ${snapshot.id} is for ${snapshot.network}.`, 409);
+ }
+ const match = compatibleSnapshots(catalogue, input.network, version).find((entry) => entry.snapshot.id === snapshot.id);
+ if (!match) {
+ throw new BootstrapEvidenceError('no-compatible-snapshot', `No commitment is pinned for snapshot ${snapshot.id} on Bitcoin Core ${version.version}, or its file format predates that release.`, 409);
+ }
+ const verification = await input.store.latestVerification(snapshot.id, input.network);
+ if (!verification || verification.state !== 'valid') {
+ throw new BootstrapEvidenceError('snapshot-not-verified', `Snapshot ${snapshot.id} has no verification run that reached valid (latest: ${verification?.state ?? 'none'}).`, 409);
+ }
+ const chain = observation.observation;
+ if (
+ chain.current_phase !== 'traditional_ibd' ||
+ chain.dual_chainstate_active ||
+ chain.active_chainstate.type !== 'ibd' ||
+ chain.tip_height >= snapshot.height ||
+ observation.observation.estimated_remaining_blocks + chain.tip_height < snapshot.height
+ ) {
+ throw new BootstrapEvidenceError('chainstate-not-eligible', `The node is in phase ${chain.current_phase} at height ${chain.tip_height}; loading needs a plain IBD chainstate below the snapshot height with headers past it.`, 409);
+ }
+ const capacity = await measureCapacity(input.env, Math.round(chain.disk_used_gb * 1073741824), input.now, input.statfs);
+ const required = requiredBytesForLoad(snapshot.sizeBytes);
+ if (capacity.free_bytes < required.required_bytes) {
+ throw new BootstrapEvidenceError('insufficient-capacity', `Measured free space ${capacity.free_bytes} bytes is below the ${required.required_bytes} bytes the load needs.`, 409);
+ }
+ return { version, pinned: match.pinned, verification, capacity, required };
+}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-store.test.ts b/backend/src/api/intelligence/bootstrap/bootstrap-store.test.ts
new file mode 100644
index 00000000000..e82a979838c
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-store.test.ts
@@ -0,0 +1,70 @@
+jest.mock('../../../database', () => ({ __esModule: true, default: { query: jest.fn() } }));
+
+import DB from '../../../database';
+import { JOBS_TABLE, MysqlBootstrapStore, VERIFICATIONS_TABLE } from './bootstrap-store';
+import { NodeBootstrapJob } from './bootstrap.models';
+
+const query = DB.query as jest.Mock;
+
+function job(overrides: Partial = {}): NodeBootstrapJob {
+ const now = '2026-09-17T00:00:00.000Z';
+ return {
+ job_id: 'job-1', job_type: 'generate_snapshot', network: 'signet', node_id: 'owned-core-signet', idempotency_key: 'idem-1', state: 'queued', status: 'queued',
+ progress_pct: 0, message: '', requested_by: 'ops', created_at: now, updated_at: now, attempts: 0, lease: null, rpc: null, preconditions: {}, evidence: {}, checkpoints: [], ...overrides,
+ };
+}
+
+describe('MysqlBootstrapStore', () => {
+ const store = new MysqlBootstrapStore();
+ beforeEach(() => query.mockReset());
+
+ it('scopes verification reads by network and tells a missing row apart from a row', async () => {
+ query.mockResolvedValueOnce([[]]);
+ expect(await store.getVerification('v1', 'signet')).toBeNull();
+ expect(query.mock.calls[0][0]).toContain(`FROM ${VERIFICATIONS_TABLE} WHERE verification_id = ? AND network = ?`);
+ expect(query.mock.calls[0][1]).toEqual(['v1', 'signet']);
+ query.mockResolvedValueOnce([[{ document: JSON.stringify({ verification_id: 'v1', state: 'valid' }) }]]);
+ expect(await store.getVerification('v1', 'signet')).toEqual({ verification_id: 'v1', state: 'valid' });
+ query.mockRejectedValueOnce(new Error('gone'));
+ await expect(store.getVerification('v1', 'signet')).rejects.toThrow('gone');
+ });
+
+ it('returns the stored job when the idempotency key is already taken', async () => {
+ query.mockResolvedValueOnce([{ affectedRows: 1 }]);
+ expect(await store.insertJob(job())).toEqual({ job: job(), created: true });
+ expect(query.mock.calls[0][0]).toContain(`INSERT IGNORE INTO ${JOBS_TABLE}`);
+ query.mockResolvedValueOnce([{ affectedRows: 0 }]);
+ query.mockResolvedValueOnce([[{ document: JSON.stringify(job({ job_id: 'job-0', state: 'completed' })) }]]);
+ const duplicate = await store.insertJob(job({ job_id: 'job-9' }));
+ expect(duplicate).toEqual({ job: job({ job_id: 'job-0', state: 'completed' }), created: false });
+ expect(query.mock.calls[2][0]).toContain('WHERE network = ? AND idempotency_key = ?');
+ expect(query.mock.calls[2][1]).toEqual(['signet', 'idem-1']);
+ });
+
+ it('claims a queued or lapsed job only through a conditional lease update', async () => {
+ query.mockResolvedValueOnce([[{ job_id: 'job-1' }]]);
+ query.mockResolvedValueOnce([{ affectedRows: 0 }]);
+ expect(await store.claimJob('signet', 'worker-a', '2026-09-17T00:00:00.000Z', 60000)).toBeNull();
+ expect(query.mock.calls[0][0]).toContain(`state = 'queued' OR (state = 'running' AND lease_expires_at < ?)`);
+ expect(query.mock.calls[1][0]).toContain(`SET state = 'running', lease_owner = ?, lease_expires_at = ?`);
+ expect(query.mock.calls[1][0]).toContain(`WHERE job_id = ? AND network = ? AND (state = 'queued' OR (state = 'running' AND lease_expires_at < ?))`);
+ query.mockReset();
+ query.mockResolvedValueOnce([[{ job_id: 'job-1' }]]);
+ query.mockResolvedValueOnce([{ affectedRows: 1 }]);
+ query.mockResolvedValueOnce([[{ document: JSON.stringify(job()) }]]);
+ const claimed = await store.claimJob('signet', 'worker-a', '2026-09-17T00:00:00.000Z', 60000);
+ expect(claimed).toMatchObject({ job_id: 'job-1', state: 'running', lease: { owner: 'worker-a', expires_at: '2026-09-17T00:01:00.000Z' } });
+ query.mockResolvedValueOnce([[]]);
+ expect(await store.claimJob('signet', 'worker-a', '2026-09-17T00:00:00.000Z', 60000)).toBeNull();
+ });
+
+ it('writes a job only while the same owner still holds the lease', async () => {
+ query.mockResolvedValueOnce([{ affectedRows: 0 }]);
+ expect(await store.updateJob(job({ state: 'running', lease: { owner: 'worker-a', expires_at: '2026-09-17T00:01:00.000Z' } }), 'worker-a')).toBe(false);
+ expect(query.mock.calls[0][0]).toContain('WHERE job_id = ? AND network = ? AND lease_owner = ?');
+ expect(query.mock.calls[0][1].slice(-3)).toEqual(['job-1', 'signet', 'worker-a']);
+ query.mockResolvedValueOnce([{ affectedRows: 1 }]);
+ expect(await store.updateJob(job({ state: 'completed', lease: null }), 'worker-a')).toBe(true);
+ expect(query.mock.calls[1][1][1]).toBeNull();
+ });
+});
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap-store.ts b/backend/src/api/intelligence/bootstrap/bootstrap-store.ts
new file mode 100644
index 00000000000..9df06eb9a30
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap-store.ts
@@ -0,0 +1,236 @@
+import DB from '../../../database';
+import { NodeBootstrapJob, NodeBootstrapVerification } from './bootstrap.models';
+
+/**
+ * Durable, network-scoped records of verification runs and operator jobs.
+ * Rows carry the backend's own network; a caller never chooses it. The JSON
+ * document is the record; the columns exist for the reads and the claims.
+ */
+export interface BootstrapStore {
+ readonly kind: 'mysql' | 'memory';
+ insertVerification(record: NodeBootstrapVerification): Promise;
+ updateVerification(record: NodeBootstrapVerification): Promise;
+ getVerification(verificationId: string, network: string): Promise;
+ /** Newest run for a snapshot, whatever its state. */
+ latestVerification(snapshotId: string, network: string): Promise;
+ /** A run that is still pending or verifying, so a duplicate request reuses it. */
+ activeVerification(snapshotId: string, network: string): Promise;
+ /** Inserts, or returns the job already stored under the same idempotency key. */
+ insertJob(job: NodeBootstrapJob): Promise<{ job: NodeBootstrapJob; created: boolean }>;
+ getJob(jobId: string, network: string): Promise;
+ /** A job still queued or running for this node, so two disruptive jobs never overlap. */
+ activeJob(network: string, nodeId: string): Promise;
+ /** Atomically leases the oldest queued job, or a running one whose lease lapsed. */
+ claimJob(network: string, owner: string, nowIso: string, leaseMs: number): Promise;
+ /** Writes the document only while this owner still holds the lease. */
+ updateJob(job: NodeBootstrapJob, owner: string): Promise;
+}
+
+export const VERIFICATIONS_TABLE = 'universe_bootstrap_verifications';
+export const JOBS_TABLE = 'universe_bootstrap_jobs';
+
+function toDate(iso: string): Date {
+ return new Date(iso);
+}
+function parseDocument(document: unknown): T {
+ if (typeof document === 'string') {
+ return JSON.parse(document);
+ }
+ if (Buffer.isBuffer(document)) {
+ return JSON.parse(document.toString('utf8'));
+ }
+ return document as T;
+}
+
+export class MysqlBootstrapStore implements BootstrapStore {
+ public readonly kind = 'mysql' as const;
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async insertVerification(record: NodeBootstrapVerification): Promise {
+ await DB.query(
+ `INSERT INTO ${VERIFICATIONS_TABLE} (verification_id, snapshot_id, network, state, created_at, updated_at, document) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [record.verification_id, record.snapshot_id, record.network, record.state, toDate(record.requested_at), toDate(record.requested_at), JSON.stringify(record)]
+ );
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async updateVerification(record: NodeBootstrapVerification): Promise {
+ await DB.query(
+ `UPDATE ${VERIFICATIONS_TABLE} SET state = ?, updated_at = ?, document = ? WHERE verification_id = ? AND network = ?`,
+ [record.state, new Date(), JSON.stringify(record), record.verification_id, record.network]
+ );
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async getVerification(verificationId: string, network: string): Promise {
+ const [rows]: any[] = await DB.query(
+ `SELECT document FROM ${VERIFICATIONS_TABLE} WHERE verification_id = ? AND network = ? LIMIT 1`,
+ [verificationId, network]
+ );
+ return rows?.length ? parseDocument(rows[0].document) : null;
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async latestVerification(snapshotId: string, network: string): Promise {
+ const [rows]: any[] = await DB.query(
+ `SELECT document FROM ${VERIFICATIONS_TABLE} WHERE snapshot_id = ? AND network = ? ORDER BY created_at DESC LIMIT 1`,
+ [snapshotId, network]
+ );
+ return rows?.length ? parseDocument(rows[0].document) : null;
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async activeVerification(snapshotId: string, network: string): Promise {
+ const [rows]: any[] = await DB.query(
+ `SELECT document FROM ${VERIFICATIONS_TABLE} WHERE snapshot_id = ? AND network = ? AND state IN ('pending', 'verifying') ORDER BY created_at DESC LIMIT 1`,
+ [snapshotId, network]
+ );
+ return rows?.length ? parseDocument(rows[0].document) : null;
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async insertJob(job: NodeBootstrapJob): Promise<{ job: NodeBootstrapJob; created: boolean }> {
+ const [result]: any[] = await DB.query(
+ `INSERT IGNORE INTO ${JOBS_TABLE} (job_id, network, node_id, job_type, idempotency_key, state, lease_owner, lease_expires_at, created_at, updated_at, document) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)`,
+ [job.job_id, job.network, job.node_id, job.job_type, job.idempotency_key, job.state, toDate(job.created_at), toDate(job.updated_at), JSON.stringify(job)]
+ );
+ if (Number(result?.affectedRows ?? 0) > 0) {
+ return { job, created: true };
+ }
+ const [rows]: any[] = await DB.query(
+ `SELECT document FROM ${JOBS_TABLE} WHERE network = ? AND idempotency_key = ? LIMIT 1`,
+ [job.network, job.idempotency_key]
+ );
+ if (!rows?.length) {
+ throw new Error('The job store neither inserted nor found the idempotent job.');
+ }
+ return { job: parseDocument(rows[0].document), created: false };
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async getJob(jobId: string, network: string): Promise {
+ const [rows]: any[] = await DB.query(
+ `SELECT document FROM ${JOBS_TABLE} WHERE job_id = ? AND network = ? LIMIT 1`,
+ [jobId, network]
+ );
+ return rows?.length ? parseDocument(rows[0].document) : null;
+ }
+
+ /** @asyncUnsafe Callers turn a rejection into an exact HTTP answer. */
+ public async activeJob(network: string, nodeId: string): Promise {
+ const [rows]: any[] = await DB.query(
+ `SELECT document FROM ${JOBS_TABLE} WHERE network = ? AND node_id = ? AND state IN ('queued', 'running') ORDER BY created_at ASC LIMIT 1`,
+ [network, nodeId]
+ );
+ return rows?.length ? parseDocument(rows[0].document) : null;
+ }
+
+ /** @asyncUnsafe The worker logs and retries on the next poll. */
+ public async claimJob(network: string, owner: string, nowIso: string, leaseMs: number): Promise {
+ const now = toDate(nowIso);
+ const [rows]: any[] = await DB.query(
+ `SELECT job_id FROM ${JOBS_TABLE} WHERE network = ? AND (state = 'queued' OR (state = 'running' AND lease_expires_at < ?)) ORDER BY created_at ASC LIMIT 1`,
+ [network, now]
+ );
+ if (!rows?.length) {
+ return null;
+ }
+ const jobId = rows[0].job_id;
+ const expires = new Date(now.getTime() + leaseMs);
+ const [result]: any[] = await DB.query(
+ `UPDATE ${JOBS_TABLE} SET state = 'running', lease_owner = ?, lease_expires_at = ?, updated_at = ? WHERE job_id = ? AND network = ? AND (state = 'queued' OR (state = 'running' AND lease_expires_at < ?))`,
+ [owner, expires, now, jobId, network, now]
+ );
+ if (Number(result?.affectedRows ?? 0) === 0) {
+ return null;
+ }
+ const job = await this.getJob(jobId, network);
+ if (!job) {
+ return null;
+ }
+ job.state = 'running';
+ job.status = 'running';
+ job.lease = { owner, expires_at: expires.toISOString() };
+ return job;
+ }
+
+ /** @asyncUnsafe The worker logs and retries on the next poll. */
+ public async updateJob(job: NodeBootstrapJob, owner: string): Promise {
+ const [result]: any[] = await DB.query(
+ `UPDATE ${JOBS_TABLE} SET state = ?, lease_owner = ?, lease_expires_at = ?, updated_at = ?, document = ? WHERE job_id = ? AND network = ? AND lease_owner = ?`,
+ [job.state, job.lease?.owner ?? null, job.lease ? toDate(job.lease.expires_at) : null, toDate(job.updated_at), JSON.stringify(job), job.job_id, job.network, owner]
+ );
+ return Number(result?.affectedRows ?? 0) > 0;
+ }
+}
+
+/** Test double with the same claim and lease semantics; never used in production. */
+export class MemoryBootstrapStore implements BootstrapStore {
+ public readonly kind = 'memory' as const;
+ public readonly verifications = new Map();
+ public readonly jobs = new Map();
+ private clone(value: T): T {
+ return JSON.parse(JSON.stringify(value));
+ }
+ async insertVerification(record: NodeBootstrapVerification): Promise {
+ this.verifications.set(record.verification_id, this.clone(record));
+ }
+ async updateVerification(record: NodeBootstrapVerification): Promise {
+ this.verifications.set(record.verification_id, this.clone(record));
+ }
+ async getVerification(id: string, network: string): Promise {
+ const found = this.verifications.get(id);
+ return found && found.network === network ? this.clone(found) : null;
+ }
+ async latestVerification(snapshotId: string, network: string): Promise {
+ const all = [...this.verifications.values()].filter((v) => v.snapshot_id === snapshotId && v.network === network);
+ all.sort((a, b) => b.requested_at.localeCompare(a.requested_at));
+ return all.length ? this.clone(all[0]) : null;
+ }
+ async activeVerification(snapshotId: string, network: string): Promise {
+ const found = [...this.verifications.values()].find(
+ (v) => v.snapshot_id === snapshotId && v.network === network && (v.state === 'pending' || v.state === 'verifying')
+ );
+ return found ? this.clone(found) : null;
+ }
+ async insertJob(job: NodeBootstrapJob): Promise<{ job: NodeBootstrapJob; created: boolean }> {
+ const existing = [...this.jobs.values()].find((j) => j.network === job.network && j.idempotency_key === job.idempotency_key);
+ if (existing) {
+ return { job: this.clone(existing), created: false };
+ }
+ this.jobs.set(job.job_id, this.clone(job));
+ return { job, created: true };
+ }
+ async getJob(jobId: string, network: string): Promise {
+ const found = this.jobs.get(jobId);
+ return found && found.network === network ? this.clone(found) : null;
+ }
+ async activeJob(network: string, nodeId: string): Promise {
+ const found = [...this.jobs.values()].find(
+ (j) => j.network === network && j.node_id === nodeId && (j.state === 'queued' || j.state === 'running')
+ );
+ return found ? this.clone(found) : null;
+ }
+ async claimJob(network: string, owner: string, nowIso: string, leaseMs: number): Promise {
+ const candidates = [...this.jobs.values()]
+ .filter((j) => j.network === network && (j.state === 'queued' || (j.state === 'running' && !!j.lease && j.lease.expires_at < nowIso)))
+ .sort((a, b) => a.created_at.localeCompare(b.created_at));
+ if (!candidates.length) {
+ return null;
+ }
+ const job = candidates[0];
+ job.state = 'running';
+ job.status = 'running';
+ job.lease = { owner, expires_at: new Date(Date.parse(nowIso) + leaseMs).toISOString() };
+ job.updated_at = nowIso;
+ return this.clone(job);
+ }
+ async updateJob(job: NodeBootstrapJob, owner: string): Promise {
+ const current = this.jobs.get(job.job_id);
+ if (!current || current.lease?.owner !== owner) {
+ return false;
+ }
+ this.jobs.set(job.job_id, this.clone(job));
+ return true;
+ }
+}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap.models.ts b/backend/src/api/intelligence/bootstrap/bootstrap.models.ts
index 8f232de421b..a244747d00c 100644
--- a/backend/src/api/intelligence/bootstrap/bootstrap.models.ts
+++ b/backend/src/api/intelligence/bootstrap/bootstrap.models.ts
@@ -50,24 +50,131 @@ export interface NodeBootstrapSnapshotManifest {
manifest_hash: string;
}
-export interface NodeBootstrapSnapshot {
- snapshot_id: string;
- name: string;
+/**
+ * Trusted catalogue file named by UNIVERSE_BOOTSTRAP_SNAPSHOT_CATALOGUE.
+ *
+ * pinnedCommitments are typed in by the operator from Bitcoin Core's
+ * src/kernel/chainparams.cpp (m_assumeutxo_data: height, blockhash and
+ * hash_serialized for the exact release the node runs). They are the
+ * independent authority: a snapshot whose bytes hash to a commitment that is
+ * not pinned for the node's exact version cannot be loaded by that node,
+ * however well signed its manifest is. Catalogue metadata is never proof of
+ * the bytes; only a verification run over the bytes is.
+ */
+export interface BootstrapCatalogueProducer {
+ id: string;
+ algorithm: 'ed25519';
+ /** 32 byte raw ed25519 public key, hex. */
+ publicKey: string;
+}
+
+export interface BootstrapPinnedCommitment {
network: string;
- base_height: number;
- base_block_hash: string;
- coins_count: number;
- txoutset_hash: string;
- size_gb: number;
- download_url: string;
- manifest: NodeBootstrapSnapshotManifest;
- is_verified: boolean;
- verification_status: 'verified' | 'unverified' | 'hash_mismatch';
+ /** Exact Core release, for example 28.0.0 or 29.0.0. */
+ coreVersion: string;
+ height: number;
+ blockHash: string;
+ /** Core hash_serialized_3, display byte order. */
+ utxoCommitment: string;
+ coinCount: number | null;
+}
+
+export interface BootstrapCatalogueSnapshot {
+ id: string;
+ network: string;
+ coreVersion: string;
+ height: number;
+ blockHash: string;
+ sizeBytes: number;
+ sha256: string;
+ /** Allowlisted https URL or local path; never taken from a request. */
+ source: string;
+ manifest: {
+ producerId: string;
+ /** ed25519 signature, hex, over deterministicSnapshotJson(snapshot). */
+ signature: string;
+ };
+}
+
+export interface BootstrapCatalogue {
+ producers: BootstrapCatalogueProducer[];
+ pinnedCommitments: BootstrapPinnedCommitment[];
+ snapshots: BootstrapCatalogueSnapshot[];
+}
+
+export type NodeBootstrapVerificationState =
+ | 'pending'
+ | 'verifying'
+ | 'valid'
+ | 'invalid'
+ | 'unavailable';
+export type NodeBootstrapCheckStatus =
+ | 'pending'
+ | 'valid'
+ | 'invalid'
+ | 'not-evaluated';
+
+export interface NodeBootstrapCheck {
+ status: NodeBootstrapCheckStatus;
+ expected?: string | number | null;
+ observed?: string | number | null;
+ reason?: string;
}
+export interface NodeBootstrapCheckpoint {
+ at: string;
+ stage: string;
+ detail?: string;
+}
+
+/**
+ * One verification run over the actual snapshot bytes. Every check is
+ * independent: sha256 of the bytes against the signed manifest, the ed25519
+ * manifest signature against the producer key, network magic, base block hash
+ * and height against the catalogue and the owned Core node, and the streamed
+ * hash_serialized_3 against the operator-pinned Core commitment. Caller
+ * supplied checksums are compared and reported in caller_inputs; they never
+ * decide the state.
+ */
export interface NodeBootstrapVerification {
verification_id: string;
snapshot_id: string;
+ network: string;
+ state: NodeBootstrapVerificationState;
+ valid: boolean;
+ status: string;
+ requested_at: string;
+ started_at?: string;
+ finished_at?: string;
+ checks: {
+ file_size: NodeBootstrapCheck;
+ sha256: NodeBootstrapCheck;
+ manifest_signature: NodeBootstrapCheck;
+ network_magic: NodeBootstrapCheck;
+ base_block_hash: NodeBootstrapCheck;
+ base_height: NodeBootstrapCheck;
+ coins_count: NodeBootstrapCheck;
+ utxo_commitment: NodeBootstrapCheck;
+ };
+ evidence: {
+ source_kind: 'file' | 'https' | null;
+ source_ref: string | null;
+ bytes_read: number;
+ header_format: 'versioned' | 'legacy' | null;
+ snapshot_version: number | null;
+ core_node_id: string | null;
+ core_block_hash_at_height: string | null;
+ pinned_commitment_source: string | null;
+ };
+ checkpoints: NodeBootstrapCheckpoint[];
+ caller_inputs: {
+ sha256: string | null;
+ utxo_hash: string | null;
+ height: number | null;
+ matched: boolean | null;
+ };
+ reason?: string;
+ // Fields kept from the earlier response shape.
file_size_valid: boolean;
sha256_valid: boolean;
manifest_hash_valid: boolean;
@@ -78,6 +185,42 @@ export interface NodeBootstrapVerification {
verified_at: string;
}
+export type NodeBootstrapSnapshotStatus =
+ | 'pinned_core'
+ | 'unverified'
+ | 'pending'
+ | 'verifying'
+ | 'invalid'
+ | 'unavailable';
+
+/** A catalogue entry joined with its pinned commitment and latest verification. */
+export interface NodeBootstrapSnapshot {
+ snapshot_id: string;
+ network: string;
+ height: number;
+ block_hash: string;
+ coins_count: number | null;
+ base_utxo_hash: string | null;
+ sha256_checksum: string;
+ file_size_bytes: number;
+ release_version: string;
+ /** pinned_core only after a verification run over the bytes reached valid. */
+ status: NodeBootstrapSnapshotStatus;
+ download_url?: string;
+ producer_id: string;
+ pinned_commitment: boolean;
+ latest_verification_id: string | null;
+ latest_verification_state: NodeBootstrapVerificationState | null;
+ // Fields kept from the earlier response shape.
+ name: string;
+ base_height: number;
+ base_block_hash: string;
+ txoutset_hash: string | null;
+ size_gb: number;
+ is_verified: boolean;
+ verification_status: 'verified' | 'unverified' | 'hash_mismatch';
+}
+
export interface NodeBootstrapChainstateObservation {
node_id: string;
active_chainstate: {
@@ -100,39 +243,117 @@ export interface NodeBootstrapChainstateObservation {
observed_at: string;
}
+export type NodeBootstrapPlanRejection =
+ | 'unsupported-network'
+ | 'unsupported-version'
+ | 'node-capability-missing'
+ | 'capacity-not-measured'
+ | 'stale-measurement'
+ | 'insufficient-capacity'
+ | 'no-compatible-snapshot'
+ | 'snapshot-not-verified'
+ | 'chainstate-not-eligible';
+
+/**
+ * A feasibility statement. Nothing in it has been executed; every number is
+ * either measured (and says where it was measured) or a stated assumption.
+ */
export interface NodeBootstrapPlan {
plan_id: string;
- node_version: string;
network: string;
- traditional_ibd: {
- estimated_download_gb: number;
- estimated_disk_gb: number;
- estimated_duration_hours_range: [number, number];
- requires_background_validation: false;
+ node_id: string;
+ node_version: string;
+ target_height: number;
+ measurement_status: 'measured';
+ measured: {
+ node_observed_at: string;
+ current_phase: NodeBootstrapChainstatePhase;
+ tip_height: number;
+ headers: number;
+ blocks_on_disk_bytes: number;
+ capacity: {
+ method: 'statfs' | 'configured';
+ measured_at: string;
+ free_bytes: number;
+ total_bytes: number | null;
+ };
+ supports_loadtxoutset: boolean;
+ };
+ selected_snapshot: {
+ snapshot_id: string;
+ height: number;
+ block_hash: string;
+ core_version: string;
+ size_bytes: number;
+ sha256: string;
+ utxo_commitment: string;
+ verification_id: string;
+ verified_at: string;
};
- assumeutxo: {
- snapshot_download_gb: number;
- temporary_disk_extra_gb: number;
- time_to_tip_minutes_range: [number, number];
- background_validation_duration_hours_range: [number, number];
- requires_background_validation: true;
+ requirements: {
+ snapshot_file_bytes: number;
+ chainstate_estimate_bytes: number;
+ headroom_bytes: number;
+ required_bytes: number;
+ free_bytes: number;
+ feasible: true;
};
- selected_snapshot_id?: string;
- index_compatibility_warning?: string;
+ assumptions: string[];
+ caller_declared: {
+ available_disk_gb: number | null;
+ bandwidth_mbps: number | null;
+ matches_measurement: boolean | null;
+ };
+ estimates: {
+ snapshot_download_hours: number | null;
+ ibd_download_hours: number | null;
+ basis: string;
+ };
+ expected_transitions: NodeBootstrapChainstatePhase[];
+ actions_not_executed: string[];
rollback_instructions: string[];
created_at: string;
}
+export type NodeBootstrapJobState =
+ | 'queued'
+ | 'running'
+ | 'completed'
+ | 'failed'
+ | 'needs-review';
+
+/**
+ * A durable operator job. Queued means accepted, nothing more; only Core's own
+ * RPC result and the chainstate observation that follows count as completion
+ * evidence.
+ */
export interface NodeBootstrapJob {
job_id: string;
- job_type: 'generate_snapshot' | 'verify_snapshot' | 'load_snapshot';
+ job_type: 'generate_snapshot' | 'load_snapshot';
+ network: string;
node_id: string;
snapshot_id?: string;
+ idempotency_key: string;
+ state: NodeBootstrapJobState;
+ status: NodeBootstrapJobState;
progress_pct: number;
- status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
message: string;
- started_at: string;
+ requested_by: string;
+ created_at: string;
+ updated_at: string;
+ started_at?: string;
finished_at?: string;
+ attempts: number;
+ lease: { owner: string; expires_at: string } | null;
+ rpc: {
+ method: 'dumptxoutset' | 'loadtxoutset';
+ started_at?: string;
+ finished_at?: string;
+ } | null;
+ preconditions: Record;
+ evidence: Record;
+ checkpoints: NodeBootstrapCheckpoint[];
+ reason?: string;
}
export interface BootstrapOverviewResponse {
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap.routes.test.ts b/backend/src/api/intelligence/bootstrap/bootstrap.routes.test.ts
new file mode 100644
index 00000000000..16e5800ac2b
--- /dev/null
+++ b/backend/src/api/intelligence/bootstrap/bootstrap.routes.test.ts
@@ -0,0 +1,144 @@
+import { createHash, createHmac } from 'crypto';
+import adminControl from '@bitcoinuniverse/ecosystem-contracts/admin-control';
+
+jest.mock('./bootstrap.service', () => {
+ class BootstrapEvidenceError extends Error {
+ constructor(public readonly code: string, message: string, public readonly status = 503) {
+ super(message);
+ }
+ }
+ return {
+ __esModule: true,
+ BootstrapEvidenceError,
+ default: {
+ startWorker: jest.fn(),
+ createOperatorJob: jest.fn(async (params: any, authorization: any) => ({ job_id: 'job-1', state: 'queued', params, authorization })),
+ getJob: jest.fn(async () => undefined),
+ },
+ };
+});
+
+import bootstrapRoutes, { OPERATOR_PREFIX } from './bootstrap.routes';
+import bootstrapService from './bootstrap.service';
+
+type Handler = (req: any, res: any, next: (err?: unknown) => void) => unknown;
+
+/** Captures what the routes mount, so a request can be pushed through the operator prefix chain. */
+function mountedApp(): { uses: Array<{ path: string; handlers: Handler[] }>; posts: Record } {
+ const uses: Array<{ path: string; handlers: Handler[] }> = [];
+ const posts: Record = {};
+ const app: any = {
+ use: (path: string, ...handlers: Handler[]) => { uses.push({ path, handlers }); return app; },
+ get: () => app,
+ post: (path: string, ...handlers: Handler[]) => { posts[path] = handlers; return app; },
+ };
+ bootstrapRoutes.initRoutes(app);
+ return { uses, posts };
+}
+
+function response(): { status: jest.Mock; json: jest.Mock; setHeader: jest.Mock; removeHeader: jest.Mock; locals: Record; sent: () => { status: number; body: any } } {
+ const res: any = { locals: {}, statusCode: 200 };
+ res.status = jest.fn((code: number) => { res.statusCode = code; return res; });
+ res.json = jest.fn((body: unknown) => { res.body = body; return res; });
+ res.setHeader = jest.fn();
+ res.removeHeader = jest.fn();
+ res.sent = () => ({ status: res.statusCode, body: res.body });
+ return res;
+}
+
+async function runChain(handlers: Handler[], req: any, res: any): Promise {
+ for (const handler of handlers) {
+ let advanced = false;
+ await handler(req, res, () => { advanced = true; });
+ if (!advanced) {
+ return;
+ }
+ }
+}
+
+function request(remoteAddress: string, body: Record, headers: Record = {}): any {
+ const raw = Buffer.from(JSON.stringify(body), 'utf8');
+ return { method: 'POST', originalUrl: `${OPERATOR_PREFIX}/loads`, headers: { 'content-type': 'application/json', ...headers }, rawBody: raw, body, socket: { remoteAddress } };
+}
+
+function signedHeaders(secret: Buffer, keyId: string, rawBody: Buffer, path: string): Record {
+ const { ADMIN_SERVICE_HEADERS, ADMIN_CONTROL_CONTRACT_VERSION, adminServiceSigningString } = adminControl as any;
+ const timestamp = new Date().toISOString();
+ const nonce = 'nonce-' + Math.random().toString(16).slice(2, 14);
+ const bodyDigest = createHash('sha256').update(rawBody).digest('hex');
+ const signature = createHmac('sha256', secret).update(adminServiceSigningString({ method: 'POST', path, query: '', keyId, timestamp, nonce, bodyDigest })).digest('hex');
+ return {
+ [ADMIN_SERVICE_HEADERS.contractVersion]: ADMIN_CONTROL_CONTRACT_VERSION,
+ [ADMIN_SERVICE_HEADERS.keyId]: keyId,
+ [ADMIN_SERVICE_HEADERS.timestamp]: timestamp,
+ [ADMIN_SERVICE_HEADERS.nonce]: nonce,
+ [ADMIN_SERVICE_HEADERS.bodyDigest]: bodyDigest,
+ [ADMIN_SERVICE_HEADERS.signature]: signature,
+ };
+}
+
+describe('bootstrap operator routes', () => {
+ const secret = Buffer.alloc(32, 7);
+ const previousKeys = process.env.EXPLORER_ADMIN_ADAPTER_KEYS;
+ beforeEach(() => {
+ (bootstrapService.createOperatorJob as jest.Mock).mockClear();
+ process.env.EXPLORER_ADMIN_ADAPTER_KEYS = 'ops:' + secret.toString('base64');
+ });
+ afterAll(() => {
+ if (previousKeys === undefined) {
+ delete process.env.EXPLORER_ADMIN_ADAPTER_KEYS;
+ } else {
+ process.env.EXPLORER_ADMIN_ADAPTER_KEYS = previousKeys;
+ }
+ });
+
+ it('mounts the admin adapter guard on the operator prefix before any handler', () => {
+ const { uses, posts } = mountedApp();
+ expect(uses.map((u) => u.path)).toEqual([OPERATOR_PREFIX]);
+ expect(uses[0].handlers).toHaveLength(2);
+ expect(Object.keys(posts)).toEqual(expect.arrayContaining([`${OPERATOR_PREFIX}/snapshots`, `${OPERATOR_PREFIX}/loads`]));
+ expect(bootstrapService.startWorker).toHaveBeenCalled();
+ });
+
+ it('answers a public origin with 404 and never reaches the service', async () => {
+ const { uses, posts } = mountedApp();
+ const res = response();
+ const req = request('203.0.113.9', { snapshot_id: 's', idempotency_key: 'idem-12345678', confirm: 'load_snapshot' });
+ await runChain([uses[0].handlers[1], ...posts[`${OPERATOR_PREFIX}/loads`]], req, res);
+ expect(res.sent()).toEqual({ status: 404, body: { code: 'NOT_FOUND', message: 'Not found.' } });
+ expect(bootstrapService.createOperatorJob).not.toHaveBeenCalled();
+ });
+
+ it('answers an unsigned private request with a rejection and never reaches the service', async () => {
+ const { uses, posts } = mountedApp();
+ const res = response();
+ const req = request('127.0.0.1', { snapshot_id: 's', idempotency_key: 'idem-12345678', confirm: 'load_snapshot' });
+ await runChain([uses[0].handlers[1], ...posts[`${OPERATOR_PREFIX}/loads`]], req, res);
+ expect([401, 403]).toContain(res.sent().status);
+ expect(bootstrapService.createOperatorJob).not.toHaveBeenCalled();
+ delete process.env.EXPLORER_ADMIN_ADAPTER_KEYS;
+ const unconfigured = response();
+ await runChain([uses[0].handlers[1], ...posts[`${OPERATOR_PREFIX}/loads`]], request('127.0.0.1', {}), unconfigured);
+ expect(unconfigured.sent().status).toBe(503);
+ expect(bootstrapService.createOperatorJob).not.toHaveBeenCalled();
+ });
+
+ it('passes a correctly signed private request to the service with its verified authorization', async () => {
+ const { uses, posts } = mountedApp();
+ const body = { snapshot_id: 'regtest-105', idempotency_key: 'idem-12345678', confirm: 'load_snapshot', adminAuthorization: { elevated: true } };
+ const raw = Buffer.from(JSON.stringify(body), 'utf8');
+ const req = request('10.0.0.5', body, signedHeaders(secret, 'ops', raw, `${OPERATOR_PREFIX}/loads`));
+ const res = response();
+ await runChain([uses[0].handlers[1], ...posts[`${OPERATOR_PREFIX}/loads`]], req, res);
+ expect(res.sent().status).toBe(202);
+ expect(bootstrapService.createOperatorJob).toHaveBeenCalledWith(
+ { job_type: 'load_snapshot', node_id: undefined, snapshot_id: 'regtest-105', idempotency_key: 'idem-12345678', confirm: 'load_snapshot' },
+ { keyId: 'ops', elevated: true }
+ );
+ const tampered = request('10.0.0.5', { ...body, snapshot_id: 'other' }, signedHeaders(secret, 'ops', raw, `${OPERATOR_PREFIX}/loads`));
+ const rejected = response();
+ await runChain([uses[0].handlers[1], ...posts[`${OPERATOR_PREFIX}/loads`]], tampered, rejected);
+ expect(rejected.sent().status).toBe(401);
+ expect(bootstrapService.createOperatorJob).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap.routes.ts b/backend/src/api/intelligence/bootstrap/bootstrap.routes.ts
index 8b5e466042c..d9cf84ea4d3 100644
--- a/backend/src/api/intelligence/bootstrap/bootstrap.routes.ts
+++ b/backend/src/api/intelligence/bootstrap/bootstrap.routes.ts
@@ -1,4 +1,8 @@
import { Application, Request, Response } from 'express';
+import {
+ adminAdapterGuard,
+ adminAdapterJsonParser,
+} from '../../admin-adapter/admin-adapter.security';
import bootstrapService, { BootstrapEvidenceError } from './bootstrap.service';
function fail(res: Response, err: unknown): Response {
@@ -7,8 +11,20 @@ function fail(res: Response, err: unknown): Response {
return res.status(500).json({ error: 'Internal error' });
}
+/**
+ * Operator routes reuse the control plane's own trust: a private-path origin
+ * and a signed request verified by the admin adapter guard, mounted here so
+ * it runs before any handler. The signature covers the exact request bytes;
+ * the adapter parser captures them, so this prefix is mounted before the
+ * general JSON parser in index.ts (see the /internal/admin/v1 line).
+ */
+export const OPERATOR_PREFIX = '/api/v1/intelligence/bootstrap/operator';
+
class BootstrapRoutes {
public initRoutes(app: Application): void {
+ app.use(OPERATOR_PREFIX, adminAdapterJsonParser(), adminAdapterGuard());
+ bootstrapService.startWorker();
+
app.get(
'/api/v1/intelligence/bootstrap/overview',
async (req: Request, res: Response) => {
@@ -70,7 +86,7 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/snapshots',
async (req: Request, res: Response) => {
try {
- const snapshots = bootstrapService.listSnapshots();
+ const snapshots = await bootstrapService.listSnapshots();
res.json(snapshots);
} catch (err: any) {
fail(res, err);
@@ -82,7 +98,7 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/snapshots/:snapshotId',
async (req: Request, res: Response) => {
try {
- const snapshot = bootstrapService.getSnapshot(req.params.snapshotId);
+ const snapshot = await bootstrapService.getSnapshot(req.params.snapshotId);
if (!snapshot) {
return res.status(404).json({ error: 'Snapshot not found' });
}
@@ -97,11 +113,11 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/snapshots/:snapshotId/manifest',
async (req: Request, res: Response) => {
try {
- const snapshot = bootstrapService.getSnapshot(req.params.snapshotId);
- if (!snapshot || !snapshot.manifest) {
+ const manifest = bootstrapService.getSnapshotManifest(req.params.snapshotId);
+ if (!manifest) {
return res.status(404).json({ error: 'Manifest not found' });
}
- res.json(snapshot.manifest);
+ res.json(manifest);
} catch (err: any) {
fail(res, err);
}
@@ -112,8 +128,8 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/verifications',
async (req: Request, res: Response) => {
try {
- const verification = bootstrapService.verifySnapshot(req.body);
- res.json(verification);
+ const verification = await bootstrapService.verifySnapshot(req.body);
+ res.status(verification.state === 'pending' || verification.state === 'verifying' ? 202 : 200).json(verification);
} catch (err: any) {
fail(res, err);
}
@@ -124,7 +140,7 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/verifications/:verificationId',
async (req: Request, res: Response) => {
try {
- const verification = bootstrapService.getVerification(
+ const verification = await bootstrapService.getVerification(
req.params.verificationId
);
if (!verification) {
@@ -143,7 +159,7 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/plans',
async (req: Request, res: Response) => {
try {
- const plan = bootstrapService.createBootstrapPlan(req.body);
+ const plan = await bootstrapService.createBootstrapPlan(req.body);
res.json(plan);
} catch (err: any) {
fail(res, err);
@@ -152,14 +168,18 @@ class BootstrapRoutes {
);
app.post(
- '/api/v1/intelligence/bootstrap/operator/snapshots',
+ `${OPERATOR_PREFIX}/snapshots`,
async (req: Request, res: Response) => {
try {
- const job = bootstrapService.createOperatorJob({
- job_type: 'generate_snapshot',
- node_id: req.body.node_id,
- });
- res.json(job);
+ const job = await bootstrapService.createOperatorJob(
+ {
+ job_type: 'generate_snapshot',
+ node_id: req.body?.node_id,
+ idempotency_key: req.body?.idempotency_key,
+ },
+ res.locals.adminAdapterAuthorization
+ );
+ res.status(202).json(job);
} catch (err: any) {
fail(res, err);
}
@@ -167,15 +187,20 @@ class BootstrapRoutes {
);
app.post(
- '/api/v1/intelligence/bootstrap/operator/loads',
+ `${OPERATOR_PREFIX}/loads`,
async (req: Request, res: Response) => {
try {
- const job = bootstrapService.createOperatorJob({
- job_type: 'load_snapshot',
- node_id: req.body.node_id,
- snapshot_id: req.body.snapshot_id,
- });
- res.json(job);
+ const job = await bootstrapService.createOperatorJob(
+ {
+ job_type: 'load_snapshot',
+ node_id: req.body?.node_id,
+ snapshot_id: req.body?.snapshot_id,
+ idempotency_key: req.body?.idempotency_key,
+ confirm: req.body?.confirm,
+ },
+ res.locals.adminAdapterAuthorization
+ );
+ res.status(202).json(job);
} catch (err: any) {
fail(res, err);
}
@@ -186,7 +211,7 @@ class BootstrapRoutes {
'/api/v1/intelligence/bootstrap/jobs/:jobId',
async (req: Request, res: Response) => {
try {
- const job = bootstrapService.getJob(req.params.jobId);
+ const job = await bootstrapService.getJob(req.params.jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap.service.ts b/backend/src/api/intelligence/bootstrap/bootstrap.service.ts
index 4b8c6406c08..0fda66572ea 100644
--- a/backend/src/api/intelligence/bootstrap/bootstrap.service.ts
+++ b/backend/src/api/intelligence/bootstrap/bootstrap.service.ts
@@ -1,36 +1,123 @@
+import { randomUUID } from 'crypto';
+import { hostname } from 'os';
+import config from '../../../config';
+import logger from '../../../logger';
+import { ownedWorkbenchCore, WorkbenchCoreReader } from '../workbench/workbench-core';
import { BootstrapNodeReader } from './node-reader';
+import { BootstrapEnvironment, readBootstrapEnvironment } from './bootstrap-config';
+import { BootstrapEvidenceError, unavailable } from './bootstrap-errors';
+import { BootstrapStore, MysqlBootstrapStore } from './bootstrap-store';
+import { BootstrapJobExecutor, OperatorCore } from './bootstrap-executor';
import {
- NodeBootstrapCapability,
+ compatibleSnapshots,
+ evaluateLoadEligibility,
+ NodeObservation,
+ requireFreshObservation,
+ requireSupportedNode,
+} from './bootstrap-preconditions';
+import { StatfsReader } from './bootstrap-capacity';
+import {
+ BootstrapCatalogue,
+ BootstrapCatalogueSnapshot,
+ NodeBootstrapChainstatePhase,
+ NodeBootstrapJob,
+ NodeBootstrapPlan,
NodeBootstrapSnapshot,
NodeBootstrapVerification,
- NodeBootstrapChainstateObservation,
- NodeBootstrapPlan,
- NodeBootstrapJob,
- BootstrapOverviewResponse,
} from './bootstrap.models';
+import { classifySource, deterministicSnapshotJson, loadCatalogue, pinnedCommitmentFor, producerFor } from './snapshot-catalogue';
+import { newVerificationRecord, SnapshotByteSource, SnapshotVerifier } from './snapshot-verifier';
-export class BootstrapEvidenceError extends Error {
- constructor(
- public readonly code: string,
- message: string,
- public readonly status = 503
- ) {
- super(message);
- }
+export { BootstrapEvidenceError } from './bootstrap-errors';
+
+const hex64 = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
+
+/** Signed control-plane identity the routes verified before calling the service. */
+export interface OperatorAuthorization {
+ keyId: string;
+ elevated: boolean;
+}
+
+export interface BootstrapServiceDependencies {
+ nodes?: BootstrapNodeReader;
+ core?: WorkbenchCoreReader;
+ operatorCore?: OperatorCore;
+ env?: BootstrapEnvironment;
+ store?: BootstrapStore | null;
+ bytes?: SnapshotByteSource;
+ network?: string;
+ clock?: () => number;
+ statfs?: StatfsReader;
+ catalogue?: () => BootstrapCatalogue;
+ workerOwner?: string;
}
-const unavailable = (code: string, prerequisite: string): never => {
- throw new BootstrapEvidenceError(
- code,
- 'Bootstrap evidence is unavailable. ' + prerequisite
- );
-};
+function longTimeoutOperatorCore(env: BootstrapEnvironment): OperatorCore {
+ // The shared client keeps a short timeout that suits reads; a snapshot RPC
+ // runs for a long time against the same fixed node identity.
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const bitcoin = require('../../../rpc-api/index');
+ const client = new bitcoin.Client({
+ host: config.CORE_RPC.HOST,
+ port: config.CORE_RPC.PORT,
+ user: config.CORE_RPC.USERNAME,
+ pass: config.CORE_RPC.PASSWORD,
+ timeout: env.jobTimeoutMs,
+ cookie: config.CORE_RPC.COOKIE ? config.CORE_RPC.COOKIE_PATH : undefined,
+ });
+ return { call: (method, params) => client.rpc.call(method, params) };
+}
-// These routes remain offered. Seeded nodes, manifests and in-memory jobs are
-// not observations or operator execution; require their actual authorities.
+/**
+ * Read-only node observation is always real. The catalogue, verification,
+ * planning and operator subfeatures each report their own availability and
+ * never invent a snapshot, a verification or a job.
+ */
export class BootstrapService {
- constructor(private nodes = new BootstrapNodeReader()) {}
- private async observation() {
+ private readonly nodes: BootstrapNodeReader;
+ private readonly core: WorkbenchCoreReader;
+ private readonly env: BootstrapEnvironment;
+ private readonly store: BootstrapStore | null;
+ private readonly network: string;
+ private readonly clock: () => number;
+ private readonly verifier: SnapshotVerifier;
+ private readonly readCatalogue: () => BootstrapCatalogue;
+ private readonly statfs?: StatfsReader;
+ private operatorCore?: OperatorCore;
+ private readonly workerOwner: string;
+ private executor?: BootstrapJobExecutor;
+ private readonly inflight = new Map>();
+
+ constructor(deps: BootstrapServiceDependencies = {}) {
+ this.nodes = deps.nodes ?? new BootstrapNodeReader();
+ this.core = deps.core ?? ownedWorkbenchCore;
+ this.env = deps.env ?? readBootstrapEnvironment();
+ this.network = deps.network ?? config.MEMPOOL.NETWORK;
+ this.store = deps.store === undefined ? (config.DATABASE.ENABLED ? new MysqlBootstrapStore() : null) : deps.store;
+ this.clock = deps.clock ?? (() => Date.now());
+ this.statfs = deps.statfs;
+ this.readCatalogue = deps.catalogue ?? ((): BootstrapCatalogue => loadCatalogue(this.env.catalogueFile));
+ this.operatorCore = deps.operatorCore;
+ this.workerOwner = deps.workerOwner ?? `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
+ this.verifier = new SnapshotVerifier(this.env, this.nodes, this.core, deps.bytes, () => new Date(this.clock()).toISOString());
+ }
+
+ private requireStore(): BootstrapStore {
+ if (!this.store) {
+ throw new BootstrapEvidenceError(
+ 'durable-store-unavailable',
+ 'Bootstrap evidence is unavailable. Verification runs and operator jobs need the durable MySQL store (DATABASE.ENABLED).'
+ );
+ }
+ return this.store;
+ }
+
+ private now(): string {
+ return new Date(this.clock()).toISOString();
+ }
+
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ private async observation(): Promise {
try {
return await this.nodes.read();
} catch {
@@ -40,25 +127,54 @@ export class BootstrapService {
);
}
}
+
+ private catalogueStatus(): { status: 'available' | 'unavailable'; reason: string | null; catalogue: BootstrapCatalogue | null } {
+ try {
+ return { status: 'available', reason: null, catalogue: this.readCatalogue() };
+ } catch (e) {
+ return { status: 'unavailable', reason: e instanceof Error ? e.message : String(e), catalogue: null };
+ }
+ }
+
/** @asyncUnsafe rejections propagate to the caller, which handles them. */
- public async getOverview() {
+ public async getOverview(): Promise> {
const { capability, observation } = await this.observation();
+ const catalogue = this.catalogueStatus();
+ let snapshots: NodeBootstrapSnapshot[] = [];
+ let snapshotReason = catalogue.reason;
+ if (catalogue.catalogue) {
+ try {
+ snapshots = await this.joinSnapshots(catalogue.catalogue);
+ } catch (e) {
+ snapshotReason = e instanceof Error ? e.message : String(e);
+ }
+ }
+ const verified = snapshots.filter((s) => s.status === 'pinned_core');
return {
total_nodes: 1,
nodes: [capability],
active_chainstates: [observation],
- snapshots: [],
- total_snapshots: null,
+ snapshots,
+ total_snapshots: catalogue.catalogue ? snapshots.length : null,
configured_nodes_count: 1,
dual_chainstate_nodes_count: observation.dual_chainstate_active ? 1 : 0,
- recommended_snapshot_height: null,
- featured_snapshots: [],
+ recommended_snapshot_height: verified.length ? Math.max(...verified.map((s) => s.height)) : null,
+ featured_snapshots: verified,
observed_nodes: [observation],
- snapshot_catalogue_status: 'unavailable',
- snapshot_catalogue_reason:
- 'A trusted signed snapshot catalogue and independently pinned Core commitments are not configured.',
+ snapshot_catalogue_status: catalogue.catalogue && !snapshotReason ? 'available' : 'unavailable',
+ snapshot_catalogue_reason: snapshotReason,
+ verification_store_status: this.store ? 'available' : 'unavailable',
+ verification_store_reason: this.store ? null : 'Verification runs and operator jobs need the durable MySQL store (DATABASE.ENABLED).',
+ planning_status: catalogue.catalogue && this.store ? 'available' : 'unavailable',
+ operator_status: this.store && this.env.snapshotDir ? 'available' : 'unavailable',
+ operator_reason: !this.store
+ ? 'The durable job store is not connected.'
+ : !this.env.snapshotDir
+ ? 'No allowlisted snapshot directory is configured (UNIVERSE_BOOTSTRAP_SNAPSHOT_DIR).'
+ : null,
};
}
+
/** @asyncUnsafe rejections propagate to the caller, which handles them. */
public async listNodes() {
return [(await this.observation()).capability];
@@ -73,21 +189,125 @@ export class BootstrapService {
return observation.node_id === nodeId ? observation : undefined;
}
- public listSnapshots(): NodeBootstrapSnapshot[] {
- return unavailable(
- 'unavailable-manifest',
- 'A trusted signed snapshot catalogue, producer keys and pinned Bitcoin Core commitments are required.'
- );
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ private async joinSnapshots(catalogue: BootstrapCatalogue): Promise {
+ const out: NodeBootstrapSnapshot[] = [];
+ for (const snapshot of catalogue.snapshots.filter((s) => s.network === this.network)) {
+ const pinned = pinnedCommitmentFor(catalogue, snapshot);
+ let latest: NodeBootstrapVerification | null = null;
+ let storeUnavailable = false;
+ if (this.store) {
+ try {
+ latest = await this.store.latestVerification(snapshot.id, this.network);
+ } catch {
+ storeUnavailable = true;
+ }
+ } else {
+ storeUnavailable = true;
+ }
+ const state = latest?.state ?? null;
+ const status: NodeBootstrapSnapshot['status'] = storeUnavailable
+ ? 'unavailable'
+ : state === 'valid'
+ ? 'pinned_core'
+ : state === 'invalid'
+ ? 'invalid'
+ : state === 'pending' || state === 'verifying'
+ ? state
+ : 'unverified';
+ let downloadUrl: string | undefined;
+ try {
+ const source = classifySource(snapshot.source, this.env.sourceAllowlist);
+ downloadUrl = source.kind === 'https' ? snapshot.source : undefined;
+ } catch {
+ downloadUrl = undefined;
+ }
+ out.push({
+ snapshot_id: snapshot.id,
+ network: snapshot.network,
+ height: snapshot.height,
+ block_hash: snapshot.blockHash,
+ coins_count: pinned?.coinCount ?? null,
+ base_utxo_hash: pinned?.utxoCommitment ?? null,
+ sha256_checksum: snapshot.sha256,
+ file_size_bytes: snapshot.sizeBytes,
+ release_version: snapshot.coreVersion,
+ status,
+ ...(downloadUrl ? { download_url: downloadUrl } : {}),
+ producer_id: snapshot.manifest.producerId,
+ pinned_commitment: !!pinned,
+ latest_verification_id: latest?.verification_id ?? null,
+ latest_verification_state: state,
+ name: `${snapshot.network} ${snapshot.height} (Bitcoin Core ${snapshot.coreVersion})`,
+ base_height: snapshot.height,
+ base_block_hash: snapshot.blockHash,
+ txoutset_hash: pinned?.utxoCommitment ?? null,
+ size_gb: snapshot.sizeBytes / 1073741824,
+ is_verified: status === 'pinned_core',
+ verification_status: status === 'pinned_core' ? 'verified' : status === 'invalid' ? 'hash_mismatch' : 'unverified',
+ });
+ }
+ return out.sort((a, b) => b.height - a.height);
+ }
+
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async listSnapshots(): Promise {
+ return this.joinSnapshots(this.readCatalogue());
}
- public getSnapshot(_snapshotId: string): NodeBootstrapSnapshot | undefined {
- return unavailable(
- 'unavailable-manifest',
- 'A trusted signed snapshot catalogue, producer keys and pinned Bitcoin Core commitments are required.'
+ /** Looks a snapshot up by id, height or block hash on this network. */
+ private resolveSnapshot(catalogue: BootstrapCatalogue, reference: string): BootstrapCatalogueSnapshot | undefined {
+ const ref = String(reference ?? '').trim();
+ return catalogue.snapshots.find(
+ (s) => s.network === this.network && (s.id === ref || String(s.height) === ref || s.blockHash === ref.toLowerCase())
);
}
- public verifySnapshot(data: {
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async getSnapshot(reference: string): Promise {
+ const catalogue = this.readCatalogue();
+ const snapshot = this.resolveSnapshot(catalogue, reference);
+ if (!snapshot) {
+ return undefined;
+ }
+ return (await this.joinSnapshots(catalogue)).find((s) => s.snapshot_id === snapshot.id);
+ }
+
+ /** The signed manifest of a catalogue snapshot: who signed which fields, and what Core pins for it. */
+ public getSnapshotManifest(reference: string): Record | undefined {
+ const catalogue = this.readCatalogue();
+ const snapshot = this.resolveSnapshot(catalogue, reference);
+ if (!snapshot) {
+ return undefined;
+ }
+ const producer = producerFor(catalogue, snapshot);
+ const pinned = pinnedCommitmentFor(catalogue, snapshot);
+ return {
+ snapshot_id: snapshot.id,
+ network: snapshot.network,
+ core_version: snapshot.coreVersion,
+ height: snapshot.height,
+ block_hash: snapshot.blockHash,
+ size_bytes: snapshot.sizeBytes,
+ sha256: snapshot.sha256,
+ producer_id: snapshot.manifest.producerId,
+ algorithm: producer?.algorithm ?? null,
+ public_key: producer?.publicKey ?? null,
+ signature: snapshot.manifest.signature,
+ signed_fields: deterministicSnapshotJson(snapshot).toString('utf8'),
+ pinned_commitment: pinned ? { utxo_commitment: pinned.utxoCommitment, coin_count: pinned.coinCount, source: 'operator-pinned Bitcoin Core chainparams for ' + pinned.coreVersion } : null,
+ note: 'A signed manifest identifies bytes; it is not evidence the bytes are valid. See the verification runs.',
+ };
+ }
+
+ /**
+ * Starts (or reuses) a verification run over the actual bytes. The answer is
+ * the durable record, which is pending until the run reaches a final state;
+ * poll getVerification. Caller checksums are recorded and compared, never
+ * trusted.
+ */
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async verifySnapshot(data: {
snapshot_id?: string;
file_sha256?: string;
base_height?: number;
@@ -95,66 +315,340 @@ export class BootstrapService {
height?: number;
sha256?: string;
utxo_hash?: string;
- }): NodeBootstrapVerification {
+ }): Promise {
const height = data?.base_height ?? data?.height;
const sha256 = data?.file_sha256 ?? data?.sha256;
const utxoHash = data?.expected_txoutset_hash ?? data?.utxo_hash;
if (
- !Number.isSafeInteger(height) ||
- Number(height) < 0 ||
- typeof sha256 !== 'string' ||
- !/^[0-9a-f]{64}$/i.test(sha256) ||
- typeof utxoHash !== 'string' ||
- !/^[0-9a-f]{64}$/i.test(utxoHash)
+ (height !== undefined && (!Number.isSafeInteger(height) || Number(height) < 0)) ||
+ (sha256 !== undefined && !hex64(sha256)) ||
+ (utxoHash !== undefined && !hex64(utxoHash)) ||
+ (data?.snapshot_id !== undefined && typeof data.snapshot_id !== 'string') ||
+ (data?.snapshot_id === undefined && height === undefined)
) {
throw new BootstrapEvidenceError(
'invalid-input',
- 'A nonnegative base height and two 32-byte hexadecimal checksums are required.',
+ 'A snapshot_id or a nonnegative base height is required; checksums, when given, are 32-byte hexadecimal.',
400
);
}
- return unavailable(
- 'unavailable-manifest',
- 'No trusted manifest, producer-key verification, snapshot bytes or pinned Core UTXO commitment were checked. Caller-supplied checksums alone cannot verify a snapshot.'
+ const catalogue = this.readCatalogue();
+ const store = this.requireStore();
+ const snapshot = this.resolveSnapshot(catalogue, data.snapshot_id ?? String(height));
+ if (!snapshot) {
+ throw new BootstrapEvidenceError('snapshot-not-in-catalogue', 'No trusted catalogue snapshot matches that reference on this network.', 404);
+ }
+ if (height !== undefined && height !== snapshot.height) {
+ throw new BootstrapEvidenceError('invalid-input', `Snapshot ${snapshot.id} is at height ${snapshot.height}, not ${height}.`, 400);
+ }
+ const active = await store.activeVerification(snapshot.id, this.network);
+ if (active) {
+ return active;
+ }
+ const record = newVerificationRecord(
+ randomUUID(),
+ snapshot,
+ { sha256: sha256 ? sha256.toLowerCase() : null, utxo_hash: utxoHash ? utxoHash.toLowerCase() : null, height: height ?? null },
+ this.now()
);
+ await store.insertVerification(record);
+ const run = this.verifier
+ .run(JSON.parse(JSON.stringify(record)), catalogue, snapshot, store)
+ .catch(async (e) => {
+ logger.err('[bootstrap] verification run failed: ' + (e instanceof Error ? e.message : e));
+ record.state = 'unavailable';
+ record.status = 'unavailable';
+ record.reason = 'run-failed';
+ record.details = 'The verification run stopped before recording an outcome: ' + (e instanceof Error ? e.message : String(e));
+ record.finished_at = this.now();
+ try {
+ await store.updateVerification(record);
+ } catch (storeError) {
+ logger.err('[bootstrap] verification outcome could not be stored: ' + (storeError instanceof Error ? storeError.message : storeError));
+ }
+ return record;
+ })
+ .finally(() => this.inflight.delete(record.verification_id));
+ this.inflight.set(record.verification_id, run);
+ return record;
}
- public getVerification(
- _verificationId: string
- ): NodeBootstrapVerification | undefined {
- return unavailable(
- 'unavailable-verification-store',
- 'No durable verification authority is connected; absence of a run cannot be established.'
- );
+ /** Resolves once the named run has a final state; for tests and callers that can wait. */
+ public awaitVerification(verificationId: string): Promise {
+ return this.inflight.get(verificationId) ?? Promise.resolve(undefined);
}
- public createBootstrapPlan(_params: {
- node_version: string;
- network: string;
- available_disk_gb: number;
- }): NodeBootstrapPlan {
- return unavailable(
- 'unavailable-node-source',
- 'Planning requires measured node capabilities, disk capacity and a compatible trusted snapshot.'
- );
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async getVerification(verificationId: string): Promise {
+ const store = this.requireStore();
+ try {
+ return (await store.getVerification(verificationId, this.network)) ?? undefined;
+ } catch (e) {
+ throw new BootstrapEvidenceError(
+ 'durable-store-unavailable',
+ 'The durable verification store did not answer; absence of the run cannot be established: ' + (e instanceof Error ? e.message : String(e))
+ );
+ }
}
- public createOperatorJob(_params: {
- job_type: 'generate_snapshot' | 'verify_snapshot' | 'load_snapshot';
- node_id: string;
+ /**
+ * A feasibility statement from measurements: the observed node, the
+ * measured capacity and a verified, pinned, compatible snapshot. Nothing is
+ * executed. Caller values are recorded as declarations or assumptions.
+ */
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async createBootstrapPlan(params: {
+ node_id?: string;
+ node_version?: string;
+ network?: string;
+ target_height?: number;
snapshot_id?: string;
- }): NodeBootstrapJob {
- return unavailable(
- 'unavailable-operator',
- 'The authorized node executor and durable job store are not connected. No node operation was started.'
- );
+ available_disk_gb?: number;
+ bandwidth_mbps?: number;
+ }): Promise {
+ const p = params ?? {};
+ if (p.network !== undefined && p.network !== this.network) {
+ throw new BootstrapEvidenceError('unsupported-network', `This backend plans for ${this.network}, not ${p.network}.`, 409);
+ }
+ const catalogue = this.readCatalogue();
+ const store = this.requireStore();
+ const observation = await this.observation();
+ const nowMs = this.clock();
+ requireFreshObservation(observation, nowMs, 60000);
+ const version = requireSupportedNode(observation, this.network);
+ if (p.node_id !== undefined && p.node_id !== observation.capability.node_id) {
+ throw new BootstrapEvidenceError('node-not-observed', `The owned node is ${observation.capability.node_id}, not ${p.node_id}.`, 404);
+ }
+ if (p.node_version !== undefined && String(p.node_version).replace(/^v/, '') !== version.version) {
+ throw new BootstrapEvidenceError('unsupported-version', `The owned node runs Bitcoin Core ${version.version}, not ${p.node_version}; plans are made for the measured version.`, 409);
+ }
+ const candidates = compatibleSnapshots(catalogue, this.network, version);
+ let chosen = candidates;
+ if (p.snapshot_id !== undefined) {
+ chosen = candidates.filter((c) => c.snapshot.id === p.snapshot_id);
+ } else if (p.target_height !== undefined) {
+ chosen = candidates.filter((c) => c.snapshot.height === p.target_height);
+ }
+ if (!chosen.length) {
+ throw new BootstrapEvidenceError('no-compatible-snapshot', `No catalogue snapshot on ${this.network} has a commitment pinned for Bitcoin Core ${version.version}` + (p.target_height !== undefined ? ` at height ${p.target_height}.` : '.'), 409);
+ }
+ let selected: Awaited> | undefined;
+ let snapshot: BootstrapCatalogueSnapshot | undefined;
+ let lastError: BootstrapEvidenceError | undefined;
+ for (const candidate of chosen) {
+ try {
+ selected = await evaluateLoadEligibility({ observation, network: this.network, snapshot: candidate.snapshot, catalogue, store, env: this.env, now: nowMs, statfs: this.statfs });
+ snapshot = candidate.snapshot;
+ break;
+ } catch (e) {
+ if (!(e instanceof BootstrapEvidenceError)) {
+ throw e;
+ }
+ lastError = e;
+ // Capacity and chainstate rejections hold for every candidate; only an
+ // unverified snapshot lets the next newest one be considered.
+ if (e.code !== 'snapshot-not-verified') {
+ throw e;
+ }
+ }
+ }
+ if (!selected || !snapshot) {
+ throw lastError ?? new BootstrapEvidenceError('no-compatible-snapshot', 'No compatible snapshot could be selected.', 409);
+ }
+ const chain = observation.observation;
+ const blocksOnDisk = Math.round(chain.disk_used_gb * 1073741824);
+ const declaredDisk = typeof p.available_disk_gb === 'number' && Number.isFinite(p.available_disk_gb) ? p.available_disk_gb : null;
+ const bandwidth = typeof p.bandwidth_mbps === 'number' && Number.isFinite(p.bandwidth_mbps) && p.bandwidth_mbps > 0 ? p.bandwidth_mbps : null;
+ const hours = (bytes: number): number | null => (bandwidth ? Math.round(((bytes * 8) / (bandwidth * 1e6) / 3600) * 100) / 100 : null);
+ const assumptions = [
+ 'The chainstate built from the snapshot is assumed to need as much disk as the snapshot file; a tenth of both is added as headroom.',
+ 'Core reports size_on_disk for block and undo data only; chainstate and indexes are not in the measured usage.',
+ bandwidth ? `Download estimates assume the caller-declared ${bandwidth} Mbps and nothing else.` : 'No bandwidth was declared, so no download time is estimated.',
+ 'Validation durations are not estimated: this backend has no measurement of the node CPU or disk throughput.',
+ ];
+ return {
+ plan_id: randomUUID(),
+ network: this.network,
+ node_id: observation.capability.node_id,
+ node_version: version.version,
+ target_height: snapshot.height,
+ measurement_status: 'measured',
+ measured: {
+ node_observed_at: chain.observed_at,
+ current_phase: chain.current_phase as NodeBootstrapChainstatePhase,
+ tip_height: chain.tip_height,
+ headers: chain.tip_height + chain.estimated_remaining_blocks,
+ blocks_on_disk_bytes: blocksOnDisk,
+ capacity: selected.capacity,
+ supports_loadtxoutset: observation.capability.supports_loadtxoutset,
+ },
+ selected_snapshot: {
+ snapshot_id: snapshot.id,
+ height: snapshot.height,
+ block_hash: snapshot.blockHash,
+ core_version: snapshot.coreVersion,
+ size_bytes: snapshot.sizeBytes,
+ sha256: snapshot.sha256,
+ utxo_commitment: selected.pinned.utxoCommitment,
+ verification_id: selected.verification.verification_id,
+ verified_at: selected.verification.verified_at,
+ },
+ requirements: {
+ snapshot_file_bytes: snapshot.sizeBytes,
+ chainstate_estimate_bytes: selected.required.chainstate_estimate_bytes,
+ headroom_bytes: selected.required.headroom_bytes,
+ required_bytes: selected.required.required_bytes,
+ free_bytes: selected.capacity.free_bytes,
+ feasible: true,
+ },
+ assumptions,
+ caller_declared: {
+ available_disk_gb: declaredDisk,
+ bandwidth_mbps: bandwidth,
+ matches_measurement: declaredDisk === null ? null : Math.abs(declaredDisk * 1073741824 - selected.capacity.free_bytes) <= selected.capacity.free_bytes * 0.1,
+ },
+ estimates: {
+ snapshot_download_hours: hours(snapshot.sizeBytes),
+ ibd_download_hours: hours(blocksOnDisk),
+ basis: bandwidth ? 'bytes over the caller-declared bandwidth; no validation time is included' : 'not estimated',
+ },
+ expected_transitions: ['snapshot_loading', 'snapshot_active_syncing_to_tip', 'background_validation', 'fully_validated'],
+ actions_not_executed: [
+ `loadtxoutset ${snapshot.source.replace(/.*[\\/]/, '')} on ${observation.capability.node_id} through an authorized operator job`,
+ 'Core validates the pinned hash_serialized_3 itself before activating the snapshot chainstate.',
+ ],
+ rollback_instructions: [
+ 'Stop the node and remove the chainstate_snapshot directory to return to the validated chainstate.',
+ 'A snapshot chainstate that finished background validation replaces the original and needs no rollback.',
+ ],
+ created_at: this.now(),
+ };
}
- public getJob(_jobId: string): NodeBootstrapJob | undefined {
- return unavailable(
- 'unavailable-operator',
- 'The durable operator job store is not connected.'
- );
+ /**
+ * Accepts a job for the owned node after the route verified the signed
+ * control-plane request. Queued is all this promises; the bounded worker
+ * runs it and records what Core answered.
+ */
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async createOperatorJob(
+ params: {
+ job_type: 'generate_snapshot' | 'load_snapshot';
+ node_id?: string;
+ snapshot_id?: string;
+ idempotency_key?: string;
+ confirm?: string;
+ },
+ authorization: OperatorAuthorization | undefined
+ ): Promise {
+ if (!authorization || typeof authorization.keyId !== 'string' || !authorization.keyId) {
+ throw new BootstrapEvidenceError('UNAUTHORIZED', 'Operator jobs need a verified signed control-plane request.', 401);
+ }
+ if (params?.job_type !== 'generate_snapshot' && params?.job_type !== 'load_snapshot') {
+ throw new BootstrapEvidenceError('invalid-input', 'job_type must be generate_snapshot or load_snapshot.', 400);
+ }
+ if (params.job_type === 'load_snapshot' && !authorization.elevated) {
+ throw new BootstrapEvidenceError('ELEVATION_REQUIRED', 'Loading a snapshot replaces the active chainstate; it requires adminAuthorization.elevated=true in the signed body.', 403);
+ }
+ if (params.job_type === 'load_snapshot' && params.confirm !== 'load_snapshot') {
+ throw new BootstrapEvidenceError('invalid-input', 'A load needs the typed confirmation confirm="load_snapshot".', 400);
+ }
+ const idempotencyKey = params.idempotency_key;
+ if (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) {
+ throw new BootstrapEvidenceError('invalid-input', 'idempotency_key (8 to 128 characters of [A-Za-z0-9._:-]) is required.', 400);
+ }
+ const store = this.requireStore();
+ if (!this.env.snapshotDir) {
+ throw new BootstrapEvidenceError('unavailable-operator', 'Bootstrap evidence is unavailable. No allowlisted snapshot directory is configured (UNIVERSE_BOOTSTRAP_SNAPSHOT_DIR); no node operation was started.');
+ }
+ const observation = await this.observation();
+ const nodeId = observation.capability.node_id;
+ if (params.node_id !== undefined && params.node_id !== nodeId) {
+ throw new BootstrapEvidenceError('node-not-observed', `The owned node is ${nodeId}, not ${params.node_id}.`, 404);
+ }
+ const catalogue = this.readCatalogue();
+ if (params.job_type === 'generate_snapshot') {
+ requireSupportedNode(observation, this.network);
+ if (!observation.capability.supports_dumptxoutset) {
+ throw new BootstrapEvidenceError('node-capability-missing', 'The owned node does not expose dumptxoutset.', 409);
+ }
+ if (observation.observation.current_phase !== 'fully_validated') {
+ throw new BootstrapEvidenceError('chainstate-not-eligible', `A snapshot is only generated from a fully validated chainstate; the node is in ${observation.observation.current_phase}.`, 409);
+ }
+ } else {
+ const snapshot = catalogue.snapshots.find((s) => s.id === params.snapshot_id && s.network === this.network);
+ if (!snapshot) {
+ throw new BootstrapEvidenceError('snapshot-not-in-catalogue', 'No trusted catalogue snapshot matches snapshot_id on this network.', 404);
+ }
+ await evaluateLoadEligibility({ observation, network: this.network, snapshot, catalogue, store, env: this.env, now: this.clock(), statfs: this.statfs });
+ }
+ const existing = await store.activeJob(this.network, nodeId);
+ if (existing && existing.idempotency_key !== idempotencyKey) {
+ throw new BootstrapEvidenceError('job-already-active', `Job ${existing.job_id} (${existing.job_type}) is ${existing.state} on ${nodeId}; one node runs one operator job at a time.`, 409);
+ }
+ const now = this.now();
+ const job: NodeBootstrapJob = {
+ job_id: randomUUID(),
+ job_type: params.job_type,
+ network: this.network,
+ node_id: nodeId,
+ ...(params.snapshot_id ? { snapshot_id: params.snapshot_id } : {}),
+ idempotency_key: idempotencyKey,
+ state: 'queued',
+ status: 'queued',
+ progress_pct: 0,
+ message: 'Queued. No node operation has started; the worker re-checks every precondition before issuing the RPC.',
+ requested_by: authorization.keyId,
+ created_at: now,
+ updated_at: now,
+ attempts: 0,
+ lease: null,
+ rpc: null,
+ preconditions: {},
+ evidence: {},
+ checkpoints: [{ at: now, stage: 'queued', detail: 'requested by ' + authorization.keyId }],
+ };
+ const stored = await store.insertJob(job);
+ this.startWorker();
+ return stored.job;
+ }
+
+ /** @asyncUnsafe rejections propagate to the caller, which handles them. */
+ public async getJob(jobId: string): Promise {
+ const store = this.requireStore();
+ try {
+ return (await store.getJob(jobId, this.network)) ?? undefined;
+ } catch (e) {
+ throw new BootstrapEvidenceError('durable-store-unavailable', 'The durable job store did not answer: ' + (e instanceof Error ? e.message : String(e)));
+ }
+ }
+
+ /** Starts the bounded worker once the durable store and output directory exist; idle otherwise. */
+ public startWorker(): BootstrapJobExecutor | undefined {
+ if (!this.store || !this.env.snapshotDir) {
+ return undefined;
+ }
+ if (!this.executor) {
+ this.executor = new BootstrapJobExecutor({
+ env: this.env,
+ network: this.network,
+ store: this.store,
+ nodes: this.nodes,
+ core: this.core,
+ operatorCore: this.operatorCore ?? longTimeoutOperatorCore(this.env),
+ catalogue: this.readCatalogue,
+ owner: this.workerOwner,
+ clock: this.clock,
+ statfs: this.statfs,
+ });
+ this.executor.start();
+ }
+ return this.executor;
+ }
+
+ public stopWorker(): void {
+ this.executor?.stop();
+ this.executor = undefined;
}
}
diff --git a/backend/src/api/intelligence/bootstrap/bootstrap.test.ts b/backend/src/api/intelligence/bootstrap/bootstrap.test.ts
index 25b294734a9..fbfd3a623d6 100644
--- a/backend/src/api/intelligence/bootstrap/bootstrap.test.ts
+++ b/backend/src/api/intelligence/bootstrap/bootstrap.test.ts
@@ -1,35 +1,302 @@
-import bootstrapService, { BootstrapEvidenceError } from './bootstrap.service';
+import { BootstrapService, BootstrapEvidenceError } from './bootstrap.service';
+import { BootstrapNodeReader } from './node-reader';
+import { MemoryBootstrapStore } from './bootstrap-store';
+import { BootstrapCatalogue, NodeBootstrapVerification } from './bootstrap.models';
+import { parseCatalogue, classifySource, verifyManifestSignature } from './snapshot-catalogue';
+import { NETWORK_MAGIC } from './snapshot-format';
+import { byteSource, catalogueFor, fakeCore, FIXTURE, FIXTURE_BYTES, producerKeys, testEnvironment } from './__fixtures__/support';
-describe('BootstrapService', () => {
- it('does not invent nodes, chainstates, manifests or verification records', () => {
- for (const read of [
- () => bootstrapService.listSnapshots(), () => bootstrapService.getSnapshot('snap-840000-mainnet'),
- () => bootstrapService.getVerification('unknown'),
- ]) expect(read).toThrow(BootstrapEvidenceError);
+const keys = producerKeys();
+const statfsPlenty = async (): Promise<{ bsize: number; bavail: number; blocks: number }> => ({ bsize: 4096, bavail: 1 << 20, blocks: 1 << 21 });
+
+function service(options: {
+ catalogue?: BootstrapCatalogue | (() => BootstrapCatalogue);
+ store?: MemoryBootstrapStore | null;
+ bytes?: Buffer;
+ core?: ReturnType