Skip to content

Commit aa28b72

Browse files
committed
ots: audit batch -- copy, localStorage gate, multi-chain msg, tests
Big batch from the post-launch audit. Each item was tested locally (jest + Playwright) before commit. User-facing ----------- - Pre-flight localStorage gate. If the browser blocks DOM Storage (Safari private mode, privacy extension), show a clear yellow banner above the diagram explaining what's blocked, why it matters, and how to recover (Safari, extensions, quota). Stamp drop-zone is disabled with a 'Stamping disabled' label; verify works fine without storage. - Multi-chain receipt UX. When a user drops a .ots whose only attestations are Litecoin/Ethereum, show a yellow educational alert listing the block heights and clarifying that ordpool only verifies Bitcoin attestations -- not just a generic warning. When a Bitcoin-anchored receipt also has non-Bitcoin attestations, a small grey footnote mentions them. When the receipt's ops include a hash we don't implement (KECCAK256 / RIPEMD160 in the chain), catch the parser error and show a specific message pointing at the official `ots` CLI for multi-chain proofs. - Yellow + red alert button contrast. btn-outline-light on alert-warning / alert-danger was illegible (white on light-yellow / light-red). Switched to btn-outline-dark everywhere it sits inside an alert. - Pending column in the calendars table relabeled 'Pending batches' with a tooltip explaining it's calendar batch txs in mempool, not user submissions. - Intro paragraph split into two for breath; added an '#recent-commits' anchor + 'examples below' link. - Cancel & forget button on queued rows: btn-outline-secondary (gray-on-dark) -> btn-outline-light to match Check now. Backend ------- - /ots/upgrade/<host>/<commit> proxy now ALWAYS returns HTTP 200 and distinguishes pending vs published via Content-Type (application/json vs application/vnd.opentimestamps.v1). Eliminates Chrome devtools auto-logging 'Failed to load resource: 404' every minute per still-pending stamp -- the 404 was the upstream's normal 'not yet published' response, not a failure from the proxy's perspective. Frontend poller updated to match (peeks Content-Type instead of status code). Tests ----- - Fixed regression in ots-calendars.component.spec.ts: the component now injects SeoService + OtsStoreService, neither of which the existing spec was providing. Added stubs. - New ots-store.service.spec.ts (15 tests): * round-trip bytesToBase64/base64ToBytes for all byte values * hexEncode lower-case + zero-pad * assembleOtsFile -- single branch layout, multi-branch 0xff separator placement, throws on empty input * bestCalendarBytes -- returns pending unchanged, splices ops + upgrade body when upgraded * persistence -- add/update/remove/clearAll, BehaviorSubject stream, schema-version gating, corrupt-JSON recovery * canClear gate semantics (download-count + status) - New ots-calendar-picker.service.spec.ts (5 tests): * picks freshest 3 by lastBlocktime * falls through to configured order when stats endpoint dies * falls back to hardcoded list when /stamp-calendars empty/dead * caches result across multiple pick() calls All 157 frontend + 288 backend tests passing locally.
1 parent 70bdcc9 commit aa28b72

14 files changed

Lines changed: 518 additions & 42 deletions

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,30 @@ class GeneralOrdpoolRoutes {
102102
`https://${calendar}/timestamp/${hash}`,
103103
{ responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true },
104104
);
105-
// Default max-age for the pending (404) case mirrors the calendar's
106-
// own cache; for the 200 case the response is immutable so we can
107-
// cache aggressively. Either way, the frontend's poller treats this
108-
// as best-effort -- worst case it just polls again next minute.
105+
// We always return HTTP 200 from this proxy and distinguish via
106+
// Content-Type:
107+
// 200 + application/vnd.opentimestamps.v1 + binary body -> upgraded
108+
// 200 + application/json + {"status":"pending"} -> calendar
109+
// hasn't
110+
// published
111+
// this hash
112+
// yet
113+
// This avoids Chrome's auto-logging "Failed to load resource: 404"
114+
// every minute for every still-pending stamp -- the response IS
115+
// expected and successful from our perspective.
116+
// Upstream 5xx maps to our 502 so genuine errors are visible.
109117
if (upstream.status === 200) {
110118
res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
111119
res.setHeader('Content-Type', 'application/vnd.opentimestamps.v1');
112-
} else {
120+
res.status(200).end(Buffer.from(upstream.data));
121+
} else if (upstream.status === 404) {
113122
res.setHeader('Cache-Control', 'public, max-age=60');
123+
res.setHeader('Content-Type', 'application/json');
124+
res.status(200).end('{"status":"pending"}');
125+
} else {
126+
res.setHeader('Cache-Control', 'no-store');
127+
res.status(502).send(`upstream returned ${upstream.status}`);
114128
}
115-
res.status(upstream.status).end(Buffer.from(upstream.data));
116129
} catch {
117130
res.status(502).send('upstream error');
118131
}

frontend/src/app/components/_ordpool/ots-calendars/ots-calendars.component.html

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ <h1>OpenTimestamps: Anchor any file to Bitcoin</h1>
1010
the root in a single OP_RETURN. That root is now part of Bitcoin's permanent
1111
ledger. Anyone with the file, the <code>.ots</code> proof, and Bitcoin's block
1212
headers can verify <em>"this existed by block N"</em>, forever, with no third
13-
party in the loop. Ordpool tags every OTS transaction wherever it appears in
14-
the explorer, so you can see Bitcoin's notary at work in real time, and you
15-
can verify your own <code>.ots</code> receipt right here, against our
16-
independent open-source implementation.
13+
party in the loop.
14+
</p>
15+
16+
<p>
17+
Ordpool tags every OTS transaction wherever it appears in the explorer, so
18+
you can see Bitcoin's notary at work in real time
19+
(<a href="#recent-commits">examples below</a>), and you can verify your own
20+
<code>.ots</code> receipt right here, against our independent open-source
21+
implementation.
1722
</p>
1823

1924
<p>
@@ -34,6 +39,24 @@ <h1>OpenTimestamps: Anchor any file to Bitcoin</h1>
3439
</em>
3540
</p>
3641

42+
@if (!localStorageAvailable) {
43+
<div class="alert alert-warning mt-4 mb-0">
44+
<strong>Stamping needs localStorage, and your browser has it disabled.</strong>
45+
Stamping is a multi-step flow: we need to remember your pending stamp across
46+
tab reloads, the calendar's response, and the final upgraded receipt. Without
47+
localStorage we'd lose your stamp the moment you switch tabs.
48+
<ul class="mb-0 mt-2">
49+
<li><strong>Safari private browsing</strong>: localStorage is blocked. Open the page in a regular Safari window.</li>
50+
<li><strong>Privacy extension or strict cookie blocker</strong>: allow site data for <code>ordpool.space</code>.</li>
51+
<li><strong>Storage quota exceeded</strong>: clear some browser data and reload.</li>
52+
</ul>
53+
<p class="mb-0 mt-2 smaller-text">
54+
Verifying a <code>.ots</code> still works without localStorage; it's a
55+
one-shot operation that doesn't need persistent state.
56+
</p>
57+
</div>
58+
}
59+
3760
<app-ots-diagram class="d-block mt-4"></app-ots-diagram>
3861

3962
<div class="row g-3 mt-1">
@@ -64,7 +87,9 @@ <h2 id="calendars" class="mt-4">Calendars</h2>
6487
<th>Calendar</th>
6588
<th class="text-right">Total commits indexed</th>
6689
<th class="text-right">Last block</th>
67-
<th class="text-right">Pending</th>
90+
<th class="text-right" title="Calendar batch transactions waiting in mempool. Each batch carries thousands of user submissions; this is the count of unconfirmed batch txs, not user submissions.">
91+
Pending batches
92+
</th>
6893
</tr>
6994
</thead>
7095
<tbody>
@@ -93,7 +118,7 @@ <h2 id="calendars" class="mt-4">Calendars</h2>
93118
</p>
94119
}
95120

96-
<h2 class="mt-4">Recent commits</h2>
121+
<h2 id="recent-commits" class="mt-4">Recent commits</h2>
97122
<p class="text-muted">
98123
The most recent OTS batch commits we've seen on Bitcoin. Each row is one
99124
calendar transaction whose OP_RETURN carries a Merkle root covering

frontend/src/app/components/_ordpool/ots-calendars/ots-calendars.component.spec.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
OrdpoolOtsRow,
88
} from '../../../services/ordinals/ordpool-api.service';
99
import { OtsCalendarsComponent } from './ots-calendars.component';
10+
import { SeoService } from '../../../services/seo.service';
11+
import { OtsStoreService } from '../ots-stamp-verify/ots-store.service';
1012

1113
describe('OtsCalendarsComponent', () => {
1214
let api: jest.Mocked<OrdpoolApiService>;
@@ -35,9 +37,18 @@ describe('OtsCalendarsComponent', () => {
3537
}
3638

3739
function setup(): void {
40+
const seoStub = {
41+
setTitle: jest.fn(), resetTitle: jest.fn(),
42+
setDescription: jest.fn(), resetDescription: jest.fn(),
43+
};
44+
const storeStub = { localStorageAvailable: true };
3845
TestBed.configureTestingModule({
3946
declarations: [OtsCalendarsComponent],
40-
providers: [{ provide: OrdpoolApiService, useValue: api }],
47+
providers: [
48+
{ provide: OrdpoolApiService, useValue: api },
49+
{ provide: SeoService, useValue: seoStub },
50+
{ provide: OtsStoreService, useValue: storeStub },
51+
],
4152
}).overrideComponent(OtsCalendarsComponent, { set: { template: '' } });
4253
}
4354

frontend/src/app/components/_ordpool/ots-calendars/ots-calendars.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
OrdpoolOtsRow,
88
} from '../../../services/ordinals/ordpool-api.service';
99
import { SeoService } from '../../../services/seo.service';
10+
import { OtsStoreService } from '../ots-stamp-verify/ots-store.service';
1011

1112
/*
1213
Test cases:
@@ -35,8 +36,11 @@ export class OtsCalendarsComponent implements OnInit, OnDestroy {
3536
private api = inject(OrdpoolApiService);
3637
private cdr = inject(ChangeDetectorRef);
3738
private seo = inject(SeoService);
39+
private store = inject(OtsStoreService);
3840
private destroy$ = new Subject<void>();
3941

42+
localStorageAvailable = this.store.localStorageAvailable;
43+
4044
calendars: OrdpoolOtsCalendarStats[] = [];
4145
recent: OrdpoolOtsRow[] = [];
4246
calendarsLoaded = false;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { HttpClient } from '@angular/common/http';
3+
import { of, throwError } from 'rxjs';
4+
5+
import { OtsCalendarPickerService } from './ots-calendar-picker.service';
6+
import { OTS_FALLBACK_CALENDARS } from './ots-store.service';
7+
8+
describe('OtsCalendarPickerService', () => {
9+
let http: jest.Mocked<HttpClient>;
10+
let picker: OtsCalendarPickerService;
11+
12+
function setup() {
13+
TestBed.resetTestingModule();
14+
TestBed.configureTestingModule({
15+
providers: [
16+
OtsCalendarPickerService,
17+
{ provide: HttpClient, useValue: http },
18+
],
19+
});
20+
picker = TestBed.inject(OtsCalendarPickerService);
21+
}
22+
23+
it('picks the freshest 3 by lastBlocktime when stats are available', async () => {
24+
http = {
25+
get: jest.fn((url: string) => {
26+
if (url.endsWith('/stamp-calendars')) {
27+
return of({ calendars: [
28+
{ nickname: 'alice', url: 'https://alice.example.org' },
29+
{ nickname: 'bob', url: 'https://bob.example.org' },
30+
{ nickname: 'finney', url: 'https://finney.example.org' },
31+
{ nickname: 'catallaxy', url: 'https://cat.example.org' },
32+
] });
33+
}
34+
// /ots/calendars stats endpoint -- finney is freshest, then bob, then catallaxy.
35+
// alice has the oldest data; should drop out of top-3.
36+
return of([
37+
{ calendar: 'alice', totalCommits: 0, lastBlockheight: 0, lastBlocktime: 1700000000, pendingCount: 0 },
38+
{ calendar: 'bob', totalCommits: 0, lastBlockheight: 0, lastBlocktime: 1700001000, pendingCount: 0 },
39+
{ calendar: 'finney', totalCommits: 0, lastBlockheight: 0, lastBlocktime: 1700002000, pendingCount: 0 },
40+
{ calendar: 'catallaxy', totalCommits: 0, lastBlockheight: 0, lastBlocktime: 1700000500, pendingCount: 0 },
41+
]);
42+
}) as any,
43+
} as any;
44+
setup();
45+
const picked = await picker.pick();
46+
expect(picked.length).toBe(3);
47+
expect(picked.map(c => c.nickname)).toEqual(['finney', 'bob', 'catallaxy']);
48+
});
49+
50+
it('falls through to configured order when stats endpoint fails', async () => {
51+
http = {
52+
get: jest.fn((url: string) => {
53+
if (url.endsWith('/stamp-calendars')) {
54+
return of({ calendars: [
55+
{ nickname: 'alice', url: 'https://alice.example.org' },
56+
{ nickname: 'bob', url: 'https://bob.example.org' },
57+
{ nickname: 'finney', url: 'https://finney.example.org' },
58+
] });
59+
}
60+
return throwError(() => new Error('stats unreachable'));
61+
}) as any,
62+
} as any;
63+
setup();
64+
const picked = await picker.pick();
65+
expect(picked.map(c => c.nickname)).toEqual(['alice', 'bob', 'finney']);
66+
});
67+
68+
it('falls back to hardcoded list when /stamp-calendars is empty', async () => {
69+
http = {
70+
get: jest.fn(() => of({ calendars: [] })) as any,
71+
} as any;
72+
setup();
73+
const picked = await picker.pick();
74+
expect(picked.length).toBe(Math.min(3, OTS_FALLBACK_CALENDARS.length));
75+
});
76+
77+
it('falls back to hardcoded list when /stamp-calendars throws', async () => {
78+
http = {
79+
get: jest.fn(() => throwError(() => new Error('config endpoint dead'))) as any,
80+
} as any;
81+
setup();
82+
const picked = await picker.pick();
83+
expect(picked.length).toBe(Math.min(3, OTS_FALLBACK_CALENDARS.length));
84+
expect(picked[0].nickname).toBe('alice');
85+
});
86+
87+
it('caches the result -- second call does not refetch', async () => {
88+
http = {
89+
get: jest.fn((url: string) => {
90+
if (url.endsWith('/stamp-calendars')) {
91+
return of({ calendars: [{ nickname: 'alice', url: 'https://alice.example.org' }] });
92+
}
93+
return of([]);
94+
}) as any,
95+
} as any;
96+
setup();
97+
await picker.pick();
98+
await picker.pick();
99+
await picker.pick();
100+
// Two calls per pick (config + stats), but only on the FIRST pick.
101+
expect(http.get).toHaveBeenCalledTimes(2);
102+
});
103+
});

frontend/src/app/components/_ordpool/ots-stamp-verify/ots-pending-queue.component.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ <h2 class="mt-4">Your stamps</h2>
5151
</div>
5252
<div class="ots-stamp-actions">
5353
<button type="button" class="btn btn-sm btn-outline-light me-2" (click)="checkNow(s)">Check now</button>
54-
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancel(s)">Cancel &amp; forget</button>
54+
<button type="button" class="btn btn-sm btn-outline-light" (click)="cancel(s)">Cancel &amp; forget</button>
5555
</div>
5656
<details class="ots-power-user smaller-text mt-2">
5757
<summary>Power user: export raw pending receipt</summary>
@@ -62,7 +62,7 @@ <h2 class="mt-4">Your stamps</h2>
6262
if you'd rather upgrade outside the browser. Most users don't
6363
need this.
6464
</p>
65-
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="downloadPendingRaw(s)">Download .pending.ots</button>
65+
<button type="button" class="btn btn-sm btn-outline-light" (click)="downloadPendingRaw(s)">Download .pending.ots</button>
6666
</details>
6767
}
6868

@@ -96,7 +96,7 @@ <h2 class="mt-4">Your stamps</h2>
9696
{{ s.downloadCount === 0 ? 'DOWNLOAD ME!' : 'Download again' }}
9797
</button>
9898
@if (canClear(s)) {
99-
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="clear(s)">Clear this entry</button>
99+
<button type="button" class="btn btn-sm btn-outline-light" (click)="clear(s)">Clear this entry</button>
100100
}
101101
</div>
102102
}
@@ -108,7 +108,7 @@ <h2 class="mt-4">Your stamps</h2>
108108
usually fixes it.
109109
</div>
110110
<div class="ots-stamp-actions">
111-
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancel(s)">Cancel &amp; forget</button>
111+
<button type="button" class="btn btn-sm btn-outline-light" (click)="cancel(s)">Cancel &amp; forget</button>
112112
</div>
113113
}
114114
</div>

frontend/src/app/components/_ordpool/ots-stamp-verify/ots-poller.service.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,17 @@ export class OtsPollerService implements OnDestroy {
117117
let errorMessage: string | null = null;
118118
try {
119119
const resp = await fetch(url, { method: 'GET' });
120-
if (resp.status === 200) {
120+
// Backend proxy always returns 200; we distinguish by Content-Type so
121+
// Chrome's devtools doesn't log "Failed to load resource: 404" every
122+
// minute while a stamp is still pending. JSON body = pending, binary
123+
// = upgraded.
124+
const ct = resp.headers.get('content-type') || '';
125+
if (resp.status === 200 && ct.includes('json')) {
126+
result = 'pending';
127+
} else if (resp.status === 200) {
121128
const buf = new Uint8Array(await resp.arrayBuffer());
122129
bodyB64 = bytesToBase64(buf);
123130
result = 'published';
124-
} else if (resp.status === 404) {
125-
result = 'pending';
126131
} else {
127132
errorMessage = 'HTTP ' + resp.status;
128133
}

frontend/src/app/components/_ordpool/ots-stamp-verify/ots-stamp.component.html

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ <h2 class="card-title mb-2">Stamp a file</h2>
99

1010
<label class="dropzone"
1111
[class.dragging]="isDragging"
12-
(dragover)="onDragOver($event)"
13-
(dragleave)="onDragLeave($event)"
14-
(drop)="onDrop($event)">
15-
<input type="file" class="dropzone-input" (change)="onPick($event)">
12+
[class.disabled]="!localStorageAvailable"
13+
(dragover)="localStorageAvailable && onDragOver($event)"
14+
(dragleave)="localStorageAvailable && onDragLeave($event)"
15+
(drop)="localStorageAvailable && onDrop($event)">
16+
<input type="file" class="dropzone-input" (change)="onPick($event)" [disabled]="!localStorageAvailable">
1617
<span class="dropzone-text">
17-
<strong>Drop a file</strong><br>
18-
or click to pick
18+
@if (localStorageAvailable) {
19+
<strong>Drop a file</strong><br>
20+
or click to pick
21+
} @else {
22+
<strong>Stamping disabled</strong><br>
23+
(localStorage is blocked &mdash; see banner above)
24+
}
1925
</span>
2026
</label>
2127

@@ -30,7 +36,7 @@ <h2 class="card-title mb-2">Stamp a file</h2>
3036
@case ('error') {
3137
<div class="alert alert-danger mt-3 mb-0">
3238
<strong>Could not stamp file:</strong> {{ status.message }}
33-
<button type="button" class="btn btn-sm btn-outline-light ms-2" (click)="reset()">Try again</button>
39+
<button type="button" class="btn btn-sm btn-outline-dark ms-2" (click)="reset()">Try again</button>
3440
</div>
3541
}
3642
@case ('wrong-zone') {

frontend/src/app/components/_ordpool/ots-stamp-verify/ots-stamp.component.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
background-color: #1a1d36;
2626
}
2727

28+
&.disabled {
29+
cursor: not-allowed;
30+
opacity: 0.55;
31+
&:hover { border-color: #2d3250; background-color: #11132a; }
32+
}
33+
2834
.dropzone-input { display: none; }
2935

3036
.dropzone-text {

frontend/src/app/components/_ordpool/ots-stamp-verify/ots-stamp.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class OtsStampComponent {
4545

4646
status: Status = { kind: 'idle' };
4747
isDragging = false;
48+
localStorageAvailable = this.store.localStorageAvailable;
4849

4950
onDragOver(ev: DragEvent): void {
5051
ev.preventDefault();

0 commit comments

Comments
 (0)