diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f001f20..b817894 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - run: npm ci diff --git a/Dockerfile b/Dockerfile index bae233b..0042809 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-slim AS build +FROM node:22-slim AS build WORKDIR /app COPY package.json package-lock.json ./ @@ -7,7 +7,7 @@ COPY . . RUN npm run build RUN npx esbuild tournament/server/server.ts --bundle --outfile=dist/server.js --platform=node --format=cjs --packages=external -FROM node:20-slim +FROM node:22-slim WORKDIR /app COPY package.json package-lock.json ./ diff --git a/index.html b/index.html index a8239ce..febe249 100644 --- a/index.html +++ b/index.html @@ -740,17 +740,33 @@

Daily

diag('transfer.timeout',{txId}); status.innerHTML='Timed out. If UCT left your wallet, your balance will update on refresh. '; status.style.color='var(--orange)'; + // This poll gives up at 45*2s = 90s, but the ConnectClient's own intent + // timeout is 120s and the host's deadline is longer still — so 'timeout' + // here means the send is very likely STILL LIVE at the wallet, not dead. + // Re-enabling Transfer would offer a second payment for the same purchase. + // Leave it disabled; a reload is the honest way forward. + btn.disabled=true; + btn.textContent='Reload to continue'; + return; }else{ diag('transfer.failed',{txId,result,err:w.error}); const msg=w.error||'Transfer cancelled'; // Distinguish hard errors (insufficient balance, etc.) from a user-cancel. // Red for errors that require action; muted gray for plain cancellation. - const isError=/insufficient|not connected|required|failed|error/i.test(msg); + const isError=/insufficient|not connected|required|failed|error|unknown/i.test(msg); status.textContent=msg; status.style.color=isError?'var(--red,#ef4444)':'var(--text3)'; status.style.fontSize=isError?'12px':'11px'; status.style.fontWeight=isError?'600':'400'; } + // INTENT_OUTCOME_UNKNOWN: the wallet had the payment and its fate is unknown. + // Re-enabling Transfer here is exactly the second spend the code must not + // invite — leave it disabled and make a reload the only way forward. + if(w.outcomeUnknown){ + btn.disabled=true; + btn.textContent='Reload to continue'; + return; + } btn.disabled=false; btn.textContent='Transfer'; }); diff --git a/package-lock.json b/package-lock.json index f3c5e18..16ee3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@babylonjs/core": "^9.3.1", "@libsql/client": "^0.17.2", - "@unicitylabs/sphere-sdk": "^0.6.0-dev.1", + "@unicitylabs/sphere-sdk": "^0.15.0", "mp4-muxer": "^5.2.2", "ws": "^8.20.0" }, @@ -29,27 +29,6 @@ "integrity": "sha512-gCAVsS40EF9SFXUoe5wl5lA03hwmRQoP9v3y8EdQ2aPSaozIApu4LrxI6yFgczzxGVa2utcj6rF6pgO5VuK7nw==", "license": "Apache-2.0" }, - "node_modules/@chainsafe/is-ip": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chainsafe/is-ip/-/is-ip-2.1.0.tgz", - "integrity": "sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==", - "license": "MIT", - "optional": true - }, - "node_modules/@dnsquery/dns-packet": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@dnsquery/dns-packet/-/dns-packet-6.1.1.tgz", - "integrity": "sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==", - "license": "MIT", - "optional": true, - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.4", - "utf8-codec": "^1.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -475,71 +454,6 @@ "node": ">=18" } }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "license": "MIT", - "optional": true - }, - "node_modules/@libp2p/crypto": { - "version": "5.1.13", - "resolved": "https://registry.npmjs.org/@libp2p/crypto/-/crypto-5.1.13.tgz", - "integrity": "sha512-8NN9cQP3jDn+p9+QE9ByiEoZ2lemDFf/unTgiKmS3JF93ph240EUVdbCyyEgOMfykzb0okTM4gzvwfx9osJebQ==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "multiformats": "^13.4.0", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/interface": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@libp2p/interface/-/interface-3.1.0.tgz", - "integrity": "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@multiformats/dns": "^1.0.6", - "@multiformats/multiaddr": "^13.0.1", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "progress-events": "^1.0.1", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/@libp2p/logger": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@libp2p/logger/-/logger-6.2.2.tgz", - "integrity": "sha512-XtanXDT+TuMuZoCK760HGV1AmJsZbwAw5AiRUxWDbsZPwAroYq64nb41AHRu9Gyc0TK9YD+p72+5+FIxbw0hzw==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@multiformats/multiaddr": "^13.0.1", - "interface-datastore": "^9.0.1", - "multiformats": "^13.4.0", - "weald": "^1.1.0" - } - }, - "node_modules/@libp2p/peer-id": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@libp2p/peer-id/-/peer-id-6.0.4.tgz", - "integrity": "sha512-Z3xK0lwwKn4bPg3ozEpPr1HxsRi2CxZdghOL+MXoFah/8uhJJHxHFA8A/jxtKn4BB8xkk6F8R5vKNIS05yaCYw==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "multiformats": "^13.4.0", - "uint8arrays": "^5.1.0" - } - }, "node_modules/@libsql/client": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.2.tgz", @@ -701,34 +615,6 @@ "win32" ] }, - "node_modules/@multiformats/dns": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.13.tgz", - "integrity": "sha512-yr4bxtA3MbvJ+2461kYIYMsiiZj/FIqKI64hE4SdvWJUdWF9EtZLar38juf20Sf5tguXKFUruluswAO6JsjS2w==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@dnsquery/dns-packet": "^6.1.1", - "@libp2p/interface": "^3.1.0", - "hashlru": "^2.3.0", - "p-queue": "^9.0.0", - "progress-events": "^1.0.0", - "uint8arrays": "^5.0.2" - } - }, - "node_modules/@multiformats/multiaddr": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@multiformats/multiaddr/-/multiaddr-13.0.1.tgz", - "integrity": "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@chainsafe/is-ip": "^2.0.1", - "multiformats": "^13.0.0", - "uint8-varint": "^2.0.1", - "uint8arrays": "^5.0.0" - } - }, "node_modules/@neon-rs/load": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", @@ -759,24 +645,24 @@ } }, "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1" + "@noble/hashes": "2.4.0" }, "engines": { "node": ">= 20.19.0" @@ -786,9 +672,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -853,9 +739,9 @@ } }, "node_modules/@unicitylabs/nostr-js-sdk": { - "version": "0.4.0-dev.1", - "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.4.0-dev.1.tgz", - "integrity": "sha512-8f0Bq6tN3aO+bItmqPiflvrYGL3uk9dCOmfDwPrnzN6xQwG6sBpscHw43uR+7wTWMB2qJPY1+pEQea6u5jk4kg==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.6.0.tgz", + "integrity": "sha512-l6WcCmFok9nxI1WkYsjIVu67svouA2QyB5Vb+GTNugWrbw7hUgJMA1Elj8qu3u0qojvSINL/L9eWTmXVWRzZUw==", "license": "MIT", "dependencies": { "@noble/ciphers": "^1.0.0", @@ -868,6 +754,18 @@ "node": ">=18.0.0" } }, + "node_modules/@unicitylabs/nostr-js-sdk/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@unicitylabs/nostr-js-sdk/node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -896,63 +794,72 @@ } }, "node_modules/@unicitylabs/sphere-sdk": { - "version": "0.6.0-dev.1", - "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.6.0-dev.1.tgz", - "integrity": "sha512-O2a0S5ylXlq3Ys/mgrmH6dRY/F/ZbSGZ+pnyr6nfGSmCOsDfN1YkHNCVY2404Zn7gTZ00DOiIdW96xTT1ZWR1A==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.15.0.tgz", + "integrity": "sha512-lKAe2PZKydGHlcx/tkUSye0E5u1Ae/mvDeYCKY/YLVQnwfBn05H70oD+5OOfaTSfBdrY58l89SN5sGk4t+RAgA==", "license": "MIT", "dependencies": { + "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", - "@unicitylabs/nostr-js-sdk": "^0.4.0-dev.1", - "@unicitylabs/state-transition-sdk": "1.6.1-rc.f37cb85", + "@unicitylabs/nostr-js-sdk": "^0.6.0", + "@unicitylabs/state-transition-sdk": "3.0.1", "bip39": "^3.1.0", "buffer": "^6.0.3", - "crypto-js": "^4.2.0", - "elliptic": "^6.6.1" + "canonicalize": "^3.0.0", + "crypto-js": "^4.2.0" }, "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/peer-id": "^6.0.4", - "ipns": "^10.0.0", - "multiformats": "^13.4.2" + "node": ">=22.0.0" }, "peerDependencies": { - "@libp2p/crypto": ">=5.0.0", - "@libp2p/peer-id": ">=6.0.0", - "ipns": ">=10.0.0", - "multiformats": ">=13.0.0", "ws": ">=8.0.0" }, "peerDependenciesMeta": { - "@libp2p/crypto": { - "optional": true - }, - "@libp2p/peer-id": { - "optional": true - }, - "ipns": { - "optional": true - }, - "multiformats": { - "optional": true - }, "ws": { "optional": true } } }, "node_modules/@unicitylabs/state-transition-sdk": { - "version": "1.6.1-rc.f37cb85", - "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-1.6.1-rc.f37cb85.tgz", - "integrity": "sha512-6chybquV+sZPdaqluJhAeceCWyO5SO2K2j8QI/RhN6cbX4wHILumfG3GKm20ubQZTL80yTfj85kMxsbKeUIGUQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-3.0.1.tgz", + "integrity": "sha512-ND4mW7nQjgr8yDdxvHNFheHH3gPijD4y0NS7XU2YPzjsdyDs4LDCYlRG/ZSSEDbz3xVgMh1biL3BY60nGPIsXQ==", "license": "ISC", "dependencies": { - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "uuid": "13.0.0" + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "uuid": "14.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/base64-js": { @@ -996,18 +903,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -1032,14 +927,16 @@ "ieee754": "^1.2.1" } }, - "node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", "license": "Apache-2.0", - "optional": true, "bin": { - "cborg": "lib/bin.js" + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/cross-fetch": { @@ -1095,21 +992,6 @@ "node": ">=8" } }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -1151,13 +1033,6 @@ "@esbuild/win32-x64": "0.24.2" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT", - "optional": true - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1208,34 +1083,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "node_modules/hashlru": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/hashlru/-/hashlru-2.3.0.tgz", - "integrity": "sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==", - "license": "MIT", - "optional": true - }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1256,49 +1103,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/interface-datastore": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/interface-datastore/-/interface-datastore-9.0.2.tgz", - "integrity": "sha512-jebn+GV/5LTDDoyicNIB4D9O0QszpPqT09Z/MpEWvf3RekjVKpXJCDguM5Au2fwIFxFDAQMZe5bSla0jMamCNg==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "interface-store": "^7.0.0", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/interface-store": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/interface-store/-/interface-store-7.0.1.tgz", - "integrity": "sha512-OPRRUO3Cs6Jr/t98BrJLQp1jUTPgrRH0PqFfuNoPAqd+J7ABN1tjFVjQdaOBiybYJTS/AyBSZnZVWLPvp3dW3w==", - "license": "Apache-2.0 OR MIT", - "optional": true - }, - "node_modules/ipns": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/ipns/-/ipns-10.1.3.tgz", - "integrity": "sha512-b2Zeh8+7qOV11NjnTsYLpG8K6T13uBMndpzk9N9E2Qjz/u80qsxvKpspSP32sErOLr/GWjdFVVc02E9PMojQNA==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "@libp2p/crypto": "^5.0.0", - "@libp2p/interface": "^3.0.2", - "@libp2p/logger": "^6.0.4", - "cborg": "^4.2.3", - "interface-datastore": "^9.0.2", - "multiformats": "^13.2.2", - "protons-runtime": "^5.5.0", - "timestamp-nano": "^1.0.1", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", @@ -1306,9 +1110,9 @@ "license": "BSD-3-Clause" }, "node_modules/libphonenumber-js": { - "version": "1.12.38", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.38.tgz", - "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", + "version": "1.13.12", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.12.tgz", + "integrity": "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==", "license": "MIT" }, "node_modules/libsql": { @@ -1343,25 +1147,6 @@ "@libsql/win32-x64-msvc": "0.5.29" } }, - "node_modules/main-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.1.tgz", - "integrity": "sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw==", - "license": "Apache-2.0 OR MIT", - "optional": true - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT" - }, "node_modules/mp4-muxer": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz", @@ -1373,23 +1158,6 @@ "@types/wicg-file-system-access": "^2020.9.5" } }, - "node_modules/ms": { - "version": "3.0.0-canary.202508261828", - "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.202508261828.tgz", - "integrity": "sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/multiformats": { - "version": "13.4.2", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", - "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", - "license": "Apache-2.0 OR MIT", - "optional": true - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -1428,36 +1196,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/p-queue": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", - "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", - "license": "MIT", - "optional": true, - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -1490,54 +1228,12 @@ "node": ">=18" } }, - "node_modules/progress-events": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/progress-events/-/progress-events-1.0.1.tgz", - "integrity": "sha512-MOzLIwhpt64KIVN64h1MwdKWiyKFNc/S6BoYKPIVUHFg0/eIEyBulhWCgn678v/4c0ri3FdGuzXymNCv02MUIw==", - "license": "Apache-2.0 OR MIT", - "optional": true - }, "node_modules/promise-limit": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", "license": "ISC" }, - "node_modules/protons-runtime": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/protons-runtime/-/protons-runtime-5.6.0.tgz", - "integrity": "sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "uint8-varint": "^2.0.2", - "uint8arraylist": "^2.4.3", - "uint8arrays": "^5.0.1" - } - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/timestamp-nano": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/timestamp-nano/-/timestamp-nano-1.0.1.tgz", - "integrity": "sha512-4oGOVZWTu5sl89PtCDnhQBSt7/vL1zVEwAfxH1p49JhTosxzVQWYBYFRFZ8nJmo0G6f824iyP/44BFAwIoKvIA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4.5.0" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -1558,54 +1254,16 @@ "node": ">=14.17" } }, - "node_modules/uint8-varint": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.4.tgz", - "integrity": "sha512-FwpTa7ZGA/f/EssWAb5/YV6pHgVF1fViKdW8cWaEarjB8t7NyofSWBdOTyFPaGuUG4gx3v1O3PQ8etsiOs3lcw==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "uint8arraylist": "^2.0.0", - "uint8arrays": "^5.0.0" - } - }, - "node_modules/uint8arraylist": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-2.4.8.tgz", - "integrity": "sha512-vc1PlGOzglLF0eae1M8mLRTBivsvrGsdmJ5RbK3e+QRvRLOZfZhQROTwH/OfyF3+ZVUg9/8hE8bmKP2CvP9quQ==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "uint8arrays": "^5.0.1" - } - }, - "node_modules/uint8arrays": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", - "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "multiformats": "^13.0.0" - } - }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, - "node_modules/utf8-codec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/utf8-codec/-/utf8-codec-1.0.0.tgz", - "integrity": "sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==", - "license": "MIT", - "optional": true - }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -1615,17 +1273,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/weald": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/weald/-/weald-1.1.1.tgz", - "integrity": "sha512-PaEQShzMCz8J/AD2N3dJMc1hTZWkJeLKS2NMeiVkV5KDHwgZe7qXLEzyodsT/SODxWDdXJJqocuwf3kHzcXhSQ==", - "license": "Apache-2.0 OR MIT", - "optional": true, - "dependencies": { - "ms": "^3.0.0-canary.1", - "supports-color": "^10.0.0" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index b514991..66c3817 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dependencies": { "@babylonjs/core": "^9.3.1", "@libsql/client": "^0.17.2", - "@unicitylabs/sphere-sdk": "^0.6.0-dev.1", + "@unicitylabs/sphere-sdk": "^0.15.0", "mp4-muxer": "^5.2.2", "ws": "^8.20.0" }, diff --git a/scripts/create-arena-wallet.ts b/scripts/create-arena-wallet.ts index 4b1a439..2455318 100644 --- a/scripts/create-arena-wallet.ts +++ b/scripts/create-arena-wallet.ts @@ -13,7 +13,10 @@ * * Usage: * CONFIRM=yes npx tsx scripts/create-arena-wallet.ts - * [--nametag=BoxyRunArena] [--network=mainnet] [--data-dir=./arena-data] + * [--nametag=BoxyRunArena] [--network=testnet2] [--data-dir=./arena-data] + * [--wallet-api=https://wallet-api.unicity.network] + * + * Env: WALLET_API_URL, AGGREGATOR_API_KEY (both have defaults, see below). * * Without CONFIRM=yes, the script prints the plan and exits without * touching the chain. This is a dry-run by default. @@ -36,8 +39,15 @@ if (typeof (globalThis as any).WebSocket === 'undefined') { import { Sphere, generateMnemonic } from '@unicitylabs/sphere-sdk'; import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; +import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api'; + +// mainnet/dev ship no embedded trust base and are refused at provider creation. +type NetworkType = 'testnet' | 'testnet2'; -type NetworkType = 'mainnet' | 'testnet' | 'dev'; +// See tournament/server/arena-watcher.ts for why these three must agree and why +// the network string has to be the backend's exact name, not an alias. +const DEFAULT_WALLET_API_URL = 'https://wallet-api.unicity.network'; +const DEFAULT_AGGREGATOR_API_KEY = 'sk_ddc3cfcc001e4a28ac3fad7407f99590'; function parseArg(name: string, fallback: string): string { const prefix = `--${name}=`; @@ -49,19 +59,25 @@ function parseArg(name: string, fallback: string): string { async function main() { const nametag = parseArg('nametag', 'boxyrunarena'); - const network = parseArg('network', 'mainnet') as NetworkType; + // 'testnet2' is the name the deployed wallet-api backends put in the auth + // challenge; the SDK verifies ours against it, so the alias 'testnet' fails + // sign-in even though it resolves to the same gateway everywhere else. + const network = parseArg('network', 'testnet2') as NetworkType; const dataDir = resolve(parseArg('data-dir', './arena-data')); const outFile = resolve(parseArg('out', './arena-wallet.json')); + const walletApiUrl = parseArg('wallet-api', process.env.WALLET_API_URL || DEFAULT_WALLET_API_URL); + const aggregatorApiKey = process.env.AGGREGATOR_API_KEY || DEFAULT_AGGREGATOR_API_KEY; const confirm = process.env.CONFIRM === 'yes'; console.log(''); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' Arena wallet creation'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(` Nametag: @${nametag}`); - console.log(` Network: ${network}`); - console.log(` Data dir: ${dataDir}`); - console.log(` Output: ${outFile}`); + console.log(` Nametag: @${nametag}`); + console.log(` Network: ${network}`); + console.log(` wallet-api: ${walletApiUrl}`); + console.log(` Data dir: ${dataDir}`); + console.log(` Output: ${outFile}`); console.log(''); if (existsSync(outFile)) { @@ -92,27 +108,31 @@ async function main() { console.log(` (${mnemonic.split(' ').length} words generated)`); console.log(`→ Initializing ${network} providers…`); - // SDK's default relay list (wss://relay.unicity.network) is stale / NXDOMAIN. - // Use the relays that actually resolve. - const mainnetRelays = [ - 'wss://sphere-relay.unicity.network', - 'wss://relay.damus.io', - 'wss://nos.lol', - ]; + // Only the Unicity-operated relay: public relays returning 5xx can hang the + // boot, because the SDK awaits every transport handshake. const testnetRelays = ['wss://nostr-relay.testnet.unicity.network']; - const providers = createNodeProviders({ + // `tokensDir` is gone — token custody is the wallet-api backend now. The + // oracle apiKey has no bundled default and the token engine needs it. + const base = createNodeProviders({ network, dataDir, - tokensDir: `${dataDir}/tokens`, - transport: { - relays: network === 'testnet' ? testnetRelays : mainnetRelays, - }, + oracle: { apiKey: aggregatorApiKey }, + transport: { relays: testnetRelays }, + }); + // Sphere.create is fail-closed without a wallet-api composition: it throws + // INVALID_CONFIG before writing anything. The wallet must be created against + // the SAME backend the watcher will later read its mailbox from. + const providers = createWalletApiProviders(base, { + baseUrl: walletApiUrl, + network, + deviceId: `boxyrun-arena-create-${network}`, }); console.log(`→ Creating wallet + registering @${nametag}… (this hits the chain, may take a minute)`); const sphere = await Sphere.create({ mnemonic, nametag, + network, ...providers, }); @@ -126,8 +146,11 @@ async function main() { createdAt: new Date().toISOString(), network, nametag: `@${nametag}`, - l1Address: identity.l1Address, - chainPubkey: (identity as any).chainPubkey ?? null, + // The L3 DIRECT address is the on-chain address (the L1 layer left + // Identity in the v2 engine cutover). Informational only — the watcher + // re-derives identity from the mnemonic, it never reads this field back. + directAddress: identity.directAddress ?? null, + chainPubkey: identity.chainPubkey, // The secret: anyone with this mnemonic controls every token in @BoxyRunArena. mnemonic, }; @@ -138,9 +161,9 @@ async function main() { console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' ✓ Wallet created'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(` Nametag: @${nametag}`); - console.log(` L1 address: ${identity.l1Address}`); - console.log(` Saved to: ${outFile}`); + console.log(` Nametag: @${nametag}`); + console.log(` Direct address: ${identity.directAddress ?? '(none)'}`); + console.log(` Saved to: ${outFile}`); console.log(''); console.log('IMPORTANT — NEXT STEPS:'); console.log(` 1. Open ${outFile}, copy the mnemonic to a password manager / hardware`); @@ -149,6 +172,8 @@ async function main() { console.log(` shred -u ${outFile}`); console.log(` (Leaving it on disk means anyone with file access controls the wallet.)`); console.log(` 3. Set ARENA_WALLET=@${nametag} in your deployment env.`); + console.log(` 4. Point the watcher at the SAME backend: WALLET_API_URL=${walletApiUrl}`); + console.log(` and the same network: SPHERE_NETWORK=${network}`); console.log(''); // Clean shutdown (closes Nostr relay sockets etc.) diff --git a/scripts/test-arena-watcher.ts b/scripts/test-arena-watcher.ts index e2cb841..b72c582 100644 --- a/scripts/test-arena-watcher.ts +++ b/scripts/test-arena-watcher.ts @@ -11,6 +11,10 @@ * Environment overrides (optional): * ARENA_WALLET_PATH path to the wallet JSON (default: ./boxyrunstaging.json) * SPHERE_DATA_DIR where Sphere caches state (default: ./arena-test-data) + * SPHERE_NETWORK default 'testnet2' (must match the backend exactly) + * WALLET_API_URL default the STAGING backend — this is a test script, so + * it points somewhere you can safely churn sign-ins + * AGGREGATOR_API_KEY default the non-secret testnet2 gateway key * * Then, from a different Sphere wallet, send some UCT to @boxyrunstaging. * If the watcher is wired correctly, you'll see: @@ -21,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { Sphere } from '@unicitylabs/sphere-sdk'; import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; +import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api'; // Polyfill global WebSocket for Node 20 (added natively in Node 22). if (typeof (globalThis as any).WebSocket === 'undefined') { @@ -56,22 +61,30 @@ async function main() { console.log(` basePath: ${parsed.wallet?.descriptorPath || '(none)'}`); console.log(''); - const network = (process.env.SPHERE_NETWORK || 'mainnet') as 'mainnet' | 'testnet' | 'dev'; - console.log(`→ Initializing Node providers (network=${network})…`); - const providers = createNodeProviders({ + // Must be the backend's exact network name — the SDK verifies it against the + // name embedded in the auth challenge, so the 'testnet' alias fails sign-in. + const network = (process.env.SPHERE_NETWORK || 'testnet2') as 'testnet' | 'testnet2'; + const walletApiUrl = process.env.WALLET_API_URL || 'https://wallet-api.staging.unicity.network'; + const aggregatorApiKey = process.env.AGGREGATOR_API_KEY || 'sk_ddc3cfcc001e4a28ac3fad7407f99590'; + console.log(`→ Initializing Node providers (network=${network} walletApi=${walletApiUrl})…`); + // No `tokensDir` — token custody is the wallet-api backend now — and the + // oracle apiKey has no bundled default. + const base = createNodeProviders({ network, dataDir, - tokensDir: `${dataDir}/tokens`, - // Mainnet default `wss://relay.unicity.network` NXDOMAINs. - // Testnet uses `wss://nostr-relay.testnet.unicity.network`. - transport: network === 'testnet' - ? { relays: ['wss://nostr-relay.testnet.unicity.network'] } - : { relays: ['wss://sphere-relay.unicity.network', 'wss://nos.lol'] }, + oracle: { apiKey: aggregatorApiKey }, + transport: { relays: ['wss://nostr-relay.testnet.unicity.network'] }, + }); + const providers = createWalletApiProviders(base, { + baseUrl: walletApiUrl, + network, + deviceId: `boxyrun-arena-test-${network}`, }); console.log('→ Importing wallet from mnemonic…'); const sphere = await Sphere.import({ mnemonic, + network, ...(parsed.derivationMode ? { derivationMode: parsed.derivationMode } : {}), ...(parsed.wallet?.descriptorPath ? { basePath: parsed.wallet.descriptorPath } : {}), ...providers, @@ -80,13 +93,13 @@ async function main() { const id = sphere.identity; console.log(''); console.log('✓ Sphere ready'); - console.log(` nametag: ${id?.nametag ? '@' + id.nametag : '(none)'}`); - console.log(` l1Address: ${id?.l1Address || '(none)'}`); - console.log(` pubkey: ${(id as any)?.chainPubkey || (id as any)?.pubkey || '(none)'}`); + console.log(` nametag: ${id?.nametag ? '@' + id.nametag : '(none)'}`); + console.log(` directAddress: ${id?.directAddress || '(none)'}`); + console.log(` pubkey: ${(id as any)?.chainPubkey || (id as any)?.pubkey || '(none)'}`); console.log(''); // Now that the transport is connected, ask the network: who owns @boxyrunstaging? - // If the answer's pubkey/l1Address matches ours, we've derived the same + // If the answer's pubkey/directAddress matches ours, we've derived the same // identity but the binding lookup just isn't being applied. If it differs, // our derivation is wrong — the wallet UI used a different path / mode. console.log('→ Resolving @boxyrunstaging on the network (post-connect)…'); @@ -96,12 +109,11 @@ async function main() { if (info) { console.log(' network says @boxyrunstaging ='); console.log(` chainPubkey: ${info.chainPubkey}`); - console.log(` l1Address: ${info.l1Address}`); console.log(` directAddress: ${info.directAddress}`); console.log(` transportPubkey: ${info.transportPubkey}`); const ourPubkey = (id as any)?.chainPubkey; - console.log(` match? chainPubkey: ${info.chainPubkey === ourPubkey ? 'YES' : 'NO'}`); - console.log(` match? l1Address: ${info.l1Address === id?.l1Address ? 'YES' : 'NO'}`); + console.log(` match? chainPubkey: ${info.chainPubkey === ourPubkey ? 'YES' : 'NO'}`); + console.log(` match? directAddress: ${info.directAddress === id?.directAddress ? 'YES' : 'NO'}`); } else { console.log(' (not found on the network — nametag may not be registered)'); } @@ -111,7 +123,7 @@ async function main() { console.log(''); // If no nametag is bound, run address discovery — this scans the - // transport (Nostr) and L1 for HD addresses owned by this wallet, + // transport (Nostr) for HD addresses owned by this wallet, // including any nametag bindings. if (!id?.nametag) { console.log(' ⚠ No nametag bound after import. Running discoverAddresses()…'); @@ -123,7 +135,7 @@ async function main() { }); console.log(` → scanned ${result.scannedCount} indices, found ${result.addresses?.length || 0} addresses`); for (const addr of result.addresses || []) { - console.log(` [${addr.index}] ${addr.l1Address} nametag=${addr.nametag || '(none)'} l1Balance=${addr.l1Balance}`); + console.log(` [${addr.index}] ${addr.directAddress} nametag=${addr.nametag || '(none)'} chainPubkey=${addr.chainPubkey}`); } const id2 = sphere.identity; console.log(` identity after discovery → nametag=${id2?.nametag ? '@' + id2.nametag : '(none)'}`); @@ -147,11 +159,25 @@ async function main() { receivedAt: new Date(transfer.receivedAt).toISOString(), }, null, 2)); }); - sphere.on('transfer:confirmed', (r: any) => { - console.log('━━ transfer:confirmed ━━', r?.id, r?.status); + // The pre-flip event names (transfer:confirmed / transfer:failed / sync:* / + // connection:changed) were REMOVED from the public event map. Subscribing to + // them is not an error — `on()` accepts any name — it just silently never + // fires, which is exactly how a diagnostic script lies to you. These are the + // v2 names. + sphere.on('transfer:updated', (r: any) => { + console.log('━━ transfer:updated ━━', r?.id, r?.status, r?.error ?? '', r?.deliveryState ?? ''); + }); + sphere.on('transfer:attention', (e: any) => { + console.log('━━ transfer:attention ━━', JSON.stringify(e)); + }); + sphere.on('inventory:updated', () => { + console.log('━━ inventory:updated ━━'); }); - sphere.on('transfer:failed', (r: any) => { - console.log('━━ transfer:failed ━━', r?.id, r?.error); + sphere.on('history:updated', (e: any) => { + console.log('━━ history:updated ━━', JSON.stringify(e)); + }); + sphere.on('connection:status', (e: any) => { + console.log('━━ connection:status ━━', JSON.stringify(e)); }); sphere.on('nametag:registered', (e: any) => { console.log('━━ nametag:registered ━━', e); @@ -159,15 +185,6 @@ async function main() { sphere.on('nametag:recovered', (e: any) => { console.log('━━ nametag:recovered ━━', e); }); - sphere.on('sync:completed', (e: any) => { - console.log('━━ sync:completed ━━', e); - }); - sphere.on('sync:error', (e: any) => { - console.log('━━ sync:error ━━', e); - }); - sphere.on('connection:changed', (e: any) => { - console.log('━━ connection:changed ━━', JSON.stringify(e)); - }); console.log('→ Subscribed to transfer:incoming + related events.'); console.log('→ Now send some UCT from another wallet to @boxyrunstaging'); diff --git a/src/sphere-connect.ts b/src/sphere-connect.ts index 5af7d66..3d36af6 100644 --- a/src/sphere-connect.ts +++ b/src/sphere-connect.ts @@ -1,9 +1,11 @@ import { ConnectClient, + ERROR_CODES, HOST_READY_TYPE, HOST_READY_TIMEOUT, INTENT_ACTIONS, PERMISSION_SCOPES, + SPHERE_NETWORKS, } from '@unicitylabs/sphere-sdk/connect'; import { PostMessageTransport, @@ -34,7 +36,13 @@ function gameWalletAddress(): string { } const ENTRY_FEE = 10; const COIN_ID = 'UCT'; -const UCT_COIN_ID_HEX = '455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89'; +// TESTNET2 UCT, from the network's own registry (unicity-ids.testnet2.json). +// The id carried here before — 455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89 +// — is the v1 testnet coin and is absent from the testnet2 registry entirely. +// This fallback is more reachable than it looks: it fires whenever the wallet +// reports no UCT asset, which is exactly the state of every wallet after the +// 2026-08-29 reset, so a wrong value here sends a coinId nobody holds. +const UCT_COIN_ID_HEX = 'f581d30f593e4b369d684a4563b5246f07b1d265f7178a2c0a82b81f39c24dc0'; const UCT_DECIMALS = 18; const FAUCET_URL = 'https://faucet.unicity.network/api/v1/faucet/request'; const SESSION_KEY = 'boxyrun-sphere-session'; @@ -47,6 +55,19 @@ interface WalletState { identity: PublicIdentity | null; balance: number | null; error: string | null; + /** + * Set when a deposit answered INTENT_OUTCOME_UNKNOWN (4201): the wallet had + * the intent and the outcome is unknown. Nothing may re-issue that payment. + * + * Deliberately IN-MEMORY, so a reload clears it — that is the design, not an + * oversight. Nothing in this page can learn whether the payment landed, so + * the reconciliation has to be a human one: reload, read the real balance and + * the game ledger (which the arena watcher credits from the chain), then + * decide. Persisting it to sessionStorage would block the retry the player is + * entitled to after checking, with nothing able to clear the flag. What the + * guard must prevent is the reflexive same-session re-click, and it does. + */ + outcomeUnknown: boolean; } let client: ConnectClient | null = null; @@ -61,6 +82,7 @@ const state: WalletState = { identity: null, balance: null, error: null, + outcomeUnknown: false, }; // ── Detection helpers ────────────────────────────────────────────────────── @@ -165,9 +187,24 @@ async function connect(): Promise { resumeSessionId = sessionStorage.getItem(SESSION_KEY) ?? undefined; } - // Connect via the resolved transport + // Connect via the resolved transport. + // + // The wallet host runs TWO handshake gates, both of which this client must + // satisfy (connect/compatibility.ts in the SDK): + // + // 1. Network (INCOMPATIBLE_NETWORK, 4008) — the dApp must declare a + // `network` whose id equals the wallet's active networkId. Omitting it + // is itself a rejection. testnet2 (networkId 4) is the network the + // deployed wallet and the arena wallet both run on. + // 2. npm-SDK floor (UNSUPPORTED_PROTOCOL_VERSION, 4007) — the host + // enforces `minSdkVersion`, defaulting to DEFAULT_MIN_CLIENT_SDK_VERSION + // = '0.14.1-0' (the P11 flip: the v1 payments era is gone). The + // ConnectClient reports its own package version, so a dApp bundled + // against sphere-sdk < 0.14.1 is refused at the handshake no matter + // what it sends. That is the hard reason this app tracks 0.15.x. client = new ConnectClient({ transport, dapp: dappMeta, permissions: [...dappPermissions], resumeSessionId, + network: SPHERE_NETWORKS.testnet2, }); const result = await client.connect(); state.isConnected = true; @@ -219,6 +256,7 @@ async function disconnect(): Promise { state.identity = null; state.balance = null; state.error = null; + state.outcomeUnknown = false; updateUI('disconnected'); } @@ -245,9 +283,56 @@ async function refreshBalance(): Promise { } } +/** + * Convert a whole-token amount (what the UI and the game ledger speak) into + * BASE UNITS (the smallest indivisible unit) as a decimal integer string. + * + * WHY THIS EXISTS — money-critical. The Connect `send` intent changed its + * `amount` contract: it used to carry a whole-token decimal, and now carries + * base units, validated by the wallet as /^\d+$/ and > 0. Both forms PASS that + * validation, so the old whole-token value is not rejected — it is silently + * reinterpreted. Sending `10` for a UCT entry fee would move 10 * 10^-18 UCT + * (dust) instead of 10 UCT: the player is debited nothing, and the arena + * watcher credits nothing (its integer divide by 10^18 floors to 0), with no + * error surfaced anywhere. Always convert here, at the dApp's UI edge. + * + * Done with BigInt, not Math.pow — 10 * 10**18 exceeds Number.MAX_SAFE_INTEGER + * and would serialise in exponential notation, failing the wallet's regex. + */ +function toBaseUnits(wholeTokens: number, decimals: number): string { + if (!Number.isFinite(wholeTokens) || wholeTokens <= 0) { + throw new Error(`Invalid amount: ${wholeTokens}`); + } + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals: ${decimals}`); + } + const scale = 10n ** BigInt(decimals); + if (Number.isInteger(wholeTokens)) { + return (BigInt(wholeTokens) * scale).toString(); + } + // Fractional input: go through a fixed-point string so we never round-trip + // through a float that cannot represent the value exactly. + const [intPart, fracPart = ''] = wholeTokens.toFixed(decimals).split('.'); + const frac = (fracPart + '0'.repeat(decimals)).slice(0, decimals); + const units = BigInt(intPart) * scale + BigInt(frac || '0'); + if (units <= 0n) throw new Error(`Amount ${wholeTokens} is below one base unit`); + return units.toString(); +} + async function deposit(amount?: number): Promise { const sendAmount = amount ?? ENTRY_FEE; + // A previous deposit's outcome is unknown; re-issuing it is the double-spend + // this guard exists to prevent. Only a reload (after the player has checked + // their balance) clears it. + if (state.outcomeUnknown) { + state.error = + 'A previous payment\'s outcome is still unknown. Reload the page and check ' + + 'your balance before paying again.'; + updateUI('connected'); + return false; + } + if (!client || !state.isConnected) { state.error = 'Not connected'; return false; @@ -275,9 +360,15 @@ async function deposit(amount?: number): Promise { uctCoinId = UCT_COIN_ID_HEX; uctDecimals = UCT_DECIMALS; } + // Never scale by a zero/absent decimals — that would send whole-token + // digits as base units, i.e. dust, which the wallet happily accepts. + if (!uctDecimals) uctDecimals = UCT_DECIMALS; + // `amount` is in BASE UNITS — see toBaseUnits() for why this conversion is + // not optional. `coinId` must be lowercase even-length hex; the wallet + // rejects a short symbol like 'UCT' with INVALID_PARAMS. await client.intent(INTENT_ACTIONS.SEND, { to: gameWalletAddress(), - amount: sendAmount, + amount: toBaseUnits(sendAmount, uctDecimals), coinId: uctCoinId, memo: 'Boxy Run entry fee', }); @@ -288,6 +379,30 @@ async function deposit(amount?: number): Promise { updateUI('ready'); return true; } catch (err) { + // INTENT_OUTCOME_UNKNOWN (4201): the wallet HAD the intent and the answer + // was lost — a host deadline fired, or the wallet locked mid-flight. The + // money may or may not have moved. Treating it like an ordinary failure is + // how a player pays twice: the old code re-enabled "Play" on every throw, + // and the natural next click re-issues the same transfer. There is no + // retry that is safe here, so refuse to offer one and let the arena + // watcher's on-chain credit settle it — that ledger is the source of truth + // and its tx_id is UNIQUE, so a deposit that DID land still credits. + const code = (err as { code?: unknown })?.code; + if (code === ERROR_CODES.INTENT_OUTCOME_UNKNOWN) { + state.outcomeUnknown = true; + state.error = + 'Payment sent, but the wallet could not confirm the outcome. Do NOT pay again — ' + + 'if it went through, your balance updates on its own within a minute.'; + state.isDepositPaid = false; + updateUI('connected'); + return false; + } + if (code === ERROR_CODES.WALLET_LOCKED) { + state.error = 'Wallet is locked. Unlock it in Sphere, then try again.'; + state.isDepositPaid = false; + updateUI('connected'); + return false; + } state.error = err instanceof Error ? err.message : 'Deposit failed'; state.isDepositPaid = false; updateUI('connected'); @@ -490,6 +605,7 @@ setInterval(() => { get identity() { return state.identity; }, get balance() { return state.balance; }, get error() { return state.error; }, + get outcomeUnknown() { return state.outcomeUnknown; }, get entryFee() { return ENTRY_FEE; }, get coinId() { return COIN_ID; }, connect, diff --git a/tournament/server/arena-watcher.ts b/tournament/server/arena-watcher.ts index 7812bb8..6c5dcc8 100644 --- a/tournament/server/arena-watcher.ts +++ b/tournament/server/arena-watcher.ts @@ -9,6 +9,13 @@ * over the Sphere network IS the source of truth, and this watcher mirrors * confirmed transfers into our internal accounting. * + * CUSTODY MODEL (sphere-sdk >= 0.14.1, the "P11 flip"): the arena wallet no + * longer keeps tokens on local disk. Inventory, blobs and the incoming mailbox + * live in the wallet-api backend; this process holds only keys and a small + * scoped KV. That makes `WALLET_API_URL` mandatory — `Sphere.import` is + * fail-closed and throws INVALID_CONFIG without a `walletApi` config, before it + * writes anything. + * * Configuration (Fly secrets): * ARENA_WALLET_FILE JSON exported from a Sphere wallet (contains * `mnemonic` field). The full file body is set @@ -16,15 +23,51 @@ * ARENA_WALLET_NAMETAG Display nametag, e.g. '@boxyrunarena'. Used * for log/diag output only — the SDK derives * identity from the mnemonic. - * SPHERE_NETWORK 'mainnet' | 'testnet' | 'dev'. Defaults to - * 'mainnet'. + * WALLET_API_URL wallet-api backend base URL. Defaults to the + * production backend, which is where the deployed + * Sphere wallet (sphere.unicity.network) keeps its + * users' tokens. A player's send deposits into the + * arena wallet's mailbox ON THAT BACKEND, so pointing + * this at a different one means deposits are accepted + * on chain and then never delivered here. + * WALLET_API_DEVICE_ID Stable per-deployment label keying the refresh-token + * row. Defaults to a name derived from the network. + * Two processes sharing one value fight over the same + * rotating token; give staging and prod distinct ones. + * AGGREGATOR_API_KEY Gateway API key for the token engine. The SDK no + * longer bundles a default. The testnet2 key is + * documented non-secret, so it is defaulted below; a + * mainnet key would be a real secret. + * SPHERE_NETWORK Defaults to 'testnet2'. This string must EXACTLY + * match the backend's configured network: the SDK + * verifies it against the `network` field embedded in + * the auth challenge, so 'testnet' — an alias of + * testnet2 everywhere else in the SDK — fails sign-in + * with ChallengeTemplateError. * SPHERE_DATA_DIR Where Sphere caches its state. Defaults to * '/data/arena-sphere' (on the Fly volume so * the cache survives restarts). * * Idempotency: each `IncomingTransfer.id` becomes the `tx_id` on the * inserted row. The column has UNIQUE so duplicate firings (after a - * reconnect, sync replay, etc.) silently no-op. + * reconnect, a crash between store and mailbox-ack, etc.) silently no-op. + * + * Two things to know about that key, neither of which is a defect introduced + * here — both are recorded so the next reader does not rediscover them: + * + * - The id is, and always was, the token's GENESIS id: `v2_` before + * the 0.15.0 bump and bare `` after it. The format change means old + * `v2_*` rows and new bare-hex rows coexist in `player_transactions`, and a + * token credited under the old format would not collide with itself under + * the new one. Unreachable in practice — the state-transition 3.x wire break + * came with a testnet reset and a backend inventory truncation on + * 2026-08-29, so no pre-migration token can arrive again. + * - Because the key is the genesis id and not (tokenId, stateHash) — which is + * what the SDK's own receive dedup uses — a token that legitimately returns + * to this wallet at a LATER state is refused as a duplicate and credits + * nothing. The event does not carry `stateHash`, so the watcher cannot key + * on it. Left as-is deliberately: the alternative keys all trade this + * under-credit for a double-credit on crash-redelivery, which is worse. */ import { existsSync, mkdirSync } from 'node:fs'; @@ -40,7 +83,7 @@ type IncomingTransfer = { readonly id: string; readonly senderPubkey: string; readonly senderNametag?: string; - readonly tokens: ReadonlyArray<{ amount?: string; decimals?: number }>; + readonly tokens: ReadonlyArray<{ coinId?: string; symbol?: string; amount?: string; decimals?: number }>; readonly memo?: string; readonly receivedAt: number; }; @@ -90,26 +133,80 @@ function readWalletFile(): ParsedWallet { * Token payload doesn't surface the decimals field. */ const UCT_DECIMALS = 18; -/** Sum UCT (or whatever the configured coin is) across the transfer's tokens. */ +/** + * The only coin the game ledger denominates in. + * + * This is the TESTNET2 id, taken from the network's own registry + * (unicity-ids.testnet2.json). The id Boxy-Run carried before — + * 455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89 — is the v1 + * testnet coin and does not appear in the testnet2 registry at all, so keying + * anything on it matches nothing that can actually arrive here. + */ +const UCT_SYMBOL = 'UCT'; +const UCT_COIN_ID_HEX = 'f581d30f593e4b369d684a4563b5246f07b1d265f7178a2c0a82b81f39c24dc0'; + +/** True when an incoming token entry is the coin this ledger accounts in. */ +function isUct(tk: { coinId?: string; symbol?: string }): boolean { + if (typeof tk.coinId === 'string' && tk.coinId.length > 0) { + return tk.coinId.toLowerCase() === UCT_COIN_ID_HEX; + } + // No coinId (older payloads): fall back to the registry-resolved symbol. + return tk.symbol === UCT_SYMBOL; +} + +/** + * Sum the UCT carried by a transfer, in whole tokens. + * + * WHY THE COIN FILTER — `IncomingTransfer.tokens` is NOT a list of tokens: it is + * one entry PER ASSET carried by the token, and every entry repeats the same + * `id` (see Receive.ts, `record.assets.map(toUiToken)`). Summing it blindly + * credits any other coin that happened to ride along as if it were UCT, at UCT's + * decimals. Under the old delivery rail the array was always `[token]`, which is + * why this went unnoticed. + */ function sumIncomingAmount(transfer: IncomingTransfer): number { - let total = 0; + // Accumulate in BASE UNITS and divide ONCE at the end. Dividing per entry + // floors each one separately, and a single 10 UCT send routinely arrives as + // several tokens of arbitrary size (only the split leg is exact; direct legs + // are whatever the sender's inventory happened to hold) — so 3.5 + 6.5 would + // credit 3 + 6 = 9 and quietly eat a whole UCT. + let baseUnits = 0n; + let decimals = UCT_DECIMALS; for (const t of transfer.tokens) { - const tk = t as any; - // Decimals: prefer what the token says, fall back to UCT's 18 if - // it's missing or 0 (we don't yet support multi-token economies and - // the SDK has been inconsistent about populating this field). - const decimals: number = (typeof tk.decimals === 'number' && tk.decimals > 0) ? tk.decimals : UCT_DECIMALS; + const tk = t as { coinId?: string; symbol?: string; amount?: string; decimals?: number }; + if (!isUct(tk)) { + console.log( + `[arena-watcher] transfer ${transfer.id} carries non-UCT asset ` + + `(coinId=${tk.coinId ?? '?'} symbol=${tk.symbol ?? '?'}) — not credited`, + ); + continue; + } + // Decimals: prefer what the token says, fall back to UCT's 18 if it is + // missing or 0. A cold TokenRegistry reports 0 for a coin it has not + // fetched yet, and taking that literally would credit raw base units as + // whole UCT — a 10^18x over-credit. + if (typeof tk.decimals === 'number' && tk.decimals > 0) decimals = tk.decimals; const raw = String(tk.amount ?? '0'); try { - const big = BigInt(raw); - const divisor = BigInt(10) ** BigInt(decimals); - const whole = Number(big / divisor); - total += whole; + baseUnits += BigInt(raw); } catch (e) { - console.warn('[arena-watcher] could not parse token amount', { raw, decimals, token: tk }, e); + console.warn('[arena-watcher] could not parse token amount', { raw, token: tk }, e); } } - return total; + if (baseUnits === 0n) return 0; + const divisor = 10n ** BigInt(decimals); + const whole = Number(baseUnits / divisor); + const remainder = baseUnits % divisor; + if (remainder !== 0n) { + // The ledger's amount column is INTEGER, so a fractional tail cannot be + // stored. Log it rather than lose it silently — it is real money. + console.warn( + `[arena-watcher] transfer ${transfer.id} has a fractional remainder ` + + `${remainder.toString()} base units (${decimals} decimals) that the INTEGER ` + + `ledger cannot hold — credited ${whole} UCT`, + ); + } + return whole; } /** @@ -167,42 +264,82 @@ export async function startArenaWatcher(): Promise { if (sphere) return; const wallet = readWalletFile(); - const network = (process.env.SPHERE_NETWORK || 'mainnet') as 'mainnet' | 'testnet' | 'dev'; + // Resolve the network ONCE and reuse the same string for the base providers, + // the wallet-api config and Sphere.import. That is load-bearing, not tidiness: + // resolvePaymentsV2Composition() string-compares walletApi.network against the + // Sphere network and throws INVALID_CONFIG on any difference, and the + // wallet-api auth challenge embeds the backend's own network name which the + // SDK verifies against ours. 'testnet2' is what both deployed backends issue. + // mainnet/dev have no embedded trust base and are refused at provider + // creation. + const network = (process.env.SPHERE_NETWORK || 'testnet2') as 'testnet' | 'testnet2'; const dataDir = process.env.SPHERE_DATA_DIR || '/data/arena-sphere'; if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); - // Stick to the Unicity-operated relay for each network — adding public - // Nostr relays (damus.io, nos.lol) can hang the boot when one of them - // returns 503 or 5xx, because the SDK awaits every transport handshake. - // `wss://relay.unicity.network` (in the SDK's mainnet defaults) - // currently NXDOMAINs — use the sphere-relay variant instead. - const mainnetRelays = ['wss://sphere-relay.unicity.network']; + // Server custody: without this the wallet has nowhere to hold tokens and + // Sphere.import refuses to boot. Default to production — that is the backend + // the deployed Sphere wallet uses, so it is where players' sends deposit. + const walletApiUrl = process.env.WALLET_API_URL || 'https://wallet-api.unicity.network'; + // Documented non-secret for testnet2; override for any network where it isn't. + const aggregatorApiKey = process.env.AGGREGATOR_API_KEY || 'sk_ddc3cfcc001e4a28ac3fad7407f99590'; + const deviceId = process.env.WALLET_API_DEVICE_ID || `boxyrun-arena-${network}`; + + // Stick to the Unicity-operated relay — adding public Nostr relays + // (damus.io, nos.lol) can hang the boot when one of them returns 503 or 5xx, + // because the SDK awaits every transport handshake. Nostr now carries only + // DMs and nametag bindings; no asset traffic rides it. const testnetRelays = ['wss://nostr-relay.testnet.unicity.network']; - console.log(`[arena-watcher] booting Sphere (network=${network} dataDir=${dataDir})`); + console.log( + `[arena-watcher] booting Sphere (network=${network} dataDir=${dataDir} walletApi=${walletApiUrl})`, + ); // Dynamic import so esbuild's CJS bundle doesn't try to `require()` // the ESM-only SDK at module load time. const sdk = await import('@unicitylabs/sphere-sdk'); const sdkNode = await import('@unicitylabs/sphere-sdk/impl/nodejs' as any); - const providers = sdkNode.createNodeProviders({ + const sdkWalletApi = await import('@unicitylabs/sphere-sdk/impl/shared/wallet-api' as any); + // `tokensDir` is gone: token custody moved to the backend, so there is no + // local token store to point at. The oracle apiKey is now required — the SDK + // ships no bundled default, and the token engine (which verifies every + // incoming token before it counts) needs the gateway. + const base = sdkNode.createNodeProviders({ network, dataDir, - tokensDir: `${dataDir}/tokens`, - transport: { - relays: network === 'testnet' ? testnetRelays : mainnetRelays, - }, + oracle: { apiKey: aggregatorApiKey }, + transport: { relays: testnetRelays }, + }); + // Attaches the plain `walletApi` transport config the payments vertical is + // composed from. Node >= 22 supplies global fetch + WebSocket, so no + // injected factories are needed. + const providers = sdkWalletApi.createWalletApiProviders(base, { + baseUrl: walletApiUrl, + network, + deviceId, }); // Stash for the auth module's nametag → chainPubkey resolver. transport = providers.transport; - // Use `import` (not `load`) — we only have the mnemonic + derivation - // fields, not a populated storage. Pass through `derivationMode` and - // `basePath` from the wallet file so the SDK reconstructs the SAME - // identity (including the nametag binding) the wallet was created with. - // Without these, the same mnemonic derives a different identity and - // `@boxyrunstaging` won't be associated with this Sphere instance. + // Use `import` (not `init`). `import` is the only entry point that ACCEPTS + // `basePath`, and the deployed wallet file may carry a descriptorPath; the + // same mnemonic under a different derivation is a DIFFERENT identity holding + // no money, so the entry point is not a detail to trade away for tidiness. + // + // Be precise about what actually applies, though: on the MNEMONIC path + // `Sphere.import` ignores `derivationMode` entirely (storeMnemonic hard-sets + // 'bip32'; options.derivationMode is only read on the masterKey path). It is + // still passed below so the masterKey path stays correct if this ever grows + // one, but do not read its presence as proof the mode is being honoured. + // + // Known cost: `Sphere.import` clears existing storage first, so each boot + // drops the scoped KV (refresh token, receive seen-set, delivery journal) + // and re-runs the challenge sign-in. Harmless here because the server is the + // record and `tx_id` is UNIQUE, so a replayed credit is a no-op insert. + // + // `network` MUST be passed: it is compared against walletApi.network, and + // omitting it (as this call used to) is an immediate INVALID_CONFIG. sphere = await sdk.Sphere.import({ mnemonic: wallet.mnemonic, + network, ...(wallet.derivationMode ? { derivationMode: wallet.derivationMode } : {}), ...(wallet.basePath ? { basePath: wallet.basePath } : {}), ...providers, @@ -210,7 +347,7 @@ export async function startArenaWatcher(): Promise { const id = sphere.identity; console.log( `[arena-watcher] Sphere ready — nametag=${id?.nametag ? '@' + id.nametag : '(none)'} ` + - `l1Address=${id?.l1Address || '?'}`, + `directAddress=${id?.directAddress || '?'}`, ); // Subscribe to incoming transfers. The SDK emits this event after the diff --git a/tsconfig.json b/tsconfig.json index 0278727..c25d612 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "@unicitylabs/sphere-sdk/connect/browser": ["./node_modules/@unicitylabs/sphere-sdk/dist/impl/browser/connect/index.d.ts"] } }, - "include": ["src/**/*.ts", "tournament/**/*.ts"], + "include": ["src/**/*.ts", "tournament/**/*.ts", "scripts/**/*.ts"], "exclude": ["tournament/tests/_legacy/**"] }