From a42b448452c4e276c84cbb35f533edff0039145a Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Mon, 22 Jun 2026 12:23:35 +0200 Subject: [PATCH 1/3] Migrate to @unicitylabs/sphere-sdk 0.10.3 Bump the SDK from 0.6.0-dev.1 to 0.10.3 (the v1->v2 token-engine cutover line) and adapt the four call sites. Browser connect (src/sphere-connect.ts): every import and method is source-compatible. Connect protocol 2.0 added a network handshake gate - a 0.10.x wallet host rejects with INCOMPATIBLE_NETWORK (4008) unless the dApp declares a `network` whose id matches the wallet's active networkId. Pass SPHERE_NETWORKS.testnet2 (networkId 4, the v2 gateway network the arena wallet runs on). Node side (arena-watcher + scripts): 0.10 removed the L1 layer, so Identity / PeerInfo / DiscoveredAddress no longer expose l1Address - switched the informational reads to directAddress (the L3 DIRECT address). Default SPHERE_NETWORK / --network to testnet (-> testnet2) because mainnet/dev gateways are not cut over to the v2 engine yet and make Sphere.import throw INVALID_CONFIG on boot. Verified: npm install (0.10.3 + auto-resolved libp2p/ipns/multiformats peer deps), tsc --noEmit (no new type errors - the 15 pre-existing Babylon/app errors are unchanged), full client build + server build all clean. --- package-lock.json | 95 +++++++++++++++++++----------- package.json | 2 +- scripts/create-arena-wallet.ts | 17 ++++-- scripts/test-arena-watcher.ts | 20 +++---- src/sphere-connect.ts | 8 ++- tournament/server/arena-watcher.ts | 8 ++- 6 files changed, 97 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3c5e18..daac24a 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.10.3", "mp4-muxer": "^5.2.2", "ws": "^8.20.0" }, @@ -759,24 +759,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.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1" + "@noble/hashes": "2.2.0" }, "engines": { "node": ">= 20.19.0" @@ -786,9 +786,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.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" @@ -853,9 +853,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.5.0", + "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.5.0.tgz", + "integrity": "sha512-v6qaZBkyuZzvfjyO/x+czqftYdAqxjyfBXipsB0QJIdqePBiWHR2aRs28zzJIhCMk1vP9HXzxGnpjRqlcnzroA==", "license": "MIT", "dependencies": { "@noble/ciphers": "^1.0.0", @@ -868,6 +868,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,22 +908,24 @@ } }, "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.10.3", + "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.10.3.tgz", + "integrity": "sha512-+ZD4KCJXsCEs5KLLncK1MVx/upm8aBLRAkc7VXX4QylzcDYbMx6gxwN5GhVkjoJEOeoL3c52wwtiYsEdNX0leg==", "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.5.0", + "@unicitylabs/state-transition-sdk": "2.0.0-rc.68bc1e5", "bip39": "^3.1.0", "buffer": "^6.0.3", + "canonicalize": "^3.0.0", "crypto-js": "^4.2.0", "elliptic": "^6.6.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "@libp2p/crypto": "^5.1.13", @@ -945,14 +959,17 @@ } }, "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": "2.0.0-rc.68bc1e5", + "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-2.0.0-rc.68bc1e5.tgz", + "integrity": "sha512-JJ1jQFTexMBRwpn3pmhm4s2FulBcOBGyNZmQ4qd5ZheenbVLUkY5KSJ++J3Gnh/Vmhd6WVuCBw+FCyqXN39aHQ==", "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/base64-js": { @@ -1032,6 +1049,18 @@ "ieee754": "^1.2.1" } }, + "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", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cborg": { "version": "4.5.8", "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", @@ -1306,9 +1335,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.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", "license": "MIT" }, "node_modules/libsql": { @@ -1603,9 +1632,9 @@ "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" diff --git a/package.json b/package.json index b514991..1b65bcb 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.10.3", "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..19502f9 100644 --- a/scripts/create-arena-wallet.ts +++ b/scripts/create-arena-wallet.ts @@ -49,7 +49,9 @@ function parseArg(name: string, fallback: string): string { async function main() { const nametag = parseArg('nametag', 'boxyrunarena'); - const network = parseArg('network', 'mainnet') as NetworkType; + // Default to testnet (→ testnet2, networkId 4) — the v2 network. Override + // with --network=mainnet once mainnet's gateway is cut over to the v2 engine. + const network = parseArg('network', 'testnet') as NetworkType; const dataDir = resolve(parseArg('data-dir', './arena-data')); const outFile = resolve(parseArg('out', './arena-wallet.json')); const confirm = process.env.CONFIRM === 'yes'; @@ -126,8 +128,11 @@ async function main() { createdAt: new Date().toISOString(), network, nametag: `@${nametag}`, - l1Address: identity.l1Address, - chainPubkey: (identity as any).chainPubkey ?? null, + // SDK 0.10 dropped the L1 layer from Identity; the L3 DIRECT address is + // the on-chain address now. 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 +143,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`); diff --git a/scripts/test-arena-watcher.ts b/scripts/test-arena-watcher.ts index e2cb841..76ee9e1 100644 --- a/scripts/test-arena-watcher.ts +++ b/scripts/test-arena-watcher.ts @@ -56,7 +56,8 @@ async function main() { console.log(` basePath: ${parsed.wallet?.descriptorPath || '(none)'}`); console.log(''); - const network = (process.env.SPHERE_NETWORK || 'mainnet') as 'mainnet' | 'testnet' | 'dev'; + // testnet → testnet2 (networkId 4), the v2 gateway network under SDK 0.10. + const network = (process.env.SPHERE_NETWORK || 'testnet') as 'mainnet' | 'testnet' | 'dev'; console.log(`→ Initializing Node providers (network=${network})…`); const providers = createNodeProviders({ network, @@ -80,13 +81,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 +97,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 +111,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 +123,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)'}`); diff --git a/src/sphere-connect.ts b/src/sphere-connect.ts index 5af7d66..87558ae 100644 --- a/src/sphere-connect.ts +++ b/src/sphere-connect.ts @@ -4,6 +4,7 @@ import { HOST_READY_TIMEOUT, INTENT_ACTIONS, PERMISSION_SCOPES, + SPHERE_NETWORKS, } from '@unicitylabs/sphere-sdk/connect'; import { PostMessageTransport, @@ -165,9 +166,14 @@ async function connect(): Promise { resumeSessionId = sessionStorage.getItem(SESSION_KEY) ?? undefined; } - // Connect via the resolved transport + // Connect via the resolved transport. + // SDK 0.10 Connect protocol 2.0 added a network handshake gate: the wallet + // host rejects with INCOMPATIBLE_NETWORK (4008) unless the dApp declares a + // `network` whose id matches the wallet's active networkId. testnet2 + // (networkId 4) is the v2 gateway network the arena wallet runs on. client = new ConnectClient({ transport, dapp: dappMeta, permissions: [...dappPermissions], resumeSessionId, + network: SPHERE_NETWORKS.testnet2, }); const result = await client.connect(); state.isConnected = true; diff --git a/tournament/server/arena-watcher.ts b/tournament/server/arena-watcher.ts index 7812bb8..56fdfa7 100644 --- a/tournament/server/arena-watcher.ts +++ b/tournament/server/arena-watcher.ts @@ -167,7 +167,11 @@ export async function startArenaWatcher(): Promise { if (sphere) return; const wallet = readWalletFile(); - const network = (process.env.SPHERE_NETWORK || 'mainnet') as 'mainnet' | 'testnet' | 'dev'; + // Default to testnet (→ testnet2, networkId 4) — the v2 gateway network with + // a working trust base under SDK 0.10. mainnet/dev gateways are not cut over + // to the v2 engine yet and make Sphere.import throw INVALID_CONFIG on boot. + // Override via the SPHERE_NETWORK env once those gateways are v2-ready. + const network = (process.env.SPHERE_NETWORK || 'testnet') as 'mainnet' | 'testnet' | 'dev'; const dataDir = process.env.SPHERE_DATA_DIR || '/data/arena-sphere'; if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); @@ -210,7 +214,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 From 49ea35a59924c53f729078036c2f7d298c283a88 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Mon, 31 Aug 2026 14:58:34 +0200 Subject: [PATCH 2/3] Take @unicitylabs/sphere-sdk 0.15.0 (P11 flip + state-transition 3.x) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.10.3 no longer talks to the deployed fleet. The wallet at sphere.unicity.network runs sphere-sdk 0.15.0, and a 0.15.0 ConnectHost enforces an npm-SDK floor on the CLIENT at the handshake (DEFAULT_MIN_CLIENT_SDK_VERSION = '0.14.1-0'), so a dApp bundling 0.10.3 is refused with UNSUPPORTED_PROTOCOL_VERSION (4007) before anything else runs. Verified against the live bundle, which carries both the 0.15.0 marker and the floor string. Three of these changes are money-correctness, not version hygiene. 1. Send amounts are BASE UNITS (src/sphere-connect.ts) The Connect `send` intent changed its `amount` contract from a whole-token decimal to base units, validated as /^\d+$/ and > 0. Both forms PASS that validation, so `amount: 10` is not rejected — it is silently reinterpreted as 10 attoUCT. The player approves a dust transfer, and the arena watcher credits nothing because its divide by 10^18 floors to 0, with no error anywhere. Added toBaseUnits() at the dApp's UI edge (BigInt, so the value never reaches exponential notation and fails the wallet's regex). 2. INTENT_OUTCOME_UNKNOWN, 4201 (src/sphere-connect.ts, index.html) New code meaning "the wallet had the intent; the money may or may not have moved — do not retry". deposit() caught every throw identically and re-enabled the pay button, walking a player straight into a second spend. Now branches on the code, sets a sticky guard that refuses to re-issue, and the Transfer button stays disabled. WALLET_LOCKED (4009) gets its own message instead of a raw error string. 3. Incoming assets are filtered by coin (tournament/server/arena-watcher.ts) IncomingTransfer.tokens is one entry PER ASSET now, not per token, all sharing the same id. Summing it blindly credits any other coin that rides along as if it were UCT at UCT's decimals. Under the old delivery rail the array was always [token], which is why this was invisible. Server custody (the P11 flip). Token custody moved to the wallet-api backend, so `walletApi` is mandatory: Sphere.import/create call resolvePaymentsV2Composition first and throw INVALID_CONFIG without it, before any storage write. All three call sites now wrap the base providers with createWalletApiProviders({ baseUrl, network, deviceId }), drop the removed `tokensDir`, and pass `oracle.apiKey` (no bundled default any more). The network default moves 'testnet' -> 'testnet2': the backend embeds its own network name in the auth challenge and the SDK verifies ours against it, so the alias fails sign-in outright. `network` is now passed to Sphere.import/create itself — it is string-compared against walletApi.network, and omitting it was an immediate INVALID_CONFIG. Also: Node 22 in the Dockerfile and CI (0.15.0 declares engines >= 22); scripts/ added to tsconfig, because both edited scripts were outside the typecheck program and their SDK call sites were never checked at all; dead event names in the test script replaced with the v2 ones (they were silently never firing). Verification: typecheck reports 16 errors, all pre-existing (15 Babylon / app-protocol, 1 in the newly-included scripts/bot-players.ts) and none in any SDK-touching file; all four bundles build; the emitted dApp bundle reports sdkVersion 0.15.0, clearing the handshake floor. The server suite fails 22/25 identically on this branch and on the base commit — a 5s server-readiness deadline under parallel load, unrelated to the SDK, which is never loaded in tests (the watcher is gated behind ARENA_WALLET_FILE). --- .github/workflows/deploy.yml | 2 +- Dockerfile | 4 +- index.html | 10 +- package-lock.json | 482 +++-------------------------- package.json | 2 +- scripts/create-arena-wallet.ts | 68 ++-- scripts/test-arena-watcher.ts | 63 ++-- src/sphere-connect.ts | 114 ++++++- tournament/server/arena-watcher.ts | 160 ++++++++-- tsconfig.json | 2 +- 10 files changed, 388 insertions(+), 519 deletions(-) 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..f325bf5 100644 --- a/index.html +++ b/index.html @@ -745,12 +745,20 @@

Daily

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 daac24a..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.10.3", + "@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", @@ -771,12 +657,12 @@ } }, "node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "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.2.0" + "@noble/hashes": "2.4.0" }, "engines": { "node": ">= 20.19.0" @@ -786,9 +672,9 @@ } }, "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==", + "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.5.0", - "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.5.0.tgz", - "integrity": "sha512-v6qaZBkyuZzvfjyO/x+czqftYdAqxjyfBXipsB0QJIdqePBiWHR2aRs28zzJIhCMk1vP9HXzxGnpjRqlcnzroA==", + "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", @@ -908,60 +794,37 @@ } }, "node_modules/@unicitylabs/sphere-sdk": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.10.3.tgz", - "integrity": "sha512-+ZD4KCJXsCEs5KLLncK1MVx/upm8aBLRAkc7VXX4QylzcDYbMx6gxwN5GhVkjoJEOeoL3c52wwtiYsEdNX0leg==", + "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.5.0", - "@unicitylabs/state-transition-sdk": "2.0.0-rc.68bc1e5", + "@unicitylabs/nostr-js-sdk": "^0.6.0", + "@unicitylabs/state-transition-sdk": "3.0.1", "bip39": "^3.1.0", "buffer": "^6.0.3", "canonicalize": "^3.0.0", - "crypto-js": "^4.2.0", - "elliptic": "^6.6.1" + "crypto-js": "^4.2.0" }, "engines": { "node": ">=22.0.0" }, - "optionalDependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/peer-id": "^6.0.4", - "ipns": "^10.0.0", - "multiformats": "^13.4.2" - }, "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": "2.0.0-rc.68bc1e5", - "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-2.0.0-rc.68bc1e5.tgz", - "integrity": "sha512-JJ1jQFTexMBRwpn3pmhm4s2FulBcOBGyNZmQ4qd5ZheenbVLUkY5KSJ++J3Gnh/Vmhd6WVuCBw+FCyqXN39aHQ==", + "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.2.0", @@ -972,6 +835,33 @@ "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": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1013,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", @@ -1061,16 +939,6 @@ "node": ">=18" } }, - "node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "optional": true, - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -1124,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", @@ -1180,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", @@ -1237,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", @@ -1285,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", @@ -1335,9 +1110,9 @@ "license": "BSD-3-Clause" }, "node_modules/libphonenumber-js": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz", - "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", + "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": { @@ -1372,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", @@ -1402,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", @@ -1457,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", @@ -1519,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", @@ -1587,50 +1254,12 @@ "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": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -1644,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 1b65bcb..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.10.3", + "@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 19502f9..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,21 +59,25 @@ function parseArg(name: string, fallback: string): string { async function main() { const nametag = parseArg('nametag', 'boxyrunarena'); - // Default to testnet (→ testnet2, networkId 4) — the v2 network. Override - // with --network=mainnet once mainnet's gateway is cut over to the v2 engine. - const network = parseArg('network', 'testnet') 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)) { @@ -94,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, }); @@ -128,9 +146,9 @@ async function main() { createdAt: new Date().toISOString(), network, nametag: `@${nametag}`, - // SDK 0.10 dropped the L1 layer from Identity; the L3 DIRECT address is - // the on-chain address now. Informational only — the watcher re-derives - // identity from the mnemonic, it never reads this field back. + // 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. @@ -154,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 76ee9e1..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,23 +61,30 @@ async function main() { console.log(` basePath: ${parsed.wallet?.descriptorPath || '(none)'}`); console.log(''); - // testnet → testnet2 (networkId 4), the v2 gateway network under SDK 0.10. - const network = (process.env.SPHERE_NETWORK || 'testnet') 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, @@ -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 87558ae..12472d0 100644 --- a/src/sphere-connect.ts +++ b/src/sphere-connect.ts @@ -1,5 +1,6 @@ import { ConnectClient, + ERROR_CODES, HOST_READY_TYPE, HOST_READY_TIMEOUT, INTENT_ACTIONS, @@ -48,6 +49,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; @@ -62,6 +76,7 @@ const state: WalletState = { identity: null, balance: null, error: null, + outcomeUnknown: false, }; // ── Detection helpers ────────────────────────────────────────────────────── @@ -167,10 +182,20 @@ async function connect(): Promise { } // Connect via the resolved transport. - // SDK 0.10 Connect protocol 2.0 added a network handshake gate: the wallet - // host rejects with INCOMPATIBLE_NETWORK (4008) unless the dApp declares a - // `network` whose id matches the wallet's active networkId. testnet2 - // (networkId 4) is the v2 gateway network the arena wallet runs on. + // + // 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, @@ -225,6 +250,7 @@ async function disconnect(): Promise { state.identity = null; state.balance = null; state.error = null; + state.outcomeUnknown = false; updateUI('disconnected'); } @@ -251,9 +277,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; @@ -281,9 +354,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', }); @@ -294,6 +373,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'); @@ -496,6 +599,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 56fdfa7..01197da 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,11 +133,40 @@ 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. */ +const UCT_SYMBOL = 'UCT'; +const UCT_COIN_ID_HEX = '455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89'; + +/** 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; for (const t of transfer.tokens) { - const tk = t as any; + 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's missing or 0 (we don't yet support multi-token economies and // the SDK has been inconsistent about populating this field). @@ -167,46 +239,76 @@ export async function startArenaWatcher(): Promise { if (sphere) return; const wallet = readWalletFile(); - // Default to testnet (→ testnet2, networkId 4) — the v2 gateway network with - // a working trust base under SDK 0.10. mainnet/dev gateways are not cut over - // to the v2 engine yet and make Sphere.import throw INVALID_CONFIG on boot. - // Override via the SPHERE_NETWORK env once those gateways are v2-ready. - const network = (process.env.SPHERE_NETWORK || 'testnet') 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`) — it is the ONLY entry point that honours + // `derivationMode` / `basePath`, and the deployed wallet file may carry them. + // The same mnemonic under a different derivation is a DIFFERENT identity + // holding no money, so this is not a detail to trade away for tidiness. + // + // 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, 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/**"] } From 28ccffad6b3c386014b8c516ff0cc4087cde91a8 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Mon, 31 Aug 2026 15:09:53 +0200 Subject: [PATCH 3/3] Fix the UCT coin id, per-leg flooring, and the 90s retry window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the 0.15.0 migration, from an adversarial pass over it. The first item is a defect the previous commit introduced. UCT_COIN_ID_HEX was the v1 testnet coin id. It does not appear in the testnet2 registry (unicity-ids.testnet2.json) at all — testnet2 UCT is f581d30f593e4b369d684a4563b5246f07b1d265f7178a2c0a82b81f39c24dc0, 18 decimals. Two consequences: - The coin filter added to the arena watcher in the previous commit keyed on the stale id, so it would have rejected every genuine deposit as "non-UCT" and credited nothing. Strictly worse than the bug it was fixing. - In the dApp it is the fallback coinId used when the wallet reports no UCT asset — which is the state of every wallet after the 2026-08-29 reset, so it is far more reachable than it looks. sumIncomingAmount divided EACH token by 10^decimals before summing. 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 held), so 3.5 + 6.5 credited 9 and 1.2 + 4.4 + 4.4 credited 8. Now accumulates base units and divides once, and logs the fractional remainder the INTEGER ledger column cannot hold rather than dropping it silently. Also treats a reported `decimals: 0` as the cold-registry sentinel it is — taking it literally would be a 10^18x over-credit. index.html raced the deposit against pollForDrop(45) at 2s = 90s, but ConnectClient's own intent timeout is 120s and the host's deadline is longer still. So 'timeout' does not mean the send is dead — it is very likely still live at the wallet — and the code re-enabled Transfer, which is the same double-spend the 4201 guard was added to prevent, one branch over. The timeout branch now leaves the button disabled too. Also corrects an overstated comment: Sphere.import ignores `derivationMode` on the MNEMONIC path (storeMnemonic hard-sets 'bip32'; the option is only read on the masterKey path). `basePath` is honoured and remains the reason to prefer `import` over `init` here. Verified: the six summing cases above (single leg, two-leg split, three-leg split, foreign coin alone, mixed UCT+USDU, cold-registry decimals) all produce the expected credit; typecheck still reports only the 16 pre-existing errors; all bundles build. --- index.html | 8 ++++ src/sphere-connect.ts | 8 +++- tournament/server/arena-watcher.ts | 65 ++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/index.html b/index.html index f325bf5..febe249 100644 --- a/index.html +++ b/index.html @@ -740,6 +740,14 @@

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'; diff --git a/src/sphere-connect.ts b/src/sphere-connect.ts index 12472d0..3d36af6 100644 --- a/src/sphere-connect.ts +++ b/src/sphere-connect.ts @@ -36,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'; diff --git a/tournament/server/arena-watcher.ts b/tournament/server/arena-watcher.ts index 01197da..6c5dcc8 100644 --- a/tournament/server/arena-watcher.ts +++ b/tournament/server/arena-watcher.ts @@ -133,9 +133,17 @@ function readWalletFile(): ParsedWallet { * Token payload doesn't surface the decimals field. */ const UCT_DECIMALS = 18; -/** The only coin the game ledger denominates in. */ +/** + * 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 = '455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89'; +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 { @@ -157,7 +165,13 @@ function isUct(tk: { coinId?: string; symbol?: string }): boolean { * 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 { coinId?: string; symbol?: string; amount?: string; decimals?: number }; if (!isUct(tk)) { @@ -167,21 +181,32 @@ function sumIncomingAmount(transfer: IncomingTransfer): number { ); continue; } - // 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; + // 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; } /** @@ -294,10 +319,16 @@ export async function startArenaWatcher(): Promise { // Stash for the auth module's nametag → chainPubkey resolver. transport = providers.transport; - // Use `import` (not `init`) — it is the ONLY entry point that honours - // `derivationMode` / `basePath`, and the deployed wallet file may carry them. - // The same mnemonic under a different derivation is a DIFFERENT identity - // holding no money, so this is not a detail to trade away for tidiness. + // 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)