Skip to content

Migrate to @unicitylabs/sphere-sdk 0.15.0 - #2

Open
MastaP wants to merge 3 commits into
tournament-modefrom
migrate-sphere-sdk-0.10.3
Open

Migrate to @unicitylabs/sphere-sdk 0.15.0#2
MastaP wants to merge 3 commits into
tournament-modefrom
migrate-sphere-sdk-0.10.3

Conversation

@MastaP

@MastaP MastaP commented Jun 22, 2026

Copy link
Copy Markdown
Member

Bumps @unicitylabs/sphere-sdk from ^0.6.0-dev.1 to ^0.15.0 (npm latest) 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.network already runs sphere-sdk 0.15.0, and a 0.15.0 ConnectHost enforces an npm-SDK floor on the client at the handshake:

// connect/host/ConnectHost.ts:569
minSdkVersion: this.config.minSdkVersion ?? DEFAULT_MIN_CLIENT_SDK_VERSION  // '0.14.1-0'

ConnectClient stamps its own compiled-in package version into the handshake, so a dApp bundling 0.10.3 is refused with UNSUPPORTED_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 the 0.14.1-0 floor string, plus the pv2g2 storage-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 send intent changed its amount contract from a whole-token decimal to base units:

// sphere wallet, ConnectIntentHandler.tsx (landed 2026-06-19, commit 63ace658)
const amountStr = params.amount == null ? '' : String(params.amount).trim();
if (!/^\d+$/.test(amountStr) || BigInt(amountStr) <= 0n)  INVALID_PARAMS

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. Added toBaseUnits() 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 path

New 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, set isDepositPaid = false and 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, and index.html leaves 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.tokens is one entry per asset, not per token, every entry repeating the same id:

// modules/payments-v2/receive/Receive.ts:295
tokens: record.assets.map((asset) => toUiToken(record.tokenId, asset, ...))

sumIncomingAmount summed 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 on coinId, with a log for anything skipped.

4. The UCT coin id was the v1 one

UCT_COIN_ID_HEX was 455ad872…, the v1 testnet coin. It does not appear in unicity-ids.testnet2.json at all — testnet2 UCT is f581d30f593e4b369d684a4563b5246f07b1d265f7178a2c0a82b81f39c24dc0 (18 decimals). In the dApp it is the fallback coinId used 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

sumIncomingAmount divided each token by 10^decimals before 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.5 credited 9; 1.2 + 4.4 + 4.4 credited 8. Now accumulates base units and divides once, logging the fractional remainder the INTEGER ledger column cannot hold instead of dropping it. A reported decimals: 0 is 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.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 did 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 walletApi mandatory: Sphere.import/create call resolvePaymentsV2Composition as their first act and throw INVALID_CONFIG without it, before any storage write. The arena watcher as written dies instantly on boot under 0.15.0.

All three SDK call sites now:

  • wrap the base providers with createWalletApiProviders(base, { baseUrl, network, deviceId }),
  • drop tokensDir (own-storage custody is gone; there is no local token store),
  • pass oracle.apiKey — the SDK ships no bundled default any more,
  • pass network to Sphere.import/create itself. It is string-compared against walletApi.network, and omitting it (as the code did) is an immediate INVALID_CONFIG.

Network default moves testnettestnet2. 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 — both wallet-api.unicity.network and wallet-api.staging.unicity.network issue {"network":"testnet2",...}, so 'testnet' fails sign-in with ChallengeTemplateError.

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

  • Node 22 in the Dockerfile (both stages) and CI — 0.15.0 declares engines: { node: '>=22.0.0' }, and both were on node:20.
  • scripts/ added to tsconfig include. 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 while tokensDir and a missing walletApi sat in them.
  • Dead event names replaced in test-arena-watcher.ts: transfer:confirmed, transfer:failed, sync:completed, sync:error are gone from the event map. on() accepts any name, so they were silently never firing — a diagnostic script quietly lying. Replaced with transfer:updated / transfer:attention / inventory:updated / history:updated / connection:status.
  • Kept Sphere.import rather than moving to Sphere.init: import 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. 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)

Var Default Notes
WALLET_API_URL https://wallet-api.unicity.network Must be the same backend the players' wallets use
AGGREGATOR_API_KEY the testnet2 key Documented non-secret for testnet2; a mainnet key would not be
WALLET_API_DEVICE_ID boxyrun-arena-<network> Keys the refresh-token row — give staging and prod distinct values
SPHERE_NETWORK testnet2 (was testnet) Must match the backend's name exactly, not an alias

Verification

  • npm run typecheck16 errors, all pre-existing (15 Babylon / app-protocol, identical on the base commit; 1 in scripts/bot-players.ts, newly visible because scripts/ joined the program, and the same class as an existing error in tournament/server/bots.ts). Zero in any SDK-touching file.
  • npm run build (sdk + game + pages) and npm run build:server → all clean.
  • Emitted dApp bundle reports sdkVersion: "0.15.0" and protocol 2.1, clearing the handshake floor.
  • toBaseUnits exercised across integer / fractional / zero-decimal / invalid inputs; every output satisfies the wallet's /^\d+$/.
  • sumIncomingAmount exercised across six cases (single leg, two-leg split, three-leg split, foreign coin alone, mixed UCT+USDU, cold-registry decimals: 0); all produce the expected credit, and four of them are cases the previous code got wrong.
  • npm run test:server22/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 behind ARENA_WALLET_FILE.

⚠️ Deploy notes

  • The arena wallet must be re-funded. The state-transition 3.x flag day came with a testnet reset and a wallet-api inventory truncation on 2026-08-29 — all pre-existing balances are void. Top up with payments.mint(coinIdHex, amount) (journal-first self-mint, no faucet).
  • There is no cross-major interop. A wallet still on 0.10.3 cannot transact with a 0.15.0 wallet in either direction, and the testnet2 gateway is already cut over to the v3 aggregator, so nothing 2.x writes gets certified at all.
  • Set SPHERE_NETWORK=testnet2 (not testnet) and give staging and prod distinct WALLET_API_DEVICE_ID values.
  • Historic ledger rows are keyed 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. IncomingTransfer does not expose stateHash, 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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/sphere-connect.ts
Comment on lines 174 to 177
client = new ConnectClient({
transport, dapp: dappMeta, permissions: [...dappPermissions], resumeSessionId,
network: SPHERE_NETWORKS.testnet2,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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,
});

Comment on lines 214 to 218
const id = sphere.identity;
console.log(
`[arena-watcher] Sphere ready — nametag=${id?.nametag ? '@' + id.nametag : '(none)'} ` +
`l1Address=${id?.l1Address || '?'}`,
`directAddress=${id?.directAddress || '?'}`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 || '?'}`,
);

Comment on lines +134 to +135
directAddress: identity.directAddress ?? null,
chainPubkey: identity.chainPubkey,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
directAddress: identity.directAddress ?? null,
chainPubkey: identity.chainPubkey,
directAddress: identity.directAddress ?? null,
chainPubkey: identity.chainPubkey ?? null,

@MastaP
MastaP requested a review from 0xt1mo June 22, 2026 10:26
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.
@MastaP
MastaP force-pushed the migrate-sphere-sdk-0.10.3 branch from eb4af7d to a42b448 Compare June 22, 2026 10:33
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).
@MastaP MastaP changed the title Migrate to @unicitylabs/sphere-sdk 0.10.3 Migrate to @unicitylabs/sphere-sdk 0.15.0 Aug 31, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant