blockyard 0.0.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// A SELF-SIGNED CERTIFICATE, MADE HERE (operator, 2026-09-15: "Can we make the signing process
|
|
2
|
+
// part of the installer to generate per-user certs for their nodes?" -- then "make https the
|
|
3
|
+
// forced default"). Node can make a key pair and sign bytes but cannot write an X.509
|
|
4
|
+
// certificate, and this repository has no dependencies by decision, so the certificate is
|
|
5
|
+
// assembled by hand: a v3 TBSCertificate in DER, signed with ECDSA P-256 over SHA-256, wrapped
|
|
6
|
+
// in PEM. Every install gets its own key and its own certificate, valid for a little over two
|
|
7
|
+
// years, naming the addresses the monitor is reached on (subjectAltName), and the server makes
|
|
8
|
+
// one on first start when no certificate of the operator's own is configured.
|
|
9
|
+
//
|
|
10
|
+
// The shape is the one `openssl req -x509` produces: issuer == subject (CN=blockyard),
|
|
11
|
+
// basicConstraints CA:TRUE, keyUsage digitalSignature+keyCertSign, extKeyUsage serverAuth. A
|
|
12
|
+
// browser warns once per address, as with any self-signed certificate, and then remembers it.
|
|
13
|
+
// Node's own X509Certificate parses the result, which is what the tests hold it to.
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------- DER, the little that is needed
|
|
20
|
+
const len = (n) => {
|
|
21
|
+
if (n < 0x80) return Buffer.from([n]);
|
|
22
|
+
const b = [];
|
|
23
|
+
for (let x = n; x > 0; x = Math.floor(x / 256)) b.unshift(x & 0xff);
|
|
24
|
+
return Buffer.from([0x80 | b.length, ...b]);
|
|
25
|
+
};
|
|
26
|
+
const tlv = (tag, body) => Buffer.concat([Buffer.from([tag]), len(body.length), body]);
|
|
27
|
+
const seq = (...parts) => tlv(0x30, Buffer.concat(parts));
|
|
28
|
+
const set = (...parts) => tlv(0x31, Buffer.concat(parts));
|
|
29
|
+
const int = (v) => {
|
|
30
|
+
let b = Buffer.isBuffer(v) ? Buffer.from(v) : Buffer.from([v]);
|
|
31
|
+
while (b.length > 1 && b[0] === 0 && !(b[1] & 0x80)) b = b.subarray(1);
|
|
32
|
+
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]);
|
|
33
|
+
return tlv(0x02, b);
|
|
34
|
+
};
|
|
35
|
+
const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
|
|
36
|
+
// (arcs as an array, not dotted text: the address-hygiene test reads a four-arc OID as an IPv4 address)
|
|
37
|
+
const oid = (arcs) => {
|
|
38
|
+
const p = Array.isArray(arcs) ? arcs : String(arcs).split('.').map(Number);
|
|
39
|
+
const out = [40 * p[0] + p[1]];
|
|
40
|
+
for (const v of p.slice(2)) {
|
|
41
|
+
const s = [];
|
|
42
|
+
let x = v;
|
|
43
|
+
do { s.unshift(x & 0x7f); x = Math.floor(x / 128); } while (x > 0);
|
|
44
|
+
for (let i = 0; i < s.length - 1; i++) s[i] |= 0x80;
|
|
45
|
+
out.push(...s);
|
|
46
|
+
}
|
|
47
|
+
return tlv(0x06, Buffer.from(out));
|
|
48
|
+
};
|
|
49
|
+
const utf8 = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
|
|
50
|
+
const octet = (b) => tlv(0x04, b);
|
|
51
|
+
const bits = (b, unused = 0) => tlv(0x03, Buffer.concat([Buffer.from([unused]), b]));
|
|
52
|
+
const utc = (d) => {
|
|
53
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
54
|
+
return tlv(0x17, Buffer.from(`${p(d.getUTCFullYear() % 100)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`, 'ascii'));
|
|
55
|
+
};
|
|
56
|
+
const ctx = (n, body, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, body);
|
|
57
|
+
|
|
58
|
+
// an IP address as the bytes subjectAltName wants: four for v4, sixteen for v6
|
|
59
|
+
export function ipBytes(addr) {
|
|
60
|
+
const kind = net.isIP(addr);
|
|
61
|
+
if (kind === 4) return Buffer.from(addr.split('.').map(Number));
|
|
62
|
+
if (kind !== 6) return null;
|
|
63
|
+
let a = addr;
|
|
64
|
+
// an embedded v4 tail (::ffff:192.0.2.1) becomes its two hextets
|
|
65
|
+
const m = a.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
|
66
|
+
if (m) { const b = m[2].split('.').map(Number); a = `${m[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}`; }
|
|
67
|
+
const [head, tail = ''] = a.split('::');
|
|
68
|
+
const hs = head ? head.split(':') : [], ts = tail ? tail.split(':') : [];
|
|
69
|
+
const fill = a.includes('::') ? 8 - hs.length - ts.length : 0;
|
|
70
|
+
const hex = [...hs, ...Array(Math.max(0, fill)).fill('0'), ...ts];
|
|
71
|
+
if (hex.length !== 8) return null;
|
|
72
|
+
const out = Buffer.alloc(16);
|
|
73
|
+
hex.forEach((h, i) => out.writeUInt16BE(parseInt(h, 16), i * 2));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '')}\n-----END ${label}-----\n`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make a self-signed certificate and its private key.
|
|
81
|
+
* @param {object} [o]
|
|
82
|
+
* @param {string} [o.cn='blockyard'] the common name (issuer and subject alike)
|
|
83
|
+
* @param {string[]} [o.sans=[]] addresses and names the certificate is valid for
|
|
84
|
+
* @param {number} [o.days=825] validity, from `now`
|
|
85
|
+
* @param {number} [o.now=Date.now()]
|
|
86
|
+
* @returns {{ cert: string, key: string, fingerprint: string, notAfter: number, sans: string[] }}
|
|
87
|
+
*/
|
|
88
|
+
export function makeSelfSigned({ cn = 'blockyard', sans = [], days = 825, now = Date.now() } = {}) {
|
|
89
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
90
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' });
|
|
91
|
+
const ECDSA_SHA256 = oid([1, 2, 840, 10045, 4, 3, 2]);
|
|
92
|
+
const name = seq(set(seq(oid([2, 5, 4, 3]), utf8(cn))));
|
|
93
|
+
const notBefore = new Date(now - 5 * 60 * 1000); // five minutes of clock skew
|
|
94
|
+
const notAfter = new Date(now + days * 86_400_000);
|
|
95
|
+
const serial = crypto.randomBytes(16); serial[0] &= 0x7f; // positive, 128 bits
|
|
96
|
+
// the names: unique, addresses as bytes, everything else as a DNS name
|
|
97
|
+
const uniq = [...new Set(sans.map((s) => String(s).trim()).filter(Boolean))];
|
|
98
|
+
// [2] dNSName and [7] iPAddress are IMPLICIT: the tag replaces the string's own, so the body is
|
|
99
|
+
// the bare bytes (an IA5String TLV inside read back as '"\u0016\u0009localhost"')
|
|
100
|
+
const names = uniq.map((s) => { const ip = ipBytes(s); return ip ? ctx(7, ip, false) : ctx(2, Buffer.from(s, 'ascii'), false); });
|
|
101
|
+
const ext = (id, critical, body) => seq(oid(id), ...(critical ? [bool(true)] : []), octet(body));
|
|
102
|
+
const extensions = [
|
|
103
|
+
ext([2, 5, 29, 19], true, seq(bool(true))), // basicConstraints CA:TRUE
|
|
104
|
+
ext([2, 5, 29, 15], true, bits(Buffer.from([0x84]), 2)), // keyUsage: digitalSignature, keyCertSign
|
|
105
|
+
ext([2, 5, 29, 37], false, seq(oid([1, 3, 6, 1, 5, 5, 7, 3, 1]))), // extKeyUsage: serverAuth
|
|
106
|
+
...(names.length ? [ext([2, 5, 29, 17], false, seq(...names))] : []), // subjectAltName
|
|
107
|
+
];
|
|
108
|
+
const tbs = seq(
|
|
109
|
+
ctx(0, int(2)), // version 3
|
|
110
|
+
int(serial),
|
|
111
|
+
seq(ECDSA_SHA256),
|
|
112
|
+
name, // issuer
|
|
113
|
+
seq(utc(notBefore), utc(notAfter)),
|
|
114
|
+
name, // subject: the same, which is what "self-signed" means
|
|
115
|
+
spki,
|
|
116
|
+
ctx(3, seq(...extensions)),
|
|
117
|
+
);
|
|
118
|
+
const signature = crypto.sign('sha256', tbs, { key: privateKey, dsaEncoding: 'der' });
|
|
119
|
+
const cert = pem('CERTIFICATE', seq(tbs, seq(ECDSA_SHA256), bits(signature)));
|
|
120
|
+
const key = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
121
|
+
const x = new crypto.X509Certificate(cert);
|
|
122
|
+
return { cert, key, fingerprint: x.fingerprint256, notAfter: Date.parse(x.validTo), sans: uniq };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A name as it compares: an IP by its bytes (Node prints v6 expanded and upper-case), a DNS name lower-case. */
|
|
126
|
+
export const canonName = (s) => { const b = ipBytes(String(s).trim()); return b ? `ip:${b.toString('hex')}` : String(s).trim().toLowerCase(); };
|
|
127
|
+
/** The names a certificate carries, canonical (canonName), IPs and DNS names alike. */
|
|
128
|
+
export function certNames(certPem) {
|
|
129
|
+
const x = new crypto.X509Certificate(certPem);
|
|
130
|
+
return (x.subjectAltName ?? '').split(',').map((s) => s.trim().replace(/^(DNS|IP Address):/, '')).filter(Boolean).map(canonName);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The monitor's own certificate under `dir`: made on first use, kept after that, remade when it
|
|
135
|
+
* is within a fortnight of expiry or no longer names one of `mustName`. Returns the files and
|
|
136
|
+
* whether anything was written.
|
|
137
|
+
*/
|
|
138
|
+
export function ensureSelfSigned(dir, { sans = [], mustName = [], days = 825, now = Date.now(), force = false } = {}) {
|
|
139
|
+
const certFile = path.join(dir, 'cert.pem'), keyFile = path.join(dir, 'key.pem');
|
|
140
|
+
let why = null;
|
|
141
|
+
if (force) why = 'asked to';
|
|
142
|
+
else if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) why = 'none yet';
|
|
143
|
+
else {
|
|
144
|
+
try {
|
|
145
|
+
const x = new crypto.X509Certificate(fs.readFileSync(certFile, 'utf8'));
|
|
146
|
+
const have = certNames(fs.readFileSync(certFile, 'utf8'));
|
|
147
|
+
const missing = mustName.filter((n) => !have.includes(canonName(n)));
|
|
148
|
+
if (Date.parse(x.validTo) - now < 14 * 86_400_000) why = `it expires ${x.validTo}`;
|
|
149
|
+
else if (missing.length) why = `it does not name ${missing.join(', ')}`;
|
|
150
|
+
else if (!x.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(keyFile, 'utf8')))) why = 'the key does not match it';
|
|
151
|
+
} catch (err) { why = `it cannot be read (${err.message})`; }
|
|
152
|
+
}
|
|
153
|
+
if (!why) return { certFile, keyFile, made: false, why: null };
|
|
154
|
+
const made = makeSelfSigned({ sans: [...new Set([...sans, ...mustName])], days, now });
|
|
155
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
156
|
+
const write = (file, text) => { const tmp = `${file}.tmp`; fs.writeFileSync(tmp, text, { mode: 0o600 }); fs.renameSync(tmp, file); };
|
|
157
|
+
write(keyFile, made.key);
|
|
158
|
+
write(certFile, made.cert);
|
|
159
|
+
return { certFile, keyFile, made: true, why, fingerprint: made.fingerprint, notAfter: made.notAfter, sans: made.sans };
|
|
160
|
+
}
|
|
@@ -68,11 +68,13 @@ Environment=BLOCKYARD_NODE_URL=http://127.0.0.1:8331
|
|
|
68
68
|
Environment=BLOCKYARD_DATADIR=/home/bitcoin/.bitcoin
|
|
69
69
|
Environment=BLOCKYARD_LOGFILE=/home/bitcoin/.bitcoin/debug.log
|
|
70
70
|
Environment=BLOCKYARD_LOG_LEVEL=info
|
|
71
|
-
# ACCESS:
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
71
|
+
# ACCESS: hardened out of the box (2026-09-15) -- bound to 127.0.0.1 and sign-in ON;
|
|
72
|
+
# the first start prints the admin password once (or set BLOCKYARD_ADMIN_PASSWORD).
|
|
73
|
+
# To serve a LAN address put it in config/local.json (`server.hosts`) or uncomment
|
|
74
|
+
# the first line; to open the monitor to readers with no account, the second --
|
|
75
|
+
# then anyone who can reach the bound addresses reads it as role "viewer".
|
|
76
|
+
#Environment=BLOCKYARD_BIND=0.0.0.0
|
|
77
|
+
#Environment=BLOCKYARD_AUTH=0
|
|
76
78
|
#
|
|
77
79
|
# Node writes stay off, and while accounts are off they stay off even if actions are
|
|
78
80
|
# enabled -- a write with no identity behind it is not attributable. Enabling needs
|
|
@@ -82,15 +84,46 @@ Environment=BLOCKYARD_LOG_LEVEL=info
|
|
|
82
84
|
#Environment=BLOCKYARD_ENABLE_ACTIONS=1
|
|
83
85
|
#Environment=BLOCKYARD_ACTIONS=testmempoolaccept,savemempool
|
|
84
86
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
# ProtectSystem=strict would
|
|
87
|
+
# SANDBOX (audit 2026-09-16, M8). This block used to be "modest hardening", and its comment said
|
|
88
|
+
# NoNewPrivileges plus a read-only /home was enough -- but no read-only /home was ever set, and it
|
|
89
|
+
# claimed ProtectSystem=strict would stop the service reading the node's cookie. It would not:
|
|
90
|
+
# strict makes paths READ-ONLY, and reading is all the monitor does with the node. Everything below
|
|
91
|
+
# was started and checked against this app (tests/unit-sandbox note in docs/INSTALL.md section 6).
|
|
92
|
+
#
|
|
93
|
+
# The whole filesystem is read-only to the service except what it writes: its data directory
|
|
94
|
+
# (sessions, history, audit trail, TLS certificate), its config directory (the node form and the
|
|
95
|
+
# Display settings save there), and the address index if you configured one. EDIT ME: these paths
|
|
96
|
+
# follow WorkingDirectory above; add the index directory (`addressIndex` in config/local.json) as a
|
|
97
|
+
# third ReadWritePaths line. The leading "-" skips a path that does not exist yet.
|
|
98
|
+
ReadWritePaths=-/storage/blockyard/data
|
|
99
|
+
ReadWritePaths=-/storage/blockyard/config
|
|
100
|
+
#ReadWritePaths=/var/lib/blockyard-index
|
|
101
|
+
ProtectSystem=strict
|
|
102
|
+
# The node's cookie and log are usually under a home directory: readable, never writable.
|
|
103
|
+
ProtectHome=read-only
|
|
104
|
+
PrivateTmp=true
|
|
105
|
+
PrivateDevices=true
|
|
106
|
+
UMask=0077
|
|
88
107
|
NoNewPrivileges=true
|
|
108
|
+
CapabilityBoundingSet=
|
|
109
|
+
AmbientCapabilities=
|
|
89
110
|
ProtectKernelTunables=true
|
|
90
111
|
ProtectKernelModules=true
|
|
112
|
+
ProtectKernelLogs=true
|
|
91
113
|
ProtectControlGroups=true
|
|
114
|
+
ProtectClock=true
|
|
115
|
+
ProtectHostname=true
|
|
116
|
+
ProtectProc=invisible
|
|
117
|
+
RestrictNamespaces=true
|
|
118
|
+
RestrictRealtime=true
|
|
92
119
|
RestrictSUIDSGID=true
|
|
93
120
|
LockPersonality=true
|
|
121
|
+
# AF_NETLINK: the boot check that a configured address exists reads the interfaces through it.
|
|
122
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
|
123
|
+
SystemCallArchitectures=native
|
|
124
|
+
SystemCallFilter=@system-service
|
|
125
|
+
SystemCallErrorNumber=EPERM
|
|
126
|
+
# NOT MemoryDenyWriteExecute: V8's JIT needs writable-executable memory, and node aborts under it.
|
|
94
127
|
|
|
95
128
|
StandardOutput=journal
|
|
96
129
|
StandardError=journal
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
# Blockchain leaderboards for the Diversions — scoping report
|
|
2
|
-
|
|
3
|
-
**Private local document.** Gitignored via `docs/PRIVATE-*.md`. Not a decision, not a commitment —
|
|
4
|
-
a scoping of what this would actually take, written 2026-09-13.
|
|
5
|
-
|
|
6
|
-
> Note on the privacy guard: `test/privacy.test.js` scans `git ls-files`, so an ignored file is
|
|
7
|
-
> outside it. That is how a real LAN address reached a commit earlier today. Nothing sensitive
|
|
8
|
-
> should go in here on the assumption a test will catch it.
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## The short answers
|
|
13
|
-
|
|
14
|
-
| question | answer |
|
|
15
|
-
|---|---|
|
|
16
|
-
| Do we need a Bitcoin address? | **Only if you want on-chain writes or signed identity.** A working leaderboard needs neither. |
|
|
17
|
-
| How do people submit scores? | **A replay, not a score.** The server re-plays the game and computes the score itself. |
|
|
18
|
-
| Can we do it for free? | **Yes, entirely** — if "use the blockchain" means *seed from a block hash and anchor by timestamp*. **No** if it means *one transaction per score*. |
|
|
19
|
-
| What is the hard part? | **Not the blockchain.** It is proving a score was earned rather than typed into a POST body. |
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## 1. The insight this whole design rests on
|
|
24
|
-
|
|
25
|
-
The three games were built with a pure-rules / screen split, and the rules are **deterministic by
|
|
26
|
-
construction**. This was done for testability, but it is exactly what a trustworthy leaderboard
|
|
27
|
-
needs.
|
|
28
|
-
|
|
29
|
-
Verified headless under Node on 2026-09-13:
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
tetris.js newGame(seed) with a seeded rng(seed); newGame(12345) twice → identical queue
|
|
33
|
-
arkanoid.js hash01(key) everywhere, NO Math.random — capsules, minion lanes, drift phases
|
|
34
|
-
breakout.js launch angle is an argument, not a roll
|
|
35
|
-
all three "no DOM, no canvas, no clock"; they import and run fine in plain Node
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
Entry points for a replay: `step(g, dtMs)` (breakout, arkanoid), `tick(g)` / `move` / `rotate` /
|
|
39
|
-
`hardDrop` (tetris). `tetrust.js` already drives the rules from a **fixed-step accumulator**
|
|
40
|
-
(`while (G.acc >= ms)`), so a game is already a discrete sequence of steps, not a wall-clock
|
|
41
|
-
animation.
|
|
42
|
-
|
|
43
|
-
**Therefore the server can replay a submitted game and recompute the score.** That is real
|
|
44
|
-
anti-cheat, and it does not involve Bitcoin at all.
|
|
45
|
-
|
|
46
|
-
## 2. What "use the blockchain" can usefully mean here
|
|
47
|
-
|
|
48
|
-
Four options, ranked by what they actually buy:
|
|
49
|
-
|
|
50
|
-
### (a) Block hash as the game seed — free, and the best fit
|
|
51
|
-
|
|
52
|
-
The tip hash at game start becomes the seed. This gives three properties for nothing:
|
|
53
|
-
|
|
54
|
-
- **Unpredictable** — nobody can pre-compute a favourable piece order, because nobody knows block
|
|
55
|
-
N+1's hash. (Miners have a marginal grinding advantage; irrelevant at this stake.)
|
|
56
|
-
- **A lower time bound** — a game seeded from block N provably was not played before block N. That
|
|
57
|
-
is a real timestamp, from our own node, at zero cost.
|
|
58
|
-
- **A fair shared round** — everyone playing "the block 966,781 round" gets the same piece order, so
|
|
59
|
-
scores are comparable in a way they never are with per-player seeds. This is the genuinely novel
|
|
60
|
-
bit: a *per-block tournament*.
|
|
61
|
-
|
|
62
|
-
We already have the data: `/api/blocks` returns hashes, and the monitor is watching a node anyway.
|
|
63
|
-
|
|
64
|
-
### (b) OpenTimestamps anchoring — free, proves *when*
|
|
65
|
-
|
|
66
|
-
Hash the leaderboard state periodically and submit the digest to public OTS calendar servers, which
|
|
67
|
-
aggregate thousands of digests into one transaction. Produces a Bitcoin-anchored proof that the
|
|
68
|
-
leaderboard said X at time T, and costs nothing because the calendar operator pays the fee.
|
|
69
|
-
|
|
70
|
-
Cost: one outbound HTTPS call to a third party, which conflicts with this project's "outbound
|
|
71
|
-
connections are limited and on demand" stance — it would need to be opt-in and documented in
|
|
72
|
-
`docs/SECURITY.md` alongside the exchange APIs.
|
|
73
|
-
|
|
74
|
-
### (c) OP_RETURN per score — cheap, not free, and probably wrong
|
|
75
|
-
|
|
76
|
-
Real numbers from the node right now: fee estimates ≈ **5.65e-06 BTC/kvB ≈ 0.57 sat/vB**, relay
|
|
77
|
-
floor 1 sat/vB. A minimal OP_RETURN transaction is ~200 vB → **~200 sats ≈ 15¢** at $77k.
|
|
78
|
-
|
|
79
|
-
But: needs a funded wallet, key custody, a UTXO set to manage, change handling, fee bumping when the
|
|
80
|
-
mempool moves, and it puts arcade scores in every archival node on Earth, for ever. It also requires
|
|
81
|
-
passing the node-writes gate, which currently needs four independent yeses and is off by default.
|
|
82
|
-
|
|
83
|
-
**Recommendation: no.** Use (a) for fairness and (b) for timestamping.
|
|
84
|
-
|
|
85
|
-
### (d) Signed scores for pseudonymous identity — free via RPC, but legacy addresses only
|
|
86
|
-
|
|
87
|
-
A player signs their submission with a Bitcoin key; the address is their leaderboard identity. No
|
|
88
|
-
funds, no on-chain footprint.
|
|
89
|
-
|
|
90
|
-
**Partly wrong as first written, corrected 2026-09-13 after the operator asked "why do we need
|
|
91
|
-
crypto primitives — can't we just use the bitcoind rpc?".** They were right.
|
|
92
|
-
|
|
93
|
-
*Verifying* a user's signature needs no crypto code at all: **`verifymessage` is already permitted
|
|
94
|
-
by our own allowlist** and the node does the pubkey recovery. Confirmed it genuinely evaluates
|
|
95
|
-
rather than stubbing — a malformed signature errors `-3 Malformed base64 encoding`, and a bech32
|
|
96
|
-
address errors `Address does not refer to key`.
|
|
97
|
-
|
|
98
|
-
What survives is an **asymmetry**: the node can verify, but it cannot sign *for us*. The Umbrel has
|
|
99
|
-
no wallet loaded (`listwallets` → `[]`), `signmessage` is in our own deny list as a wallet mutator,
|
|
100
|
-
and asking a user's node to hold the monitor's identity is the wrong layer anyway. So:
|
|
101
|
-
|
|
102
|
-
- **users proving they control an address** → pure RPC, no crypto, no dependency;
|
|
103
|
-
- **the monitor holding its own identity and signing attestations** → local keys, which is a
|
|
104
|
-
custody decision rather than a coding one.
|
|
105
|
-
|
|
106
|
-
Node 22 does have enough for the second if wanted: secp256k1 DER sign/verify, `ripemd160`, and
|
|
107
|
-
therefore hash160 — a P2WPKH address derives zero-dependency (verified: produced a valid bech32
|
|
108
|
-
address with a ~40-line encoder). It is *recovery* from a compact signature that Node lacks, and
|
|
109
|
-
only the monitor-side signing path would need it.
|
|
110
|
-
|
|
111
|
-
**THE REAL CONSTRAINT IS ADDRESS TYPE, NOT CRYPTO.** Measured against the node on 2026-09-13:
|
|
112
|
-
|
|
113
|
-
| address | `verifymessage` |
|
|
114
|
-
|---|---|
|
|
115
|
-
| P2PKH `1...` | **works** — returned `false` for a wrong signature, i.e. it evaluated |
|
|
116
|
-
| P2SH `3...` | error: `Address does not refer to key` |
|
|
117
|
-
| P2WPKH `bc1q...` | error: `Address does not refer to key` |
|
|
118
|
-
| P2TR `bc1p...` | error: `Address does not refer to key` |
|
|
119
|
-
|
|
120
|
-
Core's `verifymessage` implements the old "Bitcoin Signed Message" scheme, which is defined only
|
|
121
|
-
for P2PKH. **Most modern wallets — including the one on an Umbrel — hand out `bc1q` or `bc1p`
|
|
122
|
-
addresses, and those users cannot sign this way at all.** That is the main obstacle to the
|
|
123
|
-
user-identity design, and no amount of our own code fixes it, because the limitation is in the
|
|
124
|
-
signing standard rather than in us.
|
|
125
|
-
|
|
126
|
-
Options, in order of how much they cost:
|
|
127
|
-
|
|
128
|
-
1. **Accept legacy-only**, and say so in the UI: "paste a `1...` address". Many wallets can still
|
|
129
|
-
produce one; Sparrow, Electrum and Core itself can sign with a legacy key. Free, honest, and
|
|
130
|
-
excludes some users.
|
|
131
|
-
2. **BIP-322** — the modern generalised signing scheme covering segwit and taproot, and what `bc1`
|
|
132
|
-
wallets actually implement. **Checked, and the answer is no:** Core 31.1.0 (`/Satoshi:31.1.0/`)
|
|
133
|
-
exposes `verifymessage`, `signmessage`, `signmessagewithprivkey`, `signrawtransactionwithkey`,
|
|
134
|
-
`signrawtransactionwithwallet` and `enumeratesigners` — and **no BIP-322 method of any name**.
|
|
135
|
-
So the node cannot verify a segwit or taproot signature for us. Supporting `bc1` identities means
|
|
136
|
-
implementing BIP-322 verification ourselves, which needs precisely the secp256k1 primitives the
|
|
137
|
-
RPC route was chosen to avoid. That is a substantial piece of work and a dependency-rule
|
|
138
|
-
conversation, not an afternoon.
|
|
139
|
-
3. **Do not use Bitcoin signing for identity at all** — a chosen display name plus the replay proof.
|
|
140
|
-
The replay is what makes a score trustworthy; the address only says *who*. If the leaderboard is
|
|
141
|
-
local or shared-by-export, a name is sufficient and costs nothing.
|
|
142
|
-
|
|
143
|
-
## 3. Recommended design
|
|
144
|
-
|
|
145
|
-
**Decision taken 2026-09-13: identity means _users prove who they are_** — the monitor holds no
|
|
146
|
-
key, signs nothing, and custodies nothing. Everything below assumes that.
|
|
147
|
-
|
|
148
|
-
**Per-block tournaments, verified by replay, identified by address, anchored by timestamp.**
|
|
149
|
-
|
|
150
|
-
1. A round opens when a block lands. Its hash seeds every game in that round.
|
|
151
|
-
2. The player plays locally, exactly as now.
|
|
152
|
-
3. On game over, the client submits a **replay**: `{ game, seedBlock, seedHash, steps[], claimedScore }`
|
|
153
|
-
where `steps[]` is the discrete input sequence the fixed-step loop already produces.
|
|
154
|
-
4. The server imports the same pure rules module, replays the steps, and computes the score itself.
|
|
155
|
-
The claimed score is ignored except as a cross-check — a mismatch is a rejected submission.
|
|
156
|
-
5. Accepted entries join the round's board. When the round closes (next block), the board is hashed
|
|
157
|
-
and optionally OTS-anchored.
|
|
158
|
-
|
|
159
|
-
**Identity, if a player wants one** (optional — an anonymous entry is still replay-verified):
|
|
160
|
-
|
|
161
|
-
- The server issues a challenge string tied to the round (`blockyard:<round>:<nonce>`).
|
|
162
|
-
- The player signs it in their own wallet and pastes address + signature.
|
|
163
|
-
- We call `verifymessage` — already permitted by the allowlist, no crypto code, no key on our box.
|
|
164
|
-
- `validateaddress` first, so a `bc1` address is refused with *"legacy `1...` addresses only, see
|
|
165
|
-
BIP-322 note"* rather than a confusing `Address does not refer to key`.
|
|
166
|
-
|
|
167
|
-
Two allowlist additions are needed, one line each: `validateaddress` and `deriveaddresses`. Both
|
|
168
|
-
are read-only and currently denied only because nothing had asked for them.
|
|
169
|
-
|
|
170
|
-
**Testing this path needs no wallet.** `signmessagewithprivkey` takes a WIF and a message and
|
|
171
|
-
returns a signature, so a test can generate a genuine legacy-address signature as a fixture and
|
|
172
|
-
assert that our verification accepts it and rejects a tampered message — without depending on a
|
|
173
|
-
wallet existing on whatever node the suite runs against. That matters: the Umbrel has none
|
|
174
|
-
(`listwallets` → `[]`), and the fake node in `scripts/fake-node.js` would otherwise have to grow a
|
|
175
|
-
signing implementation.
|
|
176
|
-
|
|
177
|
-
Why this is worth doing: the leaderboard is **verifiable by anyone**. Given the replay, any third
|
|
178
|
-
party can recompute the score using the same public rules file. That is a much stronger claim than
|
|
179
|
-
most leaderboards can make, and it comes from the determinism that already exists.
|
|
180
|
-
|
|
181
|
-
## 4. What it actually costs to build
|
|
182
|
-
|
|
183
|
-
| piece | size | notes |
|
|
184
|
-
|---|---|---|
|
|
185
|
-
| Record inputs in the three screen modules | **M** | The fixed-step loop already exists; capture `(stepIndex, action)` pairs. Arkanoid is the fiddliest — mouse position per step needs quantising or it is unreplayable. |
|
|
186
|
-
| Headless replay harness | **S** | The rules already import under Node. Mostly a loop and a score readout. |
|
|
187
|
-
| Replay determinism tests | **M** | The real work: proving client and server agree on every float. **See §5.** |
|
|
188
|
-
| Submission API + storage | **S** | One POST, one GET, a JSON file per round. |
|
|
189
|
-
| Round lifecycle from block events | **S** | The monitor already emits new-tip events. |
|
|
190
|
-
| Leaderboard UI | **M** | A page, a per-round board, a "verify this replay yourself" button. |
|
|
191
|
-
| OTS anchoring | **S** | Optional, opt-in, one HTTPS call. |
|
|
192
|
-
| **Total** | **~2-4 days** | Assuming local/self-hosted scope. |
|
|
193
|
-
|
|
194
|
-
## 5. The risks, honestly
|
|
195
|
-
|
|
196
|
-
- **Floating-point determinism is the sharp edge.** `breakout.js` and `arkanoid.js` advance ball
|
|
197
|
-
physics with floats. Same JS engine, same order of operations, same result — but client and server
|
|
198
|
-
are *different Node/browser builds*, and any divergence makes an honest player's replay fail
|
|
199
|
-
verification. **This must be proven before building anything else**: record N real games in a
|
|
200
|
-
browser, replay them server-side, and assert bit-identical scores. If it does not hold, the fix is
|
|
201
|
-
integer or fixed-point physics, which is a rules rewrite. Tetris is much safer here — it is
|
|
202
|
-
integer grid logic.
|
|
203
|
-
- **Replay size.** A ten-minute Arkanoid game at 60 steps/second is ~36,000 steps. Compressible
|
|
204
|
-
(most steps are "no input"), but it is not a 20-byte score.
|
|
205
|
-
- **A leaderboard is inherently shared, and this app is not.** Blockyard is a single-user LAN
|
|
206
|
-
monitor that ships open-access with a `viewer` ceiling. A *global* leaderboard means **you run a
|
|
207
|
-
hosted service** — with moderation, abuse handling, data retention, and an endpoint strangers
|
|
208
|
-
POST to. That is a different product with different obligations, not a feature of this one.
|
|
209
|
-
Three honest scopes:
|
|
210
|
-
- **Local only** — verified replays, your own high scores, no server. Nearly free to build.
|
|
211
|
-
- **Shared export** — a signed/anchored replay file you can hand to someone else to verify.
|
|
212
|
-
- **Hosted leaderboard** — the real product, and the one with ongoing cost and duty of care.
|
|
213
|
-
- **Replay does not stop a bot.** It proves the score was *achievable under the rules*, not that a
|
|
214
|
-
human earned it. A perfect Tetris bot submits perfectly valid replays. Per-block rounds limit the
|
|
215
|
-
damage (everyone gets one shot at that seed) but do not eliminate it.
|
|
216
|
-
- **Scope creep into custody.** The moment there is a funded address, this stops being a monitor and
|
|
217
|
-
becomes something holding other people's money-adjacent state. Option (c) is where that starts,
|
|
218
|
-
which is the strongest argument against it.
|
|
219
|
-
|
|
220
|
-
## 6. Recommendation
|
|
221
|
-
|
|
222
|
-
1. **Spike the determinism question first** (half a day). Everything else is wasted if browser and
|
|
223
|
-
server disagree on ball physics.
|
|
224
|
-
2. Build **local-only** verified replays with block-hash seeds. Free, no address, no third party, no
|
|
225
|
-
hosted service, and it delivers the interesting half: provably fair rounds and self-verifying
|
|
226
|
-
scores.
|
|
227
|
-
3. Add **OTS anchoring** only if "provably at this time" matters to you. Opt-in, documented as an
|
|
228
|
-
outbound connection.
|
|
229
|
-
4. **Do not** put scores on-chain per-game, and **do not** introduce a funded wallet.
|
|
230
|
-
5. Treat a hosted global leaderboard as a separate product decision, not a Diversions feature.
|