From 403033c1e8b7d0e9bda7583b9a62aef56d375de0 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 09:00:03 +0000 Subject: [PATCH 1/2] release: prove the live socket accepts a browser handshake The overlay refuses a websocket upgrade whose Origin is not on its allowlist, and an unset allowlist refuses every origin. A handshake sent without an Origin header is allowed, so every check we had said the live socket worked while every real browser was refused and quietly fell back to polling: pages still updated, and nothing on the server said no. The cutover now sends the header a browser sends and refuses the release if the answer is not 101, naming the setting to fix. Co-Authored-By: Claude Fable 5 --- scripts/universe/release.sh | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/scripts/universe/release.sh b/scripts/universe/release.sh index 12327a1794f..45540562756 100644 --- a/scripts/universe/release.sh +++ b/scripts/universe/release.sh @@ -260,6 +260,47 @@ if bad: sys.exit(1) for name, feature in report['features'].items(): print(f"{name}: enabled={feature['enabled']} routes={feature['routesRegistered']} state={feature['state']}") +PY + + gate_live_socket || return 1 +} + +# The live socket, asked for the way a browser asks for it. +# +# The overlay refuses an upgrade whose Origin is not on its allowlist, and an +# unset allowlist refuses every origin. A handshake sent without an Origin +# header is allowed, so curl said the socket worked while every browser was +# refused and fell back to polling: the page still updated, and nothing on the +# server said no. Send the header a browser sends. +gate_live_socket() { + python3 - "$GATEWAY" <<'PY' || return 1 +import socket, sys +from urllib.parse import urlsplit + +target = urlsplit(sys.argv[1]) +host, port = target.hostname, target.port or 80 +request = ( + 'GET /api/v1/universe/ws HTTP/1.1\r\n' + f'Host: {host}:{port}\r\n' + 'Connection: Upgrade\r\n' + 'Upgrade: websocket\r\n' + 'Sec-WebSocket-Version: 13\r\n' + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' + 'Origin: https://explorer.bitcoinuniverse.io\r\n' + '\r\n' +) +try: + with socket.create_connection((host, port), timeout=10) as connection: + connection.sendall(request.encode()) + status = connection.recv(4096).split(b'\r\n', 1)[0].decode(errors='replace') +except OSError as error: + print(f'the live socket could not be reached: {error}') + sys.exit(1) +if '101' not in status: + print(f'the live socket refused a browser handshake: {status}') + print('set CORS_ORIGINS in the overlay environment to the public origin') + sys.exit(1) +print('the live socket accepts a browser handshake') PY } From db1273d00de3d609fdf84336cde2323e55dfc717 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 09:09:46 +0000 Subject: [PATCH 2/2] fix(api): answer 404 for a block hash Core does not know Core reports an unknown block as "Block not found" rather than as an HTTP status, so the route mapped it to 500 and a hash that is simply not a block became indistinguishable from a server fault. The explorer's own search consults this route for every 64 hex query, so every transaction search reported the whole Bitcoin chain unavailable while Bitcoin was healthy and had answered definitively. The transaction routes beside it already match Core's message this way. Co-Authored-By: Claude Fable 5 --- backend/src/api/bitcoin/bitcoin.routes.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 237a7caeca1..ada92ec29dd 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -421,7 +421,14 @@ class BitcoinRoutes { res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString()); res.json(block); } catch (e: any) { - handleError(req, res, e?.response?.status === 404 ? 404 : 500, 'Failed to get block'); + // Core answers an unknown hash with "Block not found" rather than an + // HTTP status, so without this a hash that is simply not a block is + // reported as a server fault. Consumers cannot tell that apart from an + // outage: the explorer's own search called the whole chain unavailable + // whenever it looked up a transaction id here, which is every search. + const notFound = e?.response?.status === 404 + || (e instanceof Error && e.message && e.message.indexOf('Block not found') > -1); + handleError(req, res, notFound ? 404 : 500, notFound ? 'Block not found' : 'Failed to get block'); } }