Skip to content

Commit c637f46

Browse files
committed
ots: inject isOtsCommit on esplora-mode /api/tx/:txid via proxy
In production (BACKEND=esplora), bitcoin.routes.ts gates off getTransaction, so /api/tx/<txid> is served by the electrs proxy and attachIsOtsCommit never runs. Frontend's lazy probe in OtsKnowledgeService rescues UX, but the wire-strip-fill was dead code on prod. Buffer the small (~1-3 KB) tx-detail JSON inside the proxy, mutate via attachIsOtsCommit, re-emit with corrected content-length. Other paths (/tx/:txid/hex, /tx/:txid/status, non-200s, non-JSON bodies, POST, /api/v1/*) stream through untouched. 6 new regression tests pin the behaviour. Also tighten Cache-Control on /resources/config.js + customize.js via a Cloudflare Pages _headers file -- max-age=0, must-revalidate -- so the window.__env GIT_COMMIT_HASH refreshes on revalidation instead of lingering for 4 hours. Updates ORDPOOL-FLAGS-ARCHITECTURE.md \xc2\xa74 to reflect the actual strip-fill site on prod.
1 parent bdd2984 commit c637f46

6 files changed

Lines changed: 265 additions & 14 deletions

File tree

ORDPOOL-FLAGS-ARCHITECTURE.md

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -456,13 +456,31 @@ Tristate semantics:
456456
457457
Where the field is populated on the wire:
458458
459-
- **REST `GET /api/v1/tx/:txId`** — `bitcoin.routes.ts:getTransaction`
460-
calls `ordpoolOtsTxidSet.has(req.params.txId)` after
461-
`$getTransactionExtended` and writes the result to `transaction.isOtsCommit`.
462-
O(1) Set.has() lookup, no DB round-trip.
463-
- **WebSocket track-tx** — `websocket-handler.ts:217` and `:227`
464-
(both bitcoind and esplora backends) write `fullTx.isOtsCommit =
465-
ordpoolOtsTxidSet.has(...)` before `JSON.stringify`.
459+
- **REST `GET /api/tx/:txId` (esplora-mode prod, the live shape)** —
460+
`electrs-proxy-middleware.ts` matches `GET /tx/<64-hex>`, buffers the
461+
electrs JSON response, calls `attachIsOtsCommit(tx)`, and re-emits with
462+
a corrected `content-length`. Other paths (`/tx/:txid/hex`,
463+
`/tx/:txid/status`, non-200s, non-JSON bodies, POST, `/api/v1/*`) stream
464+
through untouched. Pinned by `__tests__/electrs-proxy-middleware.test.ts`.
465+
- **REST `GET /api/v1/tx/:txId` (bitcoind-mode dev only)** —
466+
`bitcoin.routes.ts:getTransaction` calls `attachIsOtsCommit(transaction)`
467+
inside the `if (config.MEMPOOL.BACKEND !== 'esplora')` block. Inactive
468+
on prod, where the route itself is unregistered and the electrs proxy
469+
serves the response instead.
470+
- **WebSocket track-tx** — `websocket-handler.ts:234-253` writes
471+
`attachIsOtsCommit(tx)` in all three branches (mempool-pool hit on
472+
esplora, full-tx fetch on bitcoind, full-tx fetch on miss). Not gated
473+
against esplora — runs on prod.
474+
- **WebSocket track-txs (plural initial-subscribe)** —
475+
`websocket-handler.ts:310` calls `setIsOtsCommitByTxid(txid, txInfo)`
476+
per requested txid (the `TxTrackingInfo` shape has no `txid` field, so
477+
we attach by argument). Also unguarded by backend mode.
478+
- **WebSocket `otsCommitFlipped` re-push** — when the OTS poller adds a
479+
new txid to `ordpoolOtsTxidSet`, the subscribed broadcaster
480+
(`broadcastOtsCommitFlippedToClients`) pushes `{otsCommitFlipped:
481+
<txid>}` to every client tracking it. Frontend reacts in
482+
`transaction.component.ts` / `tracker.component.ts` by re-running
483+
`setFeatures()` / `checkAccelerationEligibility()`.
466484
467485
Where it does **not** appear (still by design):
468486
@@ -515,9 +533,12 @@ GET /api/v1/ordpool/ots/is-commit/<64-hex-txid> -> { result: boolean }
515533
516534
- **Implementation**: validates txid with `isValidTxid()` (from
517535
ordpool-parser), then one `ordpoolOtsTxidSet.has(txid)` call.
518-
- **Cache header**: `Cache-Control: public, max-age=60` (the answer can
519-
flip `false``true` as the poller catches up; never the reverse).
520-
60 s matches the poller's cycle.
536+
- **Cache header**: `Cache-Control: public, max-age=60, stale-while-revalidate=60`.
537+
The answer can flip `false` → `true` as the poller catches up (never
538+
the reverse), so a 60 s fresh window matches the poller's cycle, and
539+
SWR lets the edge serve the stale value while a fresh one is fetched
540+
in the background. Live WS `otsCommitFlipped` re-push closes any
541+
remaining lag for already-subscribed clients.
521542
- **Malformed txid**: HTTP 400 with body `invalid txid`. Pinned by the
522543
`$isOtsCommit route handler` describe block in
523544
`ordpool.routes.test.ts` (5 tests).

backend/.test-count-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"passing": 337,
2+
"passing": 343,
33
"note": "Floor for .github/workflows/test-count-floor-backend.yml. Count of passing tests from the single jest config (node). Only an ordpool core maintainer is authorised to lower this number; every other change that reduces test count is treated as an accident and rejected. To raise the floor after legitimately adding tests, bump this value and commit. See also workspace CLAUDE.md, HARD RULE 'Never delete a passing test without explicit permission'."
44
}

backend/src/__tests__/electrs-proxy-middleware.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ jest.mock('../logger', () => ({
88
default: { warn: jest.fn(), info: jest.fn(), err: jest.fn(), debug: jest.fn() },
99
}));
1010

11+
// Mock the ordpool OTS flag module so we don't drag in the database / poller
12+
// chain. The mock keeps a controllable set of "known OTS txids" that tests
13+
// can mutate via `__setOtsTxids`. `attachIsOtsCommit` mirrors the real
14+
// behaviour: writes `isOtsCommit = set.has(tx.txid)` and returns the tx.
15+
const otsTxids = new Set<string>();
16+
jest.mock('../api/ordpool-ots-flag', () => ({
17+
__esModule: true,
18+
attachIsOtsCommit: jest.fn(<T extends { txid: string; isOtsCommit?: boolean | null }>(tx: T): T => {
19+
tx.isOtsCommit = otsTxids.has(tx.txid);
20+
return tx;
21+
}),
22+
}));
23+
24+
beforeEach(() => {
25+
otsTxids.clear();
26+
});
27+
1128
type FakeHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
1229

1330
function startServer(app: Express): Promise<{ server: http.Server, url: string }> {
@@ -90,6 +107,150 @@ describe('electrs-proxy-middleware', () => {
90107
}
91108
});
92109

110+
test('injects isOtsCommit=true on GET /tx/<txid> when the txid is in the OTS set', async () => {
111+
const TXID = 'a'.repeat(64);
112+
otsTxids.add(TXID);
113+
114+
const electrs = await startFakeElectrs((req, res) => {
115+
res.writeHead(200, { 'content-type': 'application/json' });
116+
res.end(JSON.stringify({ txid: TXID, fee: 76, status: { confirmed: true } }));
117+
});
118+
119+
const app = express();
120+
app.use('/api', createElectrsProxyMiddleware(electrs.url));
121+
122+
const { server, url } = await startServer(app);
123+
try {
124+
const r = await fetchText(`${url}/api/tx/${TXID}`);
125+
expect(r.status).toBe(200);
126+
const body = JSON.parse(r.body);
127+
expect(body.isOtsCommit).toBe(true);
128+
expect(body.txid).toBe(TXID);
129+
expect(body.fee).toBe(76); // existing fields preserved
130+
// content-length must reflect the re-serialized body, not the original.
131+
expect(Number(r.headers['content-length'])).toBe(Buffer.byteLength(r.body));
132+
} finally {
133+
await close(server);
134+
await close(electrs.server);
135+
}
136+
});
137+
138+
test('injects isOtsCommit=false on GET /tx/<txid> when the txid is NOT in the OTS set', async () => {
139+
const TXID = 'b'.repeat(64);
140+
141+
const electrs = await startFakeElectrs((_req, res) => {
142+
res.writeHead(200, { 'content-type': 'application/json' });
143+
res.end(JSON.stringify({ txid: TXID }));
144+
});
145+
146+
const app = express();
147+
app.use('/api', createElectrsProxyMiddleware(electrs.url));
148+
149+
const { server, url } = await startServer(app);
150+
try {
151+
const r = await fetchText(`${url}/api/tx/${TXID}`);
152+
expect(r.status).toBe(200);
153+
expect(JSON.parse(r.body)).toEqual({ txid: TXID, isOtsCommit: false });
154+
} finally {
155+
await close(server);
156+
await close(electrs.server);
157+
}
158+
});
159+
160+
test('does NOT touch GET /tx/<txid>/hex (different path, plain text body)', async () => {
161+
const TXID = 'c'.repeat(64);
162+
otsTxids.add(TXID);
163+
164+
const electrs = await startFakeElectrs((_req, res) => {
165+
res.writeHead(200, { 'content-type': 'text/plain' });
166+
res.end('0200000001abcdef'); // raw hex blob, not JSON
167+
});
168+
169+
const app = express();
170+
app.use('/api', createElectrsProxyMiddleware(electrs.url));
171+
172+
const { server, url } = await startServer(app);
173+
try {
174+
const r = await fetchText(`${url}/api/tx/${TXID}/hex`);
175+
expect(r.status).toBe(200);
176+
expect(r.body).toBe('0200000001abcdef');
177+
expect(r.body).not.toContain('isOtsCommit');
178+
} finally {
179+
await close(server);
180+
await close(electrs.server);
181+
}
182+
});
183+
184+
test('passes through non-200 GET /tx/<txid> unchanged (no JSON parse attempt)', async () => {
185+
const TXID = 'd'.repeat(64);
186+
otsTxids.add(TXID);
187+
188+
const electrs = await startFakeElectrs((_req, res) => {
189+
res.writeHead(404, { 'content-type': 'text/plain' });
190+
res.end('Transaction not found.');
191+
});
192+
193+
const app = express();
194+
app.use('/api', createElectrsProxyMiddleware(electrs.url));
195+
196+
const { server, url } = await startServer(app);
197+
try {
198+
const r = await fetchText(`${url}/api/tx/${TXID}`);
199+
expect(r.status).toBe(404);
200+
expect(r.body).toBe('Transaction not found.');
201+
} finally {
202+
await close(server);
203+
await close(electrs.server);
204+
}
205+
});
206+
207+
test('falls back to passthrough when electrs body is not parseable JSON', async () => {
208+
const TXID = 'e'.repeat(64);
209+
otsTxids.add(TXID);
210+
211+
const electrs = await startFakeElectrs((_req, res) => {
212+
res.writeHead(200, { 'content-type': 'application/json' });
213+
res.end('{not json'); // malformed
214+
});
215+
216+
const app = express();
217+
app.use('/api', createElectrsProxyMiddleware(electrs.url));
218+
219+
const { server, url } = await startServer(app);
220+
try {
221+
const r = await fetchText(`${url}/api/tx/${TXID}`);
222+
expect(r.status).toBe(200);
223+
expect(r.body).toBe('{not json');
224+
} finally {
225+
await close(server);
226+
await close(electrs.server);
227+
}
228+
});
229+
230+
test('does NOT touch POST /tx (only GET tx-detail is intercepted)', async () => {
231+
let receivedMethod: string | undefined;
232+
const electrs = await startFakeElectrs((req, res) => {
233+
receivedMethod = req.method;
234+
res.writeHead(200, { 'content-type': 'application/json' });
235+
res.end(JSON.stringify({ txid: 'broadcast-result-txid' }));
236+
});
237+
238+
const app = express();
239+
app.use('/api', createElectrsProxyMiddleware(electrs.url));
240+
241+
const { server, url } = await startServer(app);
242+
try {
243+
const r = await fetchText(`${url}/api/tx`, { method: 'POST' });
244+
expect(r.status).toBe(200);
245+
expect(receivedMethod).toBe('POST');
246+
// POSTed broadcasts come back without `isOtsCommit` injection.
247+
expect(JSON.parse(r.body)).toEqual({ txid: 'broadcast-result-txid' });
248+
} finally {
249+
await close(server);
250+
await close(electrs.server);
251+
}
252+
});
253+
93254
test('returns 502 when electrs is unreachable', async () => {
94255
const app = express();
95256
// 127.0.0.1:1 is reserved/closed — connection refused, immediate ECONNREFUSED.

backend/src/electrs-proxy-middleware.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextFunction, Request, RequestHandler, Response } from 'express';
22
import * as http from 'http';
33
import logger from './logger';
4+
import { attachIsOtsCommit } from './api/ordpool-ots-flag';
45

56
// HACK --- Ordpool: cheap nginx replacement.
67
// Mempool's upstream production runs nginx in front of backend + electrs to path-route
@@ -16,6 +17,16 @@ import logger from './logger';
1617
// overhead vs. nginx's ~50µs is invisible relative to electrs's 10-100ms response time.
1718
// If we ever scale to where the proxy itself becomes the bottleneck, this gets replaced
1819
// by nginx in front of cloudflared and these lines deleted.
20+
//
21+
// HACK -- Ordpool: when `MEMPOOL.BACKEND === 'esplora'` (prod), upstream's
22+
// `bitcoin.routes.ts:75-80` gates off `getTransaction`, so `/api/tx/<txid>`
23+
// is served by this proxy. We intercept GET /tx/<64-hex> here, buffer the
24+
// JSON body, and inject the tristate `isOtsCommit` field via
25+
// `attachIsOtsCommit` so the frontend's OtsKnowledgeService can skip the
26+
// lazy probe on the strip wire. Everything else streams through untouched.
27+
// See ORDPOOL-FLAGS-ARCHITECTURE.md §4.
28+
const TX_DETAIL_PATH = /^\/tx\/[0-9a-f]{64}$/i;
29+
1930
export function createElectrsProxyMiddleware(electrsBaseUrl: string | undefined): RequestHandler {
2031
const electrsHost = new URL(electrsBaseUrl || 'http://127.0.0.1:3000');
2132
const port = electrsHost.port || '80';
@@ -25,15 +36,58 @@ export function createElectrsProxyMiddleware(electrsBaseUrl: string | undefined)
2536
if (req.path === '/v1' || req.path.startsWith('/v1/')) {
2637
return next();
2738
}
39+
const injectOtsCommit = req.method === 'GET' && TX_DETAIL_PATH.test(req.path);
2840
const proxyReq = http.request({
2941
host: electrsHost.hostname,
3042
port: Number(port),
3143
path: req.url,
3244
method: req.method,
3345
headers: { ...req.headers, host: hostHeader },
3446
}, (electrsRes) => {
35-
res.writeHead(electrsRes.statusCode || 502, electrsRes.headers);
36-
electrsRes.pipe(res);
47+
if (!injectOtsCommit) {
48+
res.writeHead(electrsRes.statusCode || 502, electrsRes.headers);
49+
electrsRes.pipe(res);
50+
return;
51+
}
52+
// Buffer the small (~1-3 KB) tx-detail JSON so we can inject
53+
// `isOtsCommit`. If anything looks off (non-200, content-encoding,
54+
// unparseable body, missing txid), fall back to a clean passthrough
55+
// so we never corrupt a response we don't understand.
56+
const status = electrsRes.statusCode || 502;
57+
const encoding = electrsRes.headers['content-encoding'];
58+
if (status !== 200 || encoding) {
59+
res.writeHead(status, electrsRes.headers);
60+
electrsRes.pipe(res);
61+
return;
62+
}
63+
const chunks: Buffer[] = [];
64+
electrsRes.on('data', (c: Buffer) => chunks.push(c));
65+
electrsRes.on('end', () => {
66+
const body = Buffer.concat(chunks);
67+
try {
68+
const tx = JSON.parse(body.toString('utf8'));
69+
if (!tx || typeof tx.txid !== 'string') {
70+
res.writeHead(status, electrsRes.headers);
71+
res.end(body);
72+
return;
73+
}
74+
attachIsOtsCommit(tx);
75+
const out = Buffer.from(JSON.stringify(tx));
76+
const headers: http.OutgoingHttpHeaders = { ...electrsRes.headers };
77+
headers['content-length'] = String(out.length);
78+
delete headers['transfer-encoding'];
79+
res.writeHead(status, headers);
80+
res.end(out);
81+
} catch {
82+
res.writeHead(status, electrsRes.headers);
83+
res.end(body);
84+
}
85+
});
86+
electrsRes.on('error', () => {
87+
if (!res.headersSent) {
88+
res.status(502).send('electrs proxy stream error');
89+
}
90+
});
3791
});
3892
proxyReq.on('error', (err) => {
3993
logger.warn(`electrs proxy error for ${req.method} ${req.url}: ${err.message}`);

frontend/angular.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"src/customize.js",
174174
"src/config.template.js",
175175
"src/_redirects",
176+
"src/_headers",
176177
{
177178
"glob": "*.css",
178179
"input": ".theme-build",
@@ -198,7 +199,8 @@
198199
"assets": [
199200
"src/favicon.ico",
200201
"src/robots.txt",
201-
"src/_redirects"
202+
"src/_redirects",
203+
"src/_headers"
202204
],
203205
"fileReplacements": [
204206
{

frontend/src/_headers

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Cloudflare Pages _headers — Cache-Control overrides for files that
2+
# change on every deploy but aren't fingerprinted in the URL. Wired into
3+
# the build via angular.json (assets) and shipped in dist/.
4+
#
5+
# Without this, Pages defaults to a multi-hour TTL on .js files, so
6+
# already-open tabs keep reading the previous build's window.__env
7+
# (GIT_COMMIT_HASH, BASE_MODULE, etc.) until their cache expires. ETag
8+
# revalidation closes the gap to one 304 round-trip per page load.
9+
/resources/config.js
10+
Cache-Control: public, max-age=0, must-revalidate
11+
12+
/resources/customize.js
13+
Cache-Control: public, max-age=0, must-revalidate

0 commit comments

Comments
 (0)