@unicitylabs/sphere-sdk 0.15.0-dev.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../core/logger.ts","../../../../connect/semver.ts","../../../../constants.ts","../../../../connect/protocol.ts","../../../../connect/permissions.ts","../../../../connect/host/payments-compat.ts","../../../../connect/host/host-state.ts","../../../../connect/host/WalletSnapshot.ts","../../../../connect/host/ConnectHost.ts","../../../../transport/websocket.ts","../../../../impl/nodejs/connect/WebSocketTransport.ts"],"sourcesContent":["/**\n * Centralized SDK Logger\n *\n * A lightweight singleton logger that works across all tsup bundles\n * by storing state on globalThis. Supports three log levels:\n * - debug: detailed messages (only shown when debug=true)\n * - warn: important warnings (ALWAYS shown regardless of debug flag)\n * - error: critical errors (ALWAYS shown regardless of debug flag)\n *\n * Global debug flag enables all logging. Per-tag overrides allow\n * granular control (e.g., only transport debug).\n *\n * @example\n * ```ts\n * import { logger } from '@unicitylabs/sphere-sdk';\n *\n * // Enable all debug logging\n * logger.configure({ debug: true });\n *\n * // Enable only specific tags\n * logger.setTagDebug('Nostr', true);\n *\n * // Usage in SDK classes\n * logger.debug('Payments', 'Transfer started', { amount, recipient });\n * logger.warn('Nostr', 'queryEvents timed out after 5s');\n * logger.error('Sphere', 'Critical failure', error);\n * ```\n */\n\nexport type LogLevel = 'debug' | 'warn' | 'error';\n\nexport type LogHandler = (level: LogLevel, tag: string, message: string, ...args: unknown[]) => void;\n\nexport interface LoggerConfig {\n /** Enable debug logging globally (default: false). When false, only warn and error messages are shown. */\n debug?: boolean;\n /** Custom log handler. If provided, replaces console output. Useful for tests or custom log sinks. */\n handler?: LogHandler | null;\n}\n\n// Use a unique symbol-like key on globalThis to share logger state across tsup bundles\nconst LOGGER_KEY = '__sphere_sdk_logger__';\n\ninterface LoggerState {\n debug: boolean;\n tags: Record<string, boolean>;\n handler: LogHandler | null;\n}\n\nfunction getState(): LoggerState {\n const g = globalThis as unknown as Record<string, unknown>;\n if (!g[LOGGER_KEY]) {\n g[LOGGER_KEY] = { debug: false, tags: {}, handler: null } satisfies LoggerState;\n }\n return g[LOGGER_KEY] as LoggerState;\n}\n\nfunction isEnabled(tag: string): boolean {\n const state = getState();\n // Per-tag override takes priority\n if (tag in state.tags) return state.tags[tag];\n // Fall back to global flag\n return state.debug;\n}\n\nexport const logger = {\n /**\n * Configure the logger. Can be called multiple times (last write wins).\n * Typically called by createBrowserProviders(), createNodeProviders(), or Sphere.init().\n */\n configure(config: LoggerConfig): void {\n const state = getState();\n if (config.debug !== undefined) state.debug = config.debug;\n if (config.handler !== undefined) state.handler = config.handler;\n },\n\n /**\n * Enable/disable debug logging for a specific tag.\n * Per-tag setting overrides the global debug flag.\n *\n * @example\n * ```ts\n * logger.setTagDebug('Nostr', true); // enable only Nostr logs\n * logger.setTagDebug('Nostr', false); // disable Nostr logs even if global debug=true\n * ```\n */\n setTagDebug(tag: string, enabled: boolean): void {\n getState().tags[tag] = enabled;\n },\n\n /**\n * Clear per-tag override, falling back to global debug flag.\n */\n clearTagDebug(tag: string): void {\n delete getState().tags[tag];\n },\n\n /** Returns true if debug mode is enabled for the given tag (or globally). */\n isDebugEnabled(tag?: string): boolean {\n if (tag) return isEnabled(tag);\n return getState().debug;\n },\n\n /**\n * Debug-level log. Only shown when debug is enabled (globally or for this tag).\n * Use for detailed operational information.\n */\n debug(tag: string, message: string, ...args: unknown[]): void {\n if (!isEnabled(tag)) return;\n const state = getState();\n if (state.handler) {\n state.handler('debug', tag, message, ...args);\n } else {\n console.log(`[${tag}]`, message, ...args);\n }\n },\n\n /**\n * Warning-level log. ALWAYS shown regardless of debug flag.\n * Use for important but non-critical issues (timeouts, retries, degraded state).\n */\n warn(tag: string, message: string, ...args: unknown[]): void {\n const state = getState();\n if (state.handler) {\n state.handler('warn', tag, message, ...args);\n } else {\n console.warn(`[${tag}]`, message, ...args);\n }\n },\n\n /**\n * Error-level log. ALWAYS shown regardless of debug flag.\n * Use for critical failures that should never be silenced.\n */\n error(tag: string, message: string, ...args: unknown[]): void {\n const state = getState();\n if (state.handler) {\n state.handler('error', tag, message, ...args);\n } else {\n console.error(`[${tag}]`, message, ...args);\n }\n },\n\n /** Reset all logger state (debug flag, tags, handler). Primarily for tests. */\n reset(): void {\n const g = globalThis as unknown as Record<string, unknown>;\n delete g[LOGGER_KEY];\n },\n};\n","// connect/semver.ts\n// Tiny, dependency-free semver helpers for the Connect compatibility gate.\n\n/** MAJOR component of a semver string (e.g. '2.7.3' -> 2). NaN if unparseable. */\nexport function majorOf(v: string): number {\n return parseInt(String(v).split('.')[0], 10);\n}\n\n/**\n * Compare two semver strings, prerelease-aware. Returns -1 | 0 | 1.\n * A release outranks its own prerelease: compareSemver('1.2.3', '1.2.3-rc.1') === 1.\n */\nexport function compareSemver(a: string, b: string): number {\n const parse = (v: string) => {\n const [core, pre] = String(v).split('-', 2) as [string, string | undefined];\n const nums = core.split('.').map((n) => parseInt(n, 10) || 0);\n return { nums, pre };\n };\n const pa = parse(a);\n const pb = parse(b);\n for (let i = 0; i < 3; i++) {\n const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);\n if (d !== 0) return d < 0 ? -1 : 1;\n }\n if (pa.pre === undefined && pb.pre === undefined) return 0;\n if (pa.pre === undefined) return 1; // release > prerelease\n if (pb.pre === undefined) return -1;\n const sa = pa.pre.split('.');\n const sb = pb.pre.split('.');\n for (let i = 0; i < Math.max(sa.length, sb.length); i++) {\n const x = sa[i];\n const y = sb[i];\n if (x === undefined) return -1;\n if (y === undefined) return 1;\n const nx = Number(x);\n const ny = Number(y);\n if (!Number.isNaN(nx) && !Number.isNaN(ny)) {\n if (nx !== ny) return nx < ny ? -1 : 1;\n } else if (x !== y) {\n return x < y ? -1 : 1;\n }\n }\n return 0;\n}\n","/**\n * SDK2 Constants\n * Default configuration values and storage keys\n */\n\n// =============================================================================\n// Storage Keys\n// =============================================================================\n\n/** Default prefix for all storage keys */\nexport const STORAGE_PREFIX = 'sphere_' as const;\n\n/**\n * Default encryption key for wallet data\n * WARNING: This is a placeholder. In production, use user-provided password.\n * This key is used when no password is provided to encrypt/decrypt mnemonic.\n */\nexport const DEFAULT_ENCRYPTION_KEY = 'sphere-default-key' as const;\n\n/**\n * Global storage keys (one per wallet, no address index)\n * Final key format: sphere_{key}\n */\nexport const STORAGE_KEYS_GLOBAL = {\n /** Encrypted BIP39 mnemonic */\n MNEMONIC: 'mnemonic',\n /** Encrypted master private key */\n MASTER_KEY: 'master_key',\n /** BIP32 chain code */\n CHAIN_CODE: 'chain_code',\n /** HD derivation path (full path like m/44'/0'/0'/0/0) */\n DERIVATION_PATH: 'derivation_path',\n /** Base derivation path (like m/44'/0'/0' without chain/index) */\n BASE_PATH: 'base_path',\n /** Derivation mode: bip32, wif_hmac, legacy_hmac */\n DERIVATION_MODE: 'derivation_mode',\n /** Wallet source: mnemonic, file, unknown */\n WALLET_SOURCE: 'wallet_source',\n /** Wallet existence flag */\n WALLET_EXISTS: 'wallet_exists',\n /** Current active address index */\n CURRENT_ADDRESS_INDEX: 'current_address_index',\n /** Nametag cache per address (separate from tracked addresses registry) */\n ADDRESS_NAMETAGS: 'address_nametags',\n /** Active addresses registry (JSON: TrackedAddressesStorage) */\n TRACKED_ADDRESSES: 'tracked_addresses',\n /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */\n LAST_WALLET_EVENT_TS: 'last_wallet_event_ts',\n /** Last processed Nostr DM (gift-wrap) event timestamp (unix seconds), keyed per pubkey */\n LAST_DM_EVENT_TS: 'last_dm_event_ts',\n /** Group chat: last used relay URL (stale data detection) — global, same relay for all addresses */\n GROUP_CHAT_RELAY_URL: 'group_chat_relay_url',\n /** Cached token registry JSON (fetched from remote) */\n TOKEN_REGISTRY_CACHE: 'token_registry_cache',\n /** Timestamp of last token registry cache update (ms since epoch) */\n TOKEN_REGISTRY_CACHE_TS: 'token_registry_cache_ts',\n /** Cached price data JSON (from CoinGecko or other provider) */\n PRICE_CACHE: 'price_cache',\n /** Timestamp of last price cache update (ms since epoch) */\n PRICE_CACHE_TS: 'price_cache_ts',\n} as const;\n\n/**\n * Per-address storage keys (one per derived address)\n * Final key format: sphere_{DIRECT_xxx_yyy}_{key}\n * Example: sphere_DIRECT_abc123_xyz789_pending_transfers\n *\n * Note: Token data is server custody (wallet-api backend), not here; the\n * payments vertical's durable client state self-prefixes `pv2g2:{network}:{pubkey}:`.\n */\nexport const STORAGE_KEYS_ADDRESS = {\n /** Transfer outbox for this address (pre-flip key name; kept as the network-scoping witness) */\n OUTBOX: 'outbox',\n /** Conversations for this address */\n CONVERSATIONS: 'conversations',\n /** Messages for this address */\n MESSAGES: 'messages',\n /** Group chat: joined groups for this address */\n GROUP_CHAT_GROUPS: 'group_chat_groups',\n /** Group chat: messages for this address */\n GROUP_CHAT_MESSAGES: 'group_chat_messages',\n /** Group chat: members for this address */\n GROUP_CHAT_MEMBERS: 'group_chat_members',\n /** Group chat: processed event IDs for deduplication */\n GROUP_CHAT_PROCESSED_EVENTS: 'group_chat_processed_events',\n /** Auto-return settings (pre-flip key name; kept as the network-scoping witness) */\n AUTO_RETURN: 'auto_return',\n /** Auto-return dedup ledger (pre-flip key name; kept as the network-scoping witness) */\n AUTO_RETURN_LEDGER: 'auto_return_ledger',\n /** Per-swap key prefix (pre-flip key name; kept as the network-scoping witness) */\n SWAP_RECORD_PREFIX: 'swap:',\n} as const;\n\n/**\n * Per-address keys that are ALSO per-network: token/payment operational state. Mixing these\n * across networks is unsafe. Chat/identity per-address keys (CONVERSATIONS/MESSAGES/\n * GROUP_CHAT_*) are network-AGNOSTIC and deliberately NOT listed. The payments vertical\n * self-prefixes `pv2g2:{network}:{pubkey}:` and never rides this mechanism; the entries left\n * here are the pre-flip key names the network-isolation tests pin the provider behavior\n * with (P11 stage-2 note: the full isNetworkScopedAddressKey cut needs an owner call on\n * those tests first).\n */\nconst NETWORK_SCOPED_ADDRESS_KEYS: readonly string[] = [\n STORAGE_KEYS_ADDRESS.OUTBOX,\n STORAGE_KEYS_ADDRESS.AUTO_RETURN,\n STORAGE_KEYS_ADDRESS.AUTO_RETURN_LEDGER,\n];\n\n/** Composite per-address key prefixes (modules store `{addressId}_{prefix}{id}`) — per-network. */\nconst NETWORK_SCOPED_ADDRESS_PREFIXES: readonly string[] = [\n STORAGE_KEYS_ADDRESS.SWAP_RECORD_PREFIX, // 'swap:'\n 'inv_ledger:', // AccountingModule INV_LEDGER_PREFIX\n];\n\n/**\n * True if a storage key is a per-NETWORK token/payment key (so the storage provider adds the\n * network segment). Handles the bare form ('auto_return_ledger'), the module-built addressId\n * form ('DIRECT_a_b_auto_return_ledger'), and composites ('{addressId}_swap:{id}'). Chat/identity\n * keys return false — they remain per-address, network-agnostic.\n */\nexport function isNetworkScopedAddressKey(key: string): boolean {\n for (const k of NETWORK_SCOPED_ADDRESS_KEYS) {\n if (key === k || key.endsWith(`_${k}`)) return true;\n }\n for (const p of NETWORK_SCOPED_ADDRESS_PREFIXES) {\n if (key.startsWith(p) || key.includes(`_${p}`)) return true;\n }\n return false;\n}\n\n/**\n * Build a per-address storage key using address identifier\n * @param addressId - Short identifier for the address (e.g., first 8 chars of pubkey hash, or direct address hash)\n * @param key - The key from STORAGE_KEYS_ADDRESS\n * @returns Key in format: \"{addressId}_{key}\" e.g., \"a1b2c3d4_tokens\"\n */\nexport function getAddressStorageKey(addressId: string, key: string): string {\n return `${addressId}_${key}`;\n}\n\n/**\n * Create a readable address identifier from directAddress or chainPubkey\n * Format: DIRECT_first6_last6 (sanitized for filesystem/storage)\n * @param directAddress - The L3 direct address (DIRECT:xxx) or chainPubkey\n * @returns Sanitized identifier like \"DIRECT_abc123_xyz789\"\n */\nexport function getAddressId(directAddress: string): string {\n // Remove DIRECT:// or DIRECT: prefix if present\n let hash = directAddress;\n if (hash.startsWith('DIRECT://')) {\n hash = hash.slice(9);\n } else if (hash.startsWith('DIRECT:')) {\n hash = hash.slice(7);\n }\n // Format: DIRECT_first6_last6 (sanitized)\n const first = hash.slice(0, 6).toLowerCase();\n const last = hash.slice(-6).toLowerCase();\n return `DIRECT_${first}_${last}`;\n}\n\n// =============================================================================\n// Nostr Defaults\n// =============================================================================\n\n/** Default Nostr relays */\nexport const DEFAULT_NOSTR_RELAYS = [\n 'wss://relay.unicity.network',\n 'wss://relay.damus.io',\n 'wss://nos.lol',\n 'wss://relay.nostr.band',\n] as const;\n\n/** Nostr event kinds used by SDK - must match @unicitylabs/nostr-js-sdk */\nexport const NOSTR_EVENT_KINDS = {\n /** NIP-04 encrypted direct message */\n DIRECT_MESSAGE: 4,\n /** Nametag binding (NIP-78 app-specific data) */\n NAMETAG_BINDING: 30078,\n /** Public broadcast */\n BROADCAST: 1,\n} as const;\n\n/**\n * NIP-29 Event Kinds for relay-based group chat\n * https://github.com/nostr-protocol/nips/blob/master/29.md\n */\nexport const NIP29_KINDS = {\n /** Chat message sent to group */\n CHAT_MESSAGE: 9,\n /** Thread root message */\n THREAD_ROOT: 11,\n /** Thread reply message */\n THREAD_REPLY: 12,\n /** User join request */\n JOIN_REQUEST: 9021,\n /** User leave request */\n LEAVE_REQUEST: 9022,\n /** Admin: add/update user */\n PUT_USER: 9000,\n /** Admin: remove user */\n REMOVE_USER: 9001,\n /** Admin: edit group metadata */\n EDIT_METADATA: 9002,\n /** Admin: delete event */\n DELETE_EVENT: 9005,\n /** Admin: create group */\n CREATE_GROUP: 9007,\n /** Admin: delete group */\n DELETE_GROUP: 9008,\n /** Admin: create invite code */\n CREATE_INVITE: 9009,\n /** Relay-signed group metadata */\n GROUP_METADATA: 39000,\n /** Relay-signed group admins */\n GROUP_ADMINS: 39001,\n /** Relay-signed group members */\n GROUP_MEMBERS: 39002,\n /** Relay-signed group roles */\n GROUP_ROLES: 39003,\n} as const;\n\n// =============================================================================\n// Aggregator (Oracle) Defaults\n// =============================================================================\n\n/**\n * Default aggregator URL\n * Note: The aggregator is conceptually an oracle - a trusted service that provides\n * verifiable truth about token state through cryptographic inclusion proofs.\n */\nexport const DEFAULT_AGGREGATOR_URL = 'https://aggregator.unicity.network/rpc' as const;\n\n/** Dev aggregator URL */\nexport const DEV_AGGREGATOR_URL = 'https://dev-aggregator.dyndns.org/rpc' as const;\n\n/** Default aggregator request timeout (ms) */\nexport const DEFAULT_AGGREGATOR_TIMEOUT = 30000;\n\n\n// =============================================================================\n// Wallet Defaults\n// =============================================================================\n\n/** Default BIP32 base path (without chain/index) */\nexport const DEFAULT_BASE_PATH = \"m/44'/0'/0'\" as const;\n\n/** Default BIP32 derivation path (full path with chain/index) */\nexport const DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0` as const;\n\n/** Coin types */\nexport const COIN_TYPES = {\n /** Test token */\n TEST: 'TEST',\n} as const;\n\n// =============================================================================\n// Token Registry Defaults\n// =============================================================================\n\n/** Remote token registry URL (GitHub raw) */\nexport const TOKEN_REGISTRY_URL =\n 'https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet.json' as const;\n\n/** Default token registry refresh interval (ms) — 1 hour */\nexport const TOKEN_REGISTRY_REFRESH_INTERVAL = 3_600_000;\n\n// =============================================================================\n// Network Defaults\n// =============================================================================\n\n/** Testnet Nostr relays */\nexport const TEST_NOSTR_RELAYS = [\n 'wss://nostr-relay.testnet.unicity.network',\n] as const;\n\n/** Default group chat relays (NIP-29 Zooid relay) */\nexport const DEFAULT_GROUP_RELAYS = [\n 'wss://sphere-relay.unicity.network',\n] as const;\n\n/**\n * Complete configuration for one network. Every field is required, so adding a\n * network (or a field) that is missing any of these is a COMPILE error at the\n * NETWORKS literal below (via `satisfies`) — a half-configured network can never\n * be defined silently.\n */\nexport interface NetworkConfig {\n readonly name: string;\n readonly aggregatorUrl: string;\n readonly nostrRelays: readonly string[];\n readonly groupRelays: readonly string[];\n readonly tokenRegistryUrl: string;\n /** Canonical numeric network id (= RootTrustBase.networkId; testnet2 = 4).\n * Optional: only set for networks that have a live v2 trust base. */\n readonly networkId?: number;\n}\n\n/** Network configurations */\nexport const NETWORKS = {\n mainnet: {\n name: 'Mainnet',\n aggregatorUrl: DEFAULT_AGGREGATOR_URL,\n nostrRelays: DEFAULT_NOSTR_RELAYS,\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl: TOKEN_REGISTRY_URL,\n },\n // v1 cutover: 'testnet' now POINTS AT TESTNET2 (the v2 gateway network). The\n // old goggregator testnet spoke the removed v1 protocol — a v2 engine cannot\n // run against it. 'testnet2' stays as an alias of the same configuration.\n testnet: {\n name: 'Testnet2',\n networkId: 4,\n // v2 state-transition gateway (networkId 4 comes from the trust base). apiKey is env-injected.\n aggregatorUrl: 'https://gateway.testnet2.unicity.network',\n nostrRelays: TEST_NOSTR_RELAYS, // reuse testnet infra (shared relays/ipfs)\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl:\n 'https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet2.json',\n },\n testnet2: {\n name: 'Testnet2',\n networkId: 4,\n // v2 state-transition gateway (networkId 4 comes from the trust base). apiKey is env-injected.\n aggregatorUrl: 'https://gateway.testnet2.unicity.network',\n nostrRelays: TEST_NOSTR_RELAYS, // reuse testnet infra (shared relays/ipfs)\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl:\n 'https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet2.json',\n },\n // NOTE: mainnet/dev still point at v1-era aggregators. The v2 engine cannot\n // operate against them until their gateways are cut over to the v2 protocol —\n // wallet operations on these networks fail loudly (AGGREGATOR_ERROR) until then.\n dev: {\n name: 'Development',\n aggregatorUrl: DEV_AGGREGATOR_URL,\n nostrRelays: TEST_NOSTR_RELAYS,\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl: TOKEN_REGISTRY_URL,\n },\n} as const satisfies Record<string, NetworkConfig>;\n\nexport type NetworkType = keyof typeof NETWORKS;\n\n/**\n * A network descriptor — the canonical way to identify a Unicity network across\n * the SDK and the Connect protocol. `id` is the canonical key (= RootTrustBase\n * networkId, analogous to an EIP-155 chainId); `name` is human-readable metadata.\n * Richer fields (gateway/symbol/explorer/icon) and switch/add-network are deferred.\n */\nexport interface NetworkInfo {\n readonly id: number;\n readonly name?: string;\n}\n\n/**\n * Registry of known networks for dApps and wallets. Single-sourced from NETWORKS\n * so it cannot drift. Use as `network: SPHERE_NETWORKS.testnet2`. Custom networks\n * are the same shape: `network: { id, name }`. Only live v2 networks appear here;\n * the legacy `testnet` alias is intentionally not surfaced.\n */\nexport const SPHERE_NETWORKS = {\n testnet2: { id: NETWORKS.testnet2.networkId as number, name: 'testnet2' },\n} as const satisfies Record<string, NetworkInfo>;\n\n// =============================================================================\n// Timeouts & Limits\n// =============================================================================\n\n/** Default timeouts (ms) */\nexport const TIMEOUTS = {\n /** WebSocket connection timeout */\n WEBSOCKET_CONNECT: 10000,\n /** Nostr relay reconnect delay */\n NOSTR_RECONNECT_DELAY: 3000,\n /** Max reconnect attempts */\n MAX_RECONNECT_ATTEMPTS: 5,\n /** Proof polling interval */\n PROOF_POLL_INTERVAL: 1000,\n /** Sync interval */\n SYNC_INTERVAL: 60000,\n} as const;\n\n// =============================================================================\n// Sphere Connect\n// =============================================================================\n\n/** Signal sent by wallet popup to dApp when ConnectHost is ready */\nexport const HOST_READY_TYPE = 'sphere-connect:host-ready' as const;\n\n/** Default timeout (ms) for waiting for the host-ready signal */\nexport const HOST_READY_TIMEOUT = 30_000;\n\n/** Validation limits */\nexport const LIMITS = {\n /** Min nametag length */\n NAMETAG_MIN_LENGTH: 3,\n /** Max nametag length */\n NAMETAG_MAX_LENGTH: 20,\n /** Max memo length */\n MEMO_MAX_LENGTH: 500,\n /** Max message length */\n MESSAGE_MAX_LENGTH: 10000,\n} as const;\n","/**\n * Sphere Connect Protocol\n * JSON-RPC-like message types for wallet ↔ dApp communication.\n */\n\nimport { majorOf } from './semver';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nexport const SPHERE_CONNECT_NAMESPACE = 'sphere-connect';\nexport const SPHERE_CONNECT_VERSION = '2.1'; // Connect protocol version (semver MAJOR.MINOR)\n\n// Default npm-SDK floor a host enforces at the handshake (0.14.1 = the P11 flip:\n// the v1 payments era is gone; pre-flip ConnectClients expect a wallet that no\n// longer exists). '-0' admits every 0.14.1 prerelease (compareSemver: release >\n// prerelease). Override via ConnectHostConfig.minSdkVersion; the claim is\n// compatibility hygiene, not security — a hostile client can lie about it.\nexport const DEFAULT_MIN_CLIENT_SDK_VERSION = '0.14.1-0';\n\nexport { HOST_READY_TYPE, HOST_READY_TIMEOUT, SPHERE_NETWORKS } from '../constants';\n// Import for local use (e.g. SphereHandshake.network) AND re-export for connect consumers.\n// A bare `export type { NetworkInfo } from '../constants'` would re-export without bringing\n// the name into this module's scope, breaking the local references (TS2304).\nimport type { NetworkInfo } from '../constants';\nexport type { NetworkInfo };\n\n// =============================================================================\n// RPC Method Names (query — return data, no UI)\n// =============================================================================\n\nexport const RPC_METHODS = {\n GET_IDENTITY: 'sphere_getIdentity',\n GET_BALANCE: 'sphere_getBalance',\n GET_ASSETS: 'sphere_getAssets',\n GET_FIAT_BALANCE: 'sphere_getFiatBalance',\n GET_TOKENS: 'sphere_getTokens',\n GET_HISTORY: 'sphere_getHistory',\n RESOLVE: 'sphere_resolve',\n SUBSCRIBE: 'sphere_subscribe',\n UNSUBSCRIBE: 'sphere_unsubscribe',\n DISCONNECT: 'sphere_disconnect',\n GET_CONVERSATIONS: 'sphere_getConversations',\n GET_MESSAGES: 'sphere_getMessages',\n GET_DM_UNREAD_COUNT: 'sphere_getDMUnreadCount',\n MARK_AS_READ: 'sphere_markAsRead',\n} as const;\n\nexport type RpcMethod = (typeof RPC_METHODS)[keyof typeof RPC_METHODS];\n\n// =============================================================================\n// Intent Action Names (open wallet UI, require user confirmation)\n// =============================================================================\n\nexport const INTENT_ACTIONS = {\n SEND: 'send',\n DM: 'dm',\n PAYMENT_REQUEST: 'payment_request',\n RECEIVE: 'receive',\n SIGN_MESSAGE: 'sign_message',\n MINT: 'mint',\n} as const;\n\nexport type IntentAction = (typeof INTENT_ACTIONS)[keyof typeof INTENT_ACTIONS];\n\n// =============================================================================\n// Error Codes\n// =============================================================================\n\nexport const ERROR_CODES = {\n // Standard JSON-RPC\n PARSE_ERROR: -32700,\n INVALID_REQUEST: -32600,\n METHOD_NOT_FOUND: -32601,\n INVALID_PARAMS: -32602,\n INTERNAL_ERROR: -32603,\n\n // Sphere Connect (4xxx)\n NOT_CONNECTED: 4001,\n PERMISSION_DENIED: 4002,\n USER_REJECTED: 4003,\n SESSION_EXPIRED: 4004,\n ORIGIN_BLOCKED: 4005,\n RATE_LIMITED: 4006,\n UNSUPPORTED_PROTOCOL_VERSION: 4007, // Connect MAJOR mismatch (incompatible era)\n INCOMPATIBLE_NETWORK: 4008, // dApp targets a different network than the wallet\n // Wallet locked; THE SESSION IS STILL ALIVE. A QUERY may be retried after wallet:unlocked.\n // An INTENT already delegated to the wallet is NEVER answered with this code — it gets\n // INTENT_OUTCOME_UNKNOWN (4201) instead, because a retry could double-spend.\n WALLET_LOCKED: 4009,\n INSUFFICIENT_BALANCE: 4100,\n INVALID_RECIPIENT: 4101,\n TRANSFER_FAILED: 4102,\n INTENT_CANCELLED: 4200,\n /**\n * The intent was DELEGATED to the wallet and the host lost track of the answer — a host\n * deadline fired, or the wallet locked / logged out mid-flight. **The outcome is UNKNOWN:\n * the money may or may not have moved.**\n *\n * A dApp MUST NOT retry on this code. Reconcile out of band (poll the recipient, the\n * aggregator, or your own backend) and only then decide.\n *\n * This code exists because every other answer would be a lie. `INTENT_CANCELLED` (4200)\n * asserts the user declined and nothing happened; `WALLET_LOCKED` (4009) invites a retry\n * after the unlock. Sending either for an intent the wallet had already submitted is how a\n * paid-but-not-credited order — and then a double spend on retry — happens.\n */\n INTENT_OUTCOME_UNKNOWN: 4201,\n} as const;\n\nexport type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];\n\n/** `data` carried by every WALLET_LOCKED (4009) refusal. */\nexport interface WalletLockedData {\n /** Discriminator. Always the literal 'locked' — reserved for future refusal reasons. */\n readonly reason: 'locked';\n /**\n * Whether the wallet's own unlock surface is currently on screen.\n * DECLARED in the fail-fast release and always absent there; POPULATED in the resume\n * release from ConnectHostConfig.unlockSurface(), evaluated at refusal time (visibility\n * changes over time, so a static value would lie). Feeds\n * ConnectClientConfig.onWalletAttention.\n */\n readonly unlockSurface?: 'visible' | 'background';\n}\n\n// =============================================================================\n// Message Types\n// =============================================================================\n\ninterface SphereMessageBase {\n readonly ns: typeof SPHERE_CONNECT_NAMESPACE;\n readonly v: typeof SPHERE_CONNECT_VERSION;\n}\n\n/** Query request: dApp → Wallet */\nexport interface SphereRpcRequest extends SphereMessageBase {\n readonly type: 'request';\n readonly id: string;\n readonly method: string;\n readonly params?: Record<string, unknown>;\n}\n\n/** Query response: Wallet → dApp */\nexport interface SphereRpcResponse extends SphereMessageBase {\n readonly type: 'response';\n readonly id: string;\n readonly result?: unknown;\n readonly error?: SphereRpcError;\n}\n\n/** Intent request: dApp → Wallet (opens wallet UI) */\nexport interface SphereIntentRequest extends SphereMessageBase {\n readonly type: 'intent';\n readonly id: string;\n readonly action: string;\n readonly params: Record<string, unknown>;\n}\n\n/** Intent result: Wallet → dApp (after user action) */\nexport interface SphereIntentResult extends SphereMessageBase {\n readonly type: 'intent_result';\n readonly id: string;\n readonly result?: unknown;\n readonly error?: SphereRpcError;\n}\n\n/** Event push: Wallet → dApp (unsolicited) */\nexport interface SphereEventMessage extends SphereMessageBase {\n readonly type: 'event';\n readonly event: string;\n readonly data: unknown;\n}\n\n/** Handshake: bidirectional */\nexport interface SphereHandshake extends SphereMessageBase {\n readonly type: 'handshake';\n readonly direction: 'request' | 'response';\n readonly permissions: string[];\n readonly dapp?: DAppMetadata;\n readonly sessionId?: string;\n readonly identity?: PublicIdentity;\n /** If true, wallet must NOT open any approval UI. Immediately reject if origin is not already approved. */\n readonly silent?: boolean;\n /** request: dApp target network; response: wallet active network */\n readonly network?: NetworkInfo;\n /** Informational: the dApp's npm SDK version. */\n readonly sdkVersion?: string;\n /** Response: structured rejection reason when the gate refuses the connection. */\n readonly error?: SphereRpcError;\n /** Response: non-fatal deprecation notice (does not block the connection). */\n readonly warning?: SphereRpcError;\n /** Response only: the wallet is LOCKED and the session is ALIVE. The dApp is connected\n * and must not re-handshake; it will receive `wallet:unlocked` on the same session.\n * Additive and safe for old clients: handleHandshakeResponse reads only sessionId,\n * permissions, identity, network, warning and error and ignores unknown fields. */\n readonly locked?: boolean;\n}\n\nexport interface SphereRpcError {\n readonly code: number;\n readonly message: string;\n readonly data?: unknown;\n}\n\nexport type SphereConnectMessage =\n | SphereRpcRequest\n | SphereRpcResponse\n | SphereIntentRequest\n | SphereIntentResult\n | SphereEventMessage\n | SphereHandshake;\n\n// =============================================================================\n// Shared Types\n// =============================================================================\n\nexport interface DAppMetadata {\n readonly name: string;\n readonly description?: string;\n readonly icon?: string;\n readonly url: string;\n}\n\nexport interface PublicIdentity {\n readonly chainPubkey: string;\n readonly directAddress?: string;\n readonly nametag?: string;\n}\n\n// =============================================================================\n// Wallet-initiated Events (pushed automatically by host, no subscription needed)\n// =============================================================================\n\n/**\n * Events that ConnectHost pushes proactively to connected dApps.\n * dApps can listen with client.on(WALLET_EVENTS.LOCKED, handler) etc.\n * No sphere_subscribe call needed — host sends these unconditionally.\n */\nexport const WALLET_EVENTS = {\n /** Wallet is LOCKED — the session is STILL ALIVE. Requests are answered\n * WALLET_LOCKED (4009) until `wallet:unlocked`. The dApp must NOT disconnect,\n * must NOT clear its sessionId, and must NOT re-handshake.\n * Payload: {@link WalletLockedPayload}. Pushed by ConnectHost.setLocked() and\n * immediately after a handshake response carrying `locked: true`. */\n LOCKED: 'wallet:locked',\n /** Wallet was unlocked — the SAME session continues: no re-handshake, no re-approval,\n * no re-subscribe (the host re-arms the dApp's subscriptions before pushing this).\n * Payload: {@link WalletUnlockedPayload} — carries the CURRENT identity, which may\n * differ from the one the dApp connected with. Pushed by ConnectHost.updateSphere()\n * on the locked -> live edge only. */\n UNLOCKED: 'wallet:unlocked',\n /** The session is GONE (logout, wallet deleted, dApp sphere_disconnect, expiry seen at\n * unlock, a different seed behind the lock screen, host destroy).\n * The dApp must clear its session and re-handshake to continue. Unlocking does not cure it.\n * Payload: {@link WalletDisconnectedPayload}. Pushed by ConnectHost.revokeSession(). */\n DISCONNECTED: 'wallet:disconnected',\n /** Active wallet address changed. dApp should update displayed identity.\n * Pushed automatically by ConnectHost — no sphere_subscribe needed. */\n IDENTITY_CHANGED: 'identity:changed',\n} as const;\n\nexport type WalletEvent = (typeof WALLET_EVENTS)[keyof typeof WALLET_EVENTS];\n\n/** Payload of {@link WALLET_EVENTS.LOCKED}. Intentionally empty — matches today's push,\n * so an old dApp sees no shape change. */\nexport type WalletLockedPayload = Record<string, never>;\n\n/** Payload of {@link WALLET_EVENTS.DISCONNECTED}. Intentionally empty. */\nexport type WalletDisconnectedPayload = Record<string, never>;\n\n/** Payload of {@link WALLET_EVENTS.UNLOCKED}. */\nexport interface WalletUnlockedPayload {\n /** The wallet's public identity at unlock time. Absent when the rebound Sphere has no\n * identity yet (JSON transports drop undefined keys).\n * Unlock is NOT implicitly the same wallet: the lock screen's \"Forgot password ->\n * restore from recovery phrase\" installs a DIFFERENT seed while origin approvals are\n * keyed by origin alone. The host's own lock-edge guard is authoritative (it revokes\n * instead of pushing this event on a mismatch); this field is what lets a dApp render\n * honestly and what the client-side retry queue checks before draining. */\n readonly identity?: PublicIdentity;\n}\n\n/** Payload of {@link WALLET_EVENTS.IDENTITY_CHANGED} — unchanged: the host forwards\n * Sphere's own event data verbatim and pushes a PublicIdentity from updateSphere().\n * Typed as the union for honesty. */\nexport type WalletIdentityChangedPayload = PublicIdentity | unknown;\n\n/** Events the host pushes unconditionally. They must NEVER be routed through\n * `sphere_subscribe`: Sphere.on() accepts any string and would silently never emit,\n * so the subscribe would succeed and deliver nothing forever. */\nexport const AUTO_PUSHED_EVENTS: readonly WalletEvent[] = [\n WALLET_EVENTS.LOCKED,\n WALLET_EVENTS.UNLOCKED,\n WALLET_EVENTS.DISCONNECTED,\n WALLET_EVENTS.IDENTITY_CHANGED,\n];\n\n/** True for an event the host pushes unconditionally (see {@link AUTO_PUSHED_EVENTS}). */\nexport function isAutoPushedEvent(event: string): event is WalletEvent {\n return (AUTO_PUSHED_EVENTS as readonly string[]).includes(event);\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/** Check if a message belongs to the Sphere Connect protocol.\n * Handshakes are accepted at ANY version (the version decision happens in the\n * handshake handler so an incompatible peer gets a clean typed error instead of a\n * silently-dropped message → timeout). All other (session) traffic must share the\n * same MAJOR (so MINOR versions interoperate, e.g. 2.0 ↔ 2.1). */\nexport function isSphereConnectMessage(msg: unknown): msg is SphereConnectMessage {\n if (!msg || typeof msg !== 'object') return false;\n const m = msg as Record<string, unknown>;\n if (m.ns !== SPHERE_CONNECT_NAMESPACE) return false;\n if (m.type === 'handshake') return true;\n if (typeof m.v !== 'string') return false;\n return majorOf(m.v) === majorOf(SPHERE_CONNECT_VERSION);\n}\n\n/** Create a unique request ID */\nexport function createRequestId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n // Fallback for environments without crypto.randomUUID\n return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;\n}\n","/**\n * Sphere Connect Permission System\n * Defines scopes, maps methods/intents to required permissions.\n */\n\nimport { RPC_METHODS, INTENT_ACTIONS } from './protocol';\n\n// =============================================================================\n// Permission Scopes\n// =============================================================================\n\nexport const PERMISSION_SCOPES = {\n IDENTITY_READ: 'identity:read',\n BALANCE_READ: 'balance:read',\n TOKENS_READ: 'tokens:read',\n HISTORY_READ: 'history:read',\n EVENTS_SUBSCRIBE: 'events:subscribe',\n RESOLVE_PEER: 'resolve:peer',\n TRANSFER_REQUEST: 'transfer:request',\n DM_REQUEST: 'dm:request',\n DM_READ: 'dm:read',\n DM_MANAGE: 'dm:manage',\n PAYMENT_REQUEST: 'payment:request',\n SIGN_REQUEST: 'sign:request',\n MINT_REQUEST: 'mint:request',\n} as const;\n\nexport type PermissionScope = (typeof PERMISSION_SCOPES)[keyof typeof PERMISSION_SCOPES];\n\n/** All available permission scopes */\nexport const ALL_PERMISSIONS: readonly PermissionScope[] = Object.values(PERMISSION_SCOPES);\n\n/** Permissions always granted on connect */\nexport const DEFAULT_PERMISSIONS: readonly PermissionScope[] = [\n PERMISSION_SCOPES.IDENTITY_READ,\n];\n\n// =============================================================================\n// Method → Permission Mapping\n// =============================================================================\n\nexport const METHOD_PERMISSIONS: Record<string, PermissionScope> = {\n [RPC_METHODS.GET_IDENTITY]: PERMISSION_SCOPES.IDENTITY_READ,\n [RPC_METHODS.GET_BALANCE]: PERMISSION_SCOPES.BALANCE_READ,\n [RPC_METHODS.GET_ASSETS]: PERMISSION_SCOPES.BALANCE_READ,\n [RPC_METHODS.GET_FIAT_BALANCE]: PERMISSION_SCOPES.BALANCE_READ,\n [RPC_METHODS.GET_TOKENS]: PERMISSION_SCOPES.TOKENS_READ,\n [RPC_METHODS.GET_HISTORY]: PERMISSION_SCOPES.HISTORY_READ,\n [RPC_METHODS.RESOLVE]: PERMISSION_SCOPES.RESOLVE_PEER,\n [RPC_METHODS.SUBSCRIBE]: PERMISSION_SCOPES.EVENTS_SUBSCRIBE,\n [RPC_METHODS.UNSUBSCRIBE]: PERMISSION_SCOPES.EVENTS_SUBSCRIBE,\n [RPC_METHODS.GET_CONVERSATIONS]: PERMISSION_SCOPES.DM_READ,\n [RPC_METHODS.GET_MESSAGES]: PERMISSION_SCOPES.DM_READ,\n [RPC_METHODS.GET_DM_UNREAD_COUNT]: PERMISSION_SCOPES.DM_READ,\n [RPC_METHODS.MARK_AS_READ]: PERMISSION_SCOPES.DM_MANAGE,\n};\n\n// =============================================================================\n// Intent → Permission Mapping\n// =============================================================================\n\nexport const INTENT_PERMISSIONS: Record<string, PermissionScope> = {\n [INTENT_ACTIONS.SEND]: PERMISSION_SCOPES.TRANSFER_REQUEST,\n [INTENT_ACTIONS.DM]: PERMISSION_SCOPES.DM_REQUEST,\n [INTENT_ACTIONS.PAYMENT_REQUEST]: PERMISSION_SCOPES.PAYMENT_REQUEST,\n [INTENT_ACTIONS.RECEIVE]: PERMISSION_SCOPES.IDENTITY_READ,\n [INTENT_ACTIONS.SIGN_MESSAGE]: PERMISSION_SCOPES.SIGN_REQUEST,\n [INTENT_ACTIONS.MINT]: PERMISSION_SCOPES.MINT_REQUEST,\n};\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/** Check if granted permissions allow calling a method */\nexport function hasMethodPermission(granted: ReadonlySet<string>, method: string): boolean {\n const required = METHOD_PERMISSIONS[method];\n if (!required) return false;\n return granted.has(required);\n}\n\n/** Check if granted permissions allow an intent action */\nexport function hasIntentPermission(granted: ReadonlySet<string>, action: string): boolean {\n const required = INTENT_PERMISSIONS[action];\n if (!required) return false;\n return granted.has(required);\n}\n\n/** Validate that all requested permissions are known scopes */\nexport function validatePermissions(permissions: string[]): permissions is PermissionScope[] {\n const validScopes = new Set<string>(ALL_PERMISSIONS);\n return permissions.every((p) => validScopes.has(p));\n}\n","// docs/PAYMENTS-V2-DESIGN.md §4 \"Compatibility\": old wire queries are served from\n// `sphere.payments` reads, and every old event name a dApp can `sphere_subscribe` to is\n// re-emitted from the facade's bus events — dApps change NOTHING. The old-stack host this\n// used to stay dormant for no longer exists; the facade is the only shape now.\n// Old names stay plain string literals here: post-flip this wire surface is their only home.\n\nimport type { TransferResult } from '../../types';\nimport type { PaymentRequestView } from '../../modules/payments-v2/api';\nimport type { SphereInstance } from './SphereInstance';\n\ntype Forward = (data: unknown) => void;\ntype Attach = (sphere: SphereInstance, forward: Forward) => () => void;\n\ntype PaymentRequestUpdated = {\n id: string;\n status: 'pending' | 'settling' | 'paid' | 'rejected' | 'expired';\n};\ntype TransferAttention = { transferId: string; code: string; detail?: string };\ntype ConnectionStatus = { status: 'connected' | 'degraded' | 'offline' };\n\n// Old `sphere_getFiatBalance` semantics, held exactly: sum of priced assets, null when NO\n// asset carries a price — the \"no price data\" signal a dApp may branch on.\nexport function sumFiatUsd(\n assets: ReadonlyArray<{ fiatValueUsd: number | null }>,\n): number | null {\n let total = 0;\n let priced = false;\n for (const asset of assets) {\n if (asset.fiatValueUsd != null) {\n total += asset.fiatValueUsd;\n priced = true;\n }\n }\n return priced ? total : null;\n}\n\nconst SETTLED_STATUSES: ReadonlySet<TransferResult['status']> = new Set([\n 'confirmed',\n 'delivered',\n 'completed',\n]);\n\nconst REALTIME_STATUS: Record<ConnectionStatus['status'], string> = {\n connected: 'connected',\n degraded: 'reconnecting',\n offline: 'closed',\n};\n\n// The ONE view → legacy `IncomingPaymentRequest` mapping (incoming AND the per-status\n// rebuilds): symbol is registry-resolved when known, else '' — never absent on the wire.\nfunction toLegacyRequest(view: PaymentRequestView, status: string): Record<string, unknown> {\n return { ...view, symbol: view.symbol ?? '', status };\n}\n\n// Rebuilds the old per-status `IncomingPaymentRequest` payload: full view from\n// `requests.list()` (processed requests stay listed until dismissProcessed()); if already\n// gone, id + status still carry the signal and unknowable fields are empty.\nfunction legacyRequestPayload(\n sphere: SphereInstance,\n update: PaymentRequestUpdated,\n): Record<string, unknown> {\n const view: PaymentRequestView | undefined = sphere.payments.requests\n .list()\n .find((request) => request.id === update.id);\n if (!view) {\n return {\n id: update.id,\n requestId: update.id,\n senderPubkey: '',\n amount: '',\n coinId: '',\n symbol: '',\n timestamp: Date.now(),\n status: update.status,\n };\n }\n return toLegacyRequest(view, update.status);\n}\n\nfunction requestStatusAttacher(status: 'paid' | 'rejected' | 'expired'): Attach {\n return (sphere, forward) =>\n sphere.on('payment_request:updated', (update: PaymentRequestUpdated) => {\n if (update.status === status) forward(legacyRequestPayload(sphere, update));\n });\n}\n\n// The v2 attention `code` IS the old event name (machine/journal.ts ATTENTION_* constants).\nfunction attentionAttacher(code: string, toLegacy: (a: TransferAttention) => unknown): Attach {\n return (sphere, forward) =>\n sphere.on('transfer:attention', (attention: TransferAttention) => {\n if (attention.code === code) forward(toLegacy(attention));\n });\n}\n\nfunction remoteUpdateAttacher(sphere: SphereInstance, forward: Forward): () => void {\n let sequence = 0;\n return sphere.on('inventory:updated', () => {\n sequence += 1;\n forward({ providerId: 'wallet-api', name: 'wallet-api', sequence, cid: '', added: 0, removed: 0 });\n });\n}\n\n// A Map, not a plain object: the key is dApp-controlled, and an object lookup would reach\n// the prototype chain ('constructor' would come back as a callable \"attacher\").\nconst COMPAT_ATTACHERS: ReadonlyMap<string, Attach> = new Map<string, Attach>([\n // Old completion split, held: deliveryPending ? delivery_pending : confirmed, plus the\n // failed arm (manifest judgment call #1). Payloads are the TransferResult, unchanged.\n ['transfer:confirmed', (sphere, forward) =>\n sphere.on('transfer:updated', (result: TransferResult) => {\n if (SETTLED_STATUSES.has(result.status) && result.deliveryPending !== true) forward(result);\n })],\n ['transfer:delivery_pending', (sphere, forward) =>\n sphere.on('transfer:updated', (result: TransferResult) => {\n if (result.status !== 'failed' && result.deliveryPending === true) forward(result);\n })],\n ['transfer:failed', (sphere, forward) =>\n sphere.on('transfer:updated', (result: TransferResult) => {\n if (result.status === 'failed') forward(result);\n })],\n // Same name on both wires, different payload: the raw v2 view has optional `symbol`;\n // legacy subscribers get the IncomingPaymentRequest shape via the shared mapping.\n ['payment_request:incoming', (sphere, forward) =>\n sphere.on('payment_request:incoming', (view: PaymentRequestView) => {\n forward(toLegacyRequest(view, view.status));\n })],\n ['payment_request:paid', requestStatusAttacher('paid')],\n ['payment_request:rejected', requestStatusAttacher('rejected')],\n ['payment_request:expired', requestStatusAttacher('expired')],\n // detail carries the old inner code (SPLIT_CHECKPOINT_LOST / CHECKPOINT_TRUSTBASE_MISMATCH).\n ['split:checkpoint-stuck', attentionAttacher('split:checkpoint-stuck', (attention) => ({\n transferId: attention.transferId,\n code: attention.detail ?? '',\n error: attention.detail ?? '',\n }))],\n ['delivery:undeliverable', attentionAttacher('delivery:undeliverable', (attention) => ({\n transferId: attention.transferId,\n recipientPubkey: '',\n attempts: 0,\n error: attention.detail ?? '',\n }))],\n ['delivery:deferred', attentionAttacher('delivery:deferred', (attention) => ({\n transferId: attention.transferId,\n recipientPubkey: '',\n reason: attention.detail ?? attention.code,\n deferredUntil: 0,\n }))],\n ['realtime:status', (sphere, forward) =>\n sphere.on('connection:status', (connection: ConnectionStatus) => {\n forward({ status: REALTIME_STATUS[connection.status] ?? 'closed' });\n })],\n // The server IS storage on the v2 vertical — a degraded connection is degraded storage.\n ['storage:degraded', (sphere, forward) =>\n sphere.on('connection:status', (connection: ConnectionStatus) => {\n if (connection.status !== 'degraded') return;\n forward({ providerId: 'wallet-api', error: 'wallet-api connection degraded' });\n })],\n ['sync:completed', (sphere, forward) =>\n sphere.on('inventory:updated', () => {\n forward({ source: 'payments', count: sphere.payments.tokens().length });\n })],\n ['sync:remote-update', remoteUpdateAttacher],\n]);\n\n// Attach ONE old-name subscription fed from its v2 source event; `forward` sends the wire\n// frame under the OLD name. Null when `eventName` is not an adapted name (the caller falls\n// through to a direct `sphere.on`).\nexport function attachCompatEvent(\n sphere: SphereInstance,\n eventName: string,\n forward: Forward,\n): (() => void) | null {\n const attach = COMPAT_ATTACHERS.get(eventName);\n return attach ? attach(sphere, forward) : null;\n}\n","/**\n * Wallet-binding FSM + the single total gate decision.\n *\n * PURE: no I/O, no timers, no Sphere reference, no `await` — a transition\n * table + assertTransition.\n *\n * Nothing here is re-exported from connect/index.ts — it is host-internal.\n */\n\nimport { SphereError } from '../../core/errors';\nimport { ERROR_CODES, RPC_METHODS } from '../protocol';\nimport type { WalletLockedData } from '../protocol';\nimport type { WalletState } from '../types';\n\n// ===========================================================================\n// Wire text — RECOMMENDATIONS, not contracts\n// ===========================================================================\n\n/** Recommended refusal text for WALLET_LOCKED. Discriminate on code 4009 and on\n * data.reason, NEVER on this string. Deliberately does not match the reference dApp's\n * teardown regex /not.connected|timeout|transport|closed|session/i. */\nexport const WALLET_LOCKED_MESSAGE = 'Wallet is locked';\n\n/** Fixed text for a non-SphereError throw. A raw JS message must never cross the trust\n * boundary into a third-party dApp. */\nexport const INTERNAL_ERROR_MESSAGE = 'Internal wallet error';\n/**\n * Recommended text for INTENT_OUTCOME_UNKNOWN (4201). Like every message on this wire it is\n * NOT a contract — consumers discriminate on the code — but the wording matters here because a\n * developer reading it must not conclude the intent was cancelled.\n */\nexport const INTENT_UNKNOWN_MESSAGE =\n 'Intent outcome unknown — do not retry; reconcile before acting';\n\n/** Today's literal on the host's not-connected refusals — kept byte-identical.\n * `unavailable` answers this too: unlocking cannot cure it, so promising an unlock\n * would be a lie. */\nexport const NOT_CONNECTED_MESSAGE = 'Not connected';\n\n// ===========================================================================\n// Transitions\n// ===========================================================================\n\n/** Legal edges. NO self-transitions by design: every verb must early-return when it is\n * already in the target state, so assertWalletTransition() enforces idempotency at the\n * table level rather than by reviewer diligence. */\nexport const VALID_WALLET_TRANSITIONS: Record<WalletState, readonly WalletState[]> = {\n live: ['locked', 'unavailable'],\n locked: ['live', 'unavailable'],\n unavailable: ['live', 'locked'],\n};\n\nexport function isValidWalletTransition(from: WalletState, to: WalletState): boolean {\n return VALID_WALLET_TRANSITIONS[from].includes(to);\n}\n\n/** Throws SphereError('Invalid wallet state transition: from -> to', 'VALIDATION_ERROR')\n * — an EXISTING SphereErrorCode; core/errors.ts is not touched. */\nexport function assertWalletTransition(from: WalletState, to: WalletState): void {\n if (!isValidWalletTransition(from, to)) {\n throw new SphereError(`Invalid wallet state transition: ${from} -> ${to}`, 'VALIDATION_ERROR');\n }\n}\n\n// ===========================================================================\n// Locked allow-list\n// ===========================================================================\n\n/**\n * The four methods answerable while locked. Everything else gets 4009.\n *\n * - sphere_getIdentity -> 'serve-from-snapshot'. Those exact bytes were already handed\n * to this origin in the handshake response and DEFAULT_PERMISSIONS\n * grants identity:read unconditionally, so refusing re-states a\n * revealed fact for zero security while making every dApp that\n * reads identity once on mount render \"disconnected\" for the whole\n * lock.\n * - sphere_subscribe -> 'serve'. Refusing it is a BUG, not a policy: ConnectClient.on()\n * fires sphere_subscribe fire-and-forget and NEVER retries (the\n * error goes to logger.debug), so the event stream would die\n * forever after one lock.\n * - sphere_unsubscribe -> 'serve'. Needs no Sphere at all.\n * - sphere_disconnect -> 'serve'. Without it a session can never be dropped while\n * locked; the client swallows the error and calls cleanup()\n * anyway, so the dApp shows \"disconnected\" while onDisconnect —\n * the only hook that revokes the persisted origin approval —\n * never fires.\n *\n * WHAT IS BLOCKED — the honest count, because an enumeration of only the money reads reads as\n * if messaging still works. RPC_METHODS has FOURTEEN entries; four are above. The other TEN,\n * and EVERY intent, are refused:\n *\n * sphere_getBalance, sphere_getAssets, sphere_getFiatBalance, sphere_getTokens,\n * sphere_getHistory — money state a locked wallet cannot honour. Never cached either: a\n * dApp holding a stale balance is a dApp about to offer an\n * unpayable spend.\n * sphere_resolve — nametag resolution needs the live transport.\n * sphere_getConversations, sphere_getMessages, sphere_getDMUnreadCount, sphere_markAsRead\n * — DMs are decrypted with keys that left memory. Messaging does NOT\n * keep working while locked; a dApp must stop polling and wait.\n *\n * AND THE CASE WITH NO 4009 AT ALL. Everything above assumes a host that HOLDS a session. A\n * wallet that COLD-STARTS locked (a page reload, a fresh popup — the password is memory-only,\n * so this is the common path) has no session and an EMPTY snapshot, so `handleHandshake`\n * refuses at step 0 with an errorless empty response: the dApp's connect() rejects with a bare\n * \"Connection rejected by wallet\" carrying NO code. There is nothing to match 4009 against.\n * That silence is deliberate — the refusal must reveal nothing about the wallet to an origin\n * holding no approval — so a dApp cannot distinguish it from a user pressing Reject and must\n * treat it as \"not ready yet\": keep waiting for HOST_READY, which the wallet emits when a\n * human unlocks it.\n */\nexport const LOCKED_ALLOWLIST: ReadonlySet<string> = new Set<string>([\n RPC_METHODS.GET_IDENTITY,\n RPC_METHODS.SUBSCRIBE,\n RPC_METHODS.UNSUBSCRIBE,\n RPC_METHODS.DISCONNECT,\n]);\n\n// ===========================================================================\n// Gate\n// ===========================================================================\n\nexport type RequestKind = 'query' | 'intent' | 'handshake';\n\nexport type GateDecision =\n | { readonly kind: 'serve' }\n /** Answer from WalletSnapshot. If the required snapshot field is absent the caller\n * REFUSES (4009 for a query, the empty refusal for a handshake) — it never invents a\n * fact about a wallet we have not seen, and never sends undefined-as-success. */\n | { readonly kind: 'serve-from-snapshot' }\n | {\n readonly kind: 'refuse';\n readonly error: {\n readonly code: number;\n readonly message: string;\n readonly data?: WalletLockedData;\n };\n };\n\nconst REFUSE_NOT_CONNECTED: GateDecision = {\n kind: 'refuse',\n error: { code: ERROR_CODES.NOT_CONNECTED, message: NOT_CONNECTED_MESSAGE },\n};\n\nconst REFUSE_LOCKED: GateDecision = {\n kind: 'refuse',\n error: {\n code: ERROR_CODES.WALLET_LOCKED,\n message: WALLET_LOCKED_MESSAGE,\n data: { reason: 'locked' },\n },\n};\n\n/**\n * The single total gate decision. Positional, four required arguments, no I/O.\n * `name` is an RPC method, an intent action, or the literal 'handshake'.\n *\n * Called at step 4 of the ordering contract, AFTER the session check, the expiry check and\n * the rate limit, and BEFORE the permission check — because hasMethodPermission() is false\n * for anything unmapped, so a locked unknown method would otherwise answer\n * PERMISSION_DENIED 4002, an un-retryable code that tells the dApp its permissions are\n * wrong.\n *\n * The resume release merges `unlockSurface` into `error.data` AFTER this returns — the gate\n * stays pure.\n */\nexport function gate(\n walletState: WalletState,\n hasActiveSession: boolean,\n requestKind: RequestKind,\n name: string,\n): GateDecision {\n // 'unavailable' is a dead end: revoke + wallet:disconnected already happened, and\n // unlocking does not cure it. Never 4009 here.\n if (walletState === 'unavailable') return REFUSE_NOT_CONNECTED;\n\n if (requestKind === 'handshake') {\n // While locked EVERY handshake is answered from the snapshot and forced silent, so an\n // origin without an approval still gets today's empty refusal with no UI.\n return walletState === 'locked' ? { kind: 'serve-from-snapshot' } : { kind: 'serve' };\n }\n\n // A query or an intent without a session is 4001 in every wallet state: the lock is\n // observable ONLY to an origin that already holds an approval.\n if (!hasActiveSession) return REFUSE_NOT_CONNECTED;\n\n if (walletState === 'live') return { kind: 'serve' };\n\n // locked, with an active session.\n if (requestKind === 'query' && LOCKED_ALLOWLIST.has(name)) {\n return name === RPC_METHODS.GET_IDENTITY\n ? { kind: 'serve-from-snapshot' }\n : { kind: 'serve' };\n }\n return REFUSE_LOCKED;\n}\n","/**\n * Immutable public facts about the wallet binding.\n *\n * Holds NO reference to Sphere, so \"nothing is read from a destroyed Sphere while locked\"\n * is a property of the types rather than of code review.\n *\n * NOT named SessionSnapshot and carries NO sessionId: it must exist BEFORE any session\n * does — sendHandshakeResponse reads networkId and is the transport for EVERY handshake\n * response, including the refusals.\n *\n * The lock-edge identity binding IS `snapshot.identity?.chainPubkey`; there is no separate\n * chainPubkey field and no separate boundIdentity field.\n */\n\nimport type { PublicIdentity } from '../protocol';\nimport type { SphereInstance } from './SphereInstance';\n\nexport interface WalletSnapshot {\n readonly networkId?: number;\n readonly identity?: PublicIdentity;\n /** Epoch ms of capture. 0 means \"never captured\" — see EMPTY_WALLET_SNAPSHOT. */\n readonly capturedAt: number;\n}\n\n/** capturedAt 0, no networkId, no identity. Used for a host built locked with no prior\n * binding (cold start with an encrypted wallet), after setUnavailable(), and after\n * destroy(). A handshake against an empty snapshot gets today's empty refusal —\n * including a resume. \"Never invent a fact about a wallet we have not seen.\" */\nexport const EMPTY_WALLET_SNAPSHOT: WalletSnapshot = Object.freeze({ capturedAt: 0 });\n\n/**\n * Build from a bound Sphere. `null` returns EMPTY_WALLET_SNAPSHOT.\n *\n * If sphere.identity is null the snapshot has NO identity, and sphere_getIdentity while\n * locked answers 4009 rather than undefined-as-success — a dApp reads an `undefined`\n * success result as \"the wallet has no identity\".\n */\nexport function buildWalletSnapshot(sphere: SphereInstance | null): WalletSnapshot {\n if (!sphere) return EMPTY_WALLET_SNAPSHOT;\n const id = sphere.identity;\n return Object.freeze({\n ...(typeof sphere.networkId === 'number' ? { networkId: sphere.networkId } : {}),\n ...(id\n ? {\n identity: Object.freeze({\n chainPubkey: id.chainPubkey,\n directAddress: id.directAddress,\n nametag: id.nametag,\n }),\n }\n : {}),\n capturedAt: Date.now(),\n });\n}\n","/**\n * ConnectHost — Wallet side of Sphere Connect.\n *\n * Wraps a Sphere instance and exposes its API through a ConnectTransport.\n * Handles permission checking, rate limiting, session management,\n * and delegates intents to the wallet app via callbacks.\n */\n\nimport { logger } from '../../core/logger';\nimport { SphereError } from '../../core/errors';\nimport type { SphereEventType } from '../../types';\nimport type {\n ConnectTransport,\n ConnectSession,\n ConnectHostConfig,\n WalletState,\n LockedRequestContext,\n IntentContext,\n} from '../types';\nimport type {\n SphereConnectMessage,\n SphereRpcRequest,\n SphereIntentRequest,\n SphereHandshake,\n PublicIdentity,\n SphereRpcError,\n NetworkInfo,\n WalletLockedData,\n WalletLockedPayload,\n WalletUnlockedPayload,\n WalletDisconnectedPayload,\n} from '../protocol';\nimport {\n SPHERE_CONNECT_NAMESPACE,\n SPHERE_CONNECT_VERSION,\n RPC_METHODS,\n ERROR_CODES,\n WALLET_EVENTS,\n createRequestId,\n isAutoPushedEvent,\n DEFAULT_MIN_CLIENT_SDK_VERSION,\n} from '../protocol';\nimport { checkCompatibility } from '../compatibility';\nimport { SDK_VERSION } from '../version';\nimport {\n DEFAULT_PERMISSIONS,\n hasMethodPermission,\n hasIntentPermission,\n} from '../permissions';\nimport type { PermissionScope } from '../permissions';\nimport type { SphereInstance, ConnectDirectMessage } from './SphereInstance';\nimport { attachCompatEvent, sumFiatUsd } from './payments-compat';\nimport {\n assertWalletTransition,\n gate,\n WALLET_LOCKED_MESSAGE,\n INTERNAL_ERROR_MESSAGE,\n INTENT_UNKNOWN_MESSAGE,\n NOT_CONNECTED_MESSAGE,\n} from './host-state';\nimport type { WalletSnapshot } from './WalletSnapshot';\nimport { EMPTY_WALLET_SNAPSHOT, buildWalletSnapshot } from './WalletSnapshot';\nimport { InFlightRegistry } from './InFlightRegistry';\nimport type { InFlightEntry } from './InFlightRegistry';\n\nconst DEFAULT_SESSION_TTL_MS = 86400000; // 24 hours\nconst DEFAULT_MAX_RPS = 20;\n/**\n * Codes a wallet must never use to answer an intent the host already handed it. Both describe\n * the CHANNEL, not the operation: by the time `onIntent` has been called the wallet owns the\n * decision, so \"wallet locked\" or \"not connected\" says nothing true about the spend while\n * inviting a retry — 4009's documented advice is literally \"retry after wallet:unlocked\". They\n * are downgraded to INTENT_OUTCOME_UNKNOWN.\n *\n * Deliberately NOT here: USER_REJECTED and INTENT_CANCELLED. A user who declines the prompt\n * BEFORE anything is submitted is a real, retryable rejection; downgrading it would make every\n * declined payment un-retryable, which is both false and worse UX than the bug. Ensuring the\n * wallet cannot report a cancel AFTER submitting is the WALLET's job — it must not offer a\n * cancel control once the transfer is on the wire. The general contract for third-party wallets,\n * an explicit `ctx.commit()` marking the point of no return after which these two are\n * downgraded as well, is a follow-up rather than something to fake here.\n */\nconst CHANNEL_ONLY_CODES: ReadonlySet<number> = new Set<number>([\n ERROR_CODES.WALLET_LOCKED,\n ERROR_CODES.NOT_CONNECTED,\n]);\n\nconst DEFAULT_REQUEST_DEADLINE_MS = 25000;\n// LONGER than ConnectClient's own DEFAULT_INTENT_TIMEOUT (120 s). The host must never be the\n// first to give up on a delegated intent: whoever answers first defines the outcome for the\n// dApp, and the host cannot know whether the wallet has already submitted the transfer. With\n// the client timing out first, the dApp learns \"outcome unknown\" from its own clock instead of\n// being told, authoritatively and falsely, that nothing happened.\nconst DEFAULT_INTENT_DEADLINE_MS = 180000;\nconst DEFAULT_HANDSHAKE_DEADLINE_MS = 120000;\n\n/** Resolve `promise`, or `fallback()` after `ms`. Used ONLY for onConnectionRequest, which\n * has no id and therefore cannot live in InFlightRegistry. A rejection still propagates,\n * so handleHandshake's own error handling is unchanged. */\nfunction withDeadline<T>(promise: Promise<T>, ms: number, fallback: () => T): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => resolve(fallback()), ms);\n promise.then(\n (value) => { clearTimeout(timer); resolve(value); },\n (error) => { clearTimeout(timer); reject(error); },\n );\n });\n}\n\nexport class ConnectHost {\n /** Null whenever _walletState is 'locked' or 'unavailable' (invariant B). */\n private sphere: SphereInstance | null;\n\n /** The wallet-binding axis. Underscored because `walletState` is the public getter.\n * ORTHOGONAL to `session` — a locked wallet keeps its session, a live wallet may have\n * none. Written only by the WALLET (setLocked / setUnavailable / updateSphere / destroy);\n * `session` is written by the dApp handshake, sphere_disconnect and expiry. */\n private _walletState: WalletState;\n\n /** Immutable public facts about the current binding. Refreshed on every bind\n * (constructor, updateSphere); FROZEN by setLocked(); EMPTY after setUnavailable() and\n * destroy(). Never read from Sphere while locked — that is a property of the types\n * here, not of code review. */\n private snapshot: WalletSnapshot;\n\n /** Subscription KEYS captured by setLocked() BEFORE the unsub closures are detached.\n * Sphere.destroy() kills those closures, so the keys are the only recoverable\n * information. Excludes 'identity:changed' (autoSubscribeIdentityChanged re-arms it).\n * A Set, not an array: handleSubscribe may be called twice for the same key while\n * locked. */\n private suspendedSubscriptions: Set<string> = new Set();\n\n /** Every accepted id, with its own host-side timer. The single convergence point for\n * lock / revoke / unavailable / destroy / deadline. */\n private readonly inFlight: InFlightRegistry;\n\n private readonly transport: ConnectTransport;\n private readonly config: ConnectHostConfig;\n\n private session: ConnectSession | null = null;\n private grantedPermissions: Set<string> = new Set();\n\n // Event subscription management\n private eventSubscriptions: Map<string, () => void> = new Map(); // eventName → unsub\n\n // Intent auto-approve: action → handler that bypasses wallet UI\n private autoApprovedIntents = new Map<\n string,\n (action: string, params: Record<string, unknown>, session: ConnectSession) => Promise<{ result?: unknown; error?: { code: number; message: string } }>\n >();\n\n // Rate limiting\n private rateLimitCounter = 0;\n private rateLimitResetAt = 0;\n\n private unsubscribeTransport: (() => void) | null = null;\n\n constructor(config: ConnectHostConfig) {\n this.transport = config.transport;\n this.config = config;\n\n this._walletState = config.initialWalletState ?? 'live';\n this.sphere = (config.sphere ?? null) as SphereInstance | null;\n if (this._walletState === 'live' && !this.sphere) {\n // Fail LOUD but soft: a throw here breaks the wallet's React mount.\n logger.warn(\n 'ConnectHost',\n 'Constructed live with sphere === null; coercing to unavailable. ' +\n 'Pass initialWalletState: \"locked\" when the wallet is locked at construction time.',\n );\n this._walletState = 'unavailable';\n }\n if (this._walletState !== 'live') this.sphere = null; // invariant B, unconditionally\n this.snapshot = buildWalletSnapshot(this.sphere); // EMPTY_WALLET_SNAPSHOT when null\n\n this.inFlight = new InFlightRegistry({ onExpire: (e) => this.settleExpired(e) });\n\n this.unsubscribeTransport = this.transport.onMessage(this.handleMessage.bind(this));\n }\n\n /** The wallet-binding axis. Orthogonal to {@link getSession}. Read-only —\n * transitions go through setLocked() / setUnavailable() / updateSphere(). */\n get walletState(): WalletState {\n return this._walletState;\n }\n\n /** Both axes in one read, for UI that must render \"connected AND locked\".\n * Required by the wallet's ConnectPage, which today renders a green pulsing\n * \"Connected to {dapp}\" with no regard for lock state. */\n getState(): { readonly walletState: WalletState; readonly session: ConnectSession | null } {\n return { walletState: this._walletState, session: this.session };\n }\n\n /** Get current active session */\n getSession(): ConnectSession | null {\n return this.session;\n }\n\n /** Register an auto-approve handler for an intent action (session-scoped). */\n setIntentAutoApprove(\n action: string,\n handler: (\n action: string,\n params: Record<string, unknown>,\n session: ConnectSession,\n ) => Promise<{ result?: unknown; error?: { code: number; message: string } }>,\n ): void {\n this.autoApprovedIntents.set(action, handler);\n }\n\n /** Remove auto-approve for an intent action. */\n clearIntentAutoApprove(action: string): void {\n this.autoApprovedIntents.delete(action);\n }\n\n /**\n * Bind a (new) Sphere instance. This is BOTH the re-arm path after setLocked() /\n * setUnavailable() AND the existing address-switch path in a live wallet.\n *\n * From 'live' (address switch): today's behaviour, unchanged — re-arm identity:changed,\n * push identity:changed. NO identity comparison: an address switch is legal.\n *\n * On the 'locked' -> 'live' edge, in this order:\n * 1. compare snapshot.identity?.chainPubkey with the new Sphere's chainPubkey.\n * MISMATCH => revokeSession() (which pushes wallet:disconnected) and RETURN.\n * Never wallet:unlocked. This is the \"Forgot password -> restore recovery phrase\n * installed a different seed behind an origin-keyed approval\" guard.\n * 2. session.expiresAt passed => revokeSession() and RETURN. A wallet:unlocked into a\n * dead session would make the dApp's next request answer SESSION_EXPIRED 4004.\n * 3. rebind, refresh the snapshot, go live, re-arm identity:changed, replay every\n * suspended sphere_subscribe key, and ONLY THEN push wallet:unlocked with the\n * CURRENT identity. Re-arm BEFORE push, so a dApp reacting synchronously cannot\n * race its own event streams.\n *\n * From 'unavailable' -> 'live': rebind + refresh the snapshot, no identity check\n * (nothing was bound to compare against) and no event (the session is already null).\n */\n updateSphere(newSphere: unknown): void {\n const wasLocked = this._walletState === 'locked';\n const next = (newSphere ?? null) as SphereInstance | null;\n\n // `null` is explicitly anticipated by the coalesce above, and committing 'live' without a\n // Sphere is unrepresentable: every query would dereference null and answer -32603 forever,\n // never 4009, while the client keeps reporting walletLocked with nothing able to clear it.\n // Route it to the verb that actually means \"there is no Sphere\".\n if (!next) {\n if (this._walletState === 'locked') {\n // Stay locked. The dApp is waiting for a real unlock; a spurious 'unavailable' would\n // revoke a session that a genuine unlock could still have served.\n logger.warn('ConnectHost', 'updateSphere(null) while locked — staying locked');\n return;\n }\n logger.warn('ConnectHost', 'updateSphere(null) — treating as a non-lock loss of Sphere');\n this.setUnavailable();\n return;\n }\n\n if (this._walletState === 'live') {\n // Address switch on a live host — today's behaviour, verbatim.\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n const existing = this.eventSubscriptions.get(WALLET_EVENTS.IDENTITY_CHANGED);\n if (existing) {\n existing();\n this.eventSubscriptions.delete(WALLET_EVENTS.IDENTITY_CHANGED);\n }\n if (this.session?.active) {\n this.autoSubscribeIdentityChanged();\n // Push the new identity immediately so dApp doesn't have to wait for the next event\n const identity = this.getPublicIdentity();\n if (identity) {\n this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);\n }\n }\n return;\n }\n\n // 1. Lock-edge identity guard — ONLY on locked -> live. From 'unavailable' there is\n // nothing to compare against.\n if (wasLocked && this.session?.active) {\n // FAIL CLOSED. `before && after &&` used to no-op the whole guard whenever either side\n // was absent — and an absent `before` is exactly what a wallet manufactures by calling\n // sphere.destroy() before setLocked(), an ordering the docs ask for but nothing enforces.\n // An unknown identity on either side is a mismatch: it is not evidence of sameness.\n const before = this.snapshot.identity?.chainPubkey ?? null;\n const after = next.identity?.chainPubkey ?? null;\n // A NETWORK change is equally unrecoverable and was not checked at all. On main a lock\n // revoked unconditionally, so a re-handshake ran checkCompatibility and answered 4008;\n // preserving the session across a lock skipped that gate, leaving the dApp served\n // balances from a chain it never agreed to while still reporting the old network.\n const netBefore = this.snapshot.networkId ?? null;\n const netAfter = next.networkId ?? null;\n if (before !== after || netBefore !== netAfter) {\n logger.warn(\n 'ConnectHost',\n `Wallet behind the lock screen is not the one this session was approved for — revoking instead of unlocking (origin=${this.config.origin ?? 'unverified'})`,\n );\n assertWalletTransition(this._walletState, 'live');\n this._walletState = 'live';\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n this.revokeSession(); // pushes wallet:disconnected, settles in flight with 4001\n return;\n }\n\n // 2. Expired while locked. The TTL is 24 h by default and expiresAt does not refresh.\n if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {\n logger.warn(\n 'ConnectHost',\n `Session expired while locked — re-handshake required (origin=${this.config.origin ?? 'unverified'})`,\n );\n assertWalletTransition(this._walletState, 'live');\n this._walletState = 'live';\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n this.revokeSession();\n return;\n }\n }\n\n // 3. Rebind and go live. _walletState is set BEFORE the replay, because\n // handleSubscribe() branches on it and would otherwise put every key straight back\n // into suspendedSubscriptions and arm nothing.\n assertWalletTransition(this._walletState, 'live');\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n this._walletState = 'live';\n\n if (!this.session?.active) {\n // Nothing to re-arm and nothing to announce.\n this.suspendedSubscriptions.clear();\n return;\n }\n\n this.autoSubscribeIdentityChanged();\n\n const suspended = [...this.suspendedSubscriptions];\n this.suspendedSubscriptions.clear();\n for (const eventName of suspended) {\n try {\n this.handleSubscribe(eventName);\n } catch (err) {\n logger.warn('ConnectHost', `Re-subscribe failed after unlock: ${eventName}`, err);\n }\n }\n\n logger.debug(\n 'ConnectHost',\n `Wallet unlocked — re-armed ${suspended.length} subscription(s) (origin=${this.config.origin ?? 'unverified'})`,\n );\n\n this.pushClientEvent(WALLET_EVENTS.UNLOCKED, {\n identity: this.getPublicIdentity(),\n } satisfies WalletUnlockedPayload);\n\n // AND identity:changed, which main pushed on every unlock and which the Connect 2.0 docs\n // named as the ONLY unlock signal. checkCompatibility gates on the MAJOR alone, so every\n // already-shipped 2.0 dApp connects to a 2.1 wallet happily — and then, without this, sits\n // showing \"wallet locked\" for the rest of the page's life, because wallet:unlocked is a\n // name it has never heard of. Idempotent for a 2.1 client: it has already written the same\n // identity from the payload above, and its IDENTITY_CHANGED branch just rewrites it.\n const identity = this.getPublicIdentity();\n if (identity) this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);\n }\n\n /**\n * The wallet locked (manual lock, idle auto-lock, cross-tab broadcast, cold start).\n * The session is PRESERVED — a lock is a state, not a teardown. Every request outside\n * the locked allow-list is answered WALLET_LOCKED (4009) until updateSphere().\n *\n * Idempotent: a second call is a no-op and pushes nothing. Required, because\n * SphereProvider.lock(), ConnectPage's `sphere → null` effect and broadcastLock()'s\n * same-tab loopback can all fire it for one user action.\n *\n * ORDERING CONTRACT: call this BEFORE sphere.destroy(). The host drops its Sphere\n * reference here; destroying first leaves in-flight requests reading a dead instance\n * (-32603, or `undefined` returned AS SUCCESS from sphere_getIdentity).\n */\n setLocked(): void {\n if (this._walletState === 'locked') return;\n assertWalletTransition(this._walletState, 'locked');\n\n // Freeze BEFORE dropping. snapshot.identity?.chainPubkey IS the lock-edge identity\n // binding compared in updateSphere() — there is no separate boundIdentity field.\n this.snapshot = buildWalletSnapshot(this.sphere);\n this._walletState = 'locked';\n logger.debug(\n 'ConnectHost',\n `Wallet locked — session preserved (origin=${this.config.origin ?? 'unverified'}, session=${this.session?.id ?? 'none'})`,\n );\n\n if (this.session?.active) {\n this.pushClientEvent(WALLET_EVENTS.LOCKED, {} as WalletLockedPayload);\n }\n\n // Snapshot-then-detach. cleanupEventSubscriptions() ends in .clear() and the Map holds\n // eventName → closure; Sphere.destroy() kills those closures, so the KEYS are the only\n // recoverable information. Exclude identity:changed — autoSubscribeIdentityChanged()\n // re-arms that one itself.\n for (const key of this.eventSubscriptions.keys()) {\n if (key === WALLET_EVENTS.IDENTITY_CHANGED) continue;\n this.suspendedSubscriptions.add(key);\n }\n this.cleanupEventSubscriptions();\n\n // Today only revokeSession() clears these. Under session-preserving semantics an\n // auto-approved intent would otherwise survive a lock and execute unattended after\n // the unlock.\n this.autoApprovedIntents.clear();\n\n this.settleInFlight(ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: 'locked' });\n this.sphere = null;\n }\n\n /**\n * The Sphere instance is gone for a NON-LOCK reason (a generic init failure leaves\n * `sphere === null, isLocked === false` in the wallet).\n * This is a DEAD END: unlocking does not cure it, so it revokes the session and pushes\n * wallet:disconnected rather than promising an unlock that cannot help.\n * Subsequent requests answer NOT_CONNECTED (4001); handshakes get the empty refusal\n * WITHOUT dereferencing a null Sphere.\n *\n * Idempotent. Pushes no 'wallet:unavailable' — there is no such event.\n */\n setUnavailable(): void {\n if (this._walletState === 'unavailable') return;\n assertWalletTransition(this._walletState, 'unavailable');\n\n this._walletState = 'unavailable';\n logger.warn(\n 'ConnectHost',\n `Sphere unavailable (non-lock) — session revoked (origin=${this.config.origin ?? 'unverified'})`,\n );\n\n this.snapshot = EMPTY_WALLET_SNAPSHOT; // nothing may be served from it\n this.suspendedSubscriptions.clear();\n this.revokeSession(); // pushes wallet:disconnected, settles 4001\n this.sphere = null;\n }\n\n /**\n * Destroy the SESSION (logout, wallet deleted, dApp sphere_disconnect, popup\n * beforeunload, expiry, identity mismatch at unlock). Pushes wallet:disconnected BEFORE\n * tearing down, so the dApp stops believing it is connected instead of finding out at\n * its next 4001.\n *\n * This is the TEARDOWN verb. For a lock use setLocked() — a lock never destroys the\n * session. revokeSession() does NOT touch walletState: the two axes are orthogonal.\n */\n revokeSession(): void {\n if (this.session) {\n logger.debug(\n 'ConnectHost',\n `Session revoked (origin=${this.config.origin ?? 'unverified'}, session=${this.session.id})`,\n );\n if (this.session.active) {\n this.pushClientEvent(WALLET_EVENTS.DISCONNECTED, {} as WalletDisconnectedPayload);\n }\n this.session.active = false;\n this.cleanupEventSubscriptions();\n this.autoApprovedIntents.clear();\n this.session = null;\n this.grantedPermissions.clear();\n }\n this.suspendedSubscriptions.clear();\n this.settleInFlight(ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);\n }\n\n /** Destroy the host, clean up all resources. Idempotent. */\n destroy(): void {\n this.revokeSession(); // pushes wallet:disconnected, settles 4001\n this.inFlight.destroy(); // clear timers WITHOUT invoking onExpire\n if (this.unsubscribeTransport) {\n this.unsubscribeTransport();\n this.unsubscribeTransport = null;\n }\n this.sphere = null;\n this.snapshot = EMPTY_WALLET_SNAPSHOT;\n if (this._walletState !== 'unavailable') {\n assertWalletTransition(this._walletState, 'unavailable');\n this._walletState = 'unavailable';\n }\n }\n\n // ===========================================================================\n // Message Handling\n // ===========================================================================\n\n private async handleMessage(msg: SphereConnectMessage): Promise<void> {\n try {\n if (msg.type === 'handshake' && msg.direction === 'request') {\n await this.handleHandshake(msg);\n return;\n }\n\n if (msg.type === 'request') {\n await this.handleRpcRequest(msg);\n return;\n }\n\n if (msg.type === 'intent') {\n await this.handleIntentRequest(msg);\n return;\n }\n } catch (error) {\n logger.warn('ConnectHost', 'Error handling message:', error);\n // A throw used to send NOTHING, so the dApp hung for its full client timeout and\n // ended with a bare Error('Query timeout: …') / Error('Intent timeout: …') /\n // Error('Connection timeout') carrying no .code.\n this.sendUnhandledError(msg, error);\n }\n }\n\n // ===========================================================================\n // Handshake\n // ===========================================================================\n\n private async handleHandshake(msg: SphereHandshake): Promise<void> {\n const dapp = msg.dapp;\n // A handshake without dapp metadata is malformed — deny silently (no session).\n // The compatibility gate and onConnectionRejected are intentionally not consulted\n // here: there is no app to gate or to surface a rejection reason for.\n if (!dapp) {\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // STEP 0 — a host with no live binding AND no snapshot knows nothing about a wallet.\n // BEFORE checkCompatibility on purpose: snapshot.networkId is undefined on a\n // cold-start-locked host, so the network check (a missing network is treated as a\n // mismatch) would answer INCOMPATIBLE_NETWORK 4008 to EVERY origin — leaking\n // \"a wallet lives here, network -1\" with no approval, and lying about the cause.\n if (this._walletState !== 'live' && !this.snapshot.identity) {\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Rate limit. The handshake path was entirely unmetered: an unapproved origin could\n // loop handshakes and open the approval UI without bound. The refusal is today's EMPTY\n // refusal — it carries no error and reveals nothing about the wallet's state.\n if (!this.checkRateLimit()) {\n logger.warn('ConnectHost', 'Handshake rate-limited', { dapp: dapp.name });\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Captured for the pre-await decisions; RE-READ after the approval prompt below, because a\n // human-time await gives the wallet room to lock, unlock or log out underneath us.\n const stateAtPrompt = this._walletState;\n let locked = stateAtPrompt === 'locked';\n\n // Lock gate for the handshake kind. 'unavailable' refuses here WITHOUT dereferencing a\n // null Sphere — which is the whole reason the state exists separately.\n const handshakeDecision = gate(this._walletState, !!this.session?.active, 'handshake', 'handshake');\n if (handshakeDecision.kind === 'refuse') {\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Compatibility gate — runs BEFORE resume and BEFORE onConnectionRequest, so an\n // incompatible/old client cannot slip through on a stale sessionId.\n const result = checkCompatibility({\n clientProtocol: msg.v,\n walletProtocol: SPHERE_CONNECT_VERSION,\n clientNetwork: msg.network,\n walletNetworkId: this.snapshot.networkId ?? -1,\n minMinor: this.config.minMinorVersion,\n clientSdkVersion: msg.sdkVersion,\n minSdkVersion: this.config.minSdkVersion ?? DEFAULT_MIN_CLIENT_SDK_VERSION,\n });\n if (!result.ok) {\n logger.warn('ConnectHost', 'Rejected handshake', {\n dapp: dapp.name,\n reason: (result.error.data as { reason?: string } | undefined)?.reason,\n clientProtocol: msg.v,\n walletProtocol: SPHERE_CONNECT_VERSION,\n clientNetwork: msg.network ?? null,\n walletNetwork: this.snapshot.networkId ?? null,\n });\n this.config.onConnectionRejected?.(dapp, result.error, !!msg.silent);\n this.sendHandshakeResponse([], undefined, undefined, result.error, msg.v);\n return;\n }\n\n const clientInfo = { protocolVersion: msg.v, network: msg.network, sdkVersion: msg.sdkVersion };\n\n // Session resumption: if the client presents a valid existing sessionId,\n // skip the approval popup and restore the session without user interaction.\n // A resume DURING A LOCK succeeds and carries locked: true — a reloaded dApp is the\n // most common entry into this feature, and refusing it would leave that dApp with no\n // channel at all (it is not subscribed, so it would never receive wallet:unlocked).\n if (msg.sessionId && this.session?.active && this.session.id === msg.sessionId) {\n const identity = locked ? this.snapshotIdentity() : this.getPublicIdentity();\n this.sendHandshakeResponse(\n [...this.grantedPermissions],\n this.session.id,\n identity,\n undefined,\n undefined,\n undefined,\n locked ? true : undefined,\n );\n if (locked) {\n this.pushClientEvent(WALLET_EVENTS.LOCKED, {} as WalletLockedPayload);\n this.notifyLockedRequest('handshake', 'handshake');\n }\n return;\n }\n\n const requestedPermissions = msg.permissions as PermissionScope[];\n\n // FORCED SILENT while locked: the wallet's existing `if (silent) return { approved:\n // false }` branch refuses an unapproved origin with NO UI, and a previously approved\n // origin is approved from persisted state. No dApp request may ever raise a credential\n // surface, so a locked handshake must never be able to open one.\n const silent = msg.silent === true || locked;\n\n const { approved, grantedPermissions } = await withDeadline(\n Promise.resolve(\n this.config.onConnectionRequest(dapp, requestedPermissions, silent, clientInfo),\n ),\n this.config.handshakeDeadlineMs ?? DEFAULT_HANDSHAKE_DEADLINE_MS,\n () => {\n logger.warn('ConnectHost', 'Connection approval prompt timed out', { dapp: dapp.name });\n return { approved: false, grantedPermissions: [] as PermissionScope[] };\n },\n );\n\n // `locked` was read BEFORE a human-time await. Re-read the axis now, because the wallet can\n // have moved under us while the modal was open — an idle auto-lock, a cross-tab lock, a\n // logout, or an unlock.\n //\n // Getting this wrong in either direction is bad. Landing in 'locked' or 'unavailable' used\n // to mint an ACTIVE session on a non-live host while telling the dApp it was rejected: the\n // wallet then believes an origin is connected that has forgotten it exists, and under\n // 'unavailable' the gate refuses sphere_disconnect too, so that session is unclearable for\n // its whole TTL and onDisconnect never revokes the persisted origin approval. Landing in\n // 'live' (an unlock during the prompt) used to leave the dApp permanently walletLocked,\n // because the unlock edge had no session to notify at the time it fired.\n const stateAfterPrompt = this._walletState;\n if (stateAfterPrompt !== 'live' && stateAfterPrompt !== stateAtPrompt) {\n logger.warn(\n 'ConnectHost',\n `Wallet left 'live' while the approval prompt was open — refusing the handshake instead of minting a session (state=${stateAfterPrompt}, origin=${this.config.origin ?? 'unverified'})`,\n );\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n // Recompute so the response's `locked` flag and the trailing push describe NOW, not then.\n locked = stateAfterPrompt !== 'live';\n\n if (!approved) {\n // Deliberately NO notifyLockedRequest here. This origin was DENIED — it holds no\n // approval, so there is nothing for the user to unlock *for*, and counting it would put\n // an unvetted name in the wallet's own chrome and leak the lock to it. The notify stays\n // on the resume path and the approved path, where the origin demonstrably holds one.\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Create session\n const sessionId = createRequestId();\n const allPermissions = [...new Set([...DEFAULT_PERMISSIONS, ...grantedPermissions])];\n const ttl = this.config.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;\n\n this.session = {\n id: sessionId,\n dapp,\n permissions: allPermissions,\n createdAt: Date.now(),\n expiresAt: ttl > 0 ? Date.now() + ttl : 0,\n active: true,\n };\n this.grantedPermissions = new Set(allPermissions);\n\n // Auto-push identity:changed to dApp whenever the wallet switches address.\n // MetaMask pattern: no sphere_subscribe needed — host pushes it unconditionally.\n // SKIPPED while locked: there is no Sphere to attach to. updateSphere() arms it on the\n // locked -> live edge, so no extra bookkeeping is needed.\n if (!locked) this.autoSubscribeIdentityChanged();\n\n // Build public identity — from the snapshot while locked.\n const identity = locked ? this.snapshotIdentity() : this.getPublicIdentity();\n\n this.sendHandshakeResponse(\n allPermissions,\n sessionId,\n identity,\n undefined,\n undefined,\n undefined,\n locked ? true : undefined,\n );\n if (locked) this.pushClientEvent(WALLET_EVENTS.LOCKED, {} as WalletLockedPayload);\n }\n\n // `warning` is a forward-compatible deprecation-notice slot (see SphereHandshake.warning);\n // no call site emits one yet — reserved for the deprecation-window policy.\n private sendHandshakeResponse(\n permissions: string[],\n sessionId: string | undefined,\n identity: PublicIdentity | undefined,\n error?: SphereRpcError,\n echoV?: string,\n warning?: SphereRpcError,\n locked?: boolean,\n ): void {\n const network: NetworkInfo | undefined =\n typeof this.snapshot.networkId === 'number' ? { id: this.snapshot.networkId } : undefined;\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: (error && echoV ? echoV : SPHERE_CONNECT_VERSION) as typeof SPHERE_CONNECT_VERSION,\n type: 'handshake',\n direction: 'response',\n permissions,\n sessionId,\n identity,\n network,\n sdkVersion: SDK_VERSION,\n error,\n warning,\n ...(locked ? { locked: true } : {}),\n });\n }\n\n // ===========================================================================\n // RPC Requests (query)\n // ===========================================================================\n\n private async handleRpcRequest(msg: SphereRpcRequest): Promise<void> {\n // 1. Session check\n if (!this.session?.active) {\n this.sendError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);\n return;\n }\n\n // 2. Session expiry — BEFORE the lock gate. A dead session must never be advertised\n // as \"retry after unlock\", and the check needs no Sphere.\n if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {\n // Answer THIS request before revoking: revokeSession() pushes wallet:disconnected, and\n // a client that cleans up on that event would otherwise reject this id locally before\n // our own frame arrives.\n this.sendError(msg.id, ERROR_CODES.SESSION_EXPIRED, 'Session expired');\n this.revokeSession();\n return;\n }\n\n // 3. Rate limit — BEFORE the lock gate, because this is what bounds onLockedRequest\n // volume. A dApp polling in a loop while locked is throttled by the existing\n // limiter, which is why there is no second anti-spam mechanism.\n if (!this.checkRateLimit()) {\n this.sendError(msg.id, ERROR_CODES.RATE_LIMITED, 'Too many requests');\n return;\n }\n\n // 4. Disconnect, BEFORE the lock gate. It has to work in EVERY wallet state, and the gate\n // does not allow that: 'unavailable' refuses every query with NOT_CONNECTED, so a\n // session created just before Sphere went away could never be dropped — it sat active\n // for its whole TTL (24 h by default) and onDisconnect, the only hook that revokes the\n // persisted origin approval, never fired. Also before the permission check, because\n // sphere_disconnect has no mapped permission. It stays AFTER the rate limiter so a\n // disconnect flood is still throttled.\n if (msg.method === RPC_METHODS.DISCONNECT) {\n const disconnectedSession = this.session;\n // Answer BEFORE revoking, for the same reason as the expiry branch above.\n this.sendResult(msg.id, { disconnected: true });\n this.revokeSession();\n if (disconnectedSession && this.config.onDisconnect) {\n // Fire-and-forget: don't block the response\n Promise.resolve(this.config.onDisconnect(disconnectedSession)).catch((err) => logger.warn('Connect', 'onDisconnect handler error', err));\n }\n return;\n }\n\n // 5. Lock gate — BEFORE the permission check, because hasMethodPermission() is false\n // for anything unmapped, so a locked unknown method would otherwise answer\n // PERMISSION_DENIED 4002: an un-retryable code that tells the dApp its permissions\n // are wrong.\n const decision = gate(this._walletState, true, 'query', msg.method);\n if (decision.kind === 'refuse') {\n this.sendError(msg.id, decision.error.code, decision.error.message, decision.error.data);\n if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {\n logger.debug('ConnectHost', `Refused query ${msg.method} — WALLET_LOCKED 4009 (origin=${this.config.origin ?? 'unverified'})`);\n this.notifyLockedRequest('query', msg.method);\n }\n return;\n }\n\n\n // 5. Permission check\n if (!hasMethodPermission(this.grantedPermissions, msg.method)) {\n this.sendError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for ${msg.method}`);\n return;\n }\n\n // 6. Snapshot answer. Only sphere_getIdentity reaches this branch (host-state.ts).\n // An absent snapshot identity REFUSES: `undefined` sent as a SUCCESS result reads\n // to a dApp as \"the wallet has no identity\".\n if (decision.kind === 'serve-from-snapshot') {\n const identity = this.snapshotIdentity();\n if (!identity) {\n this.sendError(msg.id, ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: 'locked' });\n this.notifyLockedRequest('query', msg.method);\n return;\n }\n this.sendResult(msg.id, identity);\n return;\n }\n\n // 7. Register BEFORE the first await, with the timer armed at insertion time, then\n // execute. Every send goes through settle(), which returns null when a lock, a\n // revoke, a destroy or the deadline already answered this id — the whole\n // \"exactly one frame per id\" mechanism.\n this.inFlight.add(msg.id, 'query', this.config.requestDeadlineMs ?? DEFAULT_REQUEST_DEADLINE_MS);\n try {\n const result = await this.executeMethod(msg.method, msg.params ?? {});\n if (!this.inFlight.settle(msg.id)) return;\n this.sendResult(msg.id, result);\n } catch (error) {\n if (!this.inFlight.settle(msg.id)) return;\n // Our OWN SphereError messages are DX ('Missing required parameter: identifier',\n // 'Communications module not available') and stay. Anything else is internal JS text\n // that must not cross the trust boundary into a third-party dApp.\n // instanceof is unreliable across bundle copies — check the name too.\n const isSphereError =\n error instanceof SphereError || (error as { name?: string })?.name === 'SphereError';\n if (isSphereError) {\n const e = error as SphereError;\n this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, e.message, { reason: e.code });\n return;\n }\n this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n }\n }\n\n // ===========================================================================\n // Intent Requests\n // ===========================================================================\n\n private async handleIntentRequest(msg: SphereIntentRequest): Promise<void> {\n // 1. Session check\n if (!this.session?.active) {\n this.sendIntentError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);\n return;\n }\n\n // 2. Session expiry — BEFORE the lock gate (same reason as the query path).\n if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {\n // Answer first — see the query path.\n this.sendIntentError(msg.id, ERROR_CODES.SESSION_EXPIRED, 'Session expired');\n this.revokeSession();\n return;\n }\n\n // 3. Rate limit. checkRateLimit() was called only from handleRpcRequest, so the intent\n // path — the money path, the one that opens wallet modals — was entirely unmetered.\n if (!this.checkRateLimit()) {\n this.sendIntentError(msg.id, ERROR_CODES.RATE_LIMITED, 'Too many requests');\n return;\n }\n\n // 4. Lock gate. NO allow-list: every intent needs a live wallet.\n const decision = gate(this._walletState, true, 'intent', msg.action);\n if (decision.kind === 'refuse') {\n this.sendIntentError(msg.id, decision.error.code, decision.error.message, decision.error.data);\n if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {\n logger.debug('ConnectHost', `Refused intent ${msg.action} — WALLET_LOCKED 4009 (origin=${this.config.origin ?? 'unverified'})`);\n this.notifyLockedRequest('intent', msg.action);\n }\n return;\n }\n\n // 5. Permission check\n if (!hasIntentPermission(this.grantedPermissions, msg.action)) {\n this.sendIntentError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for intent: ${msg.action}`);\n return;\n }\n\n // 6. Register BEFORE the first await, then delegate. The entry owns the deadline and\n // the AbortController, so lock / revoke / unavailable / destroy / deadline all\n // settle this id exactly once.\n const session = this.session;\n const entry = this.inFlight.add(\n msg.id,\n 'intent',\n this.config.intentDeadlineMs ?? DEFAULT_INTENT_DEADLINE_MS,\n );\n const ctx: IntentContext = {\n origin: this.config.origin,\n expiresAt: entry.deadline,\n signal: entry.controller.signal,\n };\n\n try {\n const autoHandler = this.autoApprovedIntents.get(msg.action);\n const response = autoHandler\n ? await autoHandler(msg.action, msg.params, session)\n : await this.config.onIntent(msg.action, msg.params, session, ctx);\n\n if (!this.inFlight.settle(msg.id)) return;\n if (response.error) {\n // Not relayed blindly: two of these codes describe the CHANNEL and say nothing true\n // about a spend the wallet has already taken ownership of. Everything else — including\n // USER_REJECTED, a legitimate pre-submission decline — passes through untouched. See\n // CHANNEL_ONLY_CODES.\n const asserts = CHANNEL_ONLY_CODES.has(response.error.code);\n if (asserts) {\n logger.warn(\n 'ConnectHost',\n `Wallet answered intent ${msg.action} with ${response.error.code}, which describes the channel rather than the spend — downgrading to INTENT_OUTCOME_UNKNOWN (origin=${this.config.origin ?? 'unverified'})`,\n );\n }\n this.sendIntentError(\n msg.id,\n asserts ? ERROR_CODES.INTENT_OUTCOME_UNKNOWN : response.error.code,\n asserts ? INTENT_UNKNOWN_MESSAGE : response.error.message,\n );\n } else {\n this.sendIntentResult(msg.id, response.result);\n }\n } catch (error) {\n // A throw from onIntent or from an auto-approve handler used to reach\n // handleMessage's catch, which sent NOTHING — the dApp then hung for its full\n // intentTimeout and ended with a bare Error('Intent timeout: …') carrying no .code.\n logger.warn('ConnectHost', `Intent handler threw: ${msg.action}`, error);\n if (!this.inFlight.settle(msg.id)) return;\n this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n }\n }\n\n // ===========================================================================\n // Method Router\n // ===========================================================================\n\n private async executeMethod(method: string, params: Record<string, unknown>): Promise<unknown> {\n // Subscription bookkeeping needs NO Sphere and is allow-listed while locked\n // (host-state.ts), so it must be answered BEFORE the dereference below — otherwise a\n // locked sphere_unsubscribe dies on requireSphere() with -32603 despite the gate\n // having let it through.\n switch (method) {\n case RPC_METHODS.SUBSCRIBE:\n return this.handleSubscribe(params.event as string);\n case RPC_METHODS.UNSUBSCRIBE:\n return this.handleUnsubscribe(params.event as string);\n }\n\n // ONE dereference for the whole router. The gate has already proven _walletState is\n // 'live' before we get here, so this is defence in depth — and it is what makes the\n // nullable field compile without a single `!`.\n const sphere = this.requireSphere();\n // §4 wire-compat (payments-compat.ts): the old query results are built from\n // facade reads. `sphere.payments` is read per branch, never up here — the\n // getter throws while no vertical runs and GET_IDENTITY must still answer.\n switch (method) {\n case RPC_METHODS.GET_IDENTITY:\n return this.getPublicIdentity();\n\n case RPC_METHODS.GET_BALANCE:\n case RPC_METHODS.GET_ASSETS:\n return sphere.payments.assets(params.coinId as string | undefined);\n\n case RPC_METHODS.GET_FIAT_BALANCE:\n return { fiatBalance: sumFiatUsd(await sphere.payments.assets()) };\n\n case RPC_METHODS.GET_TOKENS:\n return this.stripTokenSdkData(\n sphere.payments.tokens(params.coinId ? { coinId: params.coinId as string } : undefined),\n );\n\n case RPC_METHODS.GET_HISTORY: {\n // Flat entry array on the wire; entries already carry the consumed shape\n // (`timestamp` mapped from the server's `ts`, plus symbol/tokenIds).\n const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)\n ? params.limit\n : undefined;\n if (limit !== undefined) return (await sphere.payments.history({ limit })).entries;\n // INVARIANT: the legacy wire has no cursor — completeness is the contract.\n // Parameterless sphere_getHistory returned the ENTIRE ledger, so follow\n // the facade's cursors until the record is exhausted.\n let page = await sphere.payments.history(undefined);\n const entries = [...page.entries];\n while (page.more && page.cursor !== null) {\n page = await sphere.payments.history({ before: page.cursor });\n entries.push(...page.entries);\n }\n return entries;\n }\n\n case RPC_METHODS.RESOLVE:\n if (!params.identifier) {\n throw new SphereError('Missing required parameter: identifier', 'VALIDATION_ERROR');\n }\n return sphere.resolve(params.identifier as string);\n\n case RPC_METHODS.SUBSCRIBE:\n return this.handleSubscribe(params.event as string);\n\n case RPC_METHODS.UNSUBSCRIBE:\n return this.handleUnsubscribe(params.event as string);\n\n case RPC_METHODS.GET_CONVERSATIONS: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n const convos = comms.getConversations();\n const result: Array<{\n peerPubkey: string;\n peerNametag?: string;\n lastMessage: ConnectDirectMessage;\n unreadCount: number;\n messageCount: number;\n }> = [];\n // Collect conversations and track which ones need nametag resolution\n const needsResolve: Array<{ index: number; peerPubkey: string }> = [];\n for (const [peer, messages] of convos) {\n if (messages.length === 0) continue;\n const last = messages[messages.length - 1];\n // Find peer nametag from any message in the conversation\n const peerNametag =\n messages.find(m => m.senderPubkey === peer && m.senderNametag)?.senderNametag\n ?? messages.find(m => m.recipientPubkey === peer && m.recipientNametag)?.recipientNametag;\n const idx = result.length;\n result.push({\n peerPubkey: peer,\n peerNametag,\n lastMessage: last,\n unreadCount: comms.getUnreadCount(peer),\n messageCount: messages.length,\n });\n if (!peerNametag) {\n needsResolve.push({ index: idx, peerPubkey: peer });\n }\n }\n // Resolve missing nametags via transport (parallel, best-effort)\n if (needsResolve.length > 0) {\n const resolved = await Promise.all(\n needsResolve.map(({ peerPubkey }) =>\n comms.resolvePeerNametag(peerPubkey).catch((err) => { logger.debug('Connect', 'Peer Unicity ID resolution failed', err); return undefined; }),\n ),\n );\n for (let i = 0; i < needsResolve.length; i++) {\n if (resolved[i]) {\n result[needsResolve[i].index].peerNametag = resolved[i];\n }\n }\n }\n result.sort((a, b) => b.lastMessage.timestamp - a.lastMessage.timestamp);\n return result;\n }\n\n case RPC_METHODS.GET_MESSAGES: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n if (!params.peerPubkey) throw new SphereError('Missing required parameter: peerPubkey', 'VALIDATION_ERROR');\n return comms.getConversationPage(\n params.peerPubkey as string,\n {\n limit: params.limit as number | undefined,\n before: params.before as number | undefined,\n },\n );\n }\n\n case RPC_METHODS.GET_DM_UNREAD_COUNT: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n return {\n unreadCount: comms.getUnreadCount(\n params.peerPubkey as string | undefined,\n ),\n };\n }\n\n case RPC_METHODS.MARK_AS_READ: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n if (!params.messageIds || !Array.isArray(params.messageIds)) {\n throw new SphereError('Missing required parameter: messageIds (string[])', 'VALIDATION_ERROR');\n }\n await comms.markAsRead(params.messageIds as string[]);\n return { marked: true, count: (params.messageIds as string[]).length };\n }\n\n default:\n throw new SphereError(`Unknown method: ${method}`, 'VALIDATION_ERROR');\n }\n }\n\n // ===========================================================================\n // Event Subscriptions\n // ===========================================================================\n\n private autoSubscribeIdentityChanged(): void {\n if (this.eventSubscriptions.has(WALLET_EVENTS.IDENTITY_CHANGED)) return;\n const unsub = this.requireSphere().on('identity:changed' as SphereEventType, (data: unknown) => {\n this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, data);\n });\n this.eventSubscriptions.set(WALLET_EVENTS.IDENTITY_CHANGED, unsub);\n }\n\n private handleSubscribe(eventName: string): { subscribed: boolean; event: string } {\n if (!eventName) throw new SphereError('Missing required parameter: event', 'VALIDATION_ERROR');\n\n // The four auto-pushed events must NOT be attached to Sphere.on(): it accepts any string\n // and never emits for them, so the subscription would deliver nothing forever. But the\n // answer is SUCCESS, not an error — the host pushes them unconditionally, so \"you are\n // subscribed\" is true, just satisfied by a different mechanism.\n //\n // Throwing here surfaced as INTERNAL_ERROR -32603 and silently broke every dApp built\n // before 2.1: `client.on('wallet:locked', …)` fires sphere_subscribe fire-and-forget, and\n // on 2.0 that call succeeded. A MINOR bump must not turn a working call into a crash. The\n // 2.1 client skips the call altogether, so this path exists only for older dApps.\n if (isAutoPushedEvent(eventName)) {\n return { subscribed: true, event: eventName };\n }\n\n // While locked there is no Sphere to attach to: record the KEY and answer success.\n // Refusing would be a bug, not a policy — ConnectClient.on() fires sphere_subscribe\n // fire-and-forget and never retries, so the stream would die forever after one lock.\n if (this._walletState === 'locked') {\n this.suspendedSubscriptions.add(eventName);\n return { subscribed: true, event: eventName };\n }\n\n if (this.eventSubscriptions.has(eventName)) {\n return { subscribed: true, event: eventName };\n }\n\n const sphere = this.requireSphere();\n\n // §4 wire-compat (payments-compat.ts): on a v2-facade host the old event names have no\n // bus emitter — re-emit them from the v2 events so nothing a dApp subscribes to silently\n // stops firing. Keyed under the OLD name, so unsubscribe and the lock snapshot/replay\n // bookkeeping work unchanged.\n {\n const compatUnsub = attachCompatEvent(sphere, eventName, (data) =>\n this.pushClientEvent(eventName, data),\n );\n if (compatUnsub) {\n this.eventSubscriptions.set(eventName, compatUnsub);\n return { subscribed: true, event: eventName };\n }\n }\n\n const unsub = sphere.on(eventName as SphereEventType, (data: unknown) => {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'event',\n event: eventName,\n data,\n });\n });\n\n this.eventSubscriptions.set(eventName, unsub);\n return { subscribed: true, event: eventName };\n }\n\n private handleUnsubscribe(eventName: string): { unsubscribed: boolean; event: string } {\n if (!eventName) throw new SphereError('Missing required parameter: event', 'VALIDATION_ERROR');\n\n const unsub = this.eventSubscriptions.get(eventName);\n if (unsub) {\n unsub();\n this.eventSubscriptions.delete(eventName);\n }\n // Also drop it from the lock snapshot: sphere_unsubscribe is on the locked allow-list,\n // so without this the next updateSphere() re-arm would resurrect the stream.\n this.suspendedSubscriptions.delete(eventName);\n return { unsubscribed: true, event: eventName };\n }\n\n private cleanupEventSubscriptions(): void {\n for (const [, unsub] of this.eventSubscriptions) {\n unsub();\n }\n this.eventSubscriptions.clear();\n }\n\n // ===========================================================================\n // Helpers\n // ===========================================================================\n\n /** Push an event to the dApp without requiring a sphere_subscribe call. */\n private pushClientEvent(event: string, data: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'event',\n event,\n data,\n });\n }\n\n /** The bound Sphere, or a typed refusal. The ONLY way the router may reach Sphere.\n * Unreachable in practice — the gate guarantees 'live' before the router is entered —\n * so this is defence in depth, not the primary mechanism. */\n private requireSphere(): SphereInstance {\n if (!this.sphere) {\n throw new SphereError(\n this._walletState === 'locked' ? WALLET_LOCKED_MESSAGE : 'Wallet unavailable',\n 'NOT_INITIALIZED',\n );\n }\n return this.sphere;\n }\n\n /** SNAPSHOT read. `undefined` means \"we never saw an identity\": callers MUST refuse,\n * never answer undefined-as-success — a dApp reads that as \"the wallet has no\n * identity\". Two explicit methods instead of one dual-mode method, so nobody can serve\n * a snapshot believing it is live. */\n private snapshotIdentity(): PublicIdentity | undefined {\n return this.snapshot.identity;\n }\n\n /** InFlightRegistry.onExpire sink. Filled in a later task; declared here so the\n * constructor can wire it. */\n private settleExpired(entry: InFlightEntry): void {\n logger.warn(\n 'ConnectHost',\n `Host deadline reached, answering on our own: ${entry.kind} ${entry.id} (origin=${this.config.origin ?? 'unverified'})`,\n );\n if (entry.kind === 'query') {\n this.sendError(entry.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n } else {\n // NOT INTENT_CANCELLED. The wallet was handed this intent and may already have submitted\n // the transfer — the deadline says only that we stopped waiting for the answer, never\n // that nothing happened. Asserting a cancel here is how a dApp re-offers a payment that\n // already went through.\n this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);\n }\n }\n\n /** Answer every request already in flight with one coded frame each. A request in flight\n * when the Sphere goes away otherwise answers -32603 with a raw JS message, returns\n * `undefined` AS SUCCESS (sphere_getIdentity), or — for a delegated intent — never\n * answers at all until the client's own 120 s timeout. */\n private settleInFlight(code: number, message: string, data?: WalletLockedData): void {\n for (const entry of this.inFlight.settleAll()) {\n if (entry.kind === 'query') {\n this.sendError(entry.id, code, message, data);\n continue;\n }\n // An INTENT is never answered with the caller's code. 4009 invites a retry after the\n // unlock and 4001 reads as \"never happened\", but this intent was already delegated to\n // the wallet: the user may have confirmed it and the transfer may be on the wire. Only\n // \"outcome unknown\" is true, and it explicitly forbids a retry.\n this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);\n }\n }\n\n /**\n * Notify-only. The host has ALREADY answered and never waits for the wallet.\n *\n * The wallet's only permitted reaction is a PASSIVE badge in its PERMANENT chrome; a\n * dApp request may trigger a CONSENT prompt but never a credential prompt. Volume is\n * bounded by checkRateLimit(), which guards all three entry points — there is no\n * coalescing, no cooldown and no cap by design.\n *\n * A throwing handler must not break the host.\n */\n private notifyLockedRequest(kind: LockedRequestContext['kind'], name: string): void {\n try {\n this.config.onLockedRequest?.({ origin: this.config.origin, kind, name });\n } catch (err) {\n logger.warn('ConnectHost', 'onLockedRequest handler error', err);\n }\n }\n\n /** Last-resort answer for a handler that threw before its own catch could run.\n * Id-bearing frames get a coded error (InFlightRegistry guarantees exactly one answer\n * per id); a handshake gets today's empty refusal, because a failed handshake must\n * reveal nothing. */\n private sendUnhandledError(msg: SphereConnectMessage, error: unknown): void {\n if (msg.type === 'request') {\n if (!this.inFlight.settle(msg.id) && this.inFlight.has(msg.id)) return;\n this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n return;\n }\n if (msg.type === 'intent') {\n this.inFlight.settle(msg.id);\n this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n return;\n }\n if (msg.type === 'handshake' && msg.direction === 'request') {\n logger.warn('ConnectHost', 'Handshake handler threw; sending the empty refusal', error);\n this.sendHandshakeResponse([], undefined, undefined);\n }\n }\n\n private getPublicIdentity(): PublicIdentity | undefined {\n const id = this.requireSphere().identity;\n if (!id) return undefined;\n return {\n chainPubkey: id.chainPubkey,\n directAddress: id.directAddress,\n nametag: id.nametag,\n };\n }\n\n private stripTokenSdkData(tokens: unknown[]): unknown[] {\n return tokens.map((t) => {\n const token = t as Record<string, unknown>;\n // Return all fields except internal sdkData\n const { sdkData: _sdkData, ...publicFields } = token;\n return publicFields;\n });\n }\n\n private sendResult(id: string, result: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'response',\n id,\n result,\n });\n }\n\n private sendError(id: string, code: number, message: string, data?: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'response',\n id,\n error: { code, message, ...(data !== undefined ? { data } : {}) },\n });\n }\n\n private sendIntentResult(id: string, result: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'intent_result',\n id,\n result,\n });\n }\n\n private sendIntentError(id: string, code: number, message: string, data?: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'intent_result',\n id,\n error: { code, message, ...(data !== undefined ? { data } : {}) },\n });\n }\n\n private checkRateLimit(): boolean {\n const maxRps = this.config.maxRequestsPerSecond ?? DEFAULT_MAX_RPS;\n const now = Date.now();\n if (now > this.rateLimitResetAt) {\n this.rateLimitCounter = 0;\n this.rateLimitResetAt = now + 1000;\n }\n this.rateLimitCounter++;\n return this.rateLimitCounter <= maxRps;\n }\n}\n","/**\n * WebSocket Abstraction\n * Platform-independent WebSocket interface for cross-platform support\n */\n\n// =============================================================================\n// WebSocket Interface\n// =============================================================================\n\n/**\n * Minimal WebSocket interface compatible with browser and Node.js\n */\nexport interface IWebSocket {\n readonly readyState: number;\n\n send(data: string): void;\n close(code?: number, reason?: string): void;\n\n onopen: ((event: unknown) => void) | null;\n onclose: ((event: unknown) => void) | null;\n onerror: ((event: unknown) => void) | null;\n onmessage: ((event: IMessageEvent) => void) | null;\n}\n\nexport interface IMessageEvent {\n data: string;\n}\n\n/**\n * WebSocket ready states (same as native WebSocket)\n */\nexport const WebSocketReadyState = {\n CONNECTING: 0,\n OPEN: 1,\n CLOSING: 2,\n CLOSED: 3,\n} as const;\n\n/**\n * Factory function to create WebSocket instances\n * Different implementations for browser (native) vs Node.js (ws package)\n */\nexport type WebSocketFactory = (url: string) => IWebSocket;\n\n// =============================================================================\n// UUID Generator\n// =============================================================================\n\n/**\n * Generate a unique ID (platform-independent)\n * Browser: crypto.randomUUID()\n * Node: crypto.randomUUID() or uuid package\n */\nexport type UUIDGenerator = () => string;\n\n/**\n * Default UUID generator using crypto.randomUUID\n * Works in modern browsers and Node 19+\n */\nexport function defaultUUIDGenerator(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n // Fallback for older environments\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n}\n","/**\n * WebSocketTransport — Node.js transport for Sphere Connect.\n *\n * Two modes:\n * - Server: wallet runs a WS server, dApps connect to it\n * - Client: dApp connects to wallet's WS server\n *\n * Uses the existing IWebSocket/WebSocketFactory abstraction from transport/websocket.ts.\n */\n\nimport type { ConnectTransport, SphereConnectMessage } from '../../../connect';\nimport { isSphereConnectMessage } from '../../../connect';\nimport type { IWebSocket, WebSocketFactory } from '../../../transport/websocket';\nimport { WebSocketReadyState } from '../../../transport/websocket';\nimport { logger } from '../../../core/logger';\n\n// =============================================================================\n// Configuration\n// =============================================================================\n\nexport interface WebSocketServerConfig {\n /** Port to listen on */\n port: number;\n /** Host to bind to. Default: '0.0.0.0' */\n host?: string;\n}\n\nexport interface WebSocketClientConfig {\n /** WebSocket URL to connect to (e.g., 'ws://localhost:8765') */\n url: string;\n /** Factory for creating WebSocket instances */\n createWebSocket: WebSocketFactory;\n /** Reconnect on disconnect. Default: true */\n autoReconnect?: boolean;\n /** Initial reconnect delay in ms. Default: 2000 */\n reconnectDelayMs?: number;\n /** Max reconnect delay in ms. Default: 30000 */\n maxReconnectDelayMs?: number;\n /** Max reconnect attempts. Default: 10. 0 = unlimited */\n maxReconnectAttempts?: number;\n}\n\n// =============================================================================\n// Server Transport (wallet side)\n// =============================================================================\n\nexport class WebSocketServerTransport implements ConnectTransport {\n private server: unknown = null; // WebSocketServer from 'ws' package\n private clientSocket: IWebSocket | null = null;\n private handlers: Set<(message: SphereConnectMessage) => void> = new Set();\n private config: WebSocketServerConfig;\n\n constructor(config: WebSocketServerConfig) {\n this.config = config;\n }\n\n /** Start the WebSocket server. Must be called before use. */\n async start(): Promise<void> {\n // Dynamic import to avoid bundling ws in browser builds\n const { WebSocketServer } = await import('ws');\n const wss = new WebSocketServer({\n port: this.config.port,\n host: this.config.host ?? '0.0.0.0',\n });\n\n this.server = wss;\n\n wss.on('connection', (ws: IWebSocket) => {\n // Accept only one client at a time\n if (this.clientSocket) {\n ws.close(4000, 'Another client is already connected');\n return;\n }\n\n this.clientSocket = ws;\n\n ws.onmessage = (event: { data: string }) => {\n try {\n const msg = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data));\n if (isSphereConnectMessage(msg)) {\n for (const handler of this.handlers) {\n try {\n handler(msg);\n } catch (err) {\n logger.debug('WebSocket', 'Message handler error', err);\n }\n }\n }\n } catch (err) {\n logger.debug('WebSocket', 'Malformed message received', err);\n }\n };\n\n ws.onclose = () => {\n if (this.clientSocket === ws) {\n this.clientSocket = null;\n }\n };\n });\n\n // Wait for server to be listening\n await new Promise<void>((resolve, reject) => {\n wss.on('listening', resolve);\n wss.on('error', reject);\n });\n }\n\n send(message: SphereConnectMessage): void {\n if (this.clientSocket && this.clientSocket.readyState === WebSocketReadyState.OPEN) {\n this.clientSocket.send(JSON.stringify(message));\n }\n }\n\n onMessage(handler: (message: SphereConnectMessage) => void): () => void {\n this.handlers.add(handler);\n return () => {\n this.handlers.delete(handler);\n };\n }\n\n destroy(): void {\n if (this.clientSocket) {\n this.clientSocket.close();\n this.clientSocket = null;\n }\n if (this.server) {\n (this.server as { close: () => void }).close();\n this.server = null;\n }\n this.handlers.clear();\n }\n}\n\n// =============================================================================\n// Client Transport (dApp side)\n// =============================================================================\n\nexport class WebSocketClientTransport implements ConnectTransport {\n private ws: IWebSocket | null = null;\n private handlers: Set<(message: SphereConnectMessage) => void> = new Set();\n private config: WebSocketClientConfig;\n private reconnectAttempts = 0;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private destroyed = false;\n\n constructor(config: WebSocketClientConfig) {\n this.config = {\n autoReconnect: true,\n reconnectDelayMs: 2000,\n maxReconnectDelayMs: 30000,\n maxReconnectAttempts: 10,\n ...config,\n };\n }\n\n /** Connect to the WebSocket server. Must be called before use. */\n async connect(): Promise<void> {\n return this.doConnect();\n }\n\n send(message: SphereConnectMessage): void {\n if (this.ws && this.ws.readyState === WebSocketReadyState.OPEN) {\n this.ws.send(JSON.stringify(message));\n }\n }\n\n onMessage(handler: (message: SphereConnectMessage) => void): () => void {\n this.handlers.add(handler);\n return () => {\n this.handlers.delete(handler);\n };\n }\n\n destroy(): void {\n this.destroyed = true;\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n if (this.ws) {\n this.ws.close();\n this.ws = null;\n }\n this.handlers.clear();\n }\n\n // ===========================================================================\n // Private\n // ===========================================================================\n\n private doConnect(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n try {\n this.ws = this.config.createWebSocket(this.config.url);\n } catch (err) {\n reject(err);\n return;\n }\n\n this.ws.onopen = () => {\n this.reconnectAttempts = 0;\n resolve();\n };\n\n this.ws.onmessage = (event) => {\n try {\n const msg = JSON.parse(event.data);\n if (isSphereConnectMessage(msg)) {\n for (const handler of this.handlers) {\n try {\n handler(msg);\n } catch (err) {\n logger.debug('WebSocket', 'Message handler error', err);\n }\n }\n }\n } catch (err) {\n logger.debug('WebSocket', 'Malformed message received', err);\n }\n };\n\n this.ws.onerror = (err) => {\n reject(err);\n };\n\n this.ws.onclose = () => {\n this.ws = null;\n if (!this.destroyed && this.config.autoReconnect) {\n this.scheduleReconnect();\n }\n };\n });\n }\n\n private scheduleReconnect(): void {\n const maxAttempts = this.config.maxReconnectAttempts!;\n if (maxAttempts > 0 && this.reconnectAttempts >= maxAttempts) {\n return;\n }\n\n this.reconnectAttempts++;\n const baseDelay = this.config.reconnectDelayMs!;\n const maxDelay = this.config.maxReconnectDelayMs!;\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts - 1), maxDelay);\n\n this.reconnectTimer = setTimeout(() => {\n this.reconnectTimer = null;\n this.doConnect().catch((err) => logger.debug('WebSocket', 'Reconnect attempt failed', err));\n }, delay);\n }\n}\n\n// =============================================================================\n// Factory Functions\n// =============================================================================\n\nexport const WebSocketTransport = {\n /** Create a WebSocket server transport (wallet side) */\n createServer(config: WebSocketServerConfig): WebSocketServerTransport {\n return new WebSocketServerTransport(config);\n },\n\n /** Create a WebSocket client transport (dApp side) */\n createClient(config: WebSocketClientConfig): WebSocketClientTransport {\n return new WebSocketClientTransport(config);\n },\n};\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../core/logger.ts","../../../../connect/semver.ts","../../../../constants.ts","../../../../connect/protocol.ts","../../../../connect/permissions.ts","../../../../connect/host/payments-compat.ts","../../../../connect/host/host-state.ts","../../../../connect/host/WalletSnapshot.ts","../../../../connect/host/ConnectHost.ts","../../../../transport/websocket.ts","../../../../impl/nodejs/connect/WebSocketTransport.ts"],"sourcesContent":["/**\n * Centralized SDK Logger\n *\n * A lightweight singleton logger that works across all tsup bundles\n * by storing state on globalThis. Supports three log levels:\n * - debug: detailed messages (only shown when debug=true)\n * - warn: important warnings (ALWAYS shown regardless of debug flag)\n * - error: critical errors (ALWAYS shown regardless of debug flag)\n *\n * Global debug flag enables all logging. Per-tag overrides allow\n * granular control (e.g., only transport debug).\n *\n * @example\n * ```ts\n * import { logger } from '@unicitylabs/sphere-sdk';\n *\n * // Enable all debug logging\n * logger.configure({ debug: true });\n *\n * // Enable only specific tags\n * logger.setTagDebug('Nostr', true);\n *\n * // Usage in SDK classes\n * logger.debug('Payments', 'Transfer started', { amount, recipient });\n * logger.warn('Nostr', 'queryEvents timed out after 5s');\n * logger.error('Sphere', 'Critical failure', error);\n * ```\n */\n\nexport type LogLevel = 'debug' | 'warn' | 'error';\n\nexport type LogHandler = (level: LogLevel, tag: string, message: string, ...args: unknown[]) => void;\n\nexport interface LoggerConfig {\n /** Enable debug logging globally (default: false). When false, only warn and error messages are shown. */\n debug?: boolean;\n /** Custom log handler. If provided, replaces console output. Useful for tests or custom log sinks. */\n handler?: LogHandler | null;\n}\n\n// Use a unique symbol-like key on globalThis to share logger state across tsup bundles\nconst LOGGER_KEY = '__sphere_sdk_logger__';\n\ninterface LoggerState {\n debug: boolean;\n tags: Record<string, boolean>;\n handler: LogHandler | null;\n}\n\nfunction getState(): LoggerState {\n const g = globalThis as unknown as Record<string, unknown>;\n if (!g[LOGGER_KEY]) {\n g[LOGGER_KEY] = { debug: false, tags: {}, handler: null } satisfies LoggerState;\n }\n return g[LOGGER_KEY] as LoggerState;\n}\n\nfunction isEnabled(tag: string): boolean {\n const state = getState();\n // Per-tag override takes priority\n if (tag in state.tags) return state.tags[tag];\n // Fall back to global flag\n return state.debug;\n}\n\nexport const logger = {\n /**\n * Configure the logger. Can be called multiple times (last write wins).\n * Typically called by createBrowserProviders(), createNodeProviders(), or Sphere.init().\n */\n configure(config: LoggerConfig): void {\n const state = getState();\n if (config.debug !== undefined) state.debug = config.debug;\n if (config.handler !== undefined) state.handler = config.handler;\n },\n\n /**\n * Enable/disable debug logging for a specific tag.\n * Per-tag setting overrides the global debug flag.\n *\n * @example\n * ```ts\n * logger.setTagDebug('Nostr', true); // enable only Nostr logs\n * logger.setTagDebug('Nostr', false); // disable Nostr logs even if global debug=true\n * ```\n */\n setTagDebug(tag: string, enabled: boolean): void {\n getState().tags[tag] = enabled;\n },\n\n /**\n * Clear per-tag override, falling back to global debug flag.\n */\n clearTagDebug(tag: string): void {\n delete getState().tags[tag];\n },\n\n /** Returns true if debug mode is enabled for the given tag (or globally). */\n isDebugEnabled(tag?: string): boolean {\n if (tag) return isEnabled(tag);\n return getState().debug;\n },\n\n /**\n * Debug-level log. Only shown when debug is enabled (globally or for this tag).\n * Use for detailed operational information.\n */\n debug(tag: string, message: string, ...args: unknown[]): void {\n if (!isEnabled(tag)) return;\n const state = getState();\n if (state.handler) {\n state.handler('debug', tag, message, ...args);\n } else {\n console.log(`[${tag}]`, message, ...args);\n }\n },\n\n /**\n * Warning-level log. ALWAYS shown regardless of debug flag.\n * Use for important but non-critical issues (timeouts, retries, degraded state).\n */\n warn(tag: string, message: string, ...args: unknown[]): void {\n const state = getState();\n if (state.handler) {\n state.handler('warn', tag, message, ...args);\n } else {\n console.warn(`[${tag}]`, message, ...args);\n }\n },\n\n /**\n * Error-level log. ALWAYS shown regardless of debug flag.\n * Use for critical failures that should never be silenced.\n */\n error(tag: string, message: string, ...args: unknown[]): void {\n const state = getState();\n if (state.handler) {\n state.handler('error', tag, message, ...args);\n } else {\n console.error(`[${tag}]`, message, ...args);\n }\n },\n\n /** Reset all logger state (debug flag, tags, handler). Primarily for tests. */\n reset(): void {\n const g = globalThis as unknown as Record<string, unknown>;\n delete g[LOGGER_KEY];\n },\n};\n","// connect/semver.ts\n// Tiny, dependency-free semver helpers for the Connect compatibility gate.\n\n/** MAJOR component of a semver string (e.g. '2.7.3' -> 2). NaN if unparseable. */\nexport function majorOf(v: string): number {\n return parseInt(String(v).split('.')[0], 10);\n}\n\n/**\n * Compare two semver strings, prerelease-aware. Returns -1 | 0 | 1.\n * A release outranks its own prerelease: compareSemver('1.2.3', '1.2.3-rc.1') === 1.\n */\nexport function compareSemver(a: string, b: string): number {\n const parse = (v: string) => {\n const [core, pre] = String(v).split('-', 2) as [string, string | undefined];\n const nums = core.split('.').map((n) => parseInt(n, 10) || 0);\n return { nums, pre };\n };\n const pa = parse(a);\n const pb = parse(b);\n for (let i = 0; i < 3; i++) {\n const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);\n if (d !== 0) return d < 0 ? -1 : 1;\n }\n if (pa.pre === undefined && pb.pre === undefined) return 0;\n if (pa.pre === undefined) return 1; // release > prerelease\n if (pb.pre === undefined) return -1;\n const sa = pa.pre.split('.');\n const sb = pb.pre.split('.');\n for (let i = 0; i < Math.max(sa.length, sb.length); i++) {\n const x = sa[i];\n const y = sb[i];\n if (x === undefined) return -1;\n if (y === undefined) return 1;\n const nx = Number(x);\n const ny = Number(y);\n if (!Number.isNaN(nx) && !Number.isNaN(ny)) {\n if (nx !== ny) return nx < ny ? -1 : 1;\n } else if (x !== y) {\n return x < y ? -1 : 1;\n }\n }\n return 0;\n}\n","/**\n * SDK2 Constants\n * Default configuration values and storage keys\n */\n\n// =============================================================================\n// Storage Keys\n// =============================================================================\n\n/** Default prefix for all storage keys */\nexport const STORAGE_PREFIX = 'sphere_' as const;\n\n/**\n * Default encryption key for wallet data\n * WARNING: This is a placeholder. In production, use user-provided password.\n * This key is used when no password is provided to encrypt/decrypt mnemonic.\n */\nexport const DEFAULT_ENCRYPTION_KEY = 'sphere-default-key' as const;\n\n/**\n * Global storage keys (one per wallet, no address index)\n * Final key format: sphere_{key}\n */\nexport const STORAGE_KEYS_GLOBAL = {\n /** Encrypted BIP39 mnemonic */\n MNEMONIC: 'mnemonic',\n /** Encrypted master private key */\n MASTER_KEY: 'master_key',\n /** BIP32 chain code */\n CHAIN_CODE: 'chain_code',\n /** HD derivation path (full path like m/44'/0'/0'/0/0) */\n DERIVATION_PATH: 'derivation_path',\n /** Base derivation path (like m/44'/0'/0' without chain/index) */\n BASE_PATH: 'base_path',\n /** Derivation mode: bip32, wif_hmac, legacy_hmac */\n DERIVATION_MODE: 'derivation_mode',\n /** Wallet source: mnemonic, file, unknown */\n WALLET_SOURCE: 'wallet_source',\n /** Wallet existence flag */\n WALLET_EXISTS: 'wallet_exists',\n /** Current active address index */\n CURRENT_ADDRESS_INDEX: 'current_address_index',\n /** Nametag cache per address (separate from tracked addresses registry) */\n ADDRESS_NAMETAGS: 'address_nametags',\n /** Active addresses registry (JSON: TrackedAddressesStorage) */\n TRACKED_ADDRESSES: 'tracked_addresses',\n /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */\n LAST_WALLET_EVENT_TS: 'last_wallet_event_ts',\n /** Last processed Nostr DM (gift-wrap) event timestamp (unix seconds), keyed per pubkey */\n LAST_DM_EVENT_TS: 'last_dm_event_ts',\n /** Group chat: last used relay URL (stale data detection) — global, same relay for all addresses */\n GROUP_CHAT_RELAY_URL: 'group_chat_relay_url',\n /** Cached token registry JSON (fetched from remote) */\n TOKEN_REGISTRY_CACHE: 'token_registry_cache',\n /** Timestamp of last token registry cache update (ms since epoch) */\n TOKEN_REGISTRY_CACHE_TS: 'token_registry_cache_ts',\n /** Cached price data JSON (from CoinGecko or other provider) */\n PRICE_CACHE: 'price_cache',\n /** Timestamp of last price cache update (ms since epoch) */\n PRICE_CACHE_TS: 'price_cache_ts',\n} as const;\n\n/**\n * Per-address storage keys (one per derived address)\n * Final key format: sphere_{DIRECT_xxx_yyy}_{key}\n * Example: sphere_DIRECT_abc123_xyz789_pending_transfers\n *\n * Note: Token data is server custody (wallet-api backend), not here; the\n * payments vertical's durable client state self-prefixes `pv2g2:{network}:{pubkey}:`.\n */\nexport const STORAGE_KEYS_ADDRESS = {\n /** Transfer outbox for this address (pre-flip key name; kept as the network-scoping witness) */\n OUTBOX: 'outbox',\n /** Conversations for this address */\n CONVERSATIONS: 'conversations',\n /** Messages for this address */\n MESSAGES: 'messages',\n /** Group chat: joined groups for this address */\n GROUP_CHAT_GROUPS: 'group_chat_groups',\n /** Group chat: messages for this address */\n GROUP_CHAT_MESSAGES: 'group_chat_messages',\n /** Group chat: members for this address */\n GROUP_CHAT_MEMBERS: 'group_chat_members',\n /** Group chat: processed event IDs for deduplication */\n GROUP_CHAT_PROCESSED_EVENTS: 'group_chat_processed_events',\n /** Auto-return settings (pre-flip key name; kept as the network-scoping witness) */\n AUTO_RETURN: 'auto_return',\n /** Auto-return dedup ledger (pre-flip key name; kept as the network-scoping witness) */\n AUTO_RETURN_LEDGER: 'auto_return_ledger',\n /** Per-swap key prefix (pre-flip key name; kept as the network-scoping witness) */\n SWAP_RECORD_PREFIX: 'swap:',\n} as const;\n\n/**\n * Per-address keys that are ALSO per-network: token/payment operational state. Mixing these\n * across networks is unsafe. Chat/identity per-address keys (CONVERSATIONS/MESSAGES/\n * GROUP_CHAT_*) are network-AGNOSTIC and deliberately NOT listed. The payments vertical\n * self-prefixes `pv2g2:{network}:{pubkey}:` and never rides this mechanism; the entries left\n * here are the pre-flip key names the network-isolation tests pin the provider behavior\n * with (P11 stage-2 note: the full isNetworkScopedAddressKey cut needs an owner call on\n * those tests first).\n */\nconst NETWORK_SCOPED_ADDRESS_KEYS: readonly string[] = [\n STORAGE_KEYS_ADDRESS.OUTBOX,\n STORAGE_KEYS_ADDRESS.AUTO_RETURN,\n STORAGE_KEYS_ADDRESS.AUTO_RETURN_LEDGER,\n];\n\n/** Composite per-address key prefixes (modules store `{addressId}_{prefix}{id}`) — per-network. */\nconst NETWORK_SCOPED_ADDRESS_PREFIXES: readonly string[] = [\n STORAGE_KEYS_ADDRESS.SWAP_RECORD_PREFIX, // 'swap:'\n 'inv_ledger:', // AccountingModule INV_LEDGER_PREFIX\n];\n\n/**\n * True if a storage key is a per-NETWORK token/payment key (so the storage provider adds the\n * network segment). Handles the bare form ('auto_return_ledger'), the module-built addressId\n * form ('DIRECT_a_b_auto_return_ledger'), and composites ('{addressId}_swap:{id}'). Chat/identity\n * keys return false — they remain per-address, network-agnostic.\n */\nexport function isNetworkScopedAddressKey(key: string): boolean {\n for (const k of NETWORK_SCOPED_ADDRESS_KEYS) {\n if (key === k || key.endsWith(`_${k}`)) return true;\n }\n for (const p of NETWORK_SCOPED_ADDRESS_PREFIXES) {\n if (key.startsWith(p) || key.includes(`_${p}`)) return true;\n }\n return false;\n}\n\n/**\n * Build a per-address storage key using address identifier\n * @param addressId - Short identifier for the address (e.g., first 8 chars of pubkey hash, or direct address hash)\n * @param key - The key from STORAGE_KEYS_ADDRESS\n * @returns Key in format: \"{addressId}_{key}\" e.g., \"a1b2c3d4_tokens\"\n */\nexport function getAddressStorageKey(addressId: string, key: string): string {\n return `${addressId}_${key}`;\n}\n\n/**\n * Create a readable address identifier from directAddress or chainPubkey\n * Format: DIRECT_first6_last6 (sanitized for filesystem/storage)\n * @param directAddress - The L3 direct address (DIRECT:xxx) or chainPubkey\n * @returns Sanitized identifier like \"DIRECT_abc123_xyz789\"\n */\nexport function getAddressId(directAddress: string): string {\n // Remove DIRECT:// or DIRECT: prefix if present\n let hash = directAddress;\n if (hash.startsWith('DIRECT://')) {\n hash = hash.slice(9);\n } else if (hash.startsWith('DIRECT:')) {\n hash = hash.slice(7);\n }\n // Format: DIRECT_first6_last6 (sanitized)\n const first = hash.slice(0, 6).toLowerCase();\n const last = hash.slice(-6).toLowerCase();\n return `DIRECT_${first}_${last}`;\n}\n\n// =============================================================================\n// Nostr Defaults\n// =============================================================================\n\n/** Default Nostr relays */\nexport const DEFAULT_NOSTR_RELAYS = [\n 'wss://relay.unicity.network',\n 'wss://relay.damus.io',\n 'wss://nos.lol',\n 'wss://relay.nostr.band',\n] as const;\n\n/** Nostr event kinds used by SDK - must match @unicitylabs/nostr-js-sdk */\nexport const NOSTR_EVENT_KINDS = {\n /** NIP-04 encrypted direct message */\n DIRECT_MESSAGE: 4,\n /** Nametag binding (NIP-78 app-specific data) */\n NAMETAG_BINDING: 30078,\n /** Public broadcast */\n BROADCAST: 1,\n} as const;\n\n/**\n * NIP-29 Event Kinds for relay-based group chat\n * https://github.com/nostr-protocol/nips/blob/master/29.md\n */\nexport const NIP29_KINDS = {\n /** Chat message sent to group */\n CHAT_MESSAGE: 9,\n /** Thread root message */\n THREAD_ROOT: 11,\n /** Thread reply message */\n THREAD_REPLY: 12,\n /** User join request */\n JOIN_REQUEST: 9021,\n /** User leave request */\n LEAVE_REQUEST: 9022,\n /** Admin: add/update user */\n PUT_USER: 9000,\n /** Admin: remove user */\n REMOVE_USER: 9001,\n /** Admin: edit group metadata */\n EDIT_METADATA: 9002,\n /** Admin: delete event */\n DELETE_EVENT: 9005,\n /** Admin: create group */\n CREATE_GROUP: 9007,\n /** Admin: delete group */\n DELETE_GROUP: 9008,\n /** Admin: create invite code */\n CREATE_INVITE: 9009,\n /** Relay-signed group metadata */\n GROUP_METADATA: 39000,\n /** Relay-signed group admins */\n GROUP_ADMINS: 39001,\n /** Relay-signed group members */\n GROUP_MEMBERS: 39002,\n /** Relay-signed group roles */\n GROUP_ROLES: 39003,\n} as const;\n\n// =============================================================================\n// Aggregator (Oracle) Defaults\n// =============================================================================\n\n/**\n * Default aggregator URL\n * Note: The aggregator is conceptually an oracle - a trusted service that provides\n * verifiable truth about token state through cryptographic inclusion proofs.\n */\nexport const DEFAULT_AGGREGATOR_URL = 'https://aggregator.unicity.network/rpc' as const;\n\n/** Dev aggregator URL */\nexport const DEV_AGGREGATOR_URL = 'https://dev-aggregator.dyndns.org/rpc' as const;\n\n/** Default aggregator request timeout (ms) */\nexport const DEFAULT_AGGREGATOR_TIMEOUT = 30000;\n\n\n// =============================================================================\n// Wallet Defaults\n// =============================================================================\n\n/** Default BIP32 base path (without chain/index) */\nexport const DEFAULT_BASE_PATH = \"m/44'/0'/0'\" as const;\n\n/** Default BIP32 derivation path (full path with chain/index) */\nexport const DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0` as const;\n\n/** Coin types */\nexport const COIN_TYPES = {\n /** Test token */\n TEST: 'TEST',\n} as const;\n\n// =============================================================================\n// Token Registry Defaults\n// =============================================================================\n\n/** Remote token registry URL (GitHub raw) */\nexport const TOKEN_REGISTRY_URL =\n 'https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet.json' as const;\n\n/** Default token registry refresh interval (ms) — 1 hour */\nexport const TOKEN_REGISTRY_REFRESH_INTERVAL = 3_600_000;\n\n// =============================================================================\n// Network Defaults\n// =============================================================================\n\n/** Testnet Nostr relays */\nexport const TEST_NOSTR_RELAYS = [\n 'wss://nostr-relay.testnet.unicity.network',\n] as const;\n\n/** Default group chat relays (NIP-29 Zooid relay) */\nexport const DEFAULT_GROUP_RELAYS = [\n 'wss://sphere-relay.unicity.network',\n] as const;\n\n/**\n * Complete configuration for one network. Every field is required, so adding a\n * network (or a field) that is missing any of these is a COMPILE error at the\n * NETWORKS literal below (via `satisfies`) — a half-configured network can never\n * be defined silently.\n */\nexport interface NetworkConfig {\n readonly name: string;\n readonly aggregatorUrl: string;\n readonly nostrRelays: readonly string[];\n readonly groupRelays: readonly string[];\n readonly tokenRegistryUrl: string;\n /** Canonical numeric network id (= RootTrustBase.networkId; testnet2 = 4).\n * Optional: only set for networks that have a live v2 trust base. */\n readonly networkId?: number;\n}\n\n/** Network configurations */\nexport const NETWORKS = {\n mainnet: {\n name: 'Mainnet',\n aggregatorUrl: DEFAULT_AGGREGATOR_URL,\n nostrRelays: DEFAULT_NOSTR_RELAYS,\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl: TOKEN_REGISTRY_URL,\n },\n // v1 cutover: 'testnet' now POINTS AT TESTNET2 (the v2 gateway network). The\n // old goggregator testnet spoke the removed v1 protocol — a v2 engine cannot\n // run against it. 'testnet2' stays as an alias of the same configuration.\n testnet: {\n name: 'Testnet2',\n networkId: 4,\n // v2 state-transition gateway (networkId 4 comes from the trust base). apiKey is env-injected.\n aggregatorUrl: 'https://gateway.testnet2.unicity.network',\n nostrRelays: TEST_NOSTR_RELAYS, // reuse testnet infra (shared relays/ipfs)\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl:\n 'https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet2.json',\n },\n testnet2: {\n name: 'Testnet2',\n networkId: 4,\n // v2 state-transition gateway (networkId 4 comes from the trust base). apiKey is env-injected.\n aggregatorUrl: 'https://gateway.testnet2.unicity.network',\n nostrRelays: TEST_NOSTR_RELAYS, // reuse testnet infra (shared relays/ipfs)\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl:\n 'https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet2.json',\n },\n // NOTE: mainnet/dev still point at v1-era aggregators. The v2 engine cannot\n // operate against them until their gateways are cut over to the v2 protocol —\n // wallet operations on these networks fail loudly (AGGREGATOR_ERROR) until then.\n dev: {\n name: 'Development',\n aggregatorUrl: DEV_AGGREGATOR_URL,\n nostrRelays: TEST_NOSTR_RELAYS,\n groupRelays: DEFAULT_GROUP_RELAYS,\n tokenRegistryUrl: TOKEN_REGISTRY_URL,\n },\n} as const satisfies Record<string, NetworkConfig>;\n\nexport type NetworkType = keyof typeof NETWORKS;\n\n/**\n * A network descriptor — the canonical way to identify a Unicity network across\n * the SDK and the Connect protocol. `id` is the canonical key (= RootTrustBase\n * networkId, analogous to an EIP-155 chainId); `name` is human-readable metadata.\n * Richer fields (gateway/symbol/explorer/icon) and switch/add-network are deferred.\n */\nexport interface NetworkInfo {\n readonly id: number;\n readonly name?: string;\n}\n\n/**\n * Registry of known networks for dApps and wallets. Single-sourced from NETWORKS\n * so it cannot drift. Use as `network: SPHERE_NETWORKS.testnet2`. Custom networks\n * are the same shape: `network: { id, name }`. Only live v2 networks appear here;\n * the legacy `testnet` alias is intentionally not surfaced.\n */\nexport const SPHERE_NETWORKS = {\n testnet2: { id: NETWORKS.testnet2.networkId as number, name: 'testnet2' },\n} as const satisfies Record<string, NetworkInfo>;\n\n// =============================================================================\n// Timeouts & Limits\n// =============================================================================\n\n/** Default timeouts (ms) */\nexport const TIMEOUTS = {\n /** WebSocket connection timeout */\n WEBSOCKET_CONNECT: 10000,\n /** Nostr relay reconnect delay */\n NOSTR_RECONNECT_DELAY: 3000,\n /** Max reconnect attempts */\n MAX_RECONNECT_ATTEMPTS: 5,\n /** Proof polling interval */\n PROOF_POLL_INTERVAL: 1000,\n /** Sync interval */\n SYNC_INTERVAL: 60000,\n} as const;\n\n// =============================================================================\n// Sphere Connect\n// =============================================================================\n\n/** Signal sent by wallet popup to dApp when ConnectHost is ready */\nexport const HOST_READY_TYPE = 'sphere-connect:host-ready' as const;\n\n/** Default timeout (ms) for waiting for the host-ready signal */\nexport const HOST_READY_TIMEOUT = 30_000;\n\n/** Validation limits */\nexport const LIMITS = {\n /** Min nametag length */\n NAMETAG_MIN_LENGTH: 3,\n /** Max nametag length */\n NAMETAG_MAX_LENGTH: 20,\n /** Max memo length */\n MEMO_MAX_LENGTH: 500,\n /** Max message length */\n MESSAGE_MAX_LENGTH: 10000,\n} as const;\n","/**\n * Sphere Connect Protocol\n * JSON-RPC-like message types for wallet ↔ dApp communication.\n */\n\nimport { majorOf } from './semver';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nexport const SPHERE_CONNECT_NAMESPACE = 'sphere-connect';\nexport const SPHERE_CONNECT_VERSION = '2.1'; // Connect protocol version (semver MAJOR.MINOR)\n\n// Default npm-SDK floor a host enforces at the handshake (0.14.1 = the P11 flip:\n// the v1 payments era is gone; pre-flip ConnectClients expect a wallet that no\n// longer exists). '-0' admits every 0.14.1 prerelease (compareSemver: release >\n// prerelease). Override via ConnectHostConfig.minSdkVersion; the claim is\n// compatibility hygiene, not security — a hostile client can lie about it.\nexport const DEFAULT_MIN_CLIENT_SDK_VERSION = '0.14.1-0';\n\nexport { HOST_READY_TYPE, HOST_READY_TIMEOUT, SPHERE_NETWORKS } from '../constants';\n// Import for local use (e.g. SphereHandshake.network) AND re-export for connect consumers.\n// A bare `export type { NetworkInfo } from '../constants'` would re-export without bringing\n// the name into this module's scope, breaking the local references (TS2304).\nimport type { NetworkInfo } from '../constants';\nexport type { NetworkInfo };\n\n// =============================================================================\n// RPC Method Names (query — return data, no UI)\n// =============================================================================\n\nexport const RPC_METHODS = {\n GET_IDENTITY: 'sphere_getIdentity',\n GET_BALANCE: 'sphere_getBalance',\n GET_ASSETS: 'sphere_getAssets',\n GET_FIAT_BALANCE: 'sphere_getFiatBalance',\n GET_TOKENS: 'sphere_getTokens',\n GET_HISTORY: 'sphere_getHistory',\n RESOLVE: 'sphere_resolve',\n SUBSCRIBE: 'sphere_subscribe',\n UNSUBSCRIBE: 'sphere_unsubscribe',\n DISCONNECT: 'sphere_disconnect',\n GET_CONVERSATIONS: 'sphere_getConversations',\n GET_MESSAGES: 'sphere_getMessages',\n GET_DM_UNREAD_COUNT: 'sphere_getDMUnreadCount',\n MARK_AS_READ: 'sphere_markAsRead',\n} as const;\n\nexport type RpcMethod = (typeof RPC_METHODS)[keyof typeof RPC_METHODS];\n\n// =============================================================================\n// Intent Action Names (open wallet UI, require user confirmation)\n// =============================================================================\n\nexport const INTENT_ACTIONS = {\n SEND: 'send',\n DM: 'dm',\n PAYMENT_REQUEST: 'payment_request',\n RECEIVE: 'receive',\n SIGN_MESSAGE: 'sign_message',\n MINT: 'mint',\n} as const;\n\nexport type IntentAction = (typeof INTENT_ACTIONS)[keyof typeof INTENT_ACTIONS];\n\n// =============================================================================\n// Error Codes\n// =============================================================================\n\nexport const ERROR_CODES = {\n // Standard JSON-RPC\n PARSE_ERROR: -32700,\n INVALID_REQUEST: -32600,\n METHOD_NOT_FOUND: -32601,\n INVALID_PARAMS: -32602,\n INTERNAL_ERROR: -32603,\n\n // Sphere Connect (4xxx)\n NOT_CONNECTED: 4001,\n PERMISSION_DENIED: 4002,\n USER_REJECTED: 4003,\n SESSION_EXPIRED: 4004,\n ORIGIN_BLOCKED: 4005,\n RATE_LIMITED: 4006,\n UNSUPPORTED_PROTOCOL_VERSION: 4007, // Connect MAJOR mismatch (incompatible era)\n INCOMPATIBLE_NETWORK: 4008, // dApp targets a different network than the wallet\n // Wallet locked; THE SESSION IS STILL ALIVE. A QUERY may be retried after wallet:unlocked.\n // An INTENT already delegated to the wallet is NEVER answered with this code — it gets\n // INTENT_OUTCOME_UNKNOWN (4201) instead, because a retry could double-spend.\n WALLET_LOCKED: 4009,\n INSUFFICIENT_BALANCE: 4100,\n INVALID_RECIPIENT: 4101,\n TRANSFER_FAILED: 4102,\n INTENT_CANCELLED: 4200,\n /**\n * The intent was DELEGATED to the wallet and the host lost track of the answer — a host\n * deadline fired, or the wallet locked / logged out mid-flight. **The outcome is UNKNOWN:\n * the money may or may not have moved.**\n *\n * A dApp MUST NOT retry on this code. Reconcile out of band (poll the recipient, the\n * aggregator, or your own backend) and only then decide.\n *\n * This code exists because every other answer would be a lie. `INTENT_CANCELLED` (4200)\n * asserts the user declined and nothing happened; `WALLET_LOCKED` (4009) invites a retry\n * after the unlock. Sending either for an intent the wallet had already submitted is how a\n * paid-but-not-credited order — and then a double spend on retry — happens.\n */\n INTENT_OUTCOME_UNKNOWN: 4201,\n} as const;\n\nexport type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];\n\n/** `data` carried by every WALLET_LOCKED (4009) refusal. */\nexport interface WalletLockedData {\n /** Discriminator. Always the literal 'locked' — reserved for future refusal reasons. */\n readonly reason: 'locked';\n /**\n * Whether the wallet's own unlock surface is currently on screen.\n * DECLARED in the fail-fast release and always absent there; POPULATED in the resume\n * release from ConnectHostConfig.unlockSurface(), evaluated at refusal time (visibility\n * changes over time, so a static value would lie). Feeds\n * ConnectClientConfig.onWalletAttention.\n */\n readonly unlockSurface?: 'visible' | 'background';\n}\n\n// =============================================================================\n// Message Types\n// =============================================================================\n\ninterface SphereMessageBase {\n readonly ns: typeof SPHERE_CONNECT_NAMESPACE;\n readonly v: typeof SPHERE_CONNECT_VERSION;\n}\n\n/** Query request: dApp → Wallet */\nexport interface SphereRpcRequest extends SphereMessageBase {\n readonly type: 'request';\n readonly id: string;\n readonly method: string;\n readonly params?: Record<string, unknown>;\n}\n\n/** Query response: Wallet → dApp */\nexport interface SphereRpcResponse extends SphereMessageBase {\n readonly type: 'response';\n readonly id: string;\n readonly result?: unknown;\n readonly error?: SphereRpcError;\n}\n\n/** Intent request: dApp → Wallet (opens wallet UI) */\nexport interface SphereIntentRequest extends SphereMessageBase {\n readonly type: 'intent';\n readonly id: string;\n readonly action: string;\n readonly params: Record<string, unknown>;\n}\n\n/** Intent result: Wallet → dApp (after user action) */\nexport interface SphereIntentResult extends SphereMessageBase {\n readonly type: 'intent_result';\n readonly id: string;\n readonly result?: unknown;\n readonly error?: SphereRpcError;\n}\n\n/** Event push: Wallet → dApp (unsolicited) */\nexport interface SphereEventMessage extends SphereMessageBase {\n readonly type: 'event';\n readonly event: string;\n readonly data: unknown;\n}\n\n/** Handshake: bidirectional */\nexport interface SphereHandshake extends SphereMessageBase {\n readonly type: 'handshake';\n readonly direction: 'request' | 'response';\n readonly permissions: string[];\n readonly dapp?: DAppMetadata;\n readonly sessionId?: string;\n readonly identity?: PublicIdentity;\n /** If true, wallet must NOT open any approval UI. Immediately reject if origin is not already approved. */\n readonly silent?: boolean;\n /** request: dApp target network; response: wallet active network */\n readonly network?: NetworkInfo;\n /** Informational: the dApp's npm SDK version. */\n readonly sdkVersion?: string;\n /** Response: structured rejection reason when the gate refuses the connection. */\n readonly error?: SphereRpcError;\n /** Response: non-fatal deprecation notice (does not block the connection). */\n readonly warning?: SphereRpcError;\n /** Response only: the wallet is LOCKED and the session is ALIVE. The dApp is connected\n * and must not re-handshake; it will receive `wallet:unlocked` on the same session.\n * Additive and safe for old clients: handleHandshakeResponse reads only sessionId,\n * permissions, identity, network, warning and error and ignores unknown fields. */\n readonly locked?: boolean;\n}\n\nexport interface SphereRpcError {\n readonly code: number;\n readonly message: string;\n readonly data?: unknown;\n}\n\nexport type SphereConnectMessage =\n | SphereRpcRequest\n | SphereRpcResponse\n | SphereIntentRequest\n | SphereIntentResult\n | SphereEventMessage\n | SphereHandshake;\n\n// =============================================================================\n// Shared Types\n// =============================================================================\n\nexport interface DAppMetadata {\n readonly name: string;\n readonly description?: string;\n readonly icon?: string;\n readonly url: string;\n}\n\nexport interface PublicIdentity {\n readonly chainPubkey: string;\n readonly directAddress?: string;\n readonly nametag?: string;\n}\n\n// =============================================================================\n// Wallet-initiated Events (pushed automatically by host, no subscription needed)\n// =============================================================================\n\n/**\n * Events that ConnectHost pushes proactively to connected dApps.\n * dApps can listen with client.on(WALLET_EVENTS.LOCKED, handler) etc.\n * No sphere_subscribe call needed — host sends these unconditionally.\n */\nexport const WALLET_EVENTS = {\n /** Wallet is LOCKED — the session is STILL ALIVE. Requests are answered\n * WALLET_LOCKED (4009) until `wallet:unlocked`. The dApp must NOT disconnect,\n * must NOT clear its sessionId, and must NOT re-handshake.\n * Payload: {@link WalletLockedPayload}. Pushed by ConnectHost.setLocked() and\n * immediately after a handshake response carrying `locked: true`. */\n LOCKED: 'wallet:locked',\n /** Wallet was unlocked — the SAME session continues: no re-handshake, no re-approval,\n * no re-subscribe (the host re-arms the dApp's subscriptions before pushing this).\n * Payload: {@link WalletUnlockedPayload} — carries the CURRENT identity, which may\n * differ from the one the dApp connected with. Pushed by ConnectHost.updateSphere()\n * on the locked -> live edge only. */\n UNLOCKED: 'wallet:unlocked',\n /** The session is GONE (logout, wallet deleted, dApp sphere_disconnect, expiry seen at\n * unlock, a different seed behind the lock screen, host destroy).\n * The dApp must clear its session and re-handshake to continue. Unlocking does not cure it.\n * Payload: {@link WalletDisconnectedPayload}. Pushed by ConnectHost.revokeSession(). */\n DISCONNECTED: 'wallet:disconnected',\n /** Active wallet address changed. dApp should update displayed identity.\n * Pushed automatically by ConnectHost — no sphere_subscribe needed. */\n IDENTITY_CHANGED: 'identity:changed',\n} as const;\n\nexport type WalletEvent = (typeof WALLET_EVENTS)[keyof typeof WALLET_EVENTS];\n\n/** Payload of {@link WALLET_EVENTS.LOCKED}. Intentionally empty — matches today's push,\n * so an old dApp sees no shape change. */\nexport type WalletLockedPayload = Record<string, never>;\n\n/** Payload of {@link WALLET_EVENTS.DISCONNECTED}. Intentionally empty. */\nexport type WalletDisconnectedPayload = Record<string, never>;\n\n/** Payload of {@link WALLET_EVENTS.UNLOCKED}. */\nexport interface WalletUnlockedPayload {\n /** The wallet's public identity at unlock time. Absent when the rebound Sphere has no\n * identity yet (JSON transports drop undefined keys).\n * Unlock is NOT implicitly the same wallet: the lock screen's \"Forgot password ->\n * restore from recovery phrase\" installs a DIFFERENT seed while origin approvals are\n * keyed by origin alone. The host's own lock-edge guard is authoritative (it revokes\n * instead of pushing this event on a mismatch); this field is what lets a dApp render\n * honestly and what the client-side retry queue checks before draining. */\n readonly identity?: PublicIdentity;\n}\n\n/** Payload of {@link WALLET_EVENTS.IDENTITY_CHANGED} — unchanged: the host forwards\n * Sphere's own event data verbatim and pushes a PublicIdentity from updateSphere().\n * Typed as the union for honesty. */\nexport type WalletIdentityChangedPayload = PublicIdentity | unknown;\n\n/** Events the host pushes unconditionally. They must NEVER be routed through\n * `sphere_subscribe`: Sphere.on() accepts any string and would silently never emit,\n * so the subscribe would succeed and deliver nothing forever. */\nexport const AUTO_PUSHED_EVENTS: readonly WalletEvent[] = [\n WALLET_EVENTS.LOCKED,\n WALLET_EVENTS.UNLOCKED,\n WALLET_EVENTS.DISCONNECTED,\n WALLET_EVENTS.IDENTITY_CHANGED,\n];\n\n/** True for an event the host pushes unconditionally (see {@link AUTO_PUSHED_EVENTS}). */\nexport function isAutoPushedEvent(event: string): event is WalletEvent {\n return (AUTO_PUSHED_EVENTS as readonly string[]).includes(event);\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/** Check if a message belongs to the Sphere Connect protocol.\n * Handshakes are accepted at ANY version (the version decision happens in the\n * handshake handler so an incompatible peer gets a clean typed error instead of a\n * silently-dropped message → timeout). All other (session) traffic must share the\n * same MAJOR (so MINOR versions interoperate, e.g. 2.0 ↔ 2.1). */\nexport function isSphereConnectMessage(msg: unknown): msg is SphereConnectMessage {\n if (!msg || typeof msg !== 'object') return false;\n const m = msg as Record<string, unknown>;\n if (m.ns !== SPHERE_CONNECT_NAMESPACE) return false;\n if (m.type === 'handshake') return true;\n if (typeof m.v !== 'string') return false;\n return majorOf(m.v) === majorOf(SPHERE_CONNECT_VERSION);\n}\n\n/** Create a unique request ID */\nexport function createRequestId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n // Fallback for environments without crypto.randomUUID\n return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;\n}\n","/**\n * Sphere Connect Permission System\n * Defines scopes, maps methods/intents to required permissions.\n */\n\nimport { RPC_METHODS, INTENT_ACTIONS } from './protocol';\n\n// =============================================================================\n// Permission Scopes\n// =============================================================================\n\nexport const PERMISSION_SCOPES = {\n IDENTITY_READ: 'identity:read',\n BALANCE_READ: 'balance:read',\n TOKENS_READ: 'tokens:read',\n HISTORY_READ: 'history:read',\n EVENTS_SUBSCRIBE: 'events:subscribe',\n RESOLVE_PEER: 'resolve:peer',\n TRANSFER_REQUEST: 'transfer:request',\n DM_REQUEST: 'dm:request',\n DM_READ: 'dm:read',\n DM_MANAGE: 'dm:manage',\n PAYMENT_REQUEST: 'payment:request',\n SIGN_REQUEST: 'sign:request',\n MINT_REQUEST: 'mint:request',\n} as const;\n\nexport type PermissionScope = (typeof PERMISSION_SCOPES)[keyof typeof PERMISSION_SCOPES];\n\n/** All available permission scopes */\nexport const ALL_PERMISSIONS: readonly PermissionScope[] = Object.values(PERMISSION_SCOPES);\n\n/** Permissions always granted on connect */\nexport const DEFAULT_PERMISSIONS: readonly PermissionScope[] = [\n PERMISSION_SCOPES.IDENTITY_READ,\n];\n\n// =============================================================================\n// Method → Permission Mapping\n// =============================================================================\n\nexport const METHOD_PERMISSIONS: Record<string, PermissionScope> = {\n [RPC_METHODS.GET_IDENTITY]: PERMISSION_SCOPES.IDENTITY_READ,\n [RPC_METHODS.GET_BALANCE]: PERMISSION_SCOPES.BALANCE_READ,\n [RPC_METHODS.GET_ASSETS]: PERMISSION_SCOPES.BALANCE_READ,\n [RPC_METHODS.GET_FIAT_BALANCE]: PERMISSION_SCOPES.BALANCE_READ,\n [RPC_METHODS.GET_TOKENS]: PERMISSION_SCOPES.TOKENS_READ,\n [RPC_METHODS.GET_HISTORY]: PERMISSION_SCOPES.HISTORY_READ,\n [RPC_METHODS.RESOLVE]: PERMISSION_SCOPES.RESOLVE_PEER,\n [RPC_METHODS.SUBSCRIBE]: PERMISSION_SCOPES.EVENTS_SUBSCRIBE,\n [RPC_METHODS.UNSUBSCRIBE]: PERMISSION_SCOPES.EVENTS_SUBSCRIBE,\n [RPC_METHODS.GET_CONVERSATIONS]: PERMISSION_SCOPES.DM_READ,\n [RPC_METHODS.GET_MESSAGES]: PERMISSION_SCOPES.DM_READ,\n [RPC_METHODS.GET_DM_UNREAD_COUNT]: PERMISSION_SCOPES.DM_READ,\n [RPC_METHODS.MARK_AS_READ]: PERMISSION_SCOPES.DM_MANAGE,\n};\n\n// =============================================================================\n// Intent → Permission Mapping\n// =============================================================================\n\nexport const INTENT_PERMISSIONS: Record<string, PermissionScope> = {\n [INTENT_ACTIONS.SEND]: PERMISSION_SCOPES.TRANSFER_REQUEST,\n [INTENT_ACTIONS.DM]: PERMISSION_SCOPES.DM_REQUEST,\n [INTENT_ACTIONS.PAYMENT_REQUEST]: PERMISSION_SCOPES.PAYMENT_REQUEST,\n [INTENT_ACTIONS.RECEIVE]: PERMISSION_SCOPES.IDENTITY_READ,\n [INTENT_ACTIONS.SIGN_MESSAGE]: PERMISSION_SCOPES.SIGN_REQUEST,\n [INTENT_ACTIONS.MINT]: PERMISSION_SCOPES.MINT_REQUEST,\n};\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/** Check if granted permissions allow calling a method */\nexport function hasMethodPermission(granted: ReadonlySet<string>, method: string): boolean {\n const required = METHOD_PERMISSIONS[method];\n if (!required) return false;\n return granted.has(required);\n}\n\n/** Check if granted permissions allow an intent action */\nexport function hasIntentPermission(granted: ReadonlySet<string>, action: string): boolean {\n const required = INTENT_PERMISSIONS[action];\n if (!required) return false;\n return granted.has(required);\n}\n\n/** Validate that all requested permissions are known scopes */\nexport function validatePermissions(permissions: string[]): permissions is PermissionScope[] {\n const validScopes = new Set<string>(ALL_PERMISSIONS);\n return permissions.every((p) => validScopes.has(p));\n}\n","// docs/PAYMENTS-V2-DESIGN.md §4 \"Compatibility\": old wire queries are served from\n// `sphere.payments`, and every old dApp-subscribable event name is re-emitted from the\n// facade's bus — dApps change NOTHING. Old names stay plain string literals: post-flip\n// this wire surface is their only home.\n\nimport type { TransferResult } from '../../types';\nimport type { PaymentRequestView, PaymentsV2 } from '../../modules/payments-v2/api';\nimport type { SphereInstance } from './SphereInstance';\n\ntype Forward = (data: unknown) => void;\ntype Attach = (sphere: SphereInstance, forward: Forward) => () => void;\n\ntype PaymentRequestUpdated = {\n id: string;\n status: 'pending' | 'settling' | 'paid' | 'rejected' | 'expired';\n};\ntype TransferAttention = { transferId: string; code: string; detail?: string };\ntype ConnectionStatus = { status: 'connected' | 'degraded' | 'offline' };\n\n// Old `sphere_getFiatBalance` semantics, held exactly: sum of priced assets, null when NO\n// asset carries a price — the \"no price data\" signal a dApp may branch on.\nexport function sumFiatUsd(\n assets: ReadonlyArray<{ fiatValueUsd: number | null }>,\n): number | null {\n let total = 0;\n let priced = false;\n for (const asset of assets) {\n if (asset.fiatValueUsd != null) {\n total += asset.fiatValueUsd;\n priced = true;\n }\n }\n return priced ? total : null;\n}\n\nconst SETTLED_STATUSES: ReadonlySet<TransferResult['status']> = new Set([\n 'confirmed',\n 'delivered',\n 'completed',\n]);\n\nconst REALTIME_STATUS: Record<ConnectionStatus['status'], string> = {\n connected: 'connected',\n degraded: 'reconnecting',\n offline: 'closed',\n};\n\n// The ONE view → legacy `IncomingPaymentRequest` mapping (incoming AND the per-status\n// rebuilds): symbol is registry-resolved when known, else '' — never absent on the wire.\nfunction toLegacyRequest(view: PaymentRequestView, status: string): Record<string, unknown> {\n return { ...view, symbol: view.symbol ?? '', status };\n}\n\n// Rebuilds the old per-status `IncomingPaymentRequest` payload: full view from\n// `requests.list()` (processed requests stay listed until dismissProcessed()); if already\n// gone, id + status still carry the signal and unknowable fields are empty.\n/** The facade, or null while none runs — `sphere.payments` throws between verticals.\n * Callers here are EVENT callbacks, whose throw Sphere's dispatcher swallows. */\nfunction paymentsOrNull(sphere: SphereInstance): PaymentsV2 | null {\n try {\n return sphere.payments;\n } catch {\n return null;\n }\n}\n\nfunction legacyRequestPayload(\n sphere: SphereInstance,\n update: PaymentRequestUpdated,\n): Record<string, unknown> {\n const view: PaymentRequestView | undefined = paymentsOrNull(sphere)\n ?.requests.list()\n .find((request) => request.id === update.id);\n if (!view) {\n return {\n id: update.id,\n requestId: update.id,\n senderPubkey: '',\n amount: '',\n coinId: '',\n symbol: '',\n timestamp: Date.now(),\n status: update.status,\n };\n }\n return toLegacyRequest(view, update.status);\n}\n\nfunction requestStatusAttacher(status: 'paid' | 'rejected' | 'expired'): Attach {\n return (sphere, forward) =>\n sphere.on('payment_request:updated', (update: PaymentRequestUpdated) => {\n if (update.status === status) forward(legacyRequestPayload(sphere, update));\n });\n}\n\n// The v2 attention `code` IS the old event name (machine/journal.ts ATTENTION_* constants).\nfunction attentionAttacher(code: string, toLegacy: (a: TransferAttention) => unknown): Attach {\n return (sphere, forward) =>\n sphere.on('transfer:attention', (attention: TransferAttention) => {\n if (attention.code === code) forward(toLegacy(attention));\n });\n}\n\nfunction remoteUpdateAttacher(sphere: SphereInstance, forward: Forward): () => void {\n let sequence = 0;\n return sphere.on('inventory:updated', () => {\n sequence += 1;\n forward({ providerId: 'wallet-api', name: 'wallet-api', sequence, cid: '', added: 0, removed: 0 });\n });\n}\n\n// A Map, not a plain object: the key is dApp-controlled, and an object lookup would reach\n// the prototype chain ('constructor' would come back as a callable \"attacher\").\nconst COMPAT_ATTACHERS: ReadonlyMap<string, Attach> = new Map<string, Attach>([\n // Old completion split, held: deliveryPending ? delivery_pending : confirmed, plus the\n // failed arm (manifest judgment call #1). Payloads are the TransferResult, unchanged.\n ['transfer:confirmed', (sphere, forward) =>\n sphere.on('transfer:updated', (result: TransferResult) => {\n if (SETTLED_STATUSES.has(result.status) && result.deliveryPending !== true) forward(result);\n })],\n ['transfer:delivery_pending', (sphere, forward) =>\n sphere.on('transfer:updated', (result: TransferResult) => {\n if (result.status !== 'failed' && result.deliveryPending === true) forward(result);\n })],\n ['transfer:failed', (sphere, forward) =>\n sphere.on('transfer:updated', (result: TransferResult) => {\n if (result.status === 'failed') forward(result);\n })],\n // Same name on both wires, different payload: the raw v2 view has optional `symbol`;\n // legacy subscribers get the IncomingPaymentRequest shape via the shared mapping.\n ['payment_request:incoming', (sphere, forward) =>\n sphere.on('payment_request:incoming', (view: PaymentRequestView) => {\n forward(toLegacyRequest(view, view.status));\n })],\n ['payment_request:paid', requestStatusAttacher('paid')],\n ['payment_request:rejected', requestStatusAttacher('rejected')],\n ['payment_request:expired', requestStatusAttacher('expired')],\n // detail carries the old inner code (SPLIT_CHECKPOINT_LOST / CHECKPOINT_TRUSTBASE_MISMATCH).\n ['split:checkpoint-stuck', attentionAttacher('split:checkpoint-stuck', (attention) => ({\n transferId: attention.transferId,\n code: attention.detail ?? '',\n error: attention.detail ?? '',\n }))],\n ['delivery:undeliverable', attentionAttacher('delivery:undeliverable', (attention) => ({\n transferId: attention.transferId,\n recipientPubkey: '',\n attempts: 0,\n error: attention.detail ?? '',\n }))],\n ['delivery:deferred', attentionAttacher('delivery:deferred', (attention) => ({\n transferId: attention.transferId,\n recipientPubkey: '',\n reason: attention.detail ?? attention.code,\n deferredUntil: 0,\n }))],\n ['realtime:status', (sphere, forward) =>\n sphere.on('connection:status', (connection: ConnectionStatus) => {\n forward({ status: REALTIME_STATUS[connection.status] ?? 'closed' });\n })],\n // The server IS storage on the v2 vertical — a degraded connection is degraded storage.\n ['storage:degraded', (sphere, forward) =>\n sphere.on('connection:status', (connection: ConnectionStatus) => {\n if (connection.status !== 'degraded') return;\n forward({ providerId: 'wallet-api', error: 'wallet-api connection degraded' });\n })],\n ['sync:completed', (sphere, forward) =>\n sphere.on('inventory:updated', () => {\n forward({ source: 'payments', count: paymentsOrNull(sphere)?.tokens().length ?? 0 });\n })],\n ['sync:remote-update', remoteUpdateAttacher],\n]);\n\n// Attach ONE old-name subscription fed from its v2 source event; `forward` sends the wire\n// frame under the OLD name. Null when `eventName` is not an adapted name (the caller falls\n// through to a direct `sphere.on`).\nexport function attachCompatEvent(\n sphere: SphereInstance,\n eventName: string,\n forward: Forward,\n): (() => void) | null {\n const attach = COMPAT_ATTACHERS.get(eventName);\n return attach ? attach(sphere, forward) : null;\n}\n","/**\n * Wallet-binding FSM + the single total gate decision.\n *\n * PURE: no I/O, no timers, no Sphere reference, no `await` — a transition\n * table + assertTransition.\n *\n * Nothing here is re-exported from connect/index.ts — it is host-internal.\n */\n\nimport { SphereError } from '../../core/errors';\nimport { ERROR_CODES, RPC_METHODS } from '../protocol';\nimport type { WalletLockedData } from '../protocol';\nimport type { WalletState } from '../types';\n\n// ===========================================================================\n// Wire text — RECOMMENDATIONS, not contracts\n// ===========================================================================\n\n/** Recommended refusal text for WALLET_LOCKED. Discriminate on code 4009 and on\n * data.reason, NEVER on this string. Deliberately does not match the reference dApp's\n * teardown regex /not.connected|timeout|transport|closed|session/i. */\nexport const WALLET_LOCKED_MESSAGE = 'Wallet is locked';\n\n/** Fixed text for a non-SphereError throw. A raw JS message must never cross the trust\n * boundary into a third-party dApp. */\nexport const INTERNAL_ERROR_MESSAGE = 'Internal wallet error';\n/**\n * Recommended text for INTENT_OUTCOME_UNKNOWN (4201). Like every message on this wire it is\n * NOT a contract — consumers discriminate on the code — but the wording matters here because a\n * developer reading it must not conclude the intent was cancelled.\n */\nexport const INTENT_UNKNOWN_MESSAGE =\n 'Intent outcome unknown — do not retry; reconcile before acting';\n\n/** Today's literal on the host's not-connected refusals — kept byte-identical.\n * `unavailable` answers this too: unlocking cannot cure it, so promising an unlock\n * would be a lie. */\nexport const NOT_CONNECTED_MESSAGE = 'Not connected';\n\n// ===========================================================================\n// Transitions\n// ===========================================================================\n\n/** Legal edges. NO self-transitions by design: every verb must early-return when it is\n * already in the target state, so assertWalletTransition() enforces idempotency at the\n * table level rather than by reviewer diligence. */\nexport const VALID_WALLET_TRANSITIONS: Record<WalletState, readonly WalletState[]> = {\n live: ['locked', 'unavailable'],\n locked: ['live', 'unavailable'],\n unavailable: ['live', 'locked'],\n};\n\nexport function isValidWalletTransition(from: WalletState, to: WalletState): boolean {\n return VALID_WALLET_TRANSITIONS[from].includes(to);\n}\n\n/** Throws SphereError('Invalid wallet state transition: from -> to', 'VALIDATION_ERROR')\n * — an EXISTING SphereErrorCode; core/errors.ts is not touched. */\nexport function assertWalletTransition(from: WalletState, to: WalletState): void {\n if (!isValidWalletTransition(from, to)) {\n throw new SphereError(`Invalid wallet state transition: ${from} -> ${to}`, 'VALIDATION_ERROR');\n }\n}\n\n// ===========================================================================\n// Locked allow-list\n// ===========================================================================\n\n/**\n * The four methods answerable while locked. Everything else gets 4009.\n *\n * - sphere_getIdentity -> 'serve-from-snapshot'. Those exact bytes were already handed\n * to this origin in the handshake response and DEFAULT_PERMISSIONS\n * grants identity:read unconditionally, so refusing re-states a\n * revealed fact for zero security while making every dApp that\n * reads identity once on mount render \"disconnected\" for the whole\n * lock.\n * - sphere_subscribe -> 'serve'. Refusing it is a BUG, not a policy: ConnectClient.on()\n * fires sphere_subscribe fire-and-forget and NEVER retries (the\n * error goes to logger.debug), so the event stream would die\n * forever after one lock.\n * - sphere_unsubscribe -> 'serve'. Needs no Sphere at all.\n * - sphere_disconnect -> 'serve'. Without it a session can never be dropped while\n * locked; the client swallows the error and calls cleanup()\n * anyway, so the dApp shows \"disconnected\" while onDisconnect —\n * the only hook that revokes the persisted origin approval —\n * never fires.\n *\n * WHAT IS BLOCKED — the honest count, because an enumeration of only the money reads reads as\n * if messaging still works. RPC_METHODS has FOURTEEN entries; four are above. The other TEN,\n * and EVERY intent, are refused:\n *\n * sphere_getBalance, sphere_getAssets, sphere_getFiatBalance, sphere_getTokens,\n * sphere_getHistory — money state a locked wallet cannot honour. Never cached either: a\n * dApp holding a stale balance is a dApp about to offer an\n * unpayable spend.\n * sphere_resolve — nametag resolution needs the live transport.\n * sphere_getConversations, sphere_getMessages, sphere_getDMUnreadCount, sphere_markAsRead\n * — DMs are decrypted with keys that left memory. Messaging does NOT\n * keep working while locked; a dApp must stop polling and wait.\n *\n * AND THE CASE WITH NO 4009 AT ALL. Everything above assumes a host that HOLDS a session. A\n * wallet that COLD-STARTS locked (a page reload, a fresh popup — the password is memory-only,\n * so this is the common path) has no session and an EMPTY snapshot, so `handleHandshake`\n * refuses at step 0 with an errorless empty response: the dApp's connect() rejects with a bare\n * \"Connection rejected by wallet\" carrying NO code. There is nothing to match 4009 against.\n * That silence is deliberate — the refusal must reveal nothing about the wallet to an origin\n * holding no approval — so a dApp cannot distinguish it from a user pressing Reject and must\n * treat it as \"not ready yet\": keep waiting for HOST_READY, which the wallet emits when a\n * human unlocks it.\n */\nexport const LOCKED_ALLOWLIST: ReadonlySet<string> = new Set<string>([\n RPC_METHODS.GET_IDENTITY,\n RPC_METHODS.SUBSCRIBE,\n RPC_METHODS.UNSUBSCRIBE,\n RPC_METHODS.DISCONNECT,\n]);\n\n// ===========================================================================\n// Gate\n// ===========================================================================\n\nexport type RequestKind = 'query' | 'intent' | 'handshake';\n\nexport type GateDecision =\n | { readonly kind: 'serve' }\n /** Answer from WalletSnapshot. If the required snapshot field is absent the caller\n * REFUSES (4009 for a query, the empty refusal for a handshake) — it never invents a\n * fact about a wallet we have not seen, and never sends undefined-as-success. */\n | { readonly kind: 'serve-from-snapshot' }\n | {\n readonly kind: 'refuse';\n readonly error: {\n readonly code: number;\n readonly message: string;\n readonly data?: WalletLockedData;\n };\n };\n\nconst REFUSE_NOT_CONNECTED: GateDecision = {\n kind: 'refuse',\n error: { code: ERROR_CODES.NOT_CONNECTED, message: NOT_CONNECTED_MESSAGE },\n};\n\nconst REFUSE_LOCKED: GateDecision = {\n kind: 'refuse',\n error: {\n code: ERROR_CODES.WALLET_LOCKED,\n message: WALLET_LOCKED_MESSAGE,\n data: { reason: 'locked' },\n },\n};\n\n/**\n * The single total gate decision. Positional, four required arguments, no I/O.\n * `name` is an RPC method, an intent action, or the literal 'handshake'.\n *\n * Called at step 4 of the ordering contract, AFTER the session check, the expiry check and\n * the rate limit, and BEFORE the permission check — because hasMethodPermission() is false\n * for anything unmapped, so a locked unknown method would otherwise answer\n * PERMISSION_DENIED 4002, an un-retryable code that tells the dApp its permissions are\n * wrong.\n *\n * The resume release merges `unlockSurface` into `error.data` AFTER this returns — the gate\n * stays pure.\n */\nexport function gate(\n walletState: WalletState,\n hasActiveSession: boolean,\n requestKind: RequestKind,\n name: string,\n): GateDecision {\n // 'unavailable' is a dead end: revoke + wallet:disconnected already happened, and\n // unlocking does not cure it. Never 4009 here.\n if (walletState === 'unavailable') return REFUSE_NOT_CONNECTED;\n\n if (requestKind === 'handshake') {\n // While locked EVERY handshake is answered from the snapshot and forced silent, so an\n // origin without an approval still gets today's empty refusal with no UI.\n return walletState === 'locked' ? { kind: 'serve-from-snapshot' } : { kind: 'serve' };\n }\n\n // A query or an intent without a session is 4001 in every wallet state: the lock is\n // observable ONLY to an origin that already holds an approval.\n if (!hasActiveSession) return REFUSE_NOT_CONNECTED;\n\n if (walletState === 'live') return { kind: 'serve' };\n\n // locked, with an active session.\n if (requestKind === 'query' && LOCKED_ALLOWLIST.has(name)) {\n return name === RPC_METHODS.GET_IDENTITY\n ? { kind: 'serve-from-snapshot' }\n : { kind: 'serve' };\n }\n return REFUSE_LOCKED;\n}\n","/**\n * Immutable public facts about the wallet binding.\n *\n * Holds NO reference to Sphere, so \"nothing is read from a destroyed Sphere while locked\"\n * is a property of the types rather than of code review.\n *\n * NOT named SessionSnapshot and carries NO sessionId: it must exist BEFORE any session\n * does — sendHandshakeResponse reads networkId and is the transport for EVERY handshake\n * response, including the refusals.\n *\n * The lock-edge identity binding IS `snapshot.identity?.chainPubkey`; there is no separate\n * chainPubkey field and no separate boundIdentity field.\n */\n\nimport type { PublicIdentity } from '../protocol';\nimport type { SphereInstance } from './SphereInstance';\n\nexport interface WalletSnapshot {\n readonly networkId?: number;\n readonly identity?: PublicIdentity;\n /** Epoch ms of capture. 0 means \"never captured\" — see EMPTY_WALLET_SNAPSHOT. */\n readonly capturedAt: number;\n}\n\n/** capturedAt 0, no networkId, no identity. Used for a host built locked with no prior\n * binding (cold start with an encrypted wallet), after setUnavailable(), and after\n * destroy(). A handshake against an empty snapshot gets today's empty refusal —\n * including a resume. \"Never invent a fact about a wallet we have not seen.\" */\nexport const EMPTY_WALLET_SNAPSHOT: WalletSnapshot = Object.freeze({ capturedAt: 0 });\n\n/**\n * Build from a bound Sphere. `null` returns EMPTY_WALLET_SNAPSHOT.\n *\n * If sphere.identity is null the snapshot has NO identity, and sphere_getIdentity while\n * locked answers 4009 rather than undefined-as-success — a dApp reads an `undefined`\n * success result as \"the wallet has no identity\".\n */\nexport function buildWalletSnapshot(sphere: SphereInstance | null): WalletSnapshot {\n if (!sphere) return EMPTY_WALLET_SNAPSHOT;\n const id = sphere.identity;\n return Object.freeze({\n ...(typeof sphere.networkId === 'number' ? { networkId: sphere.networkId } : {}),\n ...(id\n ? {\n identity: Object.freeze({\n chainPubkey: id.chainPubkey,\n directAddress: id.directAddress,\n nametag: id.nametag,\n }),\n }\n : {}),\n capturedAt: Date.now(),\n });\n}\n","/**\n * ConnectHost — Wallet side of Sphere Connect.\n *\n * Wraps a Sphere instance and exposes its API through a ConnectTransport.\n * Handles permission checking, rate limiting, session management,\n * and delegates intents to the wallet app via callbacks.\n */\n\nimport { logger } from '../../core/logger';\nimport { SphereError } from '../../core/errors';\nimport type { SphereEventType } from '../../types';\nimport type {\n ConnectTransport,\n ConnectSession,\n ConnectHostConfig,\n WalletState,\n LockedRequestContext,\n IntentContext,\n} from '../types';\nimport type {\n SphereConnectMessage,\n SphereRpcRequest,\n SphereIntentRequest,\n SphereHandshake,\n PublicIdentity,\n SphereRpcError,\n NetworkInfo,\n WalletLockedData,\n WalletLockedPayload,\n WalletUnlockedPayload,\n WalletDisconnectedPayload,\n} from '../protocol';\nimport {\n SPHERE_CONNECT_NAMESPACE,\n SPHERE_CONNECT_VERSION,\n RPC_METHODS,\n ERROR_CODES,\n WALLET_EVENTS,\n createRequestId,\n isAutoPushedEvent,\n DEFAULT_MIN_CLIENT_SDK_VERSION,\n} from '../protocol';\nimport { checkCompatibility } from '../compatibility';\nimport { SDK_VERSION } from '../version';\nimport {\n DEFAULT_PERMISSIONS,\n hasMethodPermission,\n hasIntentPermission,\n} from '../permissions';\nimport type { PermissionScope } from '../permissions';\nimport type { SphereInstance, ConnectDirectMessage } from './SphereInstance';\nimport { attachCompatEvent, sumFiatUsd } from './payments-compat';\nimport {\n assertWalletTransition,\n gate,\n WALLET_LOCKED_MESSAGE,\n INTERNAL_ERROR_MESSAGE,\n INTENT_UNKNOWN_MESSAGE,\n NOT_CONNECTED_MESSAGE,\n} from './host-state';\nimport type { WalletSnapshot } from './WalletSnapshot';\nimport { EMPTY_WALLET_SNAPSHOT, buildWalletSnapshot } from './WalletSnapshot';\nimport { InFlightRegistry } from './InFlightRegistry';\nimport type { InFlightEntry } from './InFlightRegistry';\n\nconst DEFAULT_SESSION_TTL_MS = 86400000; // 24 hours\nconst DEFAULT_MAX_RPS = 20;\n/**\n * Codes a wallet must never use to answer an intent the host already handed it. Both describe\n * the CHANNEL, not the operation: by the time `onIntent` has been called the wallet owns the\n * decision, so \"wallet locked\" or \"not connected\" says nothing true about the spend while\n * inviting a retry — 4009's documented advice is literally \"retry after wallet:unlocked\". They\n * are downgraded to INTENT_OUTCOME_UNKNOWN.\n *\n * Deliberately NOT here: USER_REJECTED and INTENT_CANCELLED. A user who declines the prompt\n * BEFORE anything is submitted is a real, retryable rejection; downgrading it would make every\n * declined payment un-retryable, which is both false and worse UX than the bug. Ensuring the\n * wallet cannot report a cancel AFTER submitting is the WALLET's job — it must not offer a\n * cancel control once the transfer is on the wire. The general contract for third-party wallets,\n * an explicit `ctx.commit()` marking the point of no return after which these two are\n * downgraded as well, is a follow-up rather than something to fake here.\n */\nconst CHANNEL_ONLY_CODES: ReadonlySet<number> = new Set<number>([\n ERROR_CODES.WALLET_LOCKED,\n ERROR_CODES.NOT_CONNECTED,\n]);\n\nconst DEFAULT_REQUEST_DEADLINE_MS = 25000;\n// LONGER than ConnectClient's own DEFAULT_INTENT_TIMEOUT (120 s). The host must never be the\n// first to give up on a delegated intent: whoever answers first defines the outcome for the\n// dApp, and the host cannot know whether the wallet has already submitted the transfer. With\n// the client timing out first, the dApp learns \"outcome unknown\" from its own clock instead of\n// being told, authoritatively and falsely, that nothing happened.\nconst DEFAULT_INTENT_DEADLINE_MS = 180000;\nconst DEFAULT_HANDSHAKE_DEADLINE_MS = 120000;\n\n/** Resolve `promise`, or `fallback()` after `ms`. Used ONLY for onConnectionRequest, which\n * has no id and therefore cannot live in InFlightRegistry. A rejection still propagates,\n * so handleHandshake's own error handling is unchanged. */\nfunction withDeadline<T>(promise: Promise<T>, ms: number, fallback: () => T): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => resolve(fallback()), ms);\n promise.then(\n (value) => { clearTimeout(timer); resolve(value); },\n (error) => { clearTimeout(timer); reject(error); },\n );\n });\n}\n\nexport class ConnectHost {\n /** Null whenever _walletState is 'locked' or 'unavailable' (invariant B). */\n private sphere: SphereInstance | null;\n\n /** The wallet-binding axis. Underscored because `walletState` is the public getter.\n * ORTHOGONAL to `session` — a locked wallet keeps its session, a live wallet may have\n * none. Written only by the WALLET (setLocked / setUnavailable / updateSphere / destroy);\n * `session` is written by the dApp handshake, sphere_disconnect and expiry. */\n private _walletState: WalletState;\n\n /** Immutable public facts about the current binding. Refreshed on every bind\n * (constructor, updateSphere); FROZEN by setLocked(); EMPTY after setUnavailable() and\n * destroy(). Never read from Sphere while locked — that is a property of the types\n * here, not of code review. */\n private snapshot: WalletSnapshot;\n\n /** Subscription KEYS captured by setLocked() BEFORE the unsub closures are detached.\n * Sphere.destroy() kills those closures, so the keys are the only recoverable\n * information. Excludes 'identity:changed' (autoSubscribeIdentityChanged re-arms it).\n * A Set, not an array: handleSubscribe may be called twice for the same key while\n * locked. */\n private suspendedSubscriptions: Set<string> = new Set();\n\n /** Every accepted id, with its own host-side timer. The single convergence point for\n * lock / revoke / unavailable / destroy / deadline. */\n private readonly inFlight: InFlightRegistry;\n\n private readonly transport: ConnectTransport;\n private readonly config: ConnectHostConfig;\n\n private session: ConnectSession | null = null;\n private grantedPermissions: Set<string> = new Set();\n\n // Event subscription management\n private eventSubscriptions: Map<string, () => void> = new Map(); // eventName → unsub\n\n // Intent auto-approve: action → handler that bypasses wallet UI\n private autoApprovedIntents = new Map<\n string,\n (action: string, params: Record<string, unknown>, session: ConnectSession) => Promise<{ result?: unknown; error?: { code: number; message: string } }>\n >();\n\n // Rate limiting\n private rateLimitCounter = 0;\n private rateLimitResetAt = 0;\n\n private unsubscribeTransport: (() => void) | null = null;\n\n constructor(config: ConnectHostConfig) {\n this.transport = config.transport;\n this.config = config;\n\n this._walletState = config.initialWalletState ?? 'live';\n this.sphere = (config.sphere ?? null) as SphereInstance | null;\n if (this._walletState === 'live' && !this.sphere) {\n // Fail LOUD but soft: a throw here breaks the wallet's React mount.\n logger.warn(\n 'ConnectHost',\n 'Constructed live with sphere === null; coercing to unavailable. ' +\n 'Pass initialWalletState: \"locked\" when the wallet is locked at construction time.',\n );\n this._walletState = 'unavailable';\n }\n if (this._walletState !== 'live') this.sphere = null; // invariant B, unconditionally\n this.snapshot = buildWalletSnapshot(this.sphere); // EMPTY_WALLET_SNAPSHOT when null\n\n this.inFlight = new InFlightRegistry({ onExpire: (e) => this.settleExpired(e) });\n\n this.unsubscribeTransport = this.transport.onMessage(this.handleMessage.bind(this));\n }\n\n /** The wallet-binding axis. Orthogonal to {@link getSession}. Read-only —\n * transitions go through setLocked() / setUnavailable() / updateSphere(). */\n get walletState(): WalletState {\n return this._walletState;\n }\n\n /** Both axes in one read, for UI that must render \"connected AND locked\".\n * Required by the wallet's ConnectPage, which today renders a green pulsing\n * \"Connected to {dapp}\" with no regard for lock state. */\n getState(): { readonly walletState: WalletState; readonly session: ConnectSession | null } {\n return { walletState: this._walletState, session: this.session };\n }\n\n /** Get current active session */\n getSession(): ConnectSession | null {\n return this.session;\n }\n\n /** Register an auto-approve handler for an intent action (session-scoped). */\n setIntentAutoApprove(\n action: string,\n handler: (\n action: string,\n params: Record<string, unknown>,\n session: ConnectSession,\n ) => Promise<{ result?: unknown; error?: { code: number; message: string } }>,\n ): void {\n this.autoApprovedIntents.set(action, handler);\n }\n\n /** Remove auto-approve for an intent action. */\n clearIntentAutoApprove(action: string): void {\n this.autoApprovedIntents.delete(action);\n }\n\n /**\n * Bind a (new) Sphere instance. This is BOTH the re-arm path after setLocked() /\n * setUnavailable() AND the existing address-switch path in a live wallet.\n *\n * From 'live' (address switch): today's behaviour, unchanged — re-arm identity:changed,\n * push identity:changed. NO identity comparison: an address switch is legal.\n *\n * On the 'locked' -> 'live' edge, in this order:\n * 1. compare snapshot.identity?.chainPubkey with the new Sphere's chainPubkey.\n * MISMATCH => revokeSession() (which pushes wallet:disconnected) and RETURN.\n * Never wallet:unlocked. This is the \"Forgot password -> restore recovery phrase\n * installed a different seed behind an origin-keyed approval\" guard.\n * 2. session.expiresAt passed => revokeSession() and RETURN. A wallet:unlocked into a\n * dead session would make the dApp's next request answer SESSION_EXPIRED 4004.\n * 3. rebind, refresh the snapshot, go live, re-arm identity:changed, replay every\n * suspended sphere_subscribe key, and ONLY THEN push wallet:unlocked with the\n * CURRENT identity. Re-arm BEFORE push, so a dApp reacting synchronously cannot\n * race its own event streams.\n *\n * From 'unavailable' -> 'live': rebind + refresh the snapshot, no identity check\n * (nothing was bound to compare against) and no event (the session is already null).\n */\n updateSphere(newSphere: unknown): void {\n const wasLocked = this._walletState === 'locked';\n const next = (newSphere ?? null) as SphereInstance | null;\n\n // `null` is explicitly anticipated by the coalesce above, and committing 'live' without a\n // Sphere is unrepresentable: every query would dereference null and answer -32603 forever,\n // never 4009, while the client keeps reporting walletLocked with nothing able to clear it.\n // Route it to the verb that actually means \"there is no Sphere\".\n if (!next) {\n if (this._walletState === 'locked') {\n // Stay locked. The dApp is waiting for a real unlock; a spurious 'unavailable' would\n // revoke a session that a genuine unlock could still have served.\n logger.warn('ConnectHost', 'updateSphere(null) while locked — staying locked');\n return;\n }\n logger.warn('ConnectHost', 'updateSphere(null) — treating as a non-lock loss of Sphere');\n this.setUnavailable();\n return;\n }\n\n if (this._walletState === 'live') {\n // Address switch on a live host — today's behaviour, verbatim.\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n const existing = this.eventSubscriptions.get(WALLET_EVENTS.IDENTITY_CHANGED);\n if (existing) {\n existing();\n this.eventSubscriptions.delete(WALLET_EVENTS.IDENTITY_CHANGED);\n }\n if (this.session?.active) {\n this.autoSubscribeIdentityChanged();\n // Push the new identity immediately so dApp doesn't have to wait for the next event\n const identity = this.getPublicIdentity();\n if (identity) {\n this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);\n }\n }\n return;\n }\n\n // 1. Lock-edge identity guard — ONLY on locked -> live. From 'unavailable' there is\n // nothing to compare against.\n if (wasLocked && this.session?.active) {\n // FAIL CLOSED. `before && after &&` used to no-op the whole guard whenever either side\n // was absent — and an absent `before` is exactly what a wallet manufactures by calling\n // sphere.destroy() before setLocked(), an ordering the docs ask for but nothing enforces.\n // An unknown identity on either side is a mismatch: it is not evidence of sameness.\n const before = this.snapshot.identity?.chainPubkey ?? null;\n const after = next.identity?.chainPubkey ?? null;\n // A NETWORK change is equally unrecoverable and was not checked at all. On main a lock\n // revoked unconditionally, so a re-handshake ran checkCompatibility and answered 4008;\n // preserving the session across a lock skipped that gate, leaving the dApp served\n // balances from a chain it never agreed to while still reporting the old network.\n const netBefore = this.snapshot.networkId ?? null;\n const netAfter = next.networkId ?? null;\n if (before !== after || netBefore !== netAfter) {\n logger.warn(\n 'ConnectHost',\n `Wallet behind the lock screen is not the one this session was approved for — revoking instead of unlocking (origin=${this.config.origin ?? 'unverified'})`,\n );\n assertWalletTransition(this._walletState, 'live');\n this._walletState = 'live';\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n this.revokeSession(); // pushes wallet:disconnected, settles in flight with 4001\n return;\n }\n\n // 2. Expired while locked. The TTL is 24 h by default and expiresAt does not refresh.\n if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {\n logger.warn(\n 'ConnectHost',\n `Session expired while locked — re-handshake required (origin=${this.config.origin ?? 'unverified'})`,\n );\n assertWalletTransition(this._walletState, 'live');\n this._walletState = 'live';\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n this.revokeSession();\n return;\n }\n }\n\n // 3. Rebind and go live. _walletState is set BEFORE the replay, because\n // handleSubscribe() branches on it and would otherwise put every key straight back\n // into suspendedSubscriptions and arm nothing.\n assertWalletTransition(this._walletState, 'live');\n this.sphere = next;\n this.snapshot = buildWalletSnapshot(next);\n this._walletState = 'live';\n\n if (!this.session?.active) {\n // Nothing to re-arm and nothing to announce.\n this.suspendedSubscriptions.clear();\n return;\n }\n\n this.autoSubscribeIdentityChanged();\n\n const suspended = [...this.suspendedSubscriptions];\n this.suspendedSubscriptions.clear();\n for (const eventName of suspended) {\n try {\n this.handleSubscribe(eventName);\n } catch (err) {\n logger.warn('ConnectHost', `Re-subscribe failed after unlock: ${eventName}`, err);\n }\n }\n\n logger.debug(\n 'ConnectHost',\n `Wallet unlocked — re-armed ${suspended.length} subscription(s) (origin=${this.config.origin ?? 'unverified'})`,\n );\n\n this.pushClientEvent(WALLET_EVENTS.UNLOCKED, {\n identity: this.getPublicIdentity(),\n } satisfies WalletUnlockedPayload);\n\n // AND identity:changed, which main pushed on every unlock and which the Connect 2.0 docs\n // named as the ONLY unlock signal. checkCompatibility gates on the MAJOR alone, so every\n // already-shipped 2.0 dApp connects to a 2.1 wallet happily — and then, without this, sits\n // showing \"wallet locked\" for the rest of the page's life, because wallet:unlocked is a\n // name it has never heard of. Idempotent for a 2.1 client: it has already written the same\n // identity from the payload above, and its IDENTITY_CHANGED branch just rewrites it.\n const identity = this.getPublicIdentity();\n if (identity) this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, identity);\n }\n\n /**\n * The wallet locked (manual lock, idle auto-lock, cross-tab broadcast, cold start).\n * The session is PRESERVED — a lock is a state, not a teardown. Every request outside\n * the locked allow-list is answered WALLET_LOCKED (4009) until updateSphere().\n *\n * Idempotent: a second call is a no-op and pushes nothing. Required, because\n * SphereProvider.lock(), ConnectPage's `sphere → null` effect and broadcastLock()'s\n * same-tab loopback can all fire it for one user action.\n *\n * ORDERING CONTRACT: call this BEFORE sphere.destroy(). The host drops its Sphere\n * reference here; destroying first leaves in-flight requests reading a dead instance\n * (-32603, or `undefined` returned AS SUCCESS from sphere_getIdentity).\n */\n setLocked(): void {\n if (this._walletState === 'locked') return;\n assertWalletTransition(this._walletState, 'locked');\n\n // Freeze BEFORE dropping. snapshot.identity?.chainPubkey IS the lock-edge identity\n // binding compared in updateSphere() — there is no separate boundIdentity field.\n this.snapshot = buildWalletSnapshot(this.sphere);\n this._walletState = 'locked';\n logger.debug(\n 'ConnectHost',\n `Wallet locked — session preserved (origin=${this.config.origin ?? 'unverified'}, session=${this.session?.id ?? 'none'})`,\n );\n\n if (this.session?.active) {\n this.pushClientEvent(WALLET_EVENTS.LOCKED, {} as WalletLockedPayload);\n }\n\n // Snapshot-then-detach. cleanupEventSubscriptions() ends in .clear() and the Map holds\n // eventName → closure; Sphere.destroy() kills those closures, so the KEYS are the only\n // recoverable information. Exclude identity:changed — autoSubscribeIdentityChanged()\n // re-arms that one itself.\n for (const key of this.eventSubscriptions.keys()) {\n if (key === WALLET_EVENTS.IDENTITY_CHANGED) continue;\n this.suspendedSubscriptions.add(key);\n }\n this.cleanupEventSubscriptions();\n\n // Today only revokeSession() clears these. Under session-preserving semantics an\n // auto-approved intent would otherwise survive a lock and execute unattended after\n // the unlock.\n this.autoApprovedIntents.clear();\n\n this.settleInFlight(ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: 'locked' });\n this.sphere = null;\n }\n\n /**\n * The Sphere instance is gone for a NON-LOCK reason (a generic init failure leaves\n * `sphere === null, isLocked === false` in the wallet).\n * This is a DEAD END: unlocking does not cure it, so it revokes the session and pushes\n * wallet:disconnected rather than promising an unlock that cannot help.\n * Subsequent requests answer NOT_CONNECTED (4001); handshakes get the empty refusal\n * WITHOUT dereferencing a null Sphere.\n *\n * Idempotent. Pushes no 'wallet:unavailable' — there is no such event.\n */\n setUnavailable(): void {\n if (this._walletState === 'unavailable') return;\n assertWalletTransition(this._walletState, 'unavailable');\n\n this._walletState = 'unavailable';\n logger.warn(\n 'ConnectHost',\n `Sphere unavailable (non-lock) — session revoked (origin=${this.config.origin ?? 'unverified'})`,\n );\n\n this.snapshot = EMPTY_WALLET_SNAPSHOT; // nothing may be served from it\n this.suspendedSubscriptions.clear();\n this.revokeSession(); // pushes wallet:disconnected, settles 4001\n this.sphere = null;\n }\n\n /**\n * Destroy the SESSION (logout, wallet deleted, dApp sphere_disconnect, popup\n * beforeunload, expiry, identity mismatch at unlock). Pushes wallet:disconnected BEFORE\n * tearing down, so the dApp stops believing it is connected instead of finding out at\n * its next 4001.\n *\n * This is the TEARDOWN verb. For a lock use setLocked() — a lock never destroys the\n * session. revokeSession() does NOT touch walletState: the two axes are orthogonal.\n */\n revokeSession(): void {\n if (this.session) {\n logger.debug(\n 'ConnectHost',\n `Session revoked (origin=${this.config.origin ?? 'unverified'}, session=${this.session.id})`,\n );\n if (this.session.active) {\n this.pushClientEvent(WALLET_EVENTS.DISCONNECTED, {} as WalletDisconnectedPayload);\n }\n this.session.active = false;\n this.cleanupEventSubscriptions();\n this.autoApprovedIntents.clear();\n this.session = null;\n this.grantedPermissions.clear();\n }\n this.suspendedSubscriptions.clear();\n this.settleInFlight(ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);\n }\n\n /** Destroy the host, clean up all resources. Idempotent. */\n destroy(): void {\n this.revokeSession(); // pushes wallet:disconnected, settles 4001\n this.inFlight.destroy(); // clear timers WITHOUT invoking onExpire\n if (this.unsubscribeTransport) {\n this.unsubscribeTransport();\n this.unsubscribeTransport = null;\n }\n this.sphere = null;\n this.snapshot = EMPTY_WALLET_SNAPSHOT;\n if (this._walletState !== 'unavailable') {\n assertWalletTransition(this._walletState, 'unavailable');\n this._walletState = 'unavailable';\n }\n }\n\n // ===========================================================================\n // Message Handling\n // ===========================================================================\n\n private async handleMessage(msg: SphereConnectMessage): Promise<void> {\n try {\n if (msg.type === 'handshake' && msg.direction === 'request') {\n await this.handleHandshake(msg);\n return;\n }\n\n if (msg.type === 'request') {\n await this.handleRpcRequest(msg);\n return;\n }\n\n if (msg.type === 'intent') {\n await this.handleIntentRequest(msg);\n return;\n }\n } catch (error) {\n logger.warn('ConnectHost', 'Error handling message:', error);\n // A throw used to send NOTHING, so the dApp hung for its full client timeout and\n // ended with a bare Error('Query timeout: …') / Error('Intent timeout: …') /\n // Error('Connection timeout') carrying no .code.\n this.sendUnhandledError(msg, error);\n }\n }\n\n // ===========================================================================\n // Handshake\n // ===========================================================================\n\n private async handleHandshake(msg: SphereHandshake): Promise<void> {\n const dapp = msg.dapp;\n // A handshake without dapp metadata is malformed — deny silently (no session).\n // The compatibility gate and onConnectionRejected are intentionally not consulted\n // here: there is no app to gate or to surface a rejection reason for.\n if (!dapp) {\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // STEP 0 — a host with no live binding AND no snapshot knows nothing about a wallet.\n // BEFORE checkCompatibility on purpose: snapshot.networkId is undefined on a\n // cold-start-locked host, so the network check (a missing network is treated as a\n // mismatch) would answer INCOMPATIBLE_NETWORK 4008 to EVERY origin — leaking\n // \"a wallet lives here, network -1\" with no approval, and lying about the cause.\n if (this._walletState !== 'live' && !this.snapshot.identity) {\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Rate limit. The handshake path was entirely unmetered: an unapproved origin could\n // loop handshakes and open the approval UI without bound. The refusal is today's EMPTY\n // refusal — it carries no error and reveals nothing about the wallet's state.\n if (!this.checkRateLimit()) {\n logger.warn('ConnectHost', 'Handshake rate-limited', { dapp: dapp.name });\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Captured for the pre-await decisions; RE-READ after the approval prompt below, because a\n // human-time await gives the wallet room to lock, unlock or log out underneath us.\n const stateAtPrompt = this._walletState;\n let locked = stateAtPrompt === 'locked';\n\n // Lock gate for the handshake kind. 'unavailable' refuses here WITHOUT dereferencing a\n // null Sphere — which is the whole reason the state exists separately.\n const handshakeDecision = gate(this._walletState, !!this.session?.active, 'handshake', 'handshake');\n if (handshakeDecision.kind === 'refuse') {\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Compatibility gate — runs BEFORE resume and BEFORE onConnectionRequest, so an\n // incompatible/old client cannot slip through on a stale sessionId.\n const result = checkCompatibility({\n clientProtocol: msg.v,\n walletProtocol: SPHERE_CONNECT_VERSION,\n clientNetwork: msg.network,\n walletNetworkId: this.snapshot.networkId ?? -1,\n minMinor: this.config.minMinorVersion,\n clientSdkVersion: msg.sdkVersion,\n minSdkVersion: this.config.minSdkVersion ?? DEFAULT_MIN_CLIENT_SDK_VERSION,\n });\n if (!result.ok) {\n logger.warn('ConnectHost', 'Rejected handshake', {\n dapp: dapp.name,\n reason: (result.error.data as { reason?: string } | undefined)?.reason,\n clientProtocol: msg.v,\n walletProtocol: SPHERE_CONNECT_VERSION,\n clientNetwork: msg.network ?? null,\n walletNetwork: this.snapshot.networkId ?? null,\n });\n this.config.onConnectionRejected?.(dapp, result.error, !!msg.silent);\n this.sendHandshakeResponse([], undefined, undefined, result.error, msg.v);\n return;\n }\n\n const clientInfo = { protocolVersion: msg.v, network: msg.network, sdkVersion: msg.sdkVersion };\n\n // Session resumption: if the client presents a valid existing sessionId,\n // skip the approval popup and restore the session without user interaction.\n // A resume DURING A LOCK succeeds and carries locked: true — a reloaded dApp is the\n // most common entry into this feature, and refusing it would leave that dApp with no\n // channel at all (it is not subscribed, so it would never receive wallet:unlocked).\n if (msg.sessionId && this.session?.active && this.session.id === msg.sessionId) {\n const identity = locked ? this.snapshotIdentity() : this.getPublicIdentity();\n this.sendHandshakeResponse(\n [...this.grantedPermissions],\n this.session.id,\n identity,\n undefined,\n undefined,\n undefined,\n locked ? true : undefined,\n );\n if (locked) {\n this.pushClientEvent(WALLET_EVENTS.LOCKED, {} as WalletLockedPayload);\n this.notifyLockedRequest('handshake', 'handshake');\n }\n return;\n }\n\n const requestedPermissions = msg.permissions as PermissionScope[];\n\n // FORCED SILENT while locked: the wallet's existing `if (silent) return { approved:\n // false }` branch refuses an unapproved origin with NO UI, and a previously approved\n // origin is approved from persisted state. No dApp request may ever raise a credential\n // surface, so a locked handshake must never be able to open one.\n const silent = msg.silent === true || locked;\n\n const { approved, grantedPermissions } = await withDeadline(\n Promise.resolve(\n this.config.onConnectionRequest(dapp, requestedPermissions, silent, clientInfo),\n ),\n this.config.handshakeDeadlineMs ?? DEFAULT_HANDSHAKE_DEADLINE_MS,\n () => {\n logger.warn('ConnectHost', 'Connection approval prompt timed out', { dapp: dapp.name });\n return { approved: false, grantedPermissions: [] as PermissionScope[] };\n },\n );\n\n // `locked` was read BEFORE a human-time await. Re-read the axis now, because the wallet can\n // have moved under us while the modal was open — an idle auto-lock, a cross-tab lock, a\n // logout, or an unlock.\n //\n // Getting this wrong in either direction is bad. Landing in 'locked' or 'unavailable' used\n // to mint an ACTIVE session on a non-live host while telling the dApp it was rejected: the\n // wallet then believes an origin is connected that has forgotten it exists, and under\n // 'unavailable' the gate refuses sphere_disconnect too, so that session is unclearable for\n // its whole TTL and onDisconnect never revokes the persisted origin approval. Landing in\n // 'live' (an unlock during the prompt) used to leave the dApp permanently walletLocked,\n // because the unlock edge had no session to notify at the time it fired.\n const stateAfterPrompt = this._walletState;\n if (stateAfterPrompt !== 'live' && stateAfterPrompt !== stateAtPrompt) {\n logger.warn(\n 'ConnectHost',\n `Wallet left 'live' while the approval prompt was open — refusing the handshake instead of minting a session (state=${stateAfterPrompt}, origin=${this.config.origin ?? 'unverified'})`,\n );\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n // Recompute so the response's `locked` flag and the trailing push describe NOW, not then.\n locked = stateAfterPrompt !== 'live';\n\n if (!approved) {\n // Deliberately NO notifyLockedRequest here. This origin was DENIED — it holds no\n // approval, so there is nothing for the user to unlock *for*, and counting it would put\n // an unvetted name in the wallet's own chrome and leak the lock to it. The notify stays\n // on the resume path and the approved path, where the origin demonstrably holds one.\n this.sendHandshakeResponse([], undefined, undefined);\n return;\n }\n\n // Create session\n const sessionId = createRequestId();\n const allPermissions = [...new Set([...DEFAULT_PERMISSIONS, ...grantedPermissions])];\n const ttl = this.config.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;\n\n this.session = {\n id: sessionId,\n dapp,\n permissions: allPermissions,\n createdAt: Date.now(),\n expiresAt: ttl > 0 ? Date.now() + ttl : 0,\n active: true,\n };\n this.grantedPermissions = new Set(allPermissions);\n\n // Auto-push identity:changed to dApp whenever the wallet switches address.\n // MetaMask pattern: no sphere_subscribe needed — host pushes it unconditionally.\n // SKIPPED while locked: there is no Sphere to attach to. updateSphere() arms it on the\n // locked -> live edge, so no extra bookkeeping is needed.\n if (!locked) this.autoSubscribeIdentityChanged();\n\n // Build public identity — from the snapshot while locked.\n const identity = locked ? this.snapshotIdentity() : this.getPublicIdentity();\n\n this.sendHandshakeResponse(\n allPermissions,\n sessionId,\n identity,\n undefined,\n undefined,\n undefined,\n locked ? true : undefined,\n );\n if (locked) this.pushClientEvent(WALLET_EVENTS.LOCKED, {} as WalletLockedPayload);\n }\n\n // `warning` is a forward-compatible deprecation-notice slot (see SphereHandshake.warning);\n // no call site emits one yet — reserved for the deprecation-window policy.\n private sendHandshakeResponse(\n permissions: string[],\n sessionId: string | undefined,\n identity: PublicIdentity | undefined,\n error?: SphereRpcError,\n echoV?: string,\n warning?: SphereRpcError,\n locked?: boolean,\n ): void {\n const network: NetworkInfo | undefined =\n typeof this.snapshot.networkId === 'number' ? { id: this.snapshot.networkId } : undefined;\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: (error && echoV ? echoV : SPHERE_CONNECT_VERSION) as typeof SPHERE_CONNECT_VERSION,\n type: 'handshake',\n direction: 'response',\n permissions,\n sessionId,\n identity,\n network,\n sdkVersion: SDK_VERSION,\n error,\n warning,\n ...(locked ? { locked: true } : {}),\n });\n }\n\n // ===========================================================================\n // RPC Requests (query)\n // ===========================================================================\n\n private async handleRpcRequest(msg: SphereRpcRequest): Promise<void> {\n // 1. Session check\n if (!this.session?.active) {\n this.sendError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);\n return;\n }\n\n // 2. Session expiry — BEFORE the lock gate. A dead session must never be advertised\n // as \"retry after unlock\", and the check needs no Sphere.\n if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {\n // Answer THIS request before revoking: revokeSession() pushes wallet:disconnected, and\n // a client that cleans up on that event would otherwise reject this id locally before\n // our own frame arrives.\n this.sendError(msg.id, ERROR_CODES.SESSION_EXPIRED, 'Session expired');\n this.revokeSession();\n return;\n }\n\n // 3. Rate limit — BEFORE the lock gate, because this is what bounds onLockedRequest\n // volume. A dApp polling in a loop while locked is throttled by the existing\n // limiter, which is why there is no second anti-spam mechanism.\n if (!this.checkRateLimit()) {\n this.sendError(msg.id, ERROR_CODES.RATE_LIMITED, 'Too many requests');\n return;\n }\n\n // 4. Disconnect, BEFORE the lock gate. It has to work in EVERY wallet state, and the gate\n // does not allow that: 'unavailable' refuses every query with NOT_CONNECTED, so a\n // session created just before Sphere went away could never be dropped — it sat active\n // for its whole TTL (24 h by default) and onDisconnect, the only hook that revokes the\n // persisted origin approval, never fired. Also before the permission check, because\n // sphere_disconnect has no mapped permission. It stays AFTER the rate limiter so a\n // disconnect flood is still throttled.\n if (msg.method === RPC_METHODS.DISCONNECT) {\n const disconnectedSession = this.session;\n // Answer BEFORE revoking, for the same reason as the expiry branch above.\n this.sendResult(msg.id, { disconnected: true });\n this.revokeSession();\n if (disconnectedSession && this.config.onDisconnect) {\n // Fire-and-forget: don't block the response\n Promise.resolve(this.config.onDisconnect(disconnectedSession)).catch((err) => logger.warn('Connect', 'onDisconnect handler error', err));\n }\n return;\n }\n\n // 5. Lock gate — BEFORE the permission check, because hasMethodPermission() is false\n // for anything unmapped, so a locked unknown method would otherwise answer\n // PERMISSION_DENIED 4002: an un-retryable code that tells the dApp its permissions\n // are wrong.\n const decision = gate(this._walletState, true, 'query', msg.method);\n if (decision.kind === 'refuse') {\n this.sendError(msg.id, decision.error.code, decision.error.message, decision.error.data);\n if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {\n logger.debug('ConnectHost', `Refused query ${msg.method} — WALLET_LOCKED 4009 (origin=${this.config.origin ?? 'unverified'})`);\n this.notifyLockedRequest('query', msg.method);\n }\n return;\n }\n\n\n // 5. Permission check\n if (!hasMethodPermission(this.grantedPermissions, msg.method)) {\n this.sendError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for ${msg.method}`);\n return;\n }\n\n // 6. Snapshot answer. Only sphere_getIdentity reaches this branch (host-state.ts).\n // An absent snapshot identity REFUSES: `undefined` sent as a SUCCESS result reads\n // to a dApp as \"the wallet has no identity\".\n if (decision.kind === 'serve-from-snapshot') {\n const identity = this.snapshotIdentity();\n if (!identity) {\n this.sendError(msg.id, ERROR_CODES.WALLET_LOCKED, WALLET_LOCKED_MESSAGE, { reason: 'locked' });\n this.notifyLockedRequest('query', msg.method);\n return;\n }\n this.sendResult(msg.id, identity);\n return;\n }\n\n // 7. Register BEFORE the first await, with the timer armed at insertion time, then\n // execute. Every send goes through settle(), which returns null when a lock, a\n // revoke, a destroy or the deadline already answered this id — the whole\n // \"exactly one frame per id\" mechanism.\n this.inFlight.add(msg.id, 'query', this.config.requestDeadlineMs ?? DEFAULT_REQUEST_DEADLINE_MS);\n try {\n const result = await this.executeMethod(msg.method, msg.params ?? {});\n if (!this.inFlight.settle(msg.id)) return;\n this.sendResult(msg.id, result);\n } catch (error) {\n if (!this.inFlight.settle(msg.id)) return;\n // Our OWN SphereError messages are DX ('Missing required parameter: identifier',\n // 'Communications module not available') and stay. Anything else is internal JS text\n // that must not cross the trust boundary into a third-party dApp.\n // instanceof is unreliable across bundle copies — check the name too.\n const isSphereError =\n error instanceof SphereError || (error as { name?: string })?.name === 'SphereError';\n if (isSphereError) {\n const e = error as SphereError;\n this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, e.message, { reason: e.code });\n return;\n }\n this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n }\n }\n\n // ===========================================================================\n // Intent Requests\n // ===========================================================================\n\n private async handleIntentRequest(msg: SphereIntentRequest): Promise<void> {\n // 1. Session check\n if (!this.session?.active) {\n this.sendIntentError(msg.id, ERROR_CODES.NOT_CONNECTED, NOT_CONNECTED_MESSAGE);\n return;\n }\n\n // 2. Session expiry — BEFORE the lock gate (same reason as the query path).\n if (this.session.expiresAt > 0 && Date.now() > this.session.expiresAt) {\n // Answer first — see the query path.\n this.sendIntentError(msg.id, ERROR_CODES.SESSION_EXPIRED, 'Session expired');\n this.revokeSession();\n return;\n }\n\n // 3. Rate limit. checkRateLimit() was called only from handleRpcRequest, so the intent\n // path — the money path, the one that opens wallet modals — was entirely unmetered.\n if (!this.checkRateLimit()) {\n this.sendIntentError(msg.id, ERROR_CODES.RATE_LIMITED, 'Too many requests');\n return;\n }\n\n // 4. Lock gate. NO allow-list: every intent needs a live wallet.\n const decision = gate(this._walletState, true, 'intent', msg.action);\n if (decision.kind === 'refuse') {\n this.sendIntentError(msg.id, decision.error.code, decision.error.message, decision.error.data);\n if (decision.error.code === ERROR_CODES.WALLET_LOCKED) {\n logger.debug('ConnectHost', `Refused intent ${msg.action} — WALLET_LOCKED 4009 (origin=${this.config.origin ?? 'unverified'})`);\n this.notifyLockedRequest('intent', msg.action);\n }\n return;\n }\n\n // 5. Permission check\n if (!hasIntentPermission(this.grantedPermissions, msg.action)) {\n this.sendIntentError(msg.id, ERROR_CODES.PERMISSION_DENIED, `Permission denied for intent: ${msg.action}`);\n return;\n }\n\n // 6. Register BEFORE the first await, then delegate. The entry owns the deadline and\n // the AbortController, so lock / revoke / unavailable / destroy / deadline all\n // settle this id exactly once.\n const session = this.session;\n const entry = this.inFlight.add(\n msg.id,\n 'intent',\n this.config.intentDeadlineMs ?? DEFAULT_INTENT_DEADLINE_MS,\n );\n const ctx: IntentContext = {\n origin: this.config.origin,\n expiresAt: entry.deadline,\n signal: entry.controller.signal,\n };\n\n try {\n const autoHandler = this.autoApprovedIntents.get(msg.action);\n const response = autoHandler\n ? await autoHandler(msg.action, msg.params, session)\n : await this.config.onIntent(msg.action, msg.params, session, ctx);\n\n if (!this.inFlight.settle(msg.id)) return;\n if (response.error) {\n // Not relayed blindly: two of these codes describe the CHANNEL and say nothing true\n // about a spend the wallet has already taken ownership of. Everything else — including\n // USER_REJECTED, a legitimate pre-submission decline — passes through untouched. See\n // CHANNEL_ONLY_CODES.\n const asserts = CHANNEL_ONLY_CODES.has(response.error.code);\n if (asserts) {\n logger.warn(\n 'ConnectHost',\n `Wallet answered intent ${msg.action} with ${response.error.code}, which describes the channel rather than the spend — downgrading to INTENT_OUTCOME_UNKNOWN (origin=${this.config.origin ?? 'unverified'})`,\n );\n }\n this.sendIntentError(\n msg.id,\n asserts ? ERROR_CODES.INTENT_OUTCOME_UNKNOWN : response.error.code,\n asserts ? INTENT_UNKNOWN_MESSAGE : response.error.message,\n );\n } else {\n this.sendIntentResult(msg.id, response.result);\n }\n } catch (error) {\n // A throw from onIntent or from an auto-approve handler used to reach\n // handleMessage's catch, which sent NOTHING — the dApp then hung for its full\n // intentTimeout and ended with a bare Error('Intent timeout: …') carrying no .code.\n logger.warn('ConnectHost', `Intent handler threw: ${msg.action}`, error);\n if (!this.inFlight.settle(msg.id)) return;\n this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n }\n }\n\n // ===========================================================================\n // Method Router\n // ===========================================================================\n\n private async executeMethod(method: string, params: Record<string, unknown>): Promise<unknown> {\n // Subscription bookkeeping needs NO Sphere and is allow-listed while locked\n // (host-state.ts), so it must be answered BEFORE the dereference below — otherwise a\n // locked sphere_unsubscribe dies on requireSphere() with -32603 despite the gate\n // having let it through.\n switch (method) {\n case RPC_METHODS.SUBSCRIBE:\n return this.handleSubscribe(params.event as string);\n case RPC_METHODS.UNSUBSCRIBE:\n return this.handleUnsubscribe(params.event as string);\n }\n\n // ONE dereference for the whole router. The gate has already proven _walletState is\n // 'live' before we get here, so this is defence in depth — and it is what makes the\n // nullable field compile without a single `!`.\n const sphere = this.requireSphere();\n // §4 wire-compat (payments-compat.ts): the old query results are built from\n // facade reads. `sphere.payments` is read per branch, never up here — the\n // getter throws while no vertical runs and GET_IDENTITY must still answer.\n switch (method) {\n case RPC_METHODS.GET_IDENTITY:\n return this.getPublicIdentity();\n\n case RPC_METHODS.GET_BALANCE:\n case RPC_METHODS.GET_ASSETS:\n return sphere.payments.assets(params.coinId as string | undefined);\n\n case RPC_METHODS.GET_FIAT_BALANCE:\n return { fiatBalance: sumFiatUsd(await sphere.payments.assets()) };\n\n case RPC_METHODS.GET_TOKENS:\n return this.stripTokenSdkData(\n sphere.payments.tokens(params.coinId ? { coinId: params.coinId as string } : undefined),\n );\n\n case RPC_METHODS.GET_HISTORY: {\n // Flat entry array on the wire; entries already carry the consumed shape\n // (`timestamp` mapped from the server's `ts`, plus symbol/tokenIds).\n const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)\n ? params.limit\n : undefined;\n if (limit !== undefined) return (await sphere.payments.history({ limit })).entries;\n // INVARIANT: the legacy wire has no cursor — completeness is the contract.\n // Parameterless sphere_getHistory returned the ENTIRE ledger, so follow\n // the facade's cursors until the record is exhausted.\n let page = await sphere.payments.history(undefined);\n const entries = [...page.entries];\n while (page.more && page.cursor !== null) {\n page = await sphere.payments.history({ before: page.cursor });\n entries.push(...page.entries);\n }\n return entries;\n }\n\n case RPC_METHODS.RESOLVE:\n if (!params.identifier) {\n throw new SphereError('Missing required parameter: identifier', 'VALIDATION_ERROR');\n }\n return sphere.resolve(params.identifier as string);\n\n case RPC_METHODS.SUBSCRIBE:\n return this.handleSubscribe(params.event as string);\n\n case RPC_METHODS.UNSUBSCRIBE:\n return this.handleUnsubscribe(params.event as string);\n\n case RPC_METHODS.GET_CONVERSATIONS: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n const convos = comms.getConversations();\n const result: Array<{\n peerPubkey: string;\n peerNametag?: string;\n lastMessage: ConnectDirectMessage;\n unreadCount: number;\n messageCount: number;\n }> = [];\n // Collect conversations and track which ones need nametag resolution\n const needsResolve: Array<{ index: number; peerPubkey: string }> = [];\n for (const [peer, messages] of convos) {\n if (messages.length === 0) continue;\n const last = messages[messages.length - 1];\n // Find peer nametag from any message in the conversation\n const peerNametag =\n messages.find(m => m.senderPubkey === peer && m.senderNametag)?.senderNametag\n ?? messages.find(m => m.recipientPubkey === peer && m.recipientNametag)?.recipientNametag;\n const idx = result.length;\n result.push({\n peerPubkey: peer,\n peerNametag,\n lastMessage: last,\n unreadCount: comms.getUnreadCount(peer),\n messageCount: messages.length,\n });\n if (!peerNametag) {\n needsResolve.push({ index: idx, peerPubkey: peer });\n }\n }\n // Resolve missing nametags via transport (parallel, best-effort)\n if (needsResolve.length > 0) {\n const resolved = await Promise.all(\n needsResolve.map(({ peerPubkey }) =>\n comms.resolvePeerNametag(peerPubkey).catch((err) => { logger.debug('Connect', 'Peer Unicity ID resolution failed', err); return undefined; }),\n ),\n );\n for (let i = 0; i < needsResolve.length; i++) {\n if (resolved[i]) {\n result[needsResolve[i].index].peerNametag = resolved[i];\n }\n }\n }\n result.sort((a, b) => b.lastMessage.timestamp - a.lastMessage.timestamp);\n return result;\n }\n\n case RPC_METHODS.GET_MESSAGES: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n if (!params.peerPubkey) throw new SphereError('Missing required parameter: peerPubkey', 'VALIDATION_ERROR');\n return comms.getConversationPage(\n params.peerPubkey as string,\n {\n limit: params.limit as number | undefined,\n before: params.before as number | undefined,\n },\n );\n }\n\n case RPC_METHODS.GET_DM_UNREAD_COUNT: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n return {\n unreadCount: comms.getUnreadCount(\n params.peerPubkey as string | undefined,\n ),\n };\n }\n\n case RPC_METHODS.MARK_AS_READ: {\n const comms = sphere.communications;\n if (!comms) throw new SphereError('Communications module not available', 'MODULE_NOT_AVAILABLE');\n if (!params.messageIds || !Array.isArray(params.messageIds)) {\n throw new SphereError('Missing required parameter: messageIds (string[])', 'VALIDATION_ERROR');\n }\n await comms.markAsRead(params.messageIds as string[]);\n return { marked: true, count: (params.messageIds as string[]).length };\n }\n\n default:\n throw new SphereError(`Unknown method: ${method}`, 'VALIDATION_ERROR');\n }\n }\n\n // ===========================================================================\n // Event Subscriptions\n // ===========================================================================\n\n private autoSubscribeIdentityChanged(): void {\n if (this.eventSubscriptions.has(WALLET_EVENTS.IDENTITY_CHANGED)) return;\n const unsub = this.requireSphere().on('identity:changed' as SphereEventType, (data: unknown) => {\n this.pushClientEvent(WALLET_EVENTS.IDENTITY_CHANGED, data);\n });\n this.eventSubscriptions.set(WALLET_EVENTS.IDENTITY_CHANGED, unsub);\n }\n\n private handleSubscribe(eventName: string): { subscribed: boolean; event: string } {\n if (!eventName) throw new SphereError('Missing required parameter: event', 'VALIDATION_ERROR');\n\n // The four auto-pushed events must NOT be attached to Sphere.on(): it accepts any string\n // and never emits for them, so the subscription would deliver nothing forever. But the\n // answer is SUCCESS, not an error — the host pushes them unconditionally, so \"you are\n // subscribed\" is true, just satisfied by a different mechanism.\n //\n // Throwing here surfaced as INTERNAL_ERROR -32603 and silently broke every dApp built\n // before 2.1: `client.on('wallet:locked', …)` fires sphere_subscribe fire-and-forget, and\n // on 2.0 that call succeeded. A MINOR bump must not turn a working call into a crash. The\n // 2.1 client skips the call altogether, so this path exists only for older dApps.\n if (isAutoPushedEvent(eventName)) {\n return { subscribed: true, event: eventName };\n }\n\n // While locked there is no Sphere to attach to: record the KEY and answer success.\n // Refusing would be a bug, not a policy — ConnectClient.on() fires sphere_subscribe\n // fire-and-forget and never retries, so the stream would die forever after one lock.\n if (this._walletState === 'locked') {\n this.suspendedSubscriptions.add(eventName);\n return { subscribed: true, event: eventName };\n }\n\n if (this.eventSubscriptions.has(eventName)) {\n return { subscribed: true, event: eventName };\n }\n\n const sphere = this.requireSphere();\n\n // §4 wire-compat (payments-compat.ts): on a v2-facade host the old event names have no\n // bus emitter — re-emit them from the v2 events so nothing a dApp subscribes to silently\n // stops firing. Keyed under the OLD name, so unsubscribe and the lock snapshot/replay\n // bookkeeping work unchanged.\n {\n const compatUnsub = attachCompatEvent(sphere, eventName, (data) =>\n this.pushClientEvent(eventName, data),\n );\n if (compatUnsub) {\n this.eventSubscriptions.set(eventName, compatUnsub);\n return { subscribed: true, event: eventName };\n }\n }\n\n const unsub = sphere.on(eventName as SphereEventType, (data: unknown) => {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'event',\n event: eventName,\n data,\n });\n });\n\n this.eventSubscriptions.set(eventName, unsub);\n return { subscribed: true, event: eventName };\n }\n\n private handleUnsubscribe(eventName: string): { unsubscribed: boolean; event: string } {\n if (!eventName) throw new SphereError('Missing required parameter: event', 'VALIDATION_ERROR');\n\n const unsub = this.eventSubscriptions.get(eventName);\n if (unsub) {\n unsub();\n this.eventSubscriptions.delete(eventName);\n }\n // Also drop it from the lock snapshot: sphere_unsubscribe is on the locked allow-list,\n // so without this the next updateSphere() re-arm would resurrect the stream.\n this.suspendedSubscriptions.delete(eventName);\n return { unsubscribed: true, event: eventName };\n }\n\n private cleanupEventSubscriptions(): void {\n for (const [, unsub] of this.eventSubscriptions) {\n unsub();\n }\n this.eventSubscriptions.clear();\n }\n\n // ===========================================================================\n // Helpers\n // ===========================================================================\n\n /** Push an event to the dApp without requiring a sphere_subscribe call. */\n private pushClientEvent(event: string, data: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'event',\n event,\n data,\n });\n }\n\n /** The bound Sphere, or a typed refusal. The ONLY way the router may reach Sphere.\n * Unreachable in practice — the gate guarantees 'live' before the router is entered —\n * so this is defence in depth, not the primary mechanism. */\n private requireSphere(): SphereInstance {\n if (!this.sphere) {\n throw new SphereError(\n this._walletState === 'locked' ? WALLET_LOCKED_MESSAGE : 'Wallet unavailable',\n 'NOT_INITIALIZED',\n );\n }\n return this.sphere;\n }\n\n /** SNAPSHOT read. `undefined` means \"we never saw an identity\": callers MUST refuse,\n * never answer undefined-as-success — a dApp reads that as \"the wallet has no\n * identity\". Two explicit methods instead of one dual-mode method, so nobody can serve\n * a snapshot believing it is live. */\n private snapshotIdentity(): PublicIdentity | undefined {\n return this.snapshot.identity;\n }\n\n /** InFlightRegistry.onExpire sink. Filled in a later task; declared here so the\n * constructor can wire it. */\n private settleExpired(entry: InFlightEntry): void {\n logger.warn(\n 'ConnectHost',\n `Host deadline reached, answering on our own: ${entry.kind} ${entry.id} (origin=${this.config.origin ?? 'unverified'})`,\n );\n if (entry.kind === 'query') {\n this.sendError(entry.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n } else {\n // NOT INTENT_CANCELLED. The wallet was handed this intent and may already have submitted\n // the transfer — the deadline says only that we stopped waiting for the answer, never\n // that nothing happened. Asserting a cancel here is how a dApp re-offers a payment that\n // already went through.\n this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);\n }\n }\n\n /** Answer every request already in flight with one coded frame each. A request in flight\n * when the Sphere goes away otherwise answers -32603 with a raw JS message, returns\n * `undefined` AS SUCCESS (sphere_getIdentity), or — for a delegated intent — never\n * answers at all until the client's own 120 s timeout. */\n private settleInFlight(code: number, message: string, data?: WalletLockedData): void {\n for (const entry of this.inFlight.settleAll()) {\n if (entry.kind === 'query') {\n this.sendError(entry.id, code, message, data);\n continue;\n }\n // An INTENT is never answered with the caller's code. 4009 invites a retry after the\n // unlock and 4001 reads as \"never happened\", but this intent was already delegated to\n // the wallet: the user may have confirmed it and the transfer may be on the wire. Only\n // \"outcome unknown\" is true, and it explicitly forbids a retry.\n this.sendIntentError(entry.id, ERROR_CODES.INTENT_OUTCOME_UNKNOWN, INTENT_UNKNOWN_MESSAGE);\n }\n }\n\n /**\n * Notify-only. The host has ALREADY answered and never waits for the wallet.\n *\n * The wallet's only permitted reaction is a PASSIVE badge in its PERMANENT chrome; a\n * dApp request may trigger a CONSENT prompt but never a credential prompt. Volume is\n * bounded by checkRateLimit(), which guards all three entry points — there is no\n * coalescing, no cooldown and no cap by design.\n *\n * A throwing handler must not break the host.\n */\n private notifyLockedRequest(kind: LockedRequestContext['kind'], name: string): void {\n try {\n this.config.onLockedRequest?.({ origin: this.config.origin, kind, name });\n } catch (err) {\n logger.warn('ConnectHost', 'onLockedRequest handler error', err);\n }\n }\n\n /** Last-resort answer for a handler that threw before its own catch could run.\n * Id-bearing frames get a coded error (InFlightRegistry guarantees exactly one answer\n * per id); a handshake gets today's empty refusal, because a failed handshake must\n * reveal nothing. */\n private sendUnhandledError(msg: SphereConnectMessage, error: unknown): void {\n if (msg.type === 'request') {\n if (!this.inFlight.settle(msg.id) && this.inFlight.has(msg.id)) return;\n this.sendError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n return;\n }\n if (msg.type === 'intent') {\n this.inFlight.settle(msg.id);\n this.sendIntentError(msg.id, ERROR_CODES.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE);\n return;\n }\n if (msg.type === 'handshake' && msg.direction === 'request') {\n logger.warn('ConnectHost', 'Handshake handler threw; sending the empty refusal', error);\n this.sendHandshakeResponse([], undefined, undefined);\n }\n }\n\n private getPublicIdentity(): PublicIdentity | undefined {\n const id = this.requireSphere().identity;\n if (!id) return undefined;\n return {\n chainPubkey: id.chainPubkey,\n directAddress: id.directAddress,\n nametag: id.nametag,\n };\n }\n\n private stripTokenSdkData(tokens: unknown[]): unknown[] {\n return tokens.map((t) => {\n const token = t as Record<string, unknown>;\n // Return all fields except internal sdkData\n const { sdkData: _sdkData, ...publicFields } = token;\n return publicFields;\n });\n }\n\n private sendResult(id: string, result: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'response',\n id,\n result,\n });\n }\n\n private sendError(id: string, code: number, message: string, data?: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'response',\n id,\n error: { code, message, ...(data !== undefined ? { data } : {}) },\n });\n }\n\n private sendIntentResult(id: string, result: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'intent_result',\n id,\n result,\n });\n }\n\n private sendIntentError(id: string, code: number, message: string, data?: unknown): void {\n this.transport.send({\n ns: SPHERE_CONNECT_NAMESPACE,\n v: SPHERE_CONNECT_VERSION,\n type: 'intent_result',\n id,\n error: { code, message, ...(data !== undefined ? { data } : {}) },\n });\n }\n\n private checkRateLimit(): boolean {\n const maxRps = this.config.maxRequestsPerSecond ?? DEFAULT_MAX_RPS;\n const now = Date.now();\n if (now > this.rateLimitResetAt) {\n this.rateLimitCounter = 0;\n this.rateLimitResetAt = now + 1000;\n }\n this.rateLimitCounter++;\n return this.rateLimitCounter <= maxRps;\n }\n}\n","/**\n * WebSocket Abstraction\n * Platform-independent WebSocket interface for cross-platform support\n */\n\n// =============================================================================\n// WebSocket Interface\n// =============================================================================\n\n/**\n * Minimal WebSocket interface compatible with browser and Node.js\n */\nexport interface IWebSocket {\n readonly readyState: number;\n\n send(data: string): void;\n close(code?: number, reason?: string): void;\n\n onopen: ((event: unknown) => void) | null;\n onclose: ((event: unknown) => void) | null;\n onerror: ((event: unknown) => void) | null;\n onmessage: ((event: IMessageEvent) => void) | null;\n}\n\nexport interface IMessageEvent {\n data: string;\n}\n\n/**\n * WebSocket ready states (same as native WebSocket)\n */\nexport const WebSocketReadyState = {\n CONNECTING: 0,\n OPEN: 1,\n CLOSING: 2,\n CLOSED: 3,\n} as const;\n\n/**\n * Factory function to create WebSocket instances\n * Different implementations for browser (native) vs Node.js (ws package)\n */\nexport type WebSocketFactory = (url: string) => IWebSocket;\n\n// =============================================================================\n// UUID Generator\n// =============================================================================\n\n/**\n * Generate a unique ID (platform-independent)\n * Browser: crypto.randomUUID()\n * Node: crypto.randomUUID() or uuid package\n */\nexport type UUIDGenerator = () => string;\n\n/**\n * Default UUID generator using crypto.randomUUID\n * Works in modern browsers and Node 19+\n */\nexport function defaultUUIDGenerator(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n // Fallback for older environments\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n}\n","/**\n * WebSocketTransport — Node.js transport for Sphere Connect.\n *\n * Two modes:\n * - Server: wallet runs a WS server, dApps connect to it\n * - Client: dApp connects to wallet's WS server\n *\n * Uses the existing IWebSocket/WebSocketFactory abstraction from transport/websocket.ts.\n */\n\nimport type { ConnectTransport, SphereConnectMessage } from '../../../connect';\nimport { isSphereConnectMessage } from '../../../connect';\nimport type { IWebSocket, WebSocketFactory } from '../../../transport/websocket';\nimport { WebSocketReadyState } from '../../../transport/websocket';\nimport { logger } from '../../../core/logger';\n\n// =============================================================================\n// Configuration\n// =============================================================================\n\nexport interface WebSocketServerConfig {\n /** Port to listen on */\n port: number;\n /** Host to bind to. Default: '0.0.0.0' */\n host?: string;\n}\n\nexport interface WebSocketClientConfig {\n /** WebSocket URL to connect to (e.g., 'ws://localhost:8765') */\n url: string;\n /** Factory for creating WebSocket instances */\n createWebSocket: WebSocketFactory;\n /** Reconnect on disconnect. Default: true */\n autoReconnect?: boolean;\n /** Initial reconnect delay in ms. Default: 2000 */\n reconnectDelayMs?: number;\n /** Max reconnect delay in ms. Default: 30000 */\n maxReconnectDelayMs?: number;\n /** Max reconnect attempts. Default: 10. 0 = unlimited */\n maxReconnectAttempts?: number;\n}\n\n// =============================================================================\n// Server Transport (wallet side)\n// =============================================================================\n\nexport class WebSocketServerTransport implements ConnectTransport {\n private server: unknown = null; // WebSocketServer from 'ws' package\n private clientSocket: IWebSocket | null = null;\n private handlers: Set<(message: SphereConnectMessage) => void> = new Set();\n private config: WebSocketServerConfig;\n\n constructor(config: WebSocketServerConfig) {\n this.config = config;\n }\n\n /** Start the WebSocket server. Must be called before use. */\n async start(): Promise<void> {\n // Dynamic import to avoid bundling ws in browser builds\n const { WebSocketServer } = await import('ws');\n const wss = new WebSocketServer({\n port: this.config.port,\n host: this.config.host ?? '0.0.0.0',\n });\n\n this.server = wss;\n\n wss.on('connection', (ws: IWebSocket) => {\n // Accept only one client at a time\n if (this.clientSocket) {\n ws.close(4000, 'Another client is already connected');\n return;\n }\n\n this.clientSocket = ws;\n\n ws.onmessage = (event: { data: string }) => {\n try {\n const msg = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data));\n if (isSphereConnectMessage(msg)) {\n for (const handler of this.handlers) {\n try {\n handler(msg);\n } catch (err) {\n logger.debug('WebSocket', 'Message handler error', err);\n }\n }\n }\n } catch (err) {\n logger.debug('WebSocket', 'Malformed message received', err);\n }\n };\n\n ws.onclose = () => {\n if (this.clientSocket === ws) {\n this.clientSocket = null;\n }\n };\n });\n\n // Wait for server to be listening\n await new Promise<void>((resolve, reject) => {\n wss.on('listening', resolve);\n wss.on('error', reject);\n });\n }\n\n send(message: SphereConnectMessage): void {\n if (this.clientSocket && this.clientSocket.readyState === WebSocketReadyState.OPEN) {\n this.clientSocket.send(JSON.stringify(message));\n }\n }\n\n onMessage(handler: (message: SphereConnectMessage) => void): () => void {\n this.handlers.add(handler);\n return () => {\n this.handlers.delete(handler);\n };\n }\n\n destroy(): void {\n if (this.clientSocket) {\n this.clientSocket.close();\n this.clientSocket = null;\n }\n if (this.server) {\n (this.server as { close: () => void }).close();\n this.server = null;\n }\n this.handlers.clear();\n }\n}\n\n// =============================================================================\n// Client Transport (dApp side)\n// =============================================================================\n\nexport class WebSocketClientTransport implements ConnectTransport {\n private ws: IWebSocket | null = null;\n private handlers: Set<(message: SphereConnectMessage) => void> = new Set();\n private config: WebSocketClientConfig;\n private reconnectAttempts = 0;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private destroyed = false;\n\n constructor(config: WebSocketClientConfig) {\n this.config = {\n autoReconnect: true,\n reconnectDelayMs: 2000,\n maxReconnectDelayMs: 30000,\n maxReconnectAttempts: 10,\n ...config,\n };\n }\n\n /** Connect to the WebSocket server. Must be called before use. */\n async connect(): Promise<void> {\n return this.doConnect();\n }\n\n send(message: SphereConnectMessage): void {\n if (this.ws && this.ws.readyState === WebSocketReadyState.OPEN) {\n this.ws.send(JSON.stringify(message));\n }\n }\n\n onMessage(handler: (message: SphereConnectMessage) => void): () => void {\n this.handlers.add(handler);\n return () => {\n this.handlers.delete(handler);\n };\n }\n\n destroy(): void {\n this.destroyed = true;\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n if (this.ws) {\n this.ws.close();\n this.ws = null;\n }\n this.handlers.clear();\n }\n\n // ===========================================================================\n // Private\n // ===========================================================================\n\n private doConnect(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n try {\n this.ws = this.config.createWebSocket(this.config.url);\n } catch (err) {\n reject(err);\n return;\n }\n\n this.ws.onopen = () => {\n this.reconnectAttempts = 0;\n resolve();\n };\n\n this.ws.onmessage = (event) => {\n try {\n const msg = JSON.parse(event.data);\n if (isSphereConnectMessage(msg)) {\n for (const handler of this.handlers) {\n try {\n handler(msg);\n } catch (err) {\n logger.debug('WebSocket', 'Message handler error', err);\n }\n }\n }\n } catch (err) {\n logger.debug('WebSocket', 'Malformed message received', err);\n }\n };\n\n this.ws.onerror = (err) => {\n reject(err);\n };\n\n this.ws.onclose = () => {\n this.ws = null;\n if (!this.destroyed && this.config.autoReconnect) {\n this.scheduleReconnect();\n }\n };\n });\n }\n\n private scheduleReconnect(): void {\n const maxAttempts = this.config.maxReconnectAttempts!;\n if (maxAttempts > 0 && this.reconnectAttempts >= maxAttempts) {\n return;\n }\n\n this.reconnectAttempts++;\n const baseDelay = this.config.reconnectDelayMs!;\n const maxDelay = this.config.maxReconnectDelayMs!;\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts - 1), maxDelay);\n\n this.reconnectTimer = setTimeout(() => {\n this.reconnectTimer = null;\n this.doConnect().catch((err) => logger.debug('WebSocket', 'Reconnect attempt failed', err));\n }, delay);\n }\n}\n\n// =============================================================================\n// Factory Functions\n// =============================================================================\n\nexport const WebSocketTransport = {\n /** Create a WebSocket server transport (wallet side) */\n createServer(config: WebSocketServerConfig): WebSocketServerTransport {\n return new WebSocketServerTransport(config);\n },\n\n /** Create a WebSocket client transport (dApp side) */\n createClient(config: WebSocketClientConfig): WebSocketClientTransport {\n return new WebSocketClientTransport(config);\n },\n};\n"],"mappings":"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","names":[]}