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/**"]
}