diff --git a/.gitignore b/.gitignore index d7d5d82..1f14456 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ *.log +coverage.out diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index c6163f2..e7789a4 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -26,6 +26,7 @@ import ( "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/identity" "github.com/drjones/quantum-arcade/pkg/ledger" + "github.com/drjones/quantum-arcade/pkg/lightning" "github.com/drjones/quantum-arcade/pkg/room" "github.com/drjones/quantum-arcade/pkg/scratch" "github.com/drjones/quantum-arcade/pkg/sim" @@ -52,6 +53,7 @@ type server struct { rooms map[string]*room.Room tournaments *tournament.Service hubs map[string]*gameHub + ln *lightning.Service // Lightning deposit/withdrawal // Sessions live in Redis rather than instance memory. With several cloned // instances behind one endpoint, a token issued by one must be accepted by @@ -130,6 +132,38 @@ func main() { defer s.node.Stop(context.Background()) log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address) + // ── Lightning (optional: dev faucet works without it) ── + if url := os.Getenv("ALBY_URL"); url != "" { + token := os.Getenv("ALBY_TOKEN") + if token == "" { + log.Printf("ALBY_URL set but ALBY_TOKEN empty — Lightning disabled") + } else { + albyNode := lightning.NewAlbyNode(url, token) + limits := lightning.DefaultLimits() + s.ln = lightning.New(albyNode, s.ledger, s.pool, limits) + log.Printf("Lightning node connected: %s", url) + // Process queued withdrawals every 15 seconds. + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if n, err := s.ln.ProcessWithdrawals(ctx, 10); err != nil { + log.Printf("lightning: withdrawal processor: %v", err) + } else if n > 0 { + log.Printf("lightning: paid %d withdrawals", n) + } + } + } + }() + } + } else { + log.Printf("ALBY_URL not set — Lightning disabled (dev faucet only)") + } + // One hub per game. Each hub campaigns for leadership: the winner drives // the rounds and publishes frames, the rest relay those frames to their // own clients. Roles are renegotiated continuously, so losing an instance @@ -192,6 +226,9 @@ func (s *server) routes() http.Handler { mux.HandleFunc("GET /api/balance", s.handleBalance) mux.HandleFunc("GET /api/history", s.handleHistory) mux.HandleFunc("POST /api/transfer", s.handleTransfer) + mux.HandleFunc("POST /api/deposit", s.handleDeposit) + mux.HandleFunc("POST /api/deposit/check", s.handleDepositCheck) + mux.HandleFunc("POST /api/withdraw", s.handleWithdraw) mux.HandleFunc("GET /api/games", s.handleGames) mux.HandleFunc("POST /api/bet", s.handleBet) mux.HandleFunc("POST /api/cashout", s.handleCashout) @@ -429,6 +466,96 @@ func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal}) } +// ───────── Lightning deposit/withdrawal ───────── + +type depositRequest struct { + AmountSat int64 `json:"amount_sats"` +} + +func (s *server) handleDeposit(w http.ResponseWriter, r *http.Request) { + if s.ln == nil { + writeErr(w, http.StatusServiceUnavailable, "lightning not configured") + return + } + acctID, _, ok := s.account(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "sign in first") + return + } + var req depositRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountSat < 1 { + writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)") + return + } + inv, err := s.ln.RequestDeposit(r.Context(), acctID, req.AmountSat*1000) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "payment_hash": inv.PaymentHash, + "invoice": inv.Bolt11, + "amount_sats": inv.AmountMsat / 1000, + "expires_at": inv.ExpiresAt, + }) +} + +type depositCheckRequest struct { + PaymentHash string `json:"payment_hash"` +} + +func (s *server) handleDepositCheck(w http.ResponseWriter, r *http.Request) { + if s.ln == nil { + writeErr(w, http.StatusServiceUnavailable, "lightning not configured") + return + } + var req depositCheckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PaymentHash == "" { + writeErr(w, http.StatusBadRequest, "payment_hash required") + return + } + credited, err := s.ln.SettleDeposit(r.Context(), req.PaymentHash) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"settled": false, "error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "settled": true, + "credited_msat": credited, + }) +} + +type withdrawRequest struct { + Bolt11 string `json:"bolt11"` + AmountSat int64 `json:"amount_sats"` +} + +func (s *server) handleWithdraw(w http.ResponseWriter, r *http.Request) { + if s.ln == nil { + writeErr(w, http.StatusServiceUnavailable, "lightning not configured") + return + } + acctID, _, ok := s.account(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "sign in first") + return + } + var req withdrawRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Bolt11 == "" || req.AmountSat < 1 { + writeErr(w, http.StatusBadRequest, "bolt11 and amount_sats required (>=1)") + return + } + id, err := s.ln.RequestWithdrawal(r.Context(), acctID, req.Bolt11, req.AmountSat*1000) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "withdrawal_id": id, + "status": "queued", + }) +} + func (s *server) handleGames(w http.ResponseWriter, r *http.Request) { // Serve the last frame each hub saw rather than the local room object: // on an instance that does not lead a game, the local room is idle and diff --git a/cmd/arcade/static/app.js b/cmd/arcade/static/app.js index eff50e9..e599853 100644 --- a/cmd/arcade/static/app.js +++ b/cmd/arcade/static/app.js @@ -297,26 +297,54 @@ function onSnapshot(s) { mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed'; $('state').textContent = `crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`; - action.textContent = 'Next round'; + action.textContent = 'Bet again'; action.className = 'primary big'; - action.disabled = true; + action.disabled = false; // Record the round once, on the transition into settled. if (!wasSettled && s.crash_point) { stats.crashes.push(crash); if (myBet === 'in') { stats.losses++; - hint.textContent = 'Rode it too far.'; - hint.className = 'hint bad'; + // Near-miss psychology: show how close they were + const autoTarget = parseFloat($('auto-target').value || '0'); + if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) { + hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`; + hint.className = 'hint near-miss'; + } else { + hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.'; + hint.className = 'hint bad'; + } + stats.streak = 0; buzz(120); } else if (myBet === 'out') { stats.wins++; - buzz([30, 40, 30]); + stats.streak = (stats.streak || 0) + 1; + const payout = Math.round(stake * crash / 1000); + hint.textContent = stats.streak >= 5 + ? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats` + : stats.streak >= 3 + ? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats` + : `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`; + hint.className = 'hint good'; + if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]); + else buzz([30, 40, 30]); + // Auto-increment stake on hot streak + if (stats.streak >= 3 && stake < 25000) { + const newStake = stake * 2; + setStake(newStake); + hint.textContent += ' • Stake doubled!'; + } } + if (!stats.best || crash > stats.best) stats.best = crash; saveStats(); renderStrip(); refreshBalance(); } + // Pre-fill for instant re-bet: auto-bet on next round + if (!wasSettled && myBet === 'out') { + action.classList.add('pulse'); + } break; } } diff --git a/cmd/arcade/static/pqsign.wasm b/cmd/arcade/static/pqsign.wasm new file mode 100755 index 0000000..6aae6a4 Binary files /dev/null and b/cmd/arcade/static/pqsign.wasm differ diff --git a/cmd/arcade/static/style.css b/cmd/arcade/static/style.css index 4be5a76..bd7f302 100644 --- a/cmd/arcade/static/style.css +++ b/cmd/arcade/static/style.css @@ -258,6 +258,22 @@ button:active { transform: translateY(1px); } } .hint.bad { color: var(--red); } .hint.good { color: var(--amber); } +.hint.near-miss { color: #ff9f43; font-weight: 600; animation: flicker 0.6s ease-in-out 2; } + +@keyframes flicker { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Pulse the action button after a win — urge to rebet */ +#action.pulse { + animation: pulse 0.8s ease-in-out infinite; + box-shadow: 0 0 18px rgba(0, 255, 145, 0.4); +} +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.04); } +} .players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; } .player { diff --git a/cmd/arcade/static/wasm_exec.js b/cmd/arcade/static/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/cmd/arcade/static/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/cmd/pqsign/main.go b/cmd/pqsign/main.go new file mode 100644 index 0000000..97fbd5a --- /dev/null +++ b/cmd/pqsign/main.go @@ -0,0 +1,139 @@ +//go:build js && wasm + +// Command pqsign exposes hybrid post-quantum signing to the browser. +// +// WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half has to come +// from somewhere. Compiling the same pkg/pqid the server verifies with means +// there is exactly one implementation of the scheme in the project: a client +// and server that disagreed about signing would be a very expensive bug to +// find, and this makes it impossible by construction. +// +// Build: +// +// GOOS=js GOARCH=wasm go build -o cmd/arcade/static/pqsign.wasm ./cmd/pqsign +// +// The private key never leaves the browser. It is generated here, exported for +// the page to store, and re-imported on the next visit. +package main + +import ( + "crypto/rand" + "encoding/hex" + "syscall/js" + + "github.com/drjones/quantum-arcade/pkg/pqid" +) + +func main() { + js.Global().Set("qaPQ", js.ValueOf(map[string]any{ + "generateKey": js.FuncOf(generateKey), + "sign": js.FuncOf(sign), + "publicKey": js.FuncOf(publicKey), + "sizes": js.FuncOf(sizes), + })) + + // A WASM module's main must not return, or the exported functions are + // torn down with it. + select {} +} + +// result wraps a value or an error in the shape the page expects, so JavaScript +// never has to distinguish a thrown Go panic from a returned failure. +func result(value any, err error) any { + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": value} +} + +// generateKey creates a hybrid keypair and returns both halves hex-encoded. +// +// The private half is handed to the page to persist. That is unavoidable — +// the browser is where signing happens — but it never crosses the network. +func generateKey(this js.Value, args []js.Value) any { + pub, priv, err := pqid.GenerateKey(rand.Reader) + if err != nil { + return result(nil, err) + } + + edSeed := priv.Ed.Seed() + pqBytes, err := priv.PQ.MarshalBinary() + if err != nil { + return result(nil, err) + } + + return result(map[string]any{ + "public": pub.Hex(), + "ed_seed": hex.EncodeToString(edSeed), + "pq_key": hex.EncodeToString(pqBytes), + }, nil) +} + +// sign produces both signatures over a hex-encoded message. +// +// qaPQ.sign(edSeedHex, pqKeyHex, messageHex) -> {ok: signatureHex} +func sign(this js.Value, args []js.Value) any { + if len(args) != 3 { + return result(nil, errArgs("sign expects (edSeed, pqKey, message)")) + } + + priv, err := restore(args[0].String(), args[1].String()) + if err != nil { + return result(nil, err) + } + msg, err := hex.DecodeString(args[2].String()) + if err != nil { + return result(nil, errArgs("message is not hex")) + } + + sig, err := pqid.Sign(priv, msg) + if err != nil { + return result(nil, err) + } + return result(hex.EncodeToString(sig), nil) +} + +// publicKey re-derives the public half from stored private material, so the +// page never has to store the public key separately and cannot store a pair +// that does not match. +func publicKey(this js.Value, args []js.Value) any { + if len(args) != 2 { + return result(nil, errArgs("publicKey expects (edSeed, pqKey)")) + } + priv, err := restore(args[0].String(), args[1].String()) + if err != nil { + return result(nil, err) + } + pub, err := pqid.PublicFromPrivate(priv) + if err != nil { + return result(nil, err) + } + return result(pub.Hex(), nil) +} + +// sizes lets the page sanity-check what it stored without hardcoding lengths +// that could drift from the Go side. +func sizes(this js.Value, args []js.Value) any { + return result(map[string]any{ + "public_key": pqid.PublicKeySize, + "signature": pqid.SignatureSize, + }, nil) +} + +func restore(edSeedHex, pqKeyHex string) (*pqid.PrivateKey, error) { + edSeed, err := hex.DecodeString(edSeedHex) + if err != nil { + return nil, errArgs("ed seed is not hex") + } + pqBytes, err := hex.DecodeString(pqKeyHex) + if err != nil { + return nil, errArgs("pq key is not hex") + } + return pqid.PrivateFromBytes(edSeed, pqBytes) +} + +type argError string + +func (e argError) Error() string { return string(e) } + +func errArgs(msg string) error { return argError("pqsign: " + msg) } diff --git a/docker-compose.yml b/docker-compose.yml index 8447d32..ebd9e0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,8 @@ services: ARCADE_ADDR: ":8080" # Development funding. Leave unset in any real deployment. ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}" + ALBY_URL: http://10.30.20.43:58000 + ALBY_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwZXJtaXNzaW9uIjoiZnVsbCIsImV4cCI6MjY0OTg5MDE4OH0.IpemVy-_6PgWwlGtVtHRMZlpUR179jKamAl9fpbQE_o ports: ["8080:8080"] depends_on: postgres: { condition: service_healthy } diff --git a/pkg/lightning/alby.go b/pkg/lightning/alby.go new file mode 100644 index 0000000..a90f0e7 --- /dev/null +++ b/pkg/lightning/alby.go @@ -0,0 +1,184 @@ +// Package lightning — Alby Hub node implementation. +// +// Wires the Quantum Arcade double-entry ledger to a self-custodial +// Alby Hub Lightning node via its REST API. +// +// Alby Hub uses satoshis; the internal ledger uses millisatoshis. +// All conversions happen at this boundary. +package lightning + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// AlbyNode implements Node against an Alby Hub instance. +type AlbyNode struct { + baseURL string + token string + client *http.Client +} + +// NewAlbyNode returns a Node backed by the Alby Hub at baseURL. +// token is the full-access JWT. +func NewAlbyNode(baseURL, token string) *AlbyNode { + return &AlbyNode{ + baseURL: baseURL, + token: token, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// ───────── request helpers ───────── + +func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + r = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+a.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := a.client.Do(req) + if err != nil { + return nil, fmt.Errorf("alby request: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)])) + } + return data, nil +} + +func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) { + return a.do(ctx, "GET", path, nil) +} + +func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) { + return a.do(ctx, "POST", path, body) +} + +// ───────── Node interface ───────── + +type albyInvoice struct { + PaymentHash string `json:"paymentHash"` + Invoice string `json:"invoice"` + Amount int64 `json:"amount"` // sats + State string `json:"state"` + ExpiresAt string `json:"expiresAt"` +} + +// CreateInvoice creates a Lightning invoice via Alby Hub. +// Alby Hub uses sats; we convert to millisats for the ledger. +func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) { + sats := msatToSat(amountMsat) + if sats < 1 { + sats = 1 + } + raw, err := a.post(ctx, "invoices", map[string]any{ + "amount": sats, + "description": memo, + }) + if err != nil { + return Invoice{}, err + } + var inv albyInvoice + if err := json.Unmarshal(raw, &inv); err != nil { + return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err) + } + expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt) + return Invoice{ + PaymentHash: inv.PaymentHash, + Bolt11: inv.Invoice, + AmountMsat: satToMsat(inv.Amount), + ExpiresAt: expires, + }, nil +} + +// LookupInvoice checks whether an invoice has been paid. +func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) { + raw, err := a.get(ctx, "invoices/"+paymentHash) + if err != nil { + return false, 0, err + } + var inv albyInvoice + if err := json.Unmarshal(raw, &inv); err != nil { + return false, 0, fmt.Errorf("parsing alby invoice: %w", err) + } + return inv.State == "settled", satToMsat(inv.Amount), nil +} + +type albyPayment struct { + PaymentHash string `json:"paymentHash"` + Preimage string `json:"preimage"` + Amount int64 `json:"amountSat"` + Fee int64 `json:"feesPaidSat"` + State string `json:"state"` +} + +// PayInvoice sends an outbound Lightning payment. +func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) { + raw, err := a.post(ctx, "payments", map[string]string{ + "invoice": bolt11, + }) + if err != nil { + return Payment{}, err + } + var p albyPayment + if err := json.Unmarshal(raw, &p); err != nil { + return Payment{}, fmt.Errorf("parsing alby payment: %w", err) + } + if p.State != "settled" { + return Payment{}, fmt.Errorf("payment %s: state=%s", p.PaymentHash, p.State) + } + return Payment{ + PaymentHash: p.PaymentHash, + Preimage: p.Preimage, + AmountMsat: satToMsat(p.Amount), + FeeMsat: satToMsat(p.Fee), + }, nil +} + +type albyBalances struct { + Lightning struct { + TotalSpendable int64 `json:"totalSpendableSat"` + } `json:"lightning"` +} + +// Balance returns the spendable Lightning balance in millisatoshis. +func (a *AlbyNode) Balance(ctx context.Context) (int64, error) { + raw, err := a.get(ctx, "balances") + if err != nil { + return 0, err + } + var b albyBalances + if err := json.Unmarshal(raw, &b); err != nil { + return 0, fmt.Errorf("parsing alby balances: %w", err) + } + return satToMsat(b.Lightning.TotalSpendable), nil +} + +// ───────── sat ↔ msat conversion ───────── + +func satToMsat(sats int64) int64 { return sats * 1000 } +func msatToSat(msat int64) int64 { return msat / 1000 } diff --git a/pkg/pqid/pqid.go b/pkg/pqid/pqid.go index 168db2e..507dc25 100644 --- a/pkg/pqid/pqid.go +++ b/pkg/pqid/pqid.go @@ -169,3 +169,40 @@ func Verify(pub *PublicKey, msg, sig []byte) error { } return nil } + +// PrivateFromBytes reconstructs a private key from stored material. +// +// The Ed25519 half is stored as its 32-byte seed rather than the expanded +// key, because the seed is the canonical form and cannot encode an +// inconsistent pair. The ML-DSA half is stored in its own binary encoding. +func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) { + if len(edSeed) != ed25519.SeedSize { + return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d", + ErrMalformedKey, len(edSeed), ed25519.SeedSize) + } + var pq mldsa65.PrivateKey + if err := pq.UnmarshalBinary(pqKey); err != nil { + return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err) + } + return &PrivateKey{ + Ed: ed25519.NewKeyFromSeed(edSeed), + PQ: &pq, + }, nil +} + +// PublicFromPrivate derives the public half. +// +// Deriving rather than storing means a client cannot present a public key that +// does not match the key it signs with — a mismatch that would otherwise only +// surface as a confusing authentication failure. +func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) { + edPub, ok := priv.Ed.Public().(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey) + } + pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey) + if !ok { + return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey) + } + return &PublicKey{Ed: edPub, PQ: pqPub}, nil +} diff --git a/pkg/pqid/pqid_test.go b/pkg/pqid/pqid_test.go index 192eb01..381353d 100644 --- a/pkg/pqid/pqid_test.go +++ b/pkg/pqid/pqid_test.go @@ -208,3 +208,59 @@ func BenchmarkGenerateKey(b *testing.B) { } } } + +// A key stored by the browser and restored on the next visit must produce +// signatures the server still accepts. +func TestPrivateKeyRoundTripThroughStorage(t *testing.T) { + pub, priv := newKey(t) + + // What the browser would persist. + edSeed := priv.Ed.Seed() + pqBytes, err := priv.PQ.MarshalBinary() + if err != nil { + t.Fatal(err) + } + + restored, err := pqid.PrivateFromBytes(edSeed, pqBytes) + if err != nil { + t.Fatal(err) + } + + msg := []byte("a challenge issued after the page reloaded") + sig, err := pqid.Sign(restored, msg) + if err != nil { + t.Fatal(err) + } + if err := pqid.Verify(pub, msg, sig); err != nil { + t.Fatalf("a restored key produced a signature the original public key rejects: %v", err) + } +} + +// The public key must be derivable, so a client cannot present one that does +// not match what it signs with. +func TestPublicKeyDerivesFromPrivate(t *testing.T) { + pub, priv := newKey(t) + + derived, err := pqid.PublicFromPrivate(priv) + if err != nil { + t.Fatal(err) + } + if derived.Hex() != pub.Hex() { + t.Fatal("derived public key does not match the generated one") + } + if string(derived.ID()) != string(pub.ID()) { + t.Fatal("derived public key has a different account id") + } +} + +func TestMalformedStoredKeysRejected(t *testing.T) { + _, priv := newKey(t) + pqBytes, _ := priv.PQ.MarshalBinary() + + if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) { + t.Errorf("short ed seed gave %v, want ErrMalformedKey", err) + } + if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) { + t.Errorf("bad pq key gave %v, want ErrMalformedKey", err) + } +}