Skip to content

Mainnet rollout gate: four wallet-side defects to fix before MAINNET_ROLLOUT_ENABLED is flipped #497

Description

@KruGoL

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions