Migrate to @unicitylabs/sphere-sdk 0.15.0 - #2
Conversation
There was a problem hiding this comment.
Code Review
This pull request upgrades the @unicitylabs/sphere-sdk dependency to version 0.10.3 and adapts the wallet creation, connection, and watcher scripts to the new SDK changes, notably replacing L1 addresses with L3 direct addresses and defaulting to the testnet network. The review feedback suggests dynamically resolving the network in the connection client to support switching to mainnet, adding a fail-fast check in the arena watcher to prevent silent failures when the identity is missing, and explicitly defaulting chainPubkey to null during wallet creation to maintain a consistent JSON schema.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| client = new ConnectClient({ | ||
| transport, dapp: dappMeta, permissions: [...dappPermissions], resumeSessionId, | ||
| network: SPHERE_NETWORKS.testnet2, | ||
| }); |
There was a problem hiding this comment.
Hardcoding SPHERE_NETWORKS.testnet2 in the client-side dApp prevents it from working seamlessly when the server is switched to mainnet. Since the server already injects dynamic configuration like window.__BOXY_ARENA_WALLET, it is highly recommended to also inject the network type (e.g., window.__BOXY_SPHERE_NETWORK) and resolve the network dynamically with a fallback to testnet2.
| client = new ConnectClient({ | |
| transport, dapp: dappMeta, permissions: [...dappPermissions], resumeSessionId, | |
| network: SPHERE_NETWORKS.testnet2, | |
| }); | |
| const networkName = (window as any).__BOXY_SPHERE_NETWORK; | |
| const sphereNetwork = networkName === 'mainnet' ? SPHERE_NETWORKS.mainnet : SPHERE_NETWORKS.testnet2; | |
| client = new ConnectClient({ | |
| transport, dapp: dappMeta, permissions: [...dappPermissions], resumeSessionId, | |
| network: sphereNetwork, | |
| }); |
| const id = sphere.identity; | ||
| console.log( | ||
| `[arena-watcher] Sphere ready — nametag=${id?.nametag ? '@' + id.nametag : '(none)'} ` + | ||
| `l1Address=${id?.l1Address || '?'}`, | ||
| `directAddress=${id?.directAddress || '?'}`, | ||
| ); |
There was a problem hiding this comment.
If sphere.identity is null or undefined, the arena watcher will not be able to monitor incoming transfers for the correct wallet, leading to a silent failure. Adding a fail-fast check here ensures that the server crashes or logs a clear error immediately upon boot if the identity cannot be resolved.
| const id = sphere.identity; | |
| console.log( | |
| `[arena-watcher] Sphere ready — nametag=${id?.nametag ? '@' + id.nametag : '(none)'} ` + | |
| `l1Address=${id?.l1Address || '?'}`, | |
| `directAddress=${id?.directAddress || '?'}`, | |
| ); | |
| const id = sphere.identity; | |
| if (!id) { | |
| throw new Error('[arena-watcher] Sphere ready but no identity was returned. Cannot watch for transfers.'); | |
| } | |
| console.log( | |
| `[arena-watcher] Sphere ready — nametag=${id.nametag ? '@' + id.nametag : '(none)'} ` + | |
| `directAddress=${id.directAddress || '?'}`, | |
| ); |
| directAddress: identity.directAddress ?? null, | ||
| chainPubkey: identity.chainPubkey, |
There was a problem hiding this comment.
If identity.chainPubkey is undefined, the chainPubkey key will be completely omitted from the serialized JSON file. To maintain a consistent schema for arena-wallet.json and prevent potential parsing issues in external tools, it is safer to explicitly default it to null using the nullish coalescing operator.
| directAddress: identity.directAddress ?? null, | |
| chainPubkey: identity.chainPubkey, | |
| directAddress: identity.directAddress ?? null, | |
| chainPubkey: identity.chainPubkey ?? null, |
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.
eb4af7d to
a42b448
Compare
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).
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.
Bumps
@unicitylabs/sphere-sdkfrom^0.6.0-dev.1to^0.15.0(npmlatest) and adapts every call site. This supersedes the original 0.10.3 target — see below for why 0.10.3 is no longer a viable destination.Why this had to go to 0.15.0, not 0.10.3
The wallet deployed at
sphere.unicity.networkalready runs sphere-sdk 0.15.0, and a 0.15.0ConnectHostenforces an npm-SDK floor on the client at the handshake:ConnectClientstamps its own compiled-in package version into the handshake, so a dApp bundling 0.10.3 is refused withUNSUPPORTED_PROTOCOL_VERSION (4007)— "SDK version 0.10.3 is below the required minimum 0.14.1-0" — before resume and before the connection prompt. No dApp-side handling fixes it.Verified against the live deployment, not inferred: the served bundle contains both
SDK_VERSION="0.15.0"and the0.14.1-0floor string, plus thepv2g2storage-generation marker that only 0.15.0 writes.Money-correctness changes (six of these are not version hygiene)
1. Send amounts are BASE UNITS — silent, not an error
The Connect
sendintent changed itsamountcontract from a whole-token decimal to base units:amount: 10(10 whole UCT) passes that validation. It is not rejected — it is reinterpreted as 10 attoUCT. The player approves a dust transfer, and the arena watcher credits nothing (10n / 10n**18n === 0n), with no error surfaced anywhere in the chain. AddedtoBaseUnits()at the dApp's UI edge, using BigInt so the value never serialises in exponential notation and fails the wallet's regex.2.
INTENT_OUTCOME_UNKNOWN(4201) was a double-spend pathNew error code meaning "the wallet had the intent and the answer was lost — the money may or may not have moved; do not retry." Raised on a host deadline, a mid-flight lock, a client intent timeout, or a disconnect with an intent pending.
deposit()caught every throw identically, setisDepositPaid = falseand re-enabled the pay button — so a 4201 walked a player straight into a second payment. Now it branches on the numeric code, sets a sticky guard that refuses to re-issue, andindex.htmlleaves the Transfer button disabled with "Reload to continue".WALLET_LOCKED(4009) gets its own message rather than a raw error string.The guard is deliberately in-memory: nothing in the page can learn the outcome, so the reconciliation has to be a human one (reload → read the real balance and the game ledger → decide). Persisting it would strand the player with no way to clear it.
3. Incoming assets are now filtered by coin
IncomingTransfer.tokensis one entry per asset, not per token, every entry repeating the sameid:sumIncomingAmountsummed the array blindly, so any non-UCT asset riding along was credited as UCT at UCT's decimals. Under the old delivery rail the array was always[token], which is why this was invisible. Now filtered oncoinId, with a log for anything skipped.4. The UCT coin id was the v1 one
UCT_COIN_ID_HEXwas455ad872…, the v1 testnet coin. It does not appear inunicity-ids.testnet2.jsonat all — testnet2 UCT isf581d30f593e4b369d684a4563b5246f07b1d265f7178a2c0a82b81f39c24dc0(18 decimals). In the dApp it is the fallbackcoinIdused whenever 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.5. Per-leg flooring silently ate whole tokens
sumIncomingAmountdivided each token by10^decimalsbefore summing, 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 held).3.5 + 6.5credited 9;1.2 + 4.4 + 4.4credited 8. Now accumulates base units and divides once, logging the fractional remainder theINTEGERledger column cannot hold instead of dropping it. A reporteddecimals: 0is treated as the cold-registry sentinel it is — taking it literally would be a 10^18x over-credit.6. The 90-second retry window
index.htmlraced the deposit againstpollForDrop(45)at 2s = 90s, butConnectClient's own intent timeout is 120s and the host's deadline is longer still. Sotimeoutdid not mean the send was dead — it was very likely still live at the wallet — and the code re-enabled Transfer. Same double-spend as the 4201 case, one branch over. That branch now leaves the button disabled too.Server custody — the P11 flip
Token custody moved to the wallet-api backend, which makes
walletApimandatory:Sphere.import/createcallresolvePaymentsV2Compositionas their first act and throwINVALID_CONFIGwithout it, before any storage write. The arena watcher as written dies instantly on boot under 0.15.0.All three SDK call sites now:
createWalletApiProviders(base, { baseUrl, network, deviceId }),tokensDir(own-storage custody is gone; there is no local token store),oracle.apiKey— the SDK ships no bundled default any more,networktoSphere.import/createitself. It is string-compared againstwalletApi.network, and omitting it (as the code did) is an immediateINVALID_CONFIG.Network default moves
testnet→testnet2. These are aliases everywhere else in the SDK, but not here: the backend embeds its own network name in the auth challenge and the SDK verifies ours against it. Probed live — bothwallet-api.unicity.networkandwallet-api.staging.unicity.networkissue{"network":"testnet2",...}, so'testnet'fails sign-in withChallengeTemplateError.The watcher defaults to the production backend, because that is where the deployed wallet keeps its users' tokens — a player's send deposits into the arena wallet's mailbox on that backend, so pointing elsewhere means deposits are certified on chain and then never delivered. Overridable via
WALLET_API_URL.Other changes
engines: { node: '>=22.0.0' }, and both were onnode:20.scripts/added to tsconfiginclude. Both edited scripts were outside the typecheck program entirely, so their SDK call sites were never checked — which is how the previous "typecheck is clean" claim could be true whiletokensDirand a missingwalletApisat in them.test-arena-watcher.ts:transfer:confirmed,transfer:failed,sync:completed,sync:errorare gone from the event map.on()accepts any name, so they were silently never firing — a diagnostic script quietly lying. Replaced withtransfer:updated/transfer:attention/inventory:updated/history:updated/connection:status.Sphere.importrather than moving toSphere.init:importis the only entry point that honoursderivationMode/basePath, and the deployed wallet file may carry them. The same mnemonic under a different derivation is a different identity holding no money. The cost (import clears storage each boot, so the scoped KV and refresh token are re-created) is documented inline.New env vars (all have working defaults)
WALLET_API_URLhttps://wallet-api.unicity.networkAGGREGATOR_API_KEYWALLET_API_DEVICE_IDboxyrun-arena-<network>SPHERE_NETWORKtestnet2(wastestnet)Verification
npm run typecheck→ 16 errors, all pre-existing (15 Babylon / app-protocol, identical on the base commit; 1 inscripts/bot-players.ts, newly visible becausescripts/joined the program, and the same class as an existing error intournament/server/bots.ts). Zero in any SDK-touching file.npm run build(sdk + game + pages) andnpm run build:server→ all clean.sdkVersion: "0.15.0"and protocol2.1, clearing the handshake floor.toBaseUnitsexercised across integer / fractional / zero-decimal / invalid inputs; every output satisfies the wallet's/^\d+$/.sumIncomingAmountexercised across six cases (single leg, two-leg split, three-leg split, foreign coin alone, mixed UCT+USDU, cold-registrydecimals: 0); all produce the expected credit, and four of them are cases the previous code got wrong.npm run test:server→ 22/25 fail identically on this branch and on the base commit (a42b448), both measured on this machine. It is a 5s server-readiness deadline under the parallel 25-server spawn, unrelated to the SDK — which is never even loaded during tests, since the watcher is gated behindARENA_WALLET_FILE.payments.mint(coinIdHex, amount)(journal-first self-mint, no faucet).SPHERE_NETWORK=testnet2(nottestnet) and give staging and prod distinctWALLET_API_DEVICE_IDvalues.v2_<tokenId>; new ones are bare<tokenId>. Harmless — the reset means no pre-migration token can arrive again — but it is documented in the watcher so the next reader does not have to rediscover it.Known limitation (pre-existing, not introduced here)
The ledger's idempotency key is the token's genesis id, not
(tokenId, stateHash)— which is what the SDK's own receive dedup uses. A token that legitimately returns to the arena wallet at a later state is therefore refused as a duplicate and credits nothing.IncomingTransferdoes not exposestateHash, so the watcher cannot key on it. Left as-is deliberately: every alternative key available trades this under-credit for a double-credit on crash-redelivery, which is worse. Documented inline.