Skip to content

Commit b9cb9fe

Browse files
committed
ots: isOtsCommit tristate (closes the strip-wire OTS hole)
Implements the design from ORDPOOL-FLAGS-ARCHITECTURE.md §4. The OTS bit is the only indexer-derived ordpool flag; the frontend cannot recompute it from witness bytes. Strip wire surfaces (REST /api/tx/:txid, WS track-tx) were silently dropping it. This commit fills the hole without touching the upstream wire shapes. Backend: - New optional field on TransactionExtended: isOtsCommit?: boolean | null. null = 'not computed' (bulk surfaces leave it absent; OTS bit travels in tx.flags there). true/false = server-attached answer from ordpoolOtsTxidSet.has(txid). - bitcoin.routes.ts:getTransaction attaches isOtsCommit before res.json(transaction). O(1) Set.has() lookup. - websocket-handler.ts track-tx attaches isOtsCommit on both $getMempoolTransactionExtended call sites (esplora + bitcoind backends). - New lazy endpoint: GET /api/v1/ordpool/ots/is-commit/:txid -> { result: boolean }. Validates txid via isValidTxid, Cache-Control: public, max-age=60 (matches OTS poller cycle and the false-monotonicity window). Frontend: - New OtsKnowledgeService in services/ordinals/. Three-source decision: server-attached tristate -> OP_RETURN fast path (no OP_RETURN -> definitely false, no fetch) -> lazy backend probe (cached). - Cache semantics: true cached forever (monotonic), false cached 60s (poller can later flip to true). Probe failures degrade to false WITHOUT caching the failure (so retries work). - transaction.utils.ts::getTransactionFlags gains an optional otsKnowledge parameter, applies the OTS bit via the three-source logic before returning. When undefined (utility-only callers), still respects the server-attached tristate. - 3 component call sites updated to inject + pass the service: transaction.component, transaction-raw.component, tracker.component. Regression tests: - 5 backend tests for the new lazy endpoint (positive, negative, Cache-Control header, malformed-txid rejection, wrong-length rejection). Mocks ordpoolOtsTxidSet (the IO boundary). - 11 frontend tests for OtsKnowledgeService covering every branch of the three-source logic + cache semantics + graceful degradation under probe failure. Test count delta: backend 299 -> 304, frontend 173 -> 184. AOT prod build clean.
1 parent 12fa90f commit b9cb9fe

15 files changed

Lines changed: 506 additions & 7 deletions

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": 299,
2+
"passing": 304,
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/api/bitcoin/bitcoin.routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { Application, Request, Response } from 'express';
22
import axios from 'axios';
33
import * as bitcoinjs from 'bitcoinjs-lib';
44
import config from '../../config';
5+
// HACK -- Ordpool: tristate OTS-commit knowledge on strip wire surfaces;
6+
// see ORDPOOL-FLAGS-ARCHITECTURE.md §4.
7+
import ordpoolOtsTxidSet from '../ordpool-ots-txid-set';
58
import websocketHandler from '../websocket-handler';
69
import mempool from '../mempool';
710
import feeApi from '../fee-api';
@@ -251,6 +254,11 @@ class BitcoinRoutes {
251254
}
252255
try {
253256
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true, false, false, true);
257+
// HACK -- Ordpool: strip-path wire surface -- tx.flags is absent
258+
// here (upstream's strip-and-recompute pattern). Attach the tristate
259+
// OTS-commit answer so the frontend can apply the ordpool_ots bit
260+
// without recomputation. O(1) Set.has() lookup.
261+
transaction.isOtsCommit = ordpoolOtsTxidSet.has(req.params.txId);
254262
res.json(transaction);
255263
} catch (e) {
256264
let statusCode = 500;

backend/src/api/explorer/_ordpool/ordpool.routes.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ jest.mock('../../../repositories/OrdpoolOtsRepository', () => ({
3636
getByBlockheight: jest.fn(),
3737
},
3838
}));
39+
jest.mock('../../ordpool-ots-txid-set', () => ({
40+
__esModule: true,
41+
default: {
42+
has: jest.fn(),
43+
},
44+
}));
3945
jest.mock('./ordpool-inscriptions.api', () => ({ __esModule: true, default: {} }));
4046
jest.mock('./ordpool-stamps.api', () => ({ __esModule: true, default: {} }));
4147
jest.mock('./ordpool-atomicals.api', () => ({ __esModule: true, default: {} }));
@@ -205,3 +211,67 @@ describe('$getIndexerProgress route handler', () => {
205211
});
206212
});
207213
});
214+
215+
describe('$isOtsCommit route handler', () => {
216+
217+
// The handler is the lazy lookup the frontend uses when neither the
218+
// strip-wire `tx.isOtsCommit` attachment nor the client-side OP_RETURN
219+
// fast-path could decide. Tiny surface: validate txid, look up the
220+
// set, set Cache-Control, return { result: boolean }.
221+
// See ORDPOOL-FLAGS-ARCHITECTURE.md §4 for the full design.
222+
223+
// tslint:disable-next-line:no-var-requires
224+
const ordpoolOtsTxidSet = require('../../ordpool-ots-txid-set').default;
225+
226+
async function call$isOtsCommit(txid: string) {
227+
const res = makeRes();
228+
await (generalOrdpoolRoutes as any).$isOtsCommit({ params: { txid } } as unknown as Request, res);
229+
return res as Response & { status: jest.Mock; json: jest.Mock; send: jest.Mock; setHeader: jest.Mock };
230+
}
231+
232+
beforeEach(() => {
233+
jest.resetAllMocks();
234+
});
235+
236+
it('returns { result: true } when the txid is in the set', async () => {
237+
(ordpoolOtsTxidSet.has as jest.Mock).mockReturnValue(true);
238+
239+
const txid = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
240+
const res = await call$isOtsCommit(txid);
241+
242+
expect(ordpoolOtsTxidSet.has).toHaveBeenCalledWith(txid);
243+
expect(res.json).toHaveBeenCalledWith({ result: true });
244+
expect(res.status).not.toHaveBeenCalled();
245+
});
246+
247+
it('returns { result: false } when the txid is not in the set', async () => {
248+
(ordpoolOtsTxidSet.has as jest.Mock).mockReturnValue(false);
249+
250+
const res = await call$isOtsCommit('a'.repeat(64));
251+
252+
expect(res.json).toHaveBeenCalledWith({ result: false });
253+
});
254+
255+
it('sets Cache-Control: public, max-age=60 to match the OTS poller cycle', async () => {
256+
(ordpoolOtsTxidSet.has as jest.Mock).mockReturnValue(false);
257+
258+
const res = await call$isOtsCommit('b'.repeat(64));
259+
260+
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=60');
261+
});
262+
263+
it('rejects malformed txids (not 64 hex chars)', async () => {
264+
const res = await call$isOtsCommit('not-a-txid');
265+
266+
expect(res.status).toHaveBeenCalledWith(400);
267+
expect(res.send).toHaveBeenCalledWith('invalid txid');
268+
expect(ordpoolOtsTxidSet.has).not.toHaveBeenCalled();
269+
});
270+
271+
it('rejects txids with the wrong length', async () => {
272+
const res = await call$isOtsCommit('abc');
273+
274+
expect(res.status).toHaveBeenCalledWith(400);
275+
expect(ordpoolOtsTxidSet.has).not.toHaveBeenCalled();
276+
});
277+
});

backend/src/api/explorer/_ordpool/ordpool.routes.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import blocks from '../../blocks';
66
import OrdpoolMissingStats from '../../ordpool-missing-stats';
77
import ordpoolBlocksRepository from '../../../repositories/OrdpoolBlocksRepository';
88
import ordpoolOtsRepository from '../../../repositories/OrdpoolOtsRepository';
9+
import ordpoolOtsTxidSet from '../../ordpool-ots-txid-set';
910
import ordpoolSkippedBlocksRepository from '../../../repositories/OrdpoolSkippedBlocksRepository';
1011
import ordpoolAtomicalsApi from './ordpool-atomicals.api';
1112
import ordpoolInscriptionsApi from './ordpool-inscriptions.api';
@@ -32,6 +33,7 @@ class GeneralOrdpoolRoutes {
3233
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/block/:height', this.$getOtsBlock)
3334
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/upgrade/:calendar/:hash', this.$proxyOtsUpgrade)
3435
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/stamp-calendars', this.$getOtsStampCalendars)
36+
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/is-commit/:txid', this.$isOtsCommit)
3537
.get('/content/:inscriptionId', this.getInscriptionContent)
3638
.get('/preview/:inscriptionId', this.getInscriptionPreview)
3739
.get('/stamp-content/:txid', this.getStampContent)
@@ -147,6 +149,30 @@ class GeneralOrdpoolRoutes {
147149
res.json({ calendars: getOtsCalendars() });
148150
}
149151

152+
/**
153+
* Lazy point lookup against the in-memory ordpoolOtsTxidSet: "is this
154+
* tx a known OTS calendar batch commit?" Used by the frontend only
155+
* when the strip-wire surfaces (REST /tx/:txid, WS track-tx) didn't
156+
* already attach the answer as `tx.isOtsCommit`, and when the client-
157+
* side OP_RETURN fast-path can't decide. See ORDPOOL-FLAGS-ARCHITECTURE.md
158+
* §4 for the full design.
159+
*
160+
* Cache-Control max-age=60 matches the OTS poller's cycle: a `false`
161+
* answer can flip to `true` once the poller learns about a new
162+
* calendar batch, but never within a 60-second window (the answer is
163+
* monotonic in the `false -> true` direction only).
164+
*/
165+
// https://ordpool.space/api/v1/ordpool/ots/is-commit/abcd...1234
166+
private async $isOtsCommit(req: Request, res: Response): Promise<void> {
167+
const txid = req.params.txid;
168+
if (!isValidTxid(txid)) {
169+
res.status(400).send('invalid txid');
170+
return;
171+
}
172+
res.setHeader('Cache-Control', 'public, max-age=60');
173+
res.json({ result: ordpoolOtsTxidSet.has(txid) });
174+
}
175+
150176
/** All OTS commits at a given block height. Empty array if none. */
151177
// https://ordpool.space/api/v1/ordpool/ots/block/948192
152178
private async $getOtsBlock(req: Request, res: Response): Promise<void> {

backend/src/api/websocket-handler.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import memPool from './mempool';
1010
import backendInfo from './backend-info';
1111
import mempoolBlocks from './mempool-blocks';
1212
import { Common } from './common';
13+
// HACK -- Ordpool: tristate OTS-commit knowledge for the WS track-tx
14+
// strip-path; see ORDPOOL-FLAGS-ARCHITECTURE.md §4.
15+
import ordpoolOtsTxidSet from './ordpool-ots-txid-set';
1316
import loadingIndicators from './loading-indicators';
1417
import config from '../config';
1518
import transactionUtils from './transaction-utils';
@@ -210,6 +213,8 @@ class WebsocketHandler {
210213
// tx.prevout is missing from transactions when in bitcoind mode
211214
try {
212215
const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true);
216+
// HACK -- Ordpool: strip-path -- attach OTS-commit tristate.
217+
fullTx.isOtsCommit = ordpoolOtsTxidSet.has(tx.txid);
213218
response['tx'] = JSON.stringify(fullTx);
214219
} catch (e) {
215220
logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e));
@@ -218,6 +223,8 @@ class WebsocketHandler {
218223
} else {
219224
try {
220225
const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true);
226+
// HACK -- Ordpool: strip-path -- attach OTS-commit tristate.
227+
fullTx.isOtsCommit = ordpoolOtsTxidSet.has(client['track-tx']);
221228
response['tx'] = JSON.stringify(fullTx);
222229
} catch (e) {
223230
logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e));

backend/src/mempool.interfaces.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
139139
replacement?: boolean;
140140
uid?: number;
141141
flags?: number;
142+
// HACK -- Ordpool: tristate OTS-commit knowledge for strip-wire surfaces
143+
// (REST /api/v1/tx/:txId and the WS track-tx push). The frontend cannot
144+
// recompute ordpool_ots locally -- it's the only indexer-derived flag --
145+
// so on the strip path we attach the answer here:
146+
// true -- ordpoolOtsTxidSet.has(txid) was true at serialization time
147+
// false -- ordpoolOtsTxidSet.has(txid) was false at serialization time
148+
// null -- we didn't compute it (most call sites; the bulk wire shapes
149+
// already carry the OTS bit in tx.flags so they leave this
150+
// field absent and the frontend uses tx.flags directly)
151+
// See ORDPOOL-FLAGS-ARCHITECTURE.md §4 for the full design.
152+
isOtsCommit?: boolean | null;
142153
clusterId?: number;
143154
chunkIndex?: number;
144155
}

frontend/.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": 173,
2+
"passing": 184,
33
"note": "Floor for .github/workflows/test-count-floor-frontend.yml. Count of passing tests from the single jest config (jsdom). 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
}

frontend/src/app/components/tracker/tracker.component.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Component, OnInit, OnDestroy, HostListener, Inject, ChangeDetectorRef, ChangeDetectionStrategy, NgZone } from '@angular/core';
22
import { ElectrsApiService } from '@app/services/electrs-api.service';
3+
import { OtsKnowledgeService } from '@app/services/ordinals/ots-knowledge.service';
34
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
45
import {
56
switchMap,
@@ -150,6 +151,8 @@ export class TrackerComponent implements OnInit, OnDestroy {
150151
private zone: NgZone,
151152
private storageService: StorageService,
152153
@Inject(ZONE_SERVICE) private zoneService: any,
154+
// HACK -- Ordpool: resolves the indexer-derived ordpool_ots bit.
155+
private otsKnowledge: OtsKnowledgeService,
153156
) {}
154157

155158
ngOnInit() {
@@ -788,7 +791,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
788791
async checkAccelerationEligibility(): Promise<void> {
789792
if (this.tx) {
790793
const txHeight = this.tx.status?.block_height || (this.stateService.latestBlockHeight >= 0 ? this.stateService.latestBlockHeight + 1 : null);
791-
this.tx.flags = await getTransactionFlags(this.tx, null, null, txHeight, this.stateService.network);
794+
this.tx.flags = await getTransactionFlags(this.tx, null, null, txHeight, this.stateService.network, this.otsKnowledge);
792795
const replaceableInputs = (this.tx.flags & (TransactionFlags.sighash_none | TransactionFlags.sighash_acp)) > 0n;
793796
const highSigop = (this.tx.sigops * 20) > this.tx.weight;
794797
this.eligibleForAcceleration = !replaceableInputs && !highSigop;

frontend/src/app/components/transaction/transaction-raw.component.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { VbytesPipe } from '@app/shared/pipes/bytes-pipe/vbytes.pipe';
44
import { WuBytesPipe } from '@app/shared/pipes/bytes-pipe/wubytes.pipe';
55
import { Transaction, Vout } from '@interfaces/electrs.interface';
66
import { StateService } from '@app/services/state.service';
7+
import { OtsKnowledgeService } from '@app/services/ordinals/ots-knowledge.service';
78
import { Filter, toFilters } from '@app/shared/filters.utils';
89
import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops, fillUnsignedInput } from '@app/shared/transaction.utils';
910
import { catchError, firstValueFrom, Subscription, switchMap, tap, throwError, timer } from 'rxjs';
@@ -84,6 +85,8 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
8485
public bytesPipe: BytesPipe,
8586
public vbytesPipe: VbytesPipe,
8687
public wuBytesPipe: WuBytesPipe,
88+
// HACK -- Ordpool: resolves the indexer-derived ordpool_ots bit.
89+
private otsKnowledge: OtsKnowledgeService,
8790
) {}
8891

8992
ngOnInit(): void {
@@ -276,7 +279,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
276279
});
277280

278281
const txHeight = this.transaction.status?.block_height || (this.stateService.latestBlockHeight >= 0 ? this.stateService.latestBlockHeight + 1 : null);
279-
this.transaction.flags = await getTransactionFlags(this.transaction, this.cpfpInfo, null, txHeight, this.stateService.network);
282+
this.transaction.flags = await getTransactionFlags(this.transaction, this.cpfpInfo, null, txHeight, this.stateService.network, this.otsKnowledge);
280283
this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : [];
281284

282285
this.setupGraph();

frontend/src/app/components/transaction/transaction.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Component, OnInit, AfterViewInit, OnDestroy, HostListener, ViewChild, ElementRef, Inject, ChangeDetectorRef } from '@angular/core';
22
import { ElectrsApiService } from '@app/services/electrs-api.service';
3+
import { OtsKnowledgeService } from '@app/services/ordinals/ots-knowledge.service';
34
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
45
import { TransactionsListComponent } from '@components/transactions-list/transactions-list.component';
56
import {
@@ -217,6 +218,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
217218
private etaService: EtaService,
218219
private cd: ChangeDetectorRef,
219220
@Inject(ZONE_SERVICE) private zoneService: any,
221+
// HACK -- Ordpool: resolves the indexer-derived ordpool_ots bit; see
222+
// ORDPOOL-FLAGS-ARCHITECTURE.md §4.
223+
private otsKnowledge: OtsKnowledgeService,
220224
) {
221225

222226
// HACK, redirect to the correct URL if someone accidently insert a inscription ID
@@ -1013,7 +1017,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
10131017
this.taprootEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'taproot');
10141018
this.rbfEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'rbf');
10151019
const txHeight = this.tx.status?.block_height || (this.stateService.latestBlockHeight >= 0 ? this.stateService.latestBlockHeight + 1 : null);
1016-
this.tx.flags = await getTransactionFlags(this.tx, null, null, txHeight, this.stateService.network);
1020+
this.tx.flags = await getTransactionFlags(this.tx, null, null, txHeight, this.stateService.network, this.otsKnowledge);
10171021
// HACK: always show all flags, because why not?
10181022
// this.filters = this.tx.flags ? toFilters(this.tx.flags).filter(f => f.txPage) : [];
10191023
this.filters = this.tx.flags ? toFilters(this.tx.flags) : [];

0 commit comments

Comments
 (0)