Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/universe-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:
# the allowlist, green the whole time. These run the script's own text
# against healthy and unhealthy fixtures.
- name: The release gates fail on the faults they exist for
run: node --test scripts/universe/release-gates.test.mjs
run: node --test scripts/universe/release-gates.test.mjs scripts/universe/release-artifact.test.mjs

- name: Workflow inputs remain data and builds preserve the runner host
run: node --test scripts/universe/workflow-safety.test.mjs scripts/universe/backend-startup-check.test.mjs
Expand Down
29 changes: 25 additions & 4 deletions .github/workflows/universe-release-artifact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ jobs:
# compare it against the running release and refuse the hard link when it
# differs. That check belongs there, where both trees are visible; here
# only one of them is.
# The artifact carries its own acceptance: the protocol manifest, the
# acceptance envelope and every evidence file the envelope names, staged
# under docs/ with the release gate's own path and digest rules, and
# docs is in the member list. Before 2026-09-23 docs/ was staged but not
# packed, so a candidate that qualified here could never qualify on the
# host. The step after this one proves it on the packed archive.
- name: Pack
id: pack
run: |
Expand All @@ -143,9 +149,10 @@ jobs:
cp -a backend/rust-gbt "$stage/backend/rust-gbt"
cp -a frontend/dist/mempool/browser "$stage/frontend/build"
cp -a scripts/universe "$stage/scripts/universe"
mkdir -p "$stage/docs/protocols" "$stage/docs/acceptance"
cp -a docs/protocols/PROTOCOL-COVERAGE.json "$stage/docs/protocols/PROTOCOL-COVERAGE.json"
cp -a docs/acceptance/qualified-release-evidence.json "$stage/docs/acceptance/qualified-release-evidence.json"
node scripts/universe/protocol-contract.mjs --stage-acceptance "$stage" \
--manifest docs/protocols/PROTOCOL-COVERAGE.json \
--acceptance docs/acceptance/qualified-release-evidence.json \
--acceptance-root "$GITHUB_WORKSPACE"
# The unit files travel with the release rather than being fetched
# from a checkout that happens to be on the right commit. Adopting
# socket activation needed both of these on the host, and taking
Expand All @@ -168,10 +175,24 @@ jobs:
test -f "$stage/frontend/build/index.html"
test -f "$stage/scripts/universe/gateway.mjs"
out="$PWD/mempool-$sha.tar.gz"
tar -czf "$out" -C "$stage" backend frontend scripts production RELEASE-MANIFEST.json
tar -czf "$out" -C "$stage" backend frontend scripts production docs RELEASE-MANIFEST.json
sha256sum "$out" | tee "$out.sha256"
printf 'name=mempool-%s\n' "$sha" >> "$GITHUB_OUTPUT"

# The exact archive about to be uploaded, extracted into an empty
# directory and qualified by the gate it carries, with that directory
# as the evidence root. Nothing from the checkout is read, so an archive
# that needs the checkout to pass fails here and is never published.
- name: Qualify the packed archive on its own
run: |
set -euo pipefail
archive="$PWD/mempool-${{ steps.sha.outputs.short }}.tar.gz"
work=$(mktemp -d)
cp "$archive" "$work/"
cd "$work"
node "$GITHUB_WORKSPACE/scripts/universe/qualify-artifact.mjs" "$work/$(basename "$archive")" \
--commit '${{ steps.sha.outputs.sha }}' --network mainnet

- name: Upload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
Expand Down
1 change: 1 addition & 0 deletions backend/mempool-config.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"POOLS_JSON_TREE_URL": "",
"POOLS_JSON_FILE": "tasks/pools/pools-v2.json",
"POOLS_UPDATE_DELAY": 604800,
"MINING_MAX_BEHIND_TIP": 3,
"AUDIT": false,
"RUST_GBT": true,
"LIMIT_GBT": false,
Expand Down
1 change: 1 addition & 0 deletions backend/src/__fixtures__/mempool-config.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
"POOLS_JSON_FILE": "__MEMPOOL_POOLS_JSON_FILE__",
"POOLS_UPDATE_DELAY": 604800,
"MINING_MAX_BEHIND_TIP": 3,
"AUDIT": true,
"RUST_GBT": false,
"LIMIT_GBT": false,
Expand Down
27 changes: 27 additions & 0 deletions backend/src/__tests__/bitcoin-transaction-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,31 @@ describe('Bitcoin Core transaction confirmation identity', () => {
expect(transaction.status).toEqual({ confirmed: true, block_height: 0, block_hash: HASH, block_time: BLOCK.time });
expect(client.getBlockHeader).not.toHaveBeenCalled();
});
it('reads a whole block for ingestion in one request, with block status and Core fees, and no per-transaction reads', async () => {
const { api, client } = fixture();
const spend = { ...RAW, txid: 'a'.repeat(64), vin: [{ txid: TXID, vout: 0, scriptSig: { hex: '' }, sequence: 1 }], fee: 0.00000321 };
client.getBlock.mockResolvedValue({ ...BLOCK, tx: [RAW, spend] });
const transactions = await api.$getTxsForBlockWithoutPrevouts(HASH);
expect(transactions.map(tx => tx.txid)).toEqual([TXID, 'a'.repeat(64)]);
for (const transaction of transactions) {
expect(transaction.status).toEqual({ confirmed: true, block_height: BLOCK.height, block_hash: HASH, block_time: BLOCK.time });
}
expect(transactions[1].fee).toBe(321);
expect(transactions[1].vin[0].prevout).toBeNull();
expect(client.getBlock).toHaveBeenCalledTimes(1);
expect(client.getBlock).toHaveBeenCalledWith(HASH, 2);
expect(client.getRawTransaction).not.toHaveBeenCalled();
expect(client.getBlockHeader).not.toHaveBeenCalled();
expect(client.getMempoolEntry).not.toHaveBeenCalled();
});

it.each([
{ ...BLOCK, confirmations: -1, tx: [RAW] },
{ ...BLOCK, hash: 'f'.repeat(64), tx: [RAW] },
{ ...BLOCK, tx: undefined },
])('refuses a stale, mismatched or transactionless block for the one-request read: %j', async block => {
const { api, client } = fixture();
client.getBlock.mockResolvedValue(block);
await expect(api.$getTxsForBlockWithoutPrevouts(HASH)).rejects.toThrow('not an active-chain block');
});
});
144 changes: 144 additions & 0 deletions backend/src/__tests__/capabilities-mining-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* The mining section of /api/v1/capabilities, end to end through $report with
* the database and the Core reading replaced. The verdict itself is covered in
* capabilities-mining.test.ts; these prove the report feeds it the right facts
* and that a cached answer cannot outlive the state it described.
*/

interface BlockRow { total: number; lowest: number | null; highest: number | null; newest: Date | null }
const db: { up: boolean; blocks: BlockRow; pools: number } = {
up: true,
blocks: { total: 11_000, lowest: 957_300, highest: 968_299, newest: new Date('2026-09-23T17:55:00.000Z') },
pools: 180,
};
const sync: { value: Record<string, unknown> | null } = { value: null };

jest.mock('../database', () => ({
__esModule: true,
default: {
query: async (sql: string) => {
if (!db.up) {throw new Error('connect ECONNREFUSED');}
if (sql.includes('SELECT 1')) {return [[{ 1: 1 }]];}
if (sql.includes('FROM blocks')) {return [[db.blocks]];}
if (sql.includes('FROM pools')) {return [[{ total: db.pools }]];}
if (sql.includes('FROM statistics')) {return [[{ total: 1, oldest: new Date(), newest: new Date() }]];}
throw new Error('unexpected query ' + sql);
},
},
}));
jest.mock('../api/backend-info', () => ({
__esModule: true,
default: { getBackendInfo: () => ({ gitCommit: 'test', chainSync: sync.value }) },
}));
jest.mock('../api/capabilities.optional', () => ({ $optionalCapabilityReports: async () => ({}) }));
jest.mock('../api/bitcoin/address-index', () => ({
addressBackendKind: () => 'electrum',
$probeAddressIndex: async () => ({
configured: true, reachable: true, summaryAnswered: true, utxoAnswered: true, state: 'ready',
degradedReason: null, backendKind: 'electrum', indexedTip: null, chainTip: null, lagBlocks: null,
maxBehindTip: 2, sourceRelease: null,
}),
}));

import config from '../config';
import capabilities from '../api/capabilities';

function freshCore(blocks: number, chain = 'main'): Record<string, unknown> {
return { blocks, headers: blocks, initialBlockDownload: false, verificationProgress: 1, checkedAt: new Date().toISOString(), chain };
}

async function mining(): Promise<Record<string, unknown>> {
// Each case asks a fresh question; the ten second cache is exercised on its own below.
(capabilities as unknown as { cached: unknown }).cached = null;
return (await capabilities.$report()).features.mining as unknown as Record<string, unknown>;
}

describe('mining capability report', () => {
const saved = { indexing: config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, enabled: config.MEMPOOL.ENABLED, database: config.DATABASE.ENABLED, network: config.MEMPOOL.NETWORK };

beforeAll(() => {
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT = 11_000;
config.MEMPOOL.ENABLED = true;
config.DATABASE.ENABLED = true;
config.MEMPOOL.NETWORK = 'mainnet';
capabilities.markRoutesRegistered('mining');
});
afterAll(() => {
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT = saved.indexing;
config.MEMPOOL.ENABLED = saved.enabled;
config.DATABASE.ENABLED = saved.database;
config.MEMPOOL.NETWORK = saved.network;
});
beforeEach(() => {
db.up = true;
db.blocks = { total: 11_000, lowest: 957_300, highest: 968_299, newest: new Date('2026-09-23T17:55:00.000Z') };
db.pools = 180;
sync.value = freshCore(968_299);
});

it('publishes the indexed tip, Core tip and lag with a ready verdict when current', async () => {
expect(await mining()).toMatchObject({
state: 'ready', indexedTip: 968_299, bitcoinCoreTip: 968_299, lagBlocks: 0,
maxLagBlocks: config.MEMPOOL.MINING_MAX_BEHIND_TIP, degradedReason: null, rowCount: 11_000,
coverage: { from: '957300', to: '968299' },
});
});

it('reports the lagged production state as degraded with its numbers', async () => {
db.blocks = { ...db.blocks, highest: 968_172 };
expect(await mining()).toMatchObject({ state: 'degraded', indexedTip: 968_172, bitcoinCoreTip: 968_299, lagBlocks: 127 });
});

it('reports unknown, not ready, when Core has never been read', async () => {
sync.value = null;
expect(await mining()).toMatchObject({ state: 'unknown', bitcoinCoreTip: null, lagBlocks: null });
});

it('reports unknown when the Core reading has expired', async () => {
sync.value = { ...freshCore(968_299), checkedAt: new Date(Date.now() - 10 * 60_000).toISOString() };
expect((await mining()).state).toBe('unknown');
});

it('reports unknown when Core is on another network than the one served', async () => {
sync.value = freshCore(968_299, 'signet');
expect((await mining()).state).toBe('unknown');
});

it('reports the database loss as unavailable', async () => {
db.up = false;
expect(await mining()).toMatchObject({ state: 'unavailable', degradedReason: 'The mining index database is unavailable.' });
});

it('reports empty tables and missing pool metadata as degraded', async () => {
db.blocks = { total: 0, lowest: null, highest: null, newest: null };
expect((await mining()).state).toBe('degraded');
db.blocks = { total: 11_000, lowest: 957_300, highest: 968_299, newest: new Date() };
db.pools = 0;
expect(await mining()).toMatchObject({ state: 'degraded', degradedReason: 'Mining pool metadata has not been imported yet.' });
});

it('recovers to ready once the collector catches up', async () => {
db.blocks = { ...db.blocks, highest: 968_000 };
expect((await mining()).state).toBe('degraded');
db.blocks = { ...db.blocks, highest: 968_299 };
expect((await mining()).state).toBe('ready');
});

it('never serves a cached ready answer past its ten second life', async () => {
const now = jest.spyOn(Date, 'now');
try {
const start = Date.parse('2026-09-23T18:00:00.000Z');
now.mockReturnValue(start);
sync.value = { ...freshCore(968_299), checkedAt: new Date(start).toISOString() };
(capabilities as unknown as { cached: unknown }).cached = null;
expect((await capabilities.$report()).features.mining.state).toBe('ready');
db.up = false;
now.mockReturnValue(start + 5_000);
expect((await capabilities.$report()).features.mining.state).toBe('ready');
now.mockReturnValue(start + 10_001);
expect((await capabilities.$report()).features.mining.state).toBe('unavailable');
} finally {
now.mockRestore();
}
});
});
98 changes: 98 additions & 0 deletions backend/src/__tests__/capabilities-mining.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { CORE_READING_MAX_AGE_SECONDS, miningIndexVerdict, type MiningIndexFacts } from '../api/capabilities.mining';

/**
* The incident behind these: on 2026-09-23 /api/v1/capabilities called mining
* ready with the index at block 968172 and Core at 968299, because readiness
* was "the tables have rows". Every case below is a way that rule was wrong.
*/

const NOW = Date.parse('2026-09-23T18:00:00.000Z');

function facts(overrides: Partial<MiningIndexFacts> = {}): MiningIndexFacts {
return {
blockRows: 11_000,
highestHeight: 968_299,
poolRows: 180,
core: { blocks: 968_299, chain: 'main', initialBlockDownload: false, checkedAt: new Date(NOW - 10_000).toISOString() },
network: 'mainnet',
maxBehindTip: 3,
now: NOW,
...overrides,
};
}

describe('mining index readiness', () => {
it('is ready when the indexed tip matches a fresh same-network Core reading', () => {
expect(miningIndexVerdict(facts())).toEqual({
state: 'ready', degradedReason: null, indexedTip: 968_299, bitcoinCoreTip: 968_299, lagBlocks: 0, maxLagBlocks: 3,
});
});

it('refuses the production state that was published as ready', () => {
const verdict = miningIndexVerdict(facts({ highestHeight: 968_172 }));
expect(verdict.state).toBe('degraded');
expect(verdict.lagBlocks).toBe(127);
expect(verdict.degradedReason).toBe('The mining index is 127 blocks behind Bitcoin Core, more than the 3 allowed.');
});

it('holds the lag bound exactly at its boundary', () => {
expect(miningIndexVerdict(facts({ highestHeight: 968_296 })).state).toBe('ready');
expect(miningIndexVerdict(facts({ highestHeight: 968_295 })).state).toBe('degraded');
expect(miningIndexVerdict(facts({ highestHeight: 968_299, maxBehindTip: 0 })).state).toBe('ready');
expect(miningIndexVerdict(facts({ highestHeight: 968_298, maxBehindTip: 0 })).state).toBe('degraded');
});

it('never lets historical rows alone prove readiness', () => {
expect(miningIndexVerdict(facts({ core: null })).state).toBe('unknown');
});

it('reports an empty index and missing pool metadata as degraded before judging currency', () => {
expect(miningIndexVerdict(facts({ blockRows: 0, highestHeight: null }))).toMatchObject({
state: 'degraded', indexedTip: null, lagBlocks: null,
degradedReason: 'Block indexing is running but no block has been indexed yet.',
});
expect(miningIndexVerdict(facts({ poolRows: 0 }))).toMatchObject({
state: 'degraded', degradedReason: 'Mining pool metadata has not been imported yet.',
});
});

it('keeps unknown separate when the Core reading has expired, and publishes no stale lag', () => {
const expired = new Date(NOW - (CORE_READING_MAX_AGE_SECONDS + 1) * 1000).toISOString();
const verdict = miningIndexVerdict(facts({ core: { blocks: 968_299, chain: 'main', initialBlockDownload: false, checkedAt: expired } }));
expect(verdict).toMatchObject({ state: 'unknown', bitcoinCoreTip: null, lagBlocks: null });
const edge = new Date(NOW - CORE_READING_MAX_AGE_SECONDS * 1000).toISOString();
expect(miningIndexVerdict(facts({ core: { blocks: 968_299, chain: 'main', initialBlockDownload: false, checkedAt: edge } })).state).toBe('ready');
expect(miningIndexVerdict(facts({ core: { blocks: 968_299, chain: 'main', initialBlockDownload: false, checkedAt: 'not a time' } })).state).toBe('unknown');
});

it('refuses a Core reading from another network or one that does not say', () => {
for (const chain of ['test', 'signet', null]) {
const verdict = miningIndexVerdict(facts({ core: { blocks: 968_299, chain, initialBlockDownload: false, checkedAt: new Date(NOW).toISOString() } }));
expect(verdict.state).toBe('unknown');
expect(verdict.degradedReason).toContain('network');
}
});

it('matches each served network to Core\'s own chain name', () => {
for (const [network, chain] of [['signet', 'signet'], ['testnet', 'test'], ['testnet4', 'testnet4'], ['regtest', 'regtest']]) {
const verdict = miningIndexVerdict(facts({ network, core: { blocks: 968_299, chain, initialBlockDownload: false, checkedAt: new Date(NOW).toISOString() } }));
expect(verdict.state).toBe('ready');
}
expect(miningIndexVerdict(facts({ network: 'unlisted' })).state).toBe('unknown');
});

it('calls a node still in initial block download syncing, not ready', () => {
const verdict = miningIndexVerdict(facts({ core: { blocks: 968_299, chain: 'main', initialBlockDownload: true, checkedAt: new Date(NOW).toISOString() } }));
expect(verdict.state).toBe('syncing');
});

it('withholds readiness while the index is ahead of Core after a reorganization', () => {
const verdict = miningIndexVerdict(facts({ highestHeight: 968_301 }));
expect(verdict).toMatchObject({ state: 'unknown', lagBlocks: -2 });
});

it('recovers to ready once the index catches up again', () => {
expect(miningIndexVerdict(facts({ highestHeight: 968_100 })).state).toBe('degraded');
expect(miningIndexVerdict(facts({ highestHeight: 968_298 })).state).toBe('ready');
});
});
1 change: 1 addition & 0 deletions backend/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('Mempool Backend Config', () => {
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
POOLS_JSON_FILE: 'tasks/pools/pools-v2.json',
POOLS_UPDATE_DELAY: 604800,
MINING_MAX_BEHIND_TIP: 3,
AUDIT: false,
RUST_GBT: true,
LIMIT_GBT: false,
Expand Down
Loading
Loading