Summary
Four wallet-side defects must be fixed before MAINNET_ROLLOUT_ENABLED is ever set to true on any deployment.
All four are latent today and none of them block #443 — please merge that PR normally. They are latent only because the SDK has not onboarded mainnet yet (NETWORKS.mainnet carries no networkId, so unavailableReasonFor returns not-onboarded and the row can never become selectable). They all go live the moment mainnet becomes selectable.
This matters now because mainnet infrastructure is being stood up (gateway.mainnet.unicity.network is answering /health), so the gap between "latent" and "live" is short.
Each item below is a defect in code that predates the switcher — #443 does not introduce them, it makes them reachable. That is why they belong here rather than in the PR.
1. dApp approvals are not scoped to a network
Where: src/utils/connected-sites.ts (ApprovedOriginEntry has no network field), consumed at src/pages/ConnectPage.tsx:166 and src/components/agents/IframeAgent.tsx:188.
What happens: a returning origin is auto-approved with its stored scopes and no prompt at all:
const saved = getApprovedOrigin(origin);
if (saved) {
return { approved: true, grantedPermissions: saved.permissions };
}
The approved-origins store is a flat Record<origin, entry> in one localStorage slot. There is no network dimension, so an approval granted on testnet2 is indistinguishable from one granted on mainnet.
Impact: a dApp granted SEND (or MINT) on testnet2 is silently re-granted the same scopes on mainnet — no consent dialog, no indication anything changed. Test-money consent becomes real-money authority.
Suggested fix: key the store by (network, origin). Decide explicitly what happens to existing entries — either treat unscoped entries as testnet2 (matching LEGACY_URL_NETWORK) or drop them and re-prompt. Re-prompting is the safer default for a permission store.
2. PAID_PLANS_ENABLED is deployment-wide while the gateway it buys from is per-network
Where: src/config/subscription.ts:57 (the flag) vs src/config/subscription.ts:30 (the URL).
// :30 — per network
return NETWORKS[SPHERE_NETWORK].aggregatorUrl;
// :57 — per deployment
export const PAID_PLANS_ENABLED = setting('PAID_PLANS_ENABLED', ...) === 'true';
Every other money-shaped decision was made network-derived (wallet-api URL, SGW base, subscription key slot, aggregator-key policy, mint permission). This one flag was not.
Impact: docs/DEVOPS-MAINNET.md explicitly permits a mainnet deployment to also serve testnet2. On such a deployment a user who switches to testnet2 can still open the upgrade flow and pay real money for a subscription key that belongs to a test network.
Suggested fix: gate it on the same axis as minting, e.g. PAID_PLANS_ENABLED && !canSelfMint(SPHERE_NETWORK), or add a chargesRealMoney(network) predicate to src/config/networkCapabilities.ts so both live in the same fail-closed allowlist. PlansGrid already renders a "Coming on Mainnet" state, so the UI path exists.
3. The Unicity ID cache is global while nametag bindings are per-network
Where: SDK STORAGE_KEYS_GLOBAL.ADDRESS_NAMETAGS (address_nametags) — one slot, no network dimension. Nametag bindings themselves live on Nostr relays, and the relay sets differ per network (TEST_NOSTR_RELAYS for testnet2/dev vs DEFAULT_NOSTR_RELAYS for mainnet).
Impact: after a switch, the wallet presents a Unicity ID it does not own on the target network, and — because the cache looks populated — it suppresses the prompt to register the real one there. Worse if the same name is already bound to a different pubkey on the target network.
Note: this was independently hit from the infrastructure side — Nostr bindings carry no network identifier. A separate mainnet relay is the right direction but is only half the fix: with separate relays and a still-global cache, the wallet keeps showing the testnet name on mainnet. Both halves are needed.
4. DM and group-chat history is stored network-agnostically
Where: STORAGE_KEYS_ADDRESS.CONVERSATIONS / .MESSAGES / .GROUP_CHAT_MESSAGES, addressed via getAddressStorageKey(addressId, key) which produces {addressId}_{key} — no network term. The browser KV provider itself is a single sphere-storage namespace, also with no network dimension. The seed is shared across networks, so addressId is identical on both — the same slot serves both networks.
Impact: after a switch the wallet renders the other network's conversation history, and replies are published to relays where the counterparty is not listening. Chat being shared across networks was a deliberate decision made when there was only one network; disjoint relay sets change that premise.
Suggested fix: decide deliberately whether chat is shared or isolated now that relays diverge, and make the storage match the decision. If shared stays intentional, the UI must say which network a conversation belongs to.
Tripwire
tests/unit/config/network.test.ts:40 asserts that mainnet is currently not-onboarded. The day the SDK ships a networkId for mainnet, that test goes red — and every item above becomes live at once. Treat that red test as the signal to come back to this issue. Do not "fix" it by adjusting the assertion.
SDK-side prerequisites (context, not part of this issue)
Onboarding mainnet in @unicitylabs/sphere-sdk is three field changes, and two of the current values are wrong rather than missing:
| field |
current value (0.15.0) |
problem |
networkId |
absent |
must be added |
aggregatorUrl |
https://aggregator.unicity.network/rpc |
points at the old v1 aggregator, not the live mainnet gateway |
tokenRegistryUrl |
.../unicity-ids.testnet.json |
mainnet would load the testnet token registry — wrong coin ids, decimals and symbols, silently |
One related note while the mainnet gateway URL is still being chosen: SUBSCRIPTION_API_URL is derived from aggregatorUrl (src/config/subscription.ts:30). If the mainnet entry keeps a path segment such as /rpc, every SGW REST call becomes /rpc/api/... and there is no runtime override. A path-less base URL avoids this entirely.
Summary
Four wallet-side defects must be fixed before
MAINNET_ROLLOUT_ENABLEDis ever set totrueon any deployment.All four are latent today and none of them block #443 — please merge that PR normally. They are latent only because the SDK has not onboarded mainnet yet (
NETWORKS.mainnetcarries nonetworkId, sounavailableReasonForreturnsnot-onboardedand the row can never become selectable). They all go live the moment mainnet becomes selectable.This matters now because mainnet infrastructure is being stood up (
gateway.mainnet.unicity.networkis answering/health), so the gap between "latent" and "live" is short.Each item below is a defect in code that predates the switcher — #443 does not introduce them, it makes them reachable. That is why they belong here rather than in the PR.
1. dApp approvals are not scoped to a network
Where:
src/utils/connected-sites.ts(ApprovedOriginEntryhas no network field), consumed atsrc/pages/ConnectPage.tsx:166andsrc/components/agents/IframeAgent.tsx:188.What happens: a returning origin is auto-approved with its stored scopes and no prompt at all:
The approved-origins store is a flat
Record<origin, entry>in onelocalStorageslot. There is no network dimension, so an approval granted on testnet2 is indistinguishable from one granted on mainnet.Impact: a dApp granted
SEND(orMINT) on testnet2 is silently re-granted the same scopes on mainnet — no consent dialog, no indication anything changed. Test-money consent becomes real-money authority.Suggested fix: key the store by
(network, origin). Decide explicitly what happens to existing entries — either treat unscoped entries as testnet2 (matchingLEGACY_URL_NETWORK) or drop them and re-prompt. Re-prompting is the safer default for a permission store.2.
PAID_PLANS_ENABLEDis deployment-wide while the gateway it buys from is per-networkWhere:
src/config/subscription.ts:57(the flag) vssrc/config/subscription.ts:30(the URL).Every other money-shaped decision was made network-derived (wallet-api URL, SGW base, subscription key slot, aggregator-key policy, mint permission). This one flag was not.
Impact:
docs/DEVOPS-MAINNET.mdexplicitly permits a mainnet deployment to also serve testnet2. On such a deployment a user who switches to testnet2 can still open the upgrade flow and pay real money for a subscription key that belongs to a test network.Suggested fix: gate it on the same axis as minting, e.g.
PAID_PLANS_ENABLED && !canSelfMint(SPHERE_NETWORK), or add achargesRealMoney(network)predicate tosrc/config/networkCapabilities.tsso both live in the same fail-closed allowlist.PlansGridalready renders a "Coming on Mainnet" state, so the UI path exists.3. The Unicity ID cache is global while nametag bindings are per-network
Where: SDK
STORAGE_KEYS_GLOBAL.ADDRESS_NAMETAGS(address_nametags) — one slot, no network dimension. Nametag bindings themselves live on Nostr relays, and the relay sets differ per network (TEST_NOSTR_RELAYSfor testnet2/dev vsDEFAULT_NOSTR_RELAYSfor mainnet).Impact: after a switch, the wallet presents a Unicity ID it does not own on the target network, and — because the cache looks populated — it suppresses the prompt to register the real one there. Worse if the same name is already bound to a different pubkey on the target network.
Note: this was independently hit from the infrastructure side — Nostr bindings carry no network identifier. A separate mainnet relay is the right direction but is only half the fix: with separate relays and a still-global cache, the wallet keeps showing the testnet name on mainnet. Both halves are needed.
4. DM and group-chat history is stored network-agnostically
Where:
STORAGE_KEYS_ADDRESS.CONVERSATIONS/.MESSAGES/.GROUP_CHAT_MESSAGES, addressed viagetAddressStorageKey(addressId, key)which produces{addressId}_{key}— no network term. The browser KV provider itself is a singlesphere-storagenamespace, also with no network dimension. The seed is shared across networks, soaddressIdis identical on both — the same slot serves both networks.Impact: after a switch the wallet renders the other network's conversation history, and replies are published to relays where the counterparty is not listening. Chat being shared across networks was a deliberate decision made when there was only one network; disjoint relay sets change that premise.
Suggested fix: decide deliberately whether chat is shared or isolated now that relays diverge, and make the storage match the decision. If shared stays intentional, the UI must say which network a conversation belongs to.
Tripwire
tests/unit/config/network.test.ts:40asserts that mainnet is currentlynot-onboarded. The day the SDK ships anetworkIdfor mainnet, that test goes red — and every item above becomes live at once. Treat that red test as the signal to come back to this issue. Do not "fix" it by adjusting the assertion.SDK-side prerequisites (context, not part of this issue)
Onboarding mainnet in
@unicitylabs/sphere-sdkis three field changes, and two of the current values are wrong rather than missing:networkIdaggregatorUrlhttps://aggregator.unicity.network/rpctokenRegistryUrl.../unicity-ids.testnet.jsonOne related note while the mainnet gateway URL is still being chosen:
SUBSCRIPTION_API_URLis derived fromaggregatorUrl(src/config/subscription.ts:30). If the mainnet entry keeps a path segment such as/rpc, every SGW REST call becomes/rpc/api/...and there is no runtime override. A path-less base URL avoids this entirely.