@unicitylabs/sphere-sdk 0.2.5 → 0.3.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.
@@ -2303,7 +2303,17 @@ var STORAGE_KEYS_GLOBAL = {
2303
2303
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
2304
2304
  TRACKED_ADDRESSES: "tracked_addresses",
2305
2305
  /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
2306
- LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
2306
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts",
2307
+ /** Group chat: joined groups */
2308
+ GROUP_CHAT_GROUPS: "group_chat_groups",
2309
+ /** Group chat: messages */
2310
+ GROUP_CHAT_MESSAGES: "group_chat_messages",
2311
+ /** Group chat: members */
2312
+ GROUP_CHAT_MEMBERS: "group_chat_members",
2313
+ /** Group chat: processed event IDs for deduplication */
2314
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
2315
+ /** Group chat: last used relay URL (stale data detection) */
2316
+ GROUP_CHAT_RELAY_URL: "group_chat_relay_url"
2307
2317
  };
2308
2318
  var STORAGE_KEYS_ADDRESS = {
2309
2319
  /** Pending transfers for this address */
@@ -2334,13 +2344,95 @@ function getAddressId(directAddress) {
2334
2344
  const last = hash.slice(-6).toLowerCase();
2335
2345
  return `DIRECT_${first}_${last}`;
2336
2346
  }
2347
+ var DEFAULT_NOSTR_RELAYS = [
2348
+ "wss://relay.unicity.network",
2349
+ "wss://relay.damus.io",
2350
+ "wss://nos.lol",
2351
+ "wss://relay.nostr.band"
2352
+ ];
2353
+ var NIP29_KINDS = {
2354
+ /** Chat message sent to group */
2355
+ CHAT_MESSAGE: 9,
2356
+ /** Thread root message */
2357
+ THREAD_ROOT: 11,
2358
+ /** Thread reply message */
2359
+ THREAD_REPLY: 12,
2360
+ /** User join request */
2361
+ JOIN_REQUEST: 9021,
2362
+ /** User leave request */
2363
+ LEAVE_REQUEST: 9022,
2364
+ /** Admin: add/update user */
2365
+ PUT_USER: 9e3,
2366
+ /** Admin: remove user */
2367
+ REMOVE_USER: 9001,
2368
+ /** Admin: edit group metadata */
2369
+ EDIT_METADATA: 9002,
2370
+ /** Admin: delete event */
2371
+ DELETE_EVENT: 9005,
2372
+ /** Admin: create group */
2373
+ CREATE_GROUP: 9007,
2374
+ /** Admin: delete group */
2375
+ DELETE_GROUP: 9008,
2376
+ /** Admin: create invite code */
2377
+ CREATE_INVITE: 9009,
2378
+ /** Relay-signed group metadata */
2379
+ GROUP_METADATA: 39e3,
2380
+ /** Relay-signed group admins */
2381
+ GROUP_ADMINS: 39001,
2382
+ /** Relay-signed group members */
2383
+ GROUP_MEMBERS: 39002,
2384
+ /** Relay-signed group roles */
2385
+ GROUP_ROLES: 39003
2386
+ };
2387
+ var DEFAULT_AGGREGATOR_URL = "https://aggregator.unicity.network/rpc";
2388
+ var DEV_AGGREGATOR_URL = "https://dev-aggregator.dyndns.org/rpc";
2389
+ var TEST_AGGREGATOR_URL = "https://goggregator-test.unicity.network";
2390
+ var DEFAULT_IPFS_GATEWAYS = [
2391
+ "https://ipfs.unicity.network",
2392
+ "https://dweb.link",
2393
+ "https://ipfs.io"
2394
+ ];
2337
2395
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
2338
2396
  var DEFAULT_DERIVATION_PATH2 = `${DEFAULT_BASE_PATH}/0/0`;
2397
+ var DEFAULT_ELECTRUM_URL = "wss://fulcrum.alpha.unicity.network:50004";
2398
+ var TEST_ELECTRUM_URL = "wss://fulcrum.alpha.testnet.unicity.network:50004";
2399
+ var TEST_NOSTR_RELAYS = [
2400
+ "wss://nostr-relay.testnet.unicity.network"
2401
+ ];
2402
+ var DEFAULT_GROUP_RELAYS = [
2403
+ "wss://sphere-relay.unicity.network"
2404
+ ];
2405
+ var NETWORKS = {
2406
+ mainnet: {
2407
+ name: "Mainnet",
2408
+ aggregatorUrl: DEFAULT_AGGREGATOR_URL,
2409
+ nostrRelays: DEFAULT_NOSTR_RELAYS,
2410
+ ipfsGateways: DEFAULT_IPFS_GATEWAYS,
2411
+ electrumUrl: DEFAULT_ELECTRUM_URL,
2412
+ groupRelays: DEFAULT_GROUP_RELAYS
2413
+ },
2414
+ testnet: {
2415
+ name: "Testnet",
2416
+ aggregatorUrl: TEST_AGGREGATOR_URL,
2417
+ nostrRelays: TEST_NOSTR_RELAYS,
2418
+ ipfsGateways: DEFAULT_IPFS_GATEWAYS,
2419
+ electrumUrl: TEST_ELECTRUM_URL,
2420
+ groupRelays: DEFAULT_GROUP_RELAYS
2421
+ },
2422
+ dev: {
2423
+ name: "Development",
2424
+ aggregatorUrl: DEV_AGGREGATOR_URL,
2425
+ nostrRelays: TEST_NOSTR_RELAYS,
2426
+ ipfsGateways: DEFAULT_IPFS_GATEWAYS,
2427
+ electrumUrl: TEST_ELECTRUM_URL,
2428
+ groupRelays: DEFAULT_GROUP_RELAYS
2429
+ }
2430
+ };
2339
2431
 
2340
2432
  // types/txf.ts
2341
2433
  var ARCHIVED_PREFIX = "archived-";
2342
2434
  var FORKED_PREFIX = "_forked_";
2343
- var RESERVED_KEYS = ["_meta", "_nametag", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2435
+ var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2344
2436
  function isTokenKey(key) {
2345
2437
  return key.startsWith("_") && !key.startsWith(ARCHIVED_PREFIX) && !key.startsWith(FORKED_PREFIX) && !RESERVED_KEYS.includes(key);
2346
2438
  }
@@ -2376,94 +2468,388 @@ function parseForkedKey(key) {
2376
2468
  };
2377
2469
  }
2378
2470
 
2379
- // serialization/txf-serializer.ts
2380
- function bytesToHex3(bytes) {
2381
- const arr = Array.isArray(bytes) ? bytes : Array.from(bytes);
2382
- return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
2383
- }
2384
- function normalizeToHex(value) {
2385
- if (typeof value === "string") {
2386
- return value;
2471
+ // registry/token-registry.testnet.json
2472
+ var token_registry_testnet_default = [
2473
+ {
2474
+ network: "unicity:testnet",
2475
+ assetKind: "non-fungible",
2476
+ name: "unicity",
2477
+ description: "Unicity testnet token type",
2478
+ id: "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"
2479
+ },
2480
+ {
2481
+ network: "unicity:testnet",
2482
+ assetKind: "fungible",
2483
+ name: "unicity",
2484
+ symbol: "UCT",
2485
+ decimals: 18,
2486
+ description: "Unicity testnet native coin",
2487
+ icons: [
2488
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity_logo_32.png" }
2489
+ ],
2490
+ id: "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"
2491
+ },
2492
+ {
2493
+ network: "unicity:testnet",
2494
+ assetKind: "fungible",
2495
+ name: "unicity-usd",
2496
+ symbol: "USDU",
2497
+ decimals: 6,
2498
+ description: "Unicity testnet USD stablecoin",
2499
+ icons: [
2500
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/usdu_logo_32.png" }
2501
+ ],
2502
+ id: "8f0f3d7a5e7297be0ee98c63b81bcebb2740f43f616566fc290f9823a54f52d7"
2503
+ },
2504
+ {
2505
+ network: "unicity:testnet",
2506
+ assetKind: "fungible",
2507
+ name: "unicity-eur",
2508
+ symbol: "EURU",
2509
+ decimals: 6,
2510
+ description: "Unicity testnet EUR stablecoin",
2511
+ icons: [
2512
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/euru_logo_32.png" }
2513
+ ],
2514
+ id: "5e160d5e9fdbb03b553fb9c3f6e6c30efa41fa807be39fb4f18e43776e492925"
2515
+ },
2516
+ {
2517
+ network: "unicity:testnet",
2518
+ assetKind: "fungible",
2519
+ name: "solana",
2520
+ symbol: "SOL",
2521
+ decimals: 9,
2522
+ description: "Solana testnet coin on Unicity",
2523
+ icons: [
2524
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/sol.svg" },
2525
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/sol.png" }
2526
+ ],
2527
+ id: "dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93"
2528
+ },
2529
+ {
2530
+ network: "unicity:testnet",
2531
+ assetKind: "fungible",
2532
+ name: "bitcoin",
2533
+ symbol: "BTC",
2534
+ decimals: 8,
2535
+ description: "Bitcoin testnet coin on Unicity",
2536
+ icons: [
2537
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/btc.svg" },
2538
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/btc.png" }
2539
+ ],
2540
+ id: "86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa"
2541
+ },
2542
+ {
2543
+ network: "unicity:testnet",
2544
+ assetKind: "fungible",
2545
+ name: "ethereum",
2546
+ symbol: "ETH",
2547
+ decimals: 18,
2548
+ description: "Ethereum testnet coin on Unicity",
2549
+ icons: [
2550
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/eth.svg" },
2551
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/eth.png" }
2552
+ ],
2553
+ id: "3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb"
2554
+ },
2555
+ {
2556
+ network: "unicity:testnet",
2557
+ assetKind: "fungible",
2558
+ name: "alpha_test",
2559
+ symbol: "ALPHT",
2560
+ decimals: 8,
2561
+ description: "ALPHA testnet coin on Unicity",
2562
+ icons: [
2563
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/alpha_coin.png" }
2564
+ ],
2565
+ id: "cde78ded16ef65818a51f43138031c4284e519300ab0cb60c30a8f9078080e5f"
2566
+ },
2567
+ {
2568
+ network: "unicity:testnet",
2569
+ assetKind: "fungible",
2570
+ name: "tether",
2571
+ symbol: "USDT",
2572
+ decimals: 6,
2573
+ description: "Tether (Ethereum) testnet coin on Unicity",
2574
+ icons: [
2575
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdt.svg" },
2576
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdt.png" }
2577
+ ],
2578
+ id: "40d25444648418fe7efd433e147187a3a6adf049ac62bc46038bda5b960bf690"
2579
+ },
2580
+ {
2581
+ network: "unicity:testnet",
2582
+ assetKind: "fungible",
2583
+ name: "usd-coin",
2584
+ symbol: "USDC",
2585
+ decimals: 6,
2586
+ description: "USDC (Ethereum) testnet coin on Unicity",
2587
+ icons: [
2588
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdc.svg" },
2589
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdc.png" }
2590
+ ],
2591
+ id: "2265121770fa6f41131dd9a6cc571e28679263d09a53eb2642e145b5b9a5b0a2"
2387
2592
  }
2388
- if (value && typeof value === "object") {
2389
- const obj = value;
2390
- if ("bytes" in obj && (Array.isArray(obj.bytes) || obj.bytes instanceof Uint8Array)) {
2391
- return bytesToHex3(obj.bytes);
2392
- }
2393
- if (obj.type === "Buffer" && Array.isArray(obj.data)) {
2394
- return bytesToHex3(obj.data);
2395
- }
2593
+ ];
2594
+
2595
+ // registry/TokenRegistry.ts
2596
+ var TokenRegistry = class _TokenRegistry {
2597
+ static instance = null;
2598
+ definitionsById;
2599
+ definitionsBySymbol;
2600
+ definitionsByName;
2601
+ constructor() {
2602
+ this.definitionsById = /* @__PURE__ */ new Map();
2603
+ this.definitionsBySymbol = /* @__PURE__ */ new Map();
2604
+ this.definitionsByName = /* @__PURE__ */ new Map();
2605
+ this.loadRegistry();
2396
2606
  }
2397
- return String(value);
2398
- }
2399
- function normalizeSdkTokenToStorage(sdkTokenJson) {
2400
- const txf = JSON.parse(JSON.stringify(sdkTokenJson));
2401
- if (txf.genesis?.data) {
2402
- const data = txf.genesis.data;
2403
- if (data.tokenId !== void 0) {
2404
- data.tokenId = normalizeToHex(data.tokenId);
2405
- }
2406
- if (data.tokenType !== void 0) {
2407
- data.tokenType = normalizeToHex(data.tokenType);
2408
- }
2409
- if (data.salt !== void 0) {
2410
- data.salt = normalizeToHex(data.salt);
2607
+ /**
2608
+ * Get singleton instance of TokenRegistry
2609
+ */
2610
+ static getInstance() {
2611
+ if (!_TokenRegistry.instance) {
2612
+ _TokenRegistry.instance = new _TokenRegistry();
2411
2613
  }
2614
+ return _TokenRegistry.instance;
2412
2615
  }
2413
- if (txf.genesis?.inclusionProof?.authenticator) {
2414
- const auth = txf.genesis.inclusionProof.authenticator;
2415
- if (auth.publicKey !== void 0) {
2416
- auth.publicKey = normalizeToHex(auth.publicKey);
2417
- }
2418
- if (auth.signature !== void 0) {
2419
- auth.signature = normalizeToHex(auth.signature);
2420
- }
2616
+ /**
2617
+ * Reset the singleton instance (useful for testing)
2618
+ */
2619
+ static resetInstance() {
2620
+ _TokenRegistry.instance = null;
2421
2621
  }
2422
- if (Array.isArray(txf.transactions)) {
2423
- for (const tx of txf.transactions) {
2424
- if (tx.inclusionProof?.authenticator) {
2425
- const auth = tx.inclusionProof.authenticator;
2426
- if (auth.publicKey !== void 0) {
2427
- auth.publicKey = normalizeToHex(auth.publicKey);
2428
- }
2429
- if (auth.signature !== void 0) {
2430
- auth.signature = normalizeToHex(auth.signature);
2431
- }
2622
+ /**
2623
+ * Load registry data from bundled JSON
2624
+ */
2625
+ loadRegistry() {
2626
+ const definitions = token_registry_testnet_default;
2627
+ for (const def of definitions) {
2628
+ const idLower = def.id.toLowerCase();
2629
+ this.definitionsById.set(idLower, def);
2630
+ if (def.symbol) {
2631
+ this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
2432
2632
  }
2633
+ this.definitionsByName.set(def.name.toLowerCase(), def);
2433
2634
  }
2434
2635
  }
2435
- return txf;
2436
- }
2437
- function tokenToTxf(token) {
2438
- const jsonData = token.sdkData;
2439
- if (!jsonData) {
2440
- return null;
2441
- }
2442
- try {
2443
- const txfData = normalizeSdkTokenToStorage(JSON.parse(jsonData));
2444
- if (!txfData.genesis || !txfData.state) {
2445
- return null;
2446
- }
2447
- if (!txfData.version) {
2448
- txfData.version = "2.0";
2449
- }
2450
- if (!txfData.transactions) {
2451
- txfData.transactions = [];
2452
- }
2453
- if (!txfData.nametags) {
2454
- txfData.nametags = [];
2455
- }
2456
- if (!txfData._integrity) {
2457
- txfData._integrity = {
2458
- genesisDataJSONHash: "0000" + "0".repeat(60)
2459
- };
2460
- }
2461
- return txfData;
2462
- } catch {
2463
- return null;
2636
+ // ===========================================================================
2637
+ // Lookup Methods
2638
+ // ===========================================================================
2639
+ /**
2640
+ * Get token definition by hex coin ID
2641
+ * @param coinId - 64-character hex string
2642
+ * @returns Token definition or undefined if not found
2643
+ */
2644
+ getDefinition(coinId) {
2645
+ if (!coinId) return void 0;
2646
+ return this.definitionsById.get(coinId.toLowerCase());
2464
2647
  }
2465
- }
2466
- function determineTokenStatus(txf) {
2648
+ /**
2649
+ * Get token definition by symbol (e.g., "UCT", "BTC")
2650
+ * @param symbol - Token symbol (case-insensitive)
2651
+ * @returns Token definition or undefined if not found
2652
+ */
2653
+ getDefinitionBySymbol(symbol) {
2654
+ if (!symbol) return void 0;
2655
+ return this.definitionsBySymbol.get(symbol.toUpperCase());
2656
+ }
2657
+ /**
2658
+ * Get token definition by name (e.g., "bitcoin", "ethereum")
2659
+ * @param name - Token name (case-insensitive)
2660
+ * @returns Token definition or undefined if not found
2661
+ */
2662
+ getDefinitionByName(name) {
2663
+ if (!name) return void 0;
2664
+ return this.definitionsByName.get(name.toLowerCase());
2665
+ }
2666
+ /**
2667
+ * Get token symbol for a coin ID
2668
+ * @param coinId - 64-character hex string
2669
+ * @returns Symbol (e.g., "UCT") or truncated ID if not found
2670
+ */
2671
+ getSymbol(coinId) {
2672
+ const def = this.getDefinition(coinId);
2673
+ if (def?.symbol) {
2674
+ return def.symbol;
2675
+ }
2676
+ return coinId.slice(0, 6).toUpperCase();
2677
+ }
2678
+ /**
2679
+ * Get token name for a coin ID
2680
+ * @param coinId - 64-character hex string
2681
+ * @returns Name (e.g., "Bitcoin") or coin ID if not found
2682
+ */
2683
+ getName(coinId) {
2684
+ const def = this.getDefinition(coinId);
2685
+ if (def?.name) {
2686
+ return def.name.charAt(0).toUpperCase() + def.name.slice(1);
2687
+ }
2688
+ return coinId;
2689
+ }
2690
+ /**
2691
+ * Get decimal places for a coin ID
2692
+ * @param coinId - 64-character hex string
2693
+ * @returns Decimals or 0 if not found
2694
+ */
2695
+ getDecimals(coinId) {
2696
+ const def = this.getDefinition(coinId);
2697
+ return def?.decimals ?? 0;
2698
+ }
2699
+ /**
2700
+ * Get icon URL for a coin ID
2701
+ * @param coinId - 64-character hex string
2702
+ * @param preferPng - Prefer PNG format over SVG
2703
+ * @returns Icon URL or null if not found
2704
+ */
2705
+ getIconUrl(coinId, preferPng = true) {
2706
+ const def = this.getDefinition(coinId);
2707
+ if (!def?.icons || def.icons.length === 0) {
2708
+ return null;
2709
+ }
2710
+ if (preferPng) {
2711
+ const pngIcon = def.icons.find((i) => i.url.toLowerCase().includes(".png"));
2712
+ if (pngIcon) return pngIcon.url;
2713
+ }
2714
+ return def.icons[0].url;
2715
+ }
2716
+ /**
2717
+ * Check if a coin ID is known in the registry
2718
+ * @param coinId - 64-character hex string
2719
+ * @returns true if the coin is in the registry
2720
+ */
2721
+ isKnown(coinId) {
2722
+ return this.definitionsById.has(coinId.toLowerCase());
2723
+ }
2724
+ /**
2725
+ * Get all token definitions
2726
+ * @returns Array of all token definitions
2727
+ */
2728
+ getAllDefinitions() {
2729
+ return Array.from(this.definitionsById.values());
2730
+ }
2731
+ /**
2732
+ * Get all fungible token definitions
2733
+ * @returns Array of fungible token definitions
2734
+ */
2735
+ getFungibleTokens() {
2736
+ return this.getAllDefinitions().filter((def) => def.assetKind === "fungible");
2737
+ }
2738
+ /**
2739
+ * Get all non-fungible token definitions
2740
+ * @returns Array of non-fungible token definitions
2741
+ */
2742
+ getNonFungibleTokens() {
2743
+ return this.getAllDefinitions().filter((def) => def.assetKind === "non-fungible");
2744
+ }
2745
+ /**
2746
+ * Get coin ID by symbol
2747
+ * @param symbol - Token symbol (e.g., "UCT")
2748
+ * @returns Coin ID hex string or undefined if not found
2749
+ */
2750
+ getCoinIdBySymbol(symbol) {
2751
+ const def = this.getDefinitionBySymbol(symbol);
2752
+ return def?.id;
2753
+ }
2754
+ /**
2755
+ * Get coin ID by name
2756
+ * @param name - Token name (e.g., "bitcoin")
2757
+ * @returns Coin ID hex string or undefined if not found
2758
+ */
2759
+ getCoinIdByName(name) {
2760
+ const def = this.getDefinitionByName(name);
2761
+ return def?.id;
2762
+ }
2763
+ };
2764
+
2765
+ // serialization/txf-serializer.ts
2766
+ function bytesToHex3(bytes) {
2767
+ const arr = Array.isArray(bytes) ? bytes : Array.from(bytes);
2768
+ return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
2769
+ }
2770
+ function normalizeToHex(value) {
2771
+ if (typeof value === "string") {
2772
+ return value;
2773
+ }
2774
+ if (value && typeof value === "object") {
2775
+ const obj = value;
2776
+ if ("bytes" in obj && (Array.isArray(obj.bytes) || obj.bytes instanceof Uint8Array)) {
2777
+ return bytesToHex3(obj.bytes);
2778
+ }
2779
+ if (obj.type === "Buffer" && Array.isArray(obj.data)) {
2780
+ return bytesToHex3(obj.data);
2781
+ }
2782
+ }
2783
+ return String(value);
2784
+ }
2785
+ function normalizeSdkTokenToStorage(sdkTokenJson) {
2786
+ const txf = JSON.parse(JSON.stringify(sdkTokenJson));
2787
+ if (txf.genesis?.data) {
2788
+ const data = txf.genesis.data;
2789
+ if (data.tokenId !== void 0) {
2790
+ data.tokenId = normalizeToHex(data.tokenId);
2791
+ }
2792
+ if (data.tokenType !== void 0) {
2793
+ data.tokenType = normalizeToHex(data.tokenType);
2794
+ }
2795
+ if (data.salt !== void 0) {
2796
+ data.salt = normalizeToHex(data.salt);
2797
+ }
2798
+ }
2799
+ if (txf.genesis?.inclusionProof?.authenticator) {
2800
+ const auth = txf.genesis.inclusionProof.authenticator;
2801
+ if (auth.publicKey !== void 0) {
2802
+ auth.publicKey = normalizeToHex(auth.publicKey);
2803
+ }
2804
+ if (auth.signature !== void 0) {
2805
+ auth.signature = normalizeToHex(auth.signature);
2806
+ }
2807
+ }
2808
+ if (Array.isArray(txf.transactions)) {
2809
+ for (const tx of txf.transactions) {
2810
+ if (tx.inclusionProof?.authenticator) {
2811
+ const auth = tx.inclusionProof.authenticator;
2812
+ if (auth.publicKey !== void 0) {
2813
+ auth.publicKey = normalizeToHex(auth.publicKey);
2814
+ }
2815
+ if (auth.signature !== void 0) {
2816
+ auth.signature = normalizeToHex(auth.signature);
2817
+ }
2818
+ }
2819
+ }
2820
+ }
2821
+ return txf;
2822
+ }
2823
+ function tokenToTxf(token) {
2824
+ const jsonData = token.sdkData;
2825
+ if (!jsonData) {
2826
+ return null;
2827
+ }
2828
+ try {
2829
+ const txfData = normalizeSdkTokenToStorage(JSON.parse(jsonData));
2830
+ if (!txfData.genesis || !txfData.state) {
2831
+ return null;
2832
+ }
2833
+ if (!txfData.version) {
2834
+ txfData.version = "2.0";
2835
+ }
2836
+ if (!txfData.transactions) {
2837
+ txfData.transactions = [];
2838
+ }
2839
+ if (!txfData.nametags) {
2840
+ txfData.nametags = [];
2841
+ }
2842
+ if (!txfData._integrity) {
2843
+ txfData._integrity = {
2844
+ genesisDataJSONHash: "0000" + "0".repeat(60)
2845
+ };
2846
+ }
2847
+ return txfData;
2848
+ } catch {
2849
+ return null;
2850
+ }
2851
+ }
2852
+ function determineTokenStatus(txf) {
2467
2853
  if (txf.transactions.length > 0) {
2468
2854
  const lastTx = txf.transactions[txf.transactions.length - 1];
2469
2855
  if (lastTx.inclusionProof === null) {
@@ -2487,12 +2873,14 @@ function txfToToken(tokenId, txf) {
2487
2873
  const tokenType = txf.genesis.data.tokenType;
2488
2874
  const isNft = tokenType === "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89";
2489
2875
  const now = Date.now();
2876
+ const registry = TokenRegistry.getInstance();
2877
+ const def = registry.getDefinition(coinId);
2490
2878
  return {
2491
2879
  id: tokenId,
2492
2880
  coinId,
2493
- symbol: isNft ? "NFT" : "UCT",
2494
- name: isNft ? "NFT" : "Token",
2495
- decimals: isNft ? 0 : 8,
2881
+ symbol: isNft ? "NFT" : def?.symbol || coinId.slice(0, 8),
2882
+ name: isNft ? "NFT" : def?.name ? def.name.charAt(0).toUpperCase() + def.name.slice(1) : "Token",
2883
+ decimals: isNft ? 0 : def?.decimals ?? 8,
2496
2884
  amount: totalAmount.toString(),
2497
2885
  status: determineTokenStatus(txf),
2498
2886
  createdAt: now,
@@ -2507,6 +2895,9 @@ async function buildTxfStorageData(tokens, meta, options) {
2507
2895
  formatVersion: "2.0"
2508
2896
  }
2509
2897
  };
2898
+ if (options?.nametags && options.nametags.length > 0) {
2899
+ storageData._nametags = options.nametags;
2900
+ }
2510
2901
  if (options?.tombstones && options.tombstones.length > 0) {
2511
2902
  storageData._tombstones = options.tombstones;
2512
2903
  }
@@ -2545,7 +2936,7 @@ function parseTxfStorageData(data) {
2545
2936
  const result = {
2546
2937
  tokens: [],
2547
2938
  meta: null,
2548
- nametag: null,
2939
+ nametags: [],
2549
2940
  tombstones: [],
2550
2941
  archivedTokens: /* @__PURE__ */ new Map(),
2551
2942
  forkedTokens: /* @__PURE__ */ new Map(),
@@ -2562,8 +2953,20 @@ function parseTxfStorageData(data) {
2562
2953
  if (storageData._meta && typeof storageData._meta === "object") {
2563
2954
  result.meta = storageData._meta;
2564
2955
  }
2956
+ const seenNames = /* @__PURE__ */ new Set();
2957
+ if (Array.isArray(storageData._nametags)) {
2958
+ for (const entry of storageData._nametags) {
2959
+ if (entry && typeof entry === "object" && typeof entry.name === "string") {
2960
+ result.nametags.push(entry);
2961
+ seenNames.add(entry.name);
2962
+ }
2963
+ }
2964
+ }
2565
2965
  if (storageData._nametag && typeof storageData._nametag === "object") {
2566
- result.nametag = storageData._nametag;
2966
+ const legacy = storageData._nametag;
2967
+ if (typeof legacy.name === "string" && !seenNames.has(legacy.name)) {
2968
+ result.nametags.push(legacy);
2969
+ }
2567
2970
  }
2568
2971
  if (storageData._tombstones && Array.isArray(storageData._tombstones)) {
2569
2972
  for (const entry of storageData._tombstones) {
@@ -2663,300 +3066,6 @@ function getCurrentStateHash(txf) {
2663
3066
  return void 0;
2664
3067
  }
2665
3068
 
2666
- // registry/token-registry.testnet.json
2667
- var token_registry_testnet_default = [
2668
- {
2669
- network: "unicity:testnet",
2670
- assetKind: "non-fungible",
2671
- name: "unicity",
2672
- description: "Unicity testnet token type",
2673
- id: "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"
2674
- },
2675
- {
2676
- network: "unicity:testnet",
2677
- assetKind: "fungible",
2678
- name: "unicity",
2679
- symbol: "UCT",
2680
- decimals: 18,
2681
- description: "Unicity testnet native coin",
2682
- icons: [
2683
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity_logo_32.png" }
2684
- ],
2685
- id: "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"
2686
- },
2687
- {
2688
- network: "unicity:testnet",
2689
- assetKind: "fungible",
2690
- name: "unicity-usd",
2691
- symbol: "USDU",
2692
- decimals: 6,
2693
- description: "Unicity testnet USD stablecoin",
2694
- icons: [
2695
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/usdu_logo_32.png" }
2696
- ],
2697
- id: "8f0f3d7a5e7297be0ee98c63b81bcebb2740f43f616566fc290f9823a54f52d7"
2698
- },
2699
- {
2700
- network: "unicity:testnet",
2701
- assetKind: "fungible",
2702
- name: "unicity-eur",
2703
- symbol: "EURU",
2704
- decimals: 6,
2705
- description: "Unicity testnet EUR stablecoin",
2706
- icons: [
2707
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/euru_logo_32.png" }
2708
- ],
2709
- id: "5e160d5e9fdbb03b553fb9c3f6e6c30efa41fa807be39fb4f18e43776e492925"
2710
- },
2711
- {
2712
- network: "unicity:testnet",
2713
- assetKind: "fungible",
2714
- name: "solana",
2715
- symbol: "SOL",
2716
- decimals: 9,
2717
- description: "Solana testnet coin on Unicity",
2718
- icons: [
2719
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/sol.svg" },
2720
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/sol.png" }
2721
- ],
2722
- id: "dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93"
2723
- },
2724
- {
2725
- network: "unicity:testnet",
2726
- assetKind: "fungible",
2727
- name: "bitcoin",
2728
- symbol: "BTC",
2729
- decimals: 8,
2730
- description: "Bitcoin testnet coin on Unicity",
2731
- icons: [
2732
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/btc.svg" },
2733
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/btc.png" }
2734
- ],
2735
- id: "86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa"
2736
- },
2737
- {
2738
- network: "unicity:testnet",
2739
- assetKind: "fungible",
2740
- name: "ethereum",
2741
- symbol: "ETH",
2742
- decimals: 18,
2743
- description: "Ethereum testnet coin on Unicity",
2744
- icons: [
2745
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/eth.svg" },
2746
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/eth.png" }
2747
- ],
2748
- id: "3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb"
2749
- },
2750
- {
2751
- network: "unicity:testnet",
2752
- assetKind: "fungible",
2753
- name: "alpha_test",
2754
- symbol: "ALPHT",
2755
- decimals: 8,
2756
- description: "ALPHA testnet coin on Unicity",
2757
- icons: [
2758
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/alpha_coin.png" }
2759
- ],
2760
- id: "cde78ded16ef65818a51f43138031c4284e519300ab0cb60c30a8f9078080e5f"
2761
- },
2762
- {
2763
- network: "unicity:testnet",
2764
- assetKind: "fungible",
2765
- name: "tether",
2766
- symbol: "USDT",
2767
- decimals: 6,
2768
- description: "Tether (Ethereum) testnet coin on Unicity",
2769
- icons: [
2770
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdt.svg" },
2771
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdt.png" }
2772
- ],
2773
- id: "40d25444648418fe7efd433e147187a3a6adf049ac62bc46038bda5b960bf690"
2774
- },
2775
- {
2776
- network: "unicity:testnet",
2777
- assetKind: "fungible",
2778
- name: "usd-coin",
2779
- symbol: "USDC",
2780
- decimals: 6,
2781
- description: "USDC (Ethereum) testnet coin on Unicity",
2782
- icons: [
2783
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdc.svg" },
2784
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdc.png" }
2785
- ],
2786
- id: "2265121770fa6f41131dd9a6cc571e28679263d09a53eb2642e145b5b9a5b0a2"
2787
- }
2788
- ];
2789
-
2790
- // registry/TokenRegistry.ts
2791
- var TokenRegistry = class _TokenRegistry {
2792
- static instance = null;
2793
- definitionsById;
2794
- definitionsBySymbol;
2795
- definitionsByName;
2796
- constructor() {
2797
- this.definitionsById = /* @__PURE__ */ new Map();
2798
- this.definitionsBySymbol = /* @__PURE__ */ new Map();
2799
- this.definitionsByName = /* @__PURE__ */ new Map();
2800
- this.loadRegistry();
2801
- }
2802
- /**
2803
- * Get singleton instance of TokenRegistry
2804
- */
2805
- static getInstance() {
2806
- if (!_TokenRegistry.instance) {
2807
- _TokenRegistry.instance = new _TokenRegistry();
2808
- }
2809
- return _TokenRegistry.instance;
2810
- }
2811
- /**
2812
- * Reset the singleton instance (useful for testing)
2813
- */
2814
- static resetInstance() {
2815
- _TokenRegistry.instance = null;
2816
- }
2817
- /**
2818
- * Load registry data from bundled JSON
2819
- */
2820
- loadRegistry() {
2821
- const definitions = token_registry_testnet_default;
2822
- for (const def of definitions) {
2823
- const idLower = def.id.toLowerCase();
2824
- this.definitionsById.set(idLower, def);
2825
- if (def.symbol) {
2826
- this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
2827
- }
2828
- this.definitionsByName.set(def.name.toLowerCase(), def);
2829
- }
2830
- }
2831
- // ===========================================================================
2832
- // Lookup Methods
2833
- // ===========================================================================
2834
- /**
2835
- * Get token definition by hex coin ID
2836
- * @param coinId - 64-character hex string
2837
- * @returns Token definition or undefined if not found
2838
- */
2839
- getDefinition(coinId) {
2840
- if (!coinId) return void 0;
2841
- return this.definitionsById.get(coinId.toLowerCase());
2842
- }
2843
- /**
2844
- * Get token definition by symbol (e.g., "UCT", "BTC")
2845
- * @param symbol - Token symbol (case-insensitive)
2846
- * @returns Token definition or undefined if not found
2847
- */
2848
- getDefinitionBySymbol(symbol) {
2849
- if (!symbol) return void 0;
2850
- return this.definitionsBySymbol.get(symbol.toUpperCase());
2851
- }
2852
- /**
2853
- * Get token definition by name (e.g., "bitcoin", "ethereum")
2854
- * @param name - Token name (case-insensitive)
2855
- * @returns Token definition or undefined if not found
2856
- */
2857
- getDefinitionByName(name) {
2858
- if (!name) return void 0;
2859
- return this.definitionsByName.get(name.toLowerCase());
2860
- }
2861
- /**
2862
- * Get token symbol for a coin ID
2863
- * @param coinId - 64-character hex string
2864
- * @returns Symbol (e.g., "UCT") or truncated ID if not found
2865
- */
2866
- getSymbol(coinId) {
2867
- const def = this.getDefinition(coinId);
2868
- if (def?.symbol) {
2869
- return def.symbol;
2870
- }
2871
- return coinId.slice(0, 6).toUpperCase();
2872
- }
2873
- /**
2874
- * Get token name for a coin ID
2875
- * @param coinId - 64-character hex string
2876
- * @returns Name (e.g., "Bitcoin") or coin ID if not found
2877
- */
2878
- getName(coinId) {
2879
- const def = this.getDefinition(coinId);
2880
- if (def?.name) {
2881
- return def.name.charAt(0).toUpperCase() + def.name.slice(1);
2882
- }
2883
- return coinId;
2884
- }
2885
- /**
2886
- * Get decimal places for a coin ID
2887
- * @param coinId - 64-character hex string
2888
- * @returns Decimals or 0 if not found
2889
- */
2890
- getDecimals(coinId) {
2891
- const def = this.getDefinition(coinId);
2892
- return def?.decimals ?? 0;
2893
- }
2894
- /**
2895
- * Get icon URL for a coin ID
2896
- * @param coinId - 64-character hex string
2897
- * @param preferPng - Prefer PNG format over SVG
2898
- * @returns Icon URL or null if not found
2899
- */
2900
- getIconUrl(coinId, preferPng = true) {
2901
- const def = this.getDefinition(coinId);
2902
- if (!def?.icons || def.icons.length === 0) {
2903
- return null;
2904
- }
2905
- if (preferPng) {
2906
- const pngIcon = def.icons.find((i) => i.url.toLowerCase().includes(".png"));
2907
- if (pngIcon) return pngIcon.url;
2908
- }
2909
- return def.icons[0].url;
2910
- }
2911
- /**
2912
- * Check if a coin ID is known in the registry
2913
- * @param coinId - 64-character hex string
2914
- * @returns true if the coin is in the registry
2915
- */
2916
- isKnown(coinId) {
2917
- return this.definitionsById.has(coinId.toLowerCase());
2918
- }
2919
- /**
2920
- * Get all token definitions
2921
- * @returns Array of all token definitions
2922
- */
2923
- getAllDefinitions() {
2924
- return Array.from(this.definitionsById.values());
2925
- }
2926
- /**
2927
- * Get all fungible token definitions
2928
- * @returns Array of fungible token definitions
2929
- */
2930
- getFungibleTokens() {
2931
- return this.getAllDefinitions().filter((def) => def.assetKind === "fungible");
2932
- }
2933
- /**
2934
- * Get all non-fungible token definitions
2935
- * @returns Array of non-fungible token definitions
2936
- */
2937
- getNonFungibleTokens() {
2938
- return this.getAllDefinitions().filter((def) => def.assetKind === "non-fungible");
2939
- }
2940
- /**
2941
- * Get coin ID by symbol
2942
- * @param symbol - Token symbol (e.g., "UCT")
2943
- * @returns Coin ID hex string or undefined if not found
2944
- */
2945
- getCoinIdBySymbol(symbol) {
2946
- const def = this.getDefinitionBySymbol(symbol);
2947
- return def?.id;
2948
- }
2949
- /**
2950
- * Get coin ID by name
2951
- * @param name - Token name (e.g., "bitcoin")
2952
- * @returns Coin ID hex string or undefined if not found
2953
- */
2954
- getCoinIdByName(name) {
2955
- const def = this.getDefinitionByName(name);
2956
- return def?.id;
2957
- }
2958
- };
2959
-
2960
3069
  // modules/payments/InstantSplitExecutor.ts
2961
3070
  import { Token as Token3 } from "@unicitylabs/state-transition-sdk/lib/token/Token";
2962
3071
  import { TokenId as TokenId3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenId";
@@ -3990,7 +4099,7 @@ var PaymentsModule = class _PaymentsModule {
3990
4099
  archivedTokens = /* @__PURE__ */ new Map();
3991
4100
  forkedTokens = /* @__PURE__ */ new Map();
3992
4101
  transactionHistory = [];
3993
- nametag = null;
4102
+ nametags = [];
3994
4103
  // Payment Requests State (Incoming)
3995
4104
  paymentRequests = [];
3996
4105
  paymentRequestHandlers = /* @__PURE__ */ new Set();
@@ -4059,7 +4168,7 @@ var PaymentsModule = class _PaymentsModule {
4059
4168
  this.archivedTokens.clear();
4060
4169
  this.forkedTokens.clear();
4061
4170
  this.transactionHistory = [];
4062
- this.nametag = null;
4171
+ this.nametags = [];
4063
4172
  this.deps = deps;
4064
4173
  this.priceProvider = deps.price ?? null;
4065
4174
  if (this.l1) {
@@ -4108,8 +4217,6 @@ var PaymentsModule = class _PaymentsModule {
4108
4217
  }
4109
4218
  }
4110
4219
  await this.loadPendingV5Tokens();
4111
- await this.loadTokensFromFileStorage();
4112
- await this.loadNametagFromFileStorage();
4113
4220
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
4114
4221
  if (historyData) {
4115
4222
  try {
@@ -4618,9 +4725,10 @@ var PaymentsModule = class _PaymentsModule {
4618
4725
  senderPubkey,
4619
4726
  {
4620
4727
  findNametagToken: async (proxyAddress) => {
4621
- if (this.nametag?.token) {
4728
+ const currentNametag = this.getNametag();
4729
+ if (currentNametag?.token) {
4622
4730
  try {
4623
- const nametagToken = await SdkToken2.fromJSON(this.nametag.token);
4731
+ const nametagToken = await SdkToken2.fromJSON(currentNametag.token);
4624
4732
  const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
4625
4733
  const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
4626
4734
  if (proxy.address === proxyAddress) {
@@ -5409,7 +5517,6 @@ var PaymentsModule = class _PaymentsModule {
5409
5517
  sdkData: JSON.stringify(finalizedToken.toJSON())
5410
5518
  };
5411
5519
  this.tokens.set(tokenId, confirmedToken);
5412
- await this.saveTokenToFileStorage(confirmedToken);
5413
5520
  await this.addToHistory({
5414
5521
  type: "RECEIVED",
5415
5522
  amount: confirmedToken.amount,
@@ -5498,9 +5605,10 @@ var PaymentsModule = class _PaymentsModule {
5498
5605
  } catch {
5499
5606
  }
5500
5607
  }
5501
- if (nametagTokens.length === 0 && this.nametag?.token) {
5608
+ const localNametag = this.getNametag();
5609
+ if (nametagTokens.length === 0 && localNametag?.token) {
5502
5610
  try {
5503
- const nametagToken = await SdkToken2.fromJSON(this.nametag.token);
5611
+ const nametagToken = await SdkToken2.fromJSON(localNametag.token);
5504
5612
  const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5505
5613
  const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5506
5614
  if (proxy.address === recipientAddressStr) {
@@ -5661,125 +5769,16 @@ var PaymentsModule = class _PaymentsModule {
5661
5769
  });
5662
5770
  }
5663
5771
  await this.save();
5664
- if (!this.parsePendingFinalization(token.sdkData)) {
5665
- await this.saveTokenToFileStorage(token);
5666
- }
5667
5772
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
5668
5773
  return true;
5669
5774
  }
5670
5775
  /**
5671
- * Save token as individual file to token storage providers
5672
- * Similar to lottery's saveReceivedToken() pattern
5673
- */
5674
- async saveTokenToFileStorage(token) {
5675
- const providers = this.getTokenStorageProviders();
5676
- if (providers.size === 0) return;
5677
- const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
5678
- const tokenIdPrefix = sdkTokenId ? sdkTokenId.slice(0, 16) : token.id.slice(0, 16);
5679
- const filename = `token-${tokenIdPrefix}-${Date.now()}`;
5680
- const tokenData = {
5681
- token: token.sdkData ? JSON.parse(token.sdkData) : null,
5682
- receivedAt: Date.now(),
5683
- meta: {
5684
- id: token.id,
5685
- coinId: token.coinId,
5686
- symbol: token.symbol,
5687
- amount: token.amount,
5688
- status: token.status
5689
- }
5690
- };
5691
- for (const [providerId, provider] of providers) {
5692
- try {
5693
- if (provider.saveToken) {
5694
- await provider.saveToken(filename, tokenData);
5695
- this.log(`Saved token file ${filename} to ${providerId}`);
5696
- }
5697
- } catch (error) {
5698
- console.warn(`[Payments] Failed to save token to ${providerId}:`, error);
5699
- }
5700
- }
5701
- }
5702
- /**
5703
- * Load tokens from file storage providers (lottery compatibility)
5704
- * This loads tokens from file-based storage that may have been saved
5705
- * by other applications using the same storage directory.
5706
- */
5707
- async loadTokensFromFileStorage() {
5708
- const providers = this.getTokenStorageProviders();
5709
- if (providers.size === 0) return;
5710
- for (const [providerId, provider] of providers) {
5711
- if (!provider.listTokenIds || !provider.getToken) continue;
5712
- try {
5713
- const allIds = await provider.listTokenIds();
5714
- const tokenIds = allIds.filter((id) => id.startsWith("token-"));
5715
- this.log(`Found ${tokenIds.length} token files in ${providerId}`);
5716
- for (const tokenId of tokenIds) {
5717
- try {
5718
- const fileData = await provider.getToken(tokenId);
5719
- if (!fileData || typeof fileData !== "object") continue;
5720
- const data = fileData;
5721
- const tokenJson = data.token;
5722
- if (!tokenJson) continue;
5723
- if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
5724
- continue;
5725
- }
5726
- let sdkTokenId;
5727
- if (typeof tokenJson === "object" && tokenJson !== null) {
5728
- const tokenObj = tokenJson;
5729
- const genesis = tokenObj.genesis;
5730
- const genesisData = genesis?.data;
5731
- sdkTokenId = genesisData?.tokenId;
5732
- }
5733
- if (sdkTokenId) {
5734
- let exists = false;
5735
- for (const existing of this.tokens.values()) {
5736
- const existingId = extractTokenIdFromSdkData(existing.sdkData);
5737
- if (existingId === sdkTokenId) {
5738
- exists = true;
5739
- break;
5740
- }
5741
- }
5742
- if (exists) continue;
5743
- }
5744
- const tokenInfo = await parseTokenInfo(tokenJson);
5745
- const token = {
5746
- id: tokenInfo.tokenId ?? tokenId,
5747
- coinId: tokenInfo.coinId,
5748
- symbol: tokenInfo.symbol,
5749
- name: tokenInfo.name,
5750
- decimals: tokenInfo.decimals,
5751
- iconUrl: tokenInfo.iconUrl,
5752
- amount: tokenInfo.amount,
5753
- status: "confirmed",
5754
- createdAt: data.receivedAt || Date.now(),
5755
- updatedAt: Date.now(),
5756
- sdkData: typeof tokenJson === "string" ? tokenJson : JSON.stringify(tokenJson)
5757
- };
5758
- const loadedTokenId = extractTokenIdFromSdkData(token.sdkData);
5759
- const loadedStateHash = extractStateHashFromSdkData(token.sdkData);
5760
- if (loadedTokenId && loadedStateHash && this.isStateTombstoned(loadedTokenId, loadedStateHash)) {
5761
- this.log(`Skipping tombstoned token file ${tokenId} (${loadedTokenId.slice(0, 8)}...)`);
5762
- continue;
5763
- }
5764
- this.tokens.set(token.id, token);
5765
- this.log(`Loaded token from file: ${tokenId}`);
5766
- } catch (tokenError) {
5767
- console.warn(`[Payments] Failed to load token ${tokenId}:`, tokenError);
5768
- }
5769
- }
5770
- } catch (error) {
5771
- console.warn(`[Payments] Failed to load tokens from ${providerId}:`, error);
5772
- }
5773
- }
5774
- this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5775
- }
5776
- /**
5777
- * Update an existing token or add it if not found.
5778
- *
5779
- * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5780
- * `token.id`. If no match is found, falls back to {@link addToken}.
5781
- *
5782
- * @param token - The token with updated data. Must include a valid `id`.
5776
+ * Update an existing token or add it if not found.
5777
+ *
5778
+ * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5779
+ * `token.id`. If no match is found, falls back to {@link addToken}.
5780
+ *
5781
+ * @param token - The token with updated data. Must include a valid `id`.
5783
5782
  */
5784
5783
  async updateToken(token) {
5785
5784
  this.ensureInitialized();
@@ -5831,7 +5830,6 @@ var PaymentsModule = class _PaymentsModule {
5831
5830
  this.log(`Warning: Could not create tombstone for token ${tokenId.slice(0, 8)}... (missing tokenId or stateHash)`);
5832
5831
  }
5833
5832
  this.tokens.delete(tokenId);
5834
- await this.deleteTokenFiles(token);
5835
5833
  if (!skipHistory && token.coinId && token.amount) {
5836
5834
  await this.addToHistory({
5837
5835
  type: "SENT",
@@ -5844,31 +5842,6 @@ var PaymentsModule = class _PaymentsModule {
5844
5842
  }
5845
5843
  await this.save();
5846
5844
  }
5847
- /**
5848
- * Delete physical token file(s) from all storage providers.
5849
- * Finds files by matching the SDK token ID prefix in the filename.
5850
- */
5851
- async deleteTokenFiles(token) {
5852
- const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
5853
- if (!sdkTokenId) return;
5854
- const tokenIdPrefix = sdkTokenId.slice(0, 16);
5855
- const providers = this.getTokenStorageProviders();
5856
- for (const [providerId, provider] of providers) {
5857
- if (!provider.listTokenIds || !provider.deleteToken) continue;
5858
- try {
5859
- const allIds = await provider.listTokenIds();
5860
- const matchingFiles = allIds.filter(
5861
- (id) => id.startsWith(`token-${tokenIdPrefix}`)
5862
- );
5863
- for (const fileId of matchingFiles) {
5864
- await provider.deleteToken(fileId);
5865
- this.log(`Deleted token file ${fileId} from ${providerId}`);
5866
- }
5867
- } catch (error) {
5868
- console.warn(`[Payments] Failed to delete token files from ${providerId}:`, error);
5869
- }
5870
- }
5871
- }
5872
5845
  // ===========================================================================
5873
5846
  // Public API - Tombstones
5874
5847
  // ===========================================================================
@@ -6124,18 +6097,30 @@ var PaymentsModule = class _PaymentsModule {
6124
6097
  */
6125
6098
  async setNametag(nametag) {
6126
6099
  this.ensureInitialized();
6127
- this.nametag = nametag;
6100
+ const idx = this.nametags.findIndex((n) => n.name === nametag.name);
6101
+ if (idx >= 0) {
6102
+ this.nametags[idx] = nametag;
6103
+ } else {
6104
+ this.nametags.push(nametag);
6105
+ }
6128
6106
  await this.save();
6129
- await this.saveNametagToFileStorage(nametag);
6130
6107
  this.log(`Nametag set: ${nametag.name}`);
6131
6108
  }
6132
6109
  /**
6133
- * Get the current nametag data.
6110
+ * Get the current (first) nametag data.
6134
6111
  *
6135
6112
  * @returns The nametag data, or `null` if no nametag is set.
6136
6113
  */
6137
6114
  getNametag() {
6138
- return this.nametag;
6115
+ return this.nametags[0] ?? null;
6116
+ }
6117
+ /**
6118
+ * Get all nametag data entries.
6119
+ *
6120
+ * @returns A copy of the nametags array.
6121
+ */
6122
+ getNametags() {
6123
+ return [...this.nametags];
6139
6124
  }
6140
6125
  /**
6141
6126
  * Check whether a nametag is currently set.
@@ -6143,77 +6128,16 @@ var PaymentsModule = class _PaymentsModule {
6143
6128
  * @returns `true` if nametag data is present.
6144
6129
  */
6145
6130
  hasNametag() {
6146
- return this.nametag !== null;
6131
+ return this.nametags.length > 0;
6147
6132
  }
6148
6133
  /**
6149
- * Remove the current nametag data from memory and storage.
6134
+ * Remove all nametag data from memory and storage.
6150
6135
  */
6151
6136
  async clearNametag() {
6152
6137
  this.ensureInitialized();
6153
- this.nametag = null;
6138
+ this.nametags = [];
6154
6139
  await this.save();
6155
6140
  }
6156
- /**
6157
- * Save nametag to file storage for lottery compatibility
6158
- * Creates file: nametag-{name}.json
6159
- */
6160
- async saveNametagToFileStorage(nametag) {
6161
- const providers = this.getTokenStorageProviders();
6162
- if (providers.size === 0) return;
6163
- const filename = `nametag-${nametag.name}`;
6164
- const fileData = {
6165
- nametag: nametag.name,
6166
- token: nametag.token,
6167
- timestamp: nametag.timestamp || Date.now()
6168
- };
6169
- for (const [providerId, provider] of providers) {
6170
- try {
6171
- if (provider.saveToken) {
6172
- await provider.saveToken(filename, fileData);
6173
- this.log(`Saved nametag file ${filename} to ${providerId}`);
6174
- }
6175
- } catch (error) {
6176
- console.warn(`[Payments] Failed to save nametag to ${providerId}:`, error);
6177
- }
6178
- }
6179
- }
6180
- /**
6181
- * Load nametag from file storage (lottery compatibility)
6182
- * Looks for file: nametag-{name}.json
6183
- */
6184
- async loadNametagFromFileStorage() {
6185
- if (this.nametag) return;
6186
- const providers = this.getTokenStorageProviders();
6187
- if (providers.size === 0) return;
6188
- for (const [providerId, provider] of providers) {
6189
- if (!provider.listTokenIds || !provider.getToken) continue;
6190
- try {
6191
- const tokenIds = await provider.listTokenIds();
6192
- const nametagFiles = tokenIds.filter((id) => id.startsWith("nametag-"));
6193
- for (const nametagFile of nametagFiles) {
6194
- try {
6195
- const fileData = await provider.getToken(nametagFile);
6196
- if (!fileData || typeof fileData !== "object") continue;
6197
- const data = fileData;
6198
- if (!data.token || !data.nametag) continue;
6199
- this.nametag = {
6200
- name: data.nametag,
6201
- token: data.token,
6202
- timestamp: data.timestamp || Date.now(),
6203
- format: "lottery",
6204
- version: "1.0"
6205
- };
6206
- this.log(`Loaded nametag from file: ${nametagFile}`);
6207
- return;
6208
- } catch (fileError) {
6209
- console.warn(`[Payments] Failed to load nametag file ${nametagFile}:`, fileError);
6210
- }
6211
- }
6212
- } catch (error) {
6213
- console.warn(`[Payments] Failed to search nametag files in ${providerId}:`, error);
6214
- }
6215
- }
6216
- }
6217
6141
  /**
6218
6142
  * Mint a nametag token on-chain (like Sphere wallet and lottery)
6219
6143
  * This creates the nametag token required for receiving tokens via PROXY addresses
@@ -6701,10 +6625,11 @@ var PaymentsModule = class _PaymentsModule {
6701
6625
  let nametagTokens = [];
6702
6626
  if (addressScheme === AddressScheme.PROXY) {
6703
6627
  const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
6704
- if (!this.nametag?.token) {
6628
+ const proxyNametag = this.getNametag();
6629
+ if (!proxyNametag?.token) {
6705
6630
  throw new Error("Cannot finalize PROXY transfer - no nametag token");
6706
6631
  }
6707
- const nametagToken = await SdkToken2.fromJSON(this.nametag.token);
6632
+ const nametagToken = await SdkToken2.fromJSON(proxyNametag.token);
6708
6633
  const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
6709
6634
  if (proxy.address !== recipientAddress.address) {
6710
6635
  throw new Error(
@@ -6765,7 +6690,6 @@ var PaymentsModule = class _PaymentsModule {
6765
6690
  };
6766
6691
  this.tokens.set(tokenId, finalizedToken);
6767
6692
  await this.save();
6768
- await this.saveTokenToFileStorage(finalizedToken);
6769
6693
  this.log(`NOSTR-FIRST: Token ${tokenId.slice(0, 8)}... finalized and confirmed`);
6770
6694
  this.deps.emitEvent("transfer:confirmed", {
6771
6695
  id: crypto.randomUUID(),
@@ -6809,9 +6733,6 @@ var PaymentsModule = class _PaymentsModule {
6809
6733
  if (instantBundle) {
6810
6734
  this.log("Processing INSTANT_SPLIT bundle...");
6811
6735
  try {
6812
- if (!this.nametag) {
6813
- await this.loadNametagFromFileStorage();
6814
- }
6815
6736
  const result = await this.processInstantSplitBundle(
6816
6737
  instantBundle,
6817
6738
  transfer.senderTransportPubkey
@@ -7011,6 +6932,7 @@ var PaymentsModule = class _PaymentsModule {
7011
6932
  ipnsName: this.deps.identity.ipnsName ?? ""
7012
6933
  },
7013
6934
  {
6935
+ nametags: this.nametags,
7014
6936
  tombstones: this.tombstones,
7015
6937
  archivedTokens: this.archivedTokens,
7016
6938
  forkedTokens: this.forkedTokens
@@ -7032,9 +6954,7 @@ var PaymentsModule = class _PaymentsModule {
7032
6954
  }
7033
6955
  this.archivedTokens = parsed.archivedTokens;
7034
6956
  this.forkedTokens = parsed.forkedTokens;
7035
- if (parsed.nametag !== null) {
7036
- this.nametag = parsed.nametag;
7037
- }
6957
+ this.nametags = parsed.nametags;
7038
6958
  }
7039
6959
  // ===========================================================================
7040
6960
  // Private: NOSTR-FIRST Proof Polling
@@ -7252,228 +7172,1609 @@ var CommunicationsModule = class {
7252
7172
  this.broadcastSubscriptions.clear();
7253
7173
  }
7254
7174
  // ===========================================================================
7255
- // Public API - Direct Messages
7175
+ // Public API - Direct Messages
7176
+ // ===========================================================================
7177
+ /**
7178
+ * Send direct message
7179
+ */
7180
+ async sendDM(recipient, content) {
7181
+ this.ensureInitialized();
7182
+ const recipientPubkey = await this.resolveRecipient(recipient);
7183
+ const eventId = await this.deps.transport.sendMessage(recipientPubkey, content);
7184
+ const message = {
7185
+ id: eventId,
7186
+ senderPubkey: this.deps.identity.chainPubkey,
7187
+ senderNametag: this.deps.identity.nametag,
7188
+ recipientPubkey,
7189
+ content,
7190
+ timestamp: Date.now(),
7191
+ isRead: true
7192
+ };
7193
+ this.messages.set(message.id, message);
7194
+ if (this.config.autoSave) {
7195
+ await this.save();
7196
+ }
7197
+ return message;
7198
+ }
7199
+ /**
7200
+ * Get conversation with peer
7201
+ */
7202
+ getConversation(peerPubkey) {
7203
+ return Array.from(this.messages.values()).filter(
7204
+ (m) => m.senderPubkey === peerPubkey || m.recipientPubkey === peerPubkey
7205
+ ).sort((a, b) => a.timestamp - b.timestamp);
7206
+ }
7207
+ /**
7208
+ * Get all conversations grouped by peer
7209
+ */
7210
+ getConversations() {
7211
+ const conversations = /* @__PURE__ */ new Map();
7212
+ for (const message of this.messages.values()) {
7213
+ const peer = message.senderPubkey === this.deps?.identity.chainPubkey ? message.recipientPubkey : message.senderPubkey;
7214
+ if (!conversations.has(peer)) {
7215
+ conversations.set(peer, []);
7216
+ }
7217
+ conversations.get(peer).push(message);
7218
+ }
7219
+ for (const msgs of conversations.values()) {
7220
+ msgs.sort((a, b) => a.timestamp - b.timestamp);
7221
+ }
7222
+ return conversations;
7223
+ }
7224
+ /**
7225
+ * Mark messages as read
7226
+ */
7227
+ async markAsRead(messageIds) {
7228
+ for (const id of messageIds) {
7229
+ const msg = this.messages.get(id);
7230
+ if (msg) {
7231
+ msg.isRead = true;
7232
+ }
7233
+ }
7234
+ if (this.config.autoSave) {
7235
+ await this.save();
7236
+ }
7237
+ }
7238
+ /**
7239
+ * Get unread count
7240
+ */
7241
+ getUnreadCount(peerPubkey) {
7242
+ let messages = Array.from(this.messages.values()).filter(
7243
+ (m) => !m.isRead && m.senderPubkey !== this.deps?.identity.chainPubkey
7244
+ );
7245
+ if (peerPubkey) {
7246
+ messages = messages.filter((m) => m.senderPubkey === peerPubkey);
7247
+ }
7248
+ return messages.length;
7249
+ }
7250
+ /**
7251
+ * Subscribe to incoming DMs
7252
+ */
7253
+ onDirectMessage(handler) {
7254
+ this.dmHandlers.add(handler);
7255
+ return () => this.dmHandlers.delete(handler);
7256
+ }
7257
+ // ===========================================================================
7258
+ // Public API - Broadcasts
7259
+ // ===========================================================================
7260
+ /**
7261
+ * Publish broadcast message
7262
+ */
7263
+ async broadcast(content, tags) {
7264
+ this.ensureInitialized();
7265
+ const eventId = await this.deps.transport.publishBroadcast?.(content, tags);
7266
+ const message = {
7267
+ id: eventId ?? crypto.randomUUID(),
7268
+ authorPubkey: this.deps.identity.chainPubkey,
7269
+ authorNametag: this.deps.identity.nametag,
7270
+ content,
7271
+ timestamp: Date.now(),
7272
+ tags
7273
+ };
7274
+ this.broadcasts.set(message.id, message);
7275
+ return message;
7276
+ }
7277
+ /**
7278
+ * Subscribe to broadcasts with tags
7279
+ */
7280
+ subscribeToBroadcasts(tags) {
7281
+ this.ensureInitialized();
7282
+ const key = tags.sort().join(":");
7283
+ if (this.broadcastSubscriptions.has(key)) {
7284
+ return () => {
7285
+ };
7286
+ }
7287
+ const unsub = this.deps.transport.subscribeToBroadcast?.(tags, (broadcast2) => {
7288
+ this.handleIncomingBroadcast(broadcast2);
7289
+ });
7290
+ if (unsub) {
7291
+ this.broadcastSubscriptions.set(key, unsub);
7292
+ }
7293
+ return () => {
7294
+ const sub = this.broadcastSubscriptions.get(key);
7295
+ if (sub) {
7296
+ sub();
7297
+ this.broadcastSubscriptions.delete(key);
7298
+ }
7299
+ };
7300
+ }
7301
+ /**
7302
+ * Get broadcasts
7303
+ */
7304
+ getBroadcasts(limit) {
7305
+ const messages = Array.from(this.broadcasts.values()).sort((a, b) => b.timestamp - a.timestamp);
7306
+ return limit ? messages.slice(0, limit) : messages;
7307
+ }
7308
+ /**
7309
+ * Subscribe to incoming broadcasts
7310
+ */
7311
+ onBroadcast(handler) {
7312
+ this.broadcastHandlers.add(handler);
7313
+ return () => this.broadcastHandlers.delete(handler);
7314
+ }
7315
+ // ===========================================================================
7316
+ // Private: Message Handling
7317
+ // ===========================================================================
7318
+ handleIncomingMessage(msg) {
7319
+ if (msg.senderTransportPubkey === this.deps?.identity.chainPubkey) return;
7320
+ const message = {
7321
+ id: msg.id,
7322
+ senderPubkey: msg.senderTransportPubkey,
7323
+ senderNametag: msg.senderNametag,
7324
+ recipientPubkey: this.deps.identity.chainPubkey,
7325
+ content: msg.content,
7326
+ timestamp: msg.timestamp,
7327
+ isRead: false
7328
+ };
7329
+ this.messages.set(message.id, message);
7330
+ this.deps.emitEvent("message:dm", message);
7331
+ for (const handler of this.dmHandlers) {
7332
+ try {
7333
+ handler(message);
7334
+ } catch (error) {
7335
+ console.error("[Communications] Handler error:", error);
7336
+ }
7337
+ }
7338
+ if (this.config.autoSave) {
7339
+ this.save();
7340
+ }
7341
+ this.pruneIfNeeded();
7342
+ }
7343
+ handleIncomingBroadcast(incoming) {
7344
+ const message = {
7345
+ id: incoming.id,
7346
+ authorPubkey: incoming.authorTransportPubkey,
7347
+ content: incoming.content,
7348
+ timestamp: incoming.timestamp,
7349
+ tags: incoming.tags
7350
+ };
7351
+ this.broadcasts.set(message.id, message);
7352
+ this.deps.emitEvent("message:broadcast", message);
7353
+ for (const handler of this.broadcastHandlers) {
7354
+ try {
7355
+ handler(message);
7356
+ } catch (error) {
7357
+ console.error("[Communications] Handler error:", error);
7358
+ }
7359
+ }
7360
+ }
7361
+ // ===========================================================================
7362
+ // Private: Storage
7363
+ // ===========================================================================
7364
+ async save() {
7365
+ const messages = Array.from(this.messages.values());
7366
+ await this.deps.storage.set("direct_messages", JSON.stringify(messages));
7367
+ }
7368
+ pruneIfNeeded() {
7369
+ if (this.messages.size <= this.config.maxMessages) return;
7370
+ const sorted = Array.from(this.messages.entries()).sort(([, a], [, b]) => a.timestamp - b.timestamp);
7371
+ const toRemove = sorted.slice(0, sorted.length - this.config.maxMessages);
7372
+ for (const [id] of toRemove) {
7373
+ this.messages.delete(id);
7374
+ }
7375
+ }
7376
+ // ===========================================================================
7377
+ // Private: Helpers
7378
+ // ===========================================================================
7379
+ async resolveRecipient(recipient) {
7380
+ if (recipient.startsWith("@")) {
7381
+ const pubkey = await this.deps.transport.resolveNametag?.(recipient.slice(1));
7382
+ if (!pubkey) {
7383
+ throw new Error(`Nametag not found: ${recipient}`);
7384
+ }
7385
+ return pubkey;
7386
+ }
7387
+ return recipient;
7388
+ }
7389
+ ensureInitialized() {
7390
+ if (!this.deps) {
7391
+ throw new Error("CommunicationsModule not initialized");
7392
+ }
7393
+ }
7394
+ };
7395
+ function createCommunicationsModule(config) {
7396
+ return new CommunicationsModule(config);
7397
+ }
7398
+
7399
+ // modules/groupchat/GroupChatModule.ts
7400
+ import {
7401
+ NostrClient,
7402
+ NostrKeyManager,
7403
+ Filter
7404
+ } from "@unicitylabs/nostr-js-sdk";
7405
+
7406
+ // modules/groupchat/types.ts
7407
+ var GroupRole = {
7408
+ ADMIN: "ADMIN",
7409
+ MODERATOR: "MODERATOR",
7410
+ MEMBER: "MEMBER"
7411
+ };
7412
+ var GroupVisibility = {
7413
+ PUBLIC: "PUBLIC",
7414
+ PRIVATE: "PRIVATE"
7415
+ };
7416
+
7417
+ // modules/groupchat/GroupChatModule.ts
7418
+ function createNip29Filter(data) {
7419
+ return new Filter(data);
7420
+ }
7421
+ var GroupChatModule = class {
7422
+ config;
7423
+ deps = null;
7424
+ // Nostr connection (separate from wallet relay)
7425
+ client = null;
7426
+ keyManager = null;
7427
+ connected = false;
7428
+ connecting = false;
7429
+ connectPromise = null;
7430
+ reconnectAttempts = 0;
7431
+ reconnectTimer = null;
7432
+ // Subscription tracking (for cleanup)
7433
+ subscriptionIds = [];
7434
+ // In-memory state
7435
+ groups = /* @__PURE__ */ new Map();
7436
+ messages = /* @__PURE__ */ new Map();
7437
+ // groupId -> messages
7438
+ members = /* @__PURE__ */ new Map();
7439
+ // groupId -> members
7440
+ processedEventIds = /* @__PURE__ */ new Set();
7441
+ pendingLeaves = /* @__PURE__ */ new Set();
7442
+ // Persistence debounce
7443
+ persistTimer = null;
7444
+ persistPromise = null;
7445
+ // Relay admin cache
7446
+ relayAdminPubkeys = null;
7447
+ relayAdminFetchPromise = null;
7448
+ // Listeners
7449
+ messageHandlers = /* @__PURE__ */ new Set();
7450
+ constructor(config) {
7451
+ this.config = {
7452
+ relays: config?.relays ?? [],
7453
+ defaultMessageLimit: config?.defaultMessageLimit ?? 50,
7454
+ maxPreviousTags: config?.maxPreviousTags ?? 3,
7455
+ reconnectDelayMs: config?.reconnectDelayMs ?? 3e3,
7456
+ maxReconnectAttempts: config?.maxReconnectAttempts ?? 5
7457
+ };
7458
+ }
7459
+ // ===========================================================================
7460
+ // Lifecycle
7461
+ // ===========================================================================
7462
+ initialize(deps) {
7463
+ if (this.deps) {
7464
+ this.destroyConnection();
7465
+ }
7466
+ this.deps = deps;
7467
+ const secretKey = Buffer.from(deps.identity.privateKey, "hex");
7468
+ this.keyManager = NostrKeyManager.fromPrivateKey(secretKey);
7469
+ }
7470
+ async load() {
7471
+ this.ensureInitialized();
7472
+ const storage = this.deps.storage;
7473
+ const groupsJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_GROUPS);
7474
+ if (groupsJson) {
7475
+ try {
7476
+ const parsed = JSON.parse(groupsJson);
7477
+ this.groups.clear();
7478
+ for (const g of parsed) {
7479
+ this.groups.set(g.id, g);
7480
+ }
7481
+ } catch {
7482
+ }
7483
+ }
7484
+ const messagesJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MESSAGES);
7485
+ if (messagesJson) {
7486
+ try {
7487
+ const parsed = JSON.parse(messagesJson);
7488
+ this.messages.clear();
7489
+ for (const m of parsed) {
7490
+ const groupId = m.groupId;
7491
+ if (!this.messages.has(groupId)) {
7492
+ this.messages.set(groupId, []);
7493
+ }
7494
+ this.messages.get(groupId).push(m);
7495
+ }
7496
+ } catch {
7497
+ }
7498
+ }
7499
+ const membersJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MEMBERS);
7500
+ if (membersJson) {
7501
+ try {
7502
+ const parsed = JSON.parse(membersJson);
7503
+ this.members.clear();
7504
+ for (const m of parsed) {
7505
+ const groupId = m.groupId;
7506
+ if (!this.members.has(groupId)) {
7507
+ this.members.set(groupId, []);
7508
+ }
7509
+ this.members.get(groupId).push(m);
7510
+ }
7511
+ } catch {
7512
+ }
7513
+ }
7514
+ const processedJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_PROCESSED_EVENTS);
7515
+ if (processedJson) {
7516
+ try {
7517
+ const parsed = JSON.parse(processedJson);
7518
+ this.processedEventIds = new Set(parsed);
7519
+ } catch {
7520
+ }
7521
+ }
7522
+ }
7523
+ destroy() {
7524
+ this.destroyConnection();
7525
+ this.groups.clear();
7526
+ this.messages.clear();
7527
+ this.members.clear();
7528
+ this.processedEventIds.clear();
7529
+ this.pendingLeaves.clear();
7530
+ this.messageHandlers.clear();
7531
+ this.relayAdminPubkeys = null;
7532
+ this.relayAdminFetchPromise = null;
7533
+ if (this.persistTimer) {
7534
+ clearTimeout(this.persistTimer);
7535
+ this.persistTimer = null;
7536
+ }
7537
+ this.deps = null;
7538
+ }
7539
+ destroyConnection() {
7540
+ if (this.reconnectTimer) {
7541
+ clearTimeout(this.reconnectTimer);
7542
+ this.reconnectTimer = null;
7543
+ }
7544
+ if (this.client) {
7545
+ for (const subId of this.subscriptionIds) {
7546
+ try {
7547
+ this.client.unsubscribe(subId);
7548
+ } catch {
7549
+ }
7550
+ }
7551
+ this.subscriptionIds = [];
7552
+ try {
7553
+ this.client.disconnect();
7554
+ } catch {
7555
+ }
7556
+ this.client = null;
7557
+ }
7558
+ this.connected = false;
7559
+ this.connecting = false;
7560
+ this.connectPromise = null;
7561
+ this.reconnectAttempts = 0;
7562
+ this.keyManager = null;
7563
+ }
7564
+ // ===========================================================================
7565
+ // Connection
7566
+ // ===========================================================================
7567
+ async connect() {
7568
+ if (this.connected) return;
7569
+ if (this.connectPromise) {
7570
+ return this.connectPromise;
7571
+ }
7572
+ this.connecting = true;
7573
+ this.connectPromise = this.doConnect().finally(() => {
7574
+ this.connecting = false;
7575
+ this.connectPromise = null;
7576
+ });
7577
+ return this.connectPromise;
7578
+ }
7579
+ getConnectionStatus() {
7580
+ return this.connected;
7581
+ }
7582
+ async doConnect() {
7583
+ this.ensureInitialized();
7584
+ if (!this.keyManager) {
7585
+ const secretKey = Buffer.from(this.deps.identity.privateKey, "hex");
7586
+ this.keyManager = NostrKeyManager.fromPrivateKey(secretKey);
7587
+ }
7588
+ const primaryRelay = this.config.relays[0];
7589
+ if (primaryRelay) {
7590
+ await this.checkAndClearOnRelayChange(primaryRelay);
7591
+ }
7592
+ this.client = new NostrClient(this.keyManager);
7593
+ try {
7594
+ await this.client.connect(...this.config.relays);
7595
+ this.connected = true;
7596
+ this.reconnectAttempts = 0;
7597
+ this.deps.emitEvent("groupchat:connection", { connected: true });
7598
+ if (this.groups.size === 0) {
7599
+ await this.restoreJoinedGroups();
7600
+ } else {
7601
+ await this.subscribeToJoinedGroups();
7602
+ }
7603
+ } catch (error) {
7604
+ console.error("[GroupChat] Failed to connect to relays", error);
7605
+ this.deps.emitEvent("groupchat:connection", { connected: false });
7606
+ this.scheduleReconnect();
7607
+ }
7608
+ }
7609
+ scheduleReconnect() {
7610
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
7611
+ console.error("[GroupChat] Max reconnection attempts reached");
7612
+ return;
7613
+ }
7614
+ this.reconnectAttempts++;
7615
+ this.reconnectTimer = setTimeout(() => {
7616
+ this.reconnectTimer = null;
7617
+ if (this.deps) {
7618
+ this.connect().catch(console.error);
7619
+ }
7620
+ }, this.config.reconnectDelayMs);
7621
+ }
7622
+ // ===========================================================================
7623
+ // Subscription Management
7624
+ // ===========================================================================
7625
+ async subscribeToJoinedGroups() {
7626
+ if (!this.client) return;
7627
+ const groupIds = Array.from(this.groups.keys());
7628
+ if (groupIds.length === 0) return;
7629
+ this.trackSubscription(
7630
+ createNip29Filter({
7631
+ kinds: [NIP29_KINDS.CHAT_MESSAGE, NIP29_KINDS.THREAD_ROOT, NIP29_KINDS.THREAD_REPLY],
7632
+ "#h": groupIds
7633
+ }),
7634
+ { onEvent: (event) => this.handleGroupEvent(event) }
7635
+ );
7636
+ this.trackSubscription(
7637
+ createNip29Filter({
7638
+ kinds: [NIP29_KINDS.GROUP_METADATA, NIP29_KINDS.GROUP_MEMBERS, NIP29_KINDS.GROUP_ADMINS],
7639
+ "#d": groupIds
7640
+ }),
7641
+ { onEvent: (event) => this.handleMetadataEvent(event) }
7642
+ );
7643
+ this.trackSubscription(
7644
+ createNip29Filter({
7645
+ kinds: [NIP29_KINDS.DELETE_EVENT, NIP29_KINDS.REMOVE_USER, NIP29_KINDS.DELETE_GROUP],
7646
+ "#h": groupIds
7647
+ }),
7648
+ { onEvent: (event) => this.handleModerationEvent(event) }
7649
+ );
7650
+ }
7651
+ subscribeToGroup(groupId) {
7652
+ if (!this.client) return;
7653
+ this.trackSubscription(
7654
+ createNip29Filter({
7655
+ kinds: [NIP29_KINDS.CHAT_MESSAGE, NIP29_KINDS.THREAD_ROOT, NIP29_KINDS.THREAD_REPLY],
7656
+ "#h": [groupId]
7657
+ }),
7658
+ { onEvent: (event) => this.handleGroupEvent(event) }
7659
+ );
7660
+ this.trackSubscription(
7661
+ createNip29Filter({
7662
+ kinds: [NIP29_KINDS.DELETE_EVENT, NIP29_KINDS.REMOVE_USER, NIP29_KINDS.DELETE_GROUP],
7663
+ "#h": [groupId]
7664
+ }),
7665
+ { onEvent: (event) => this.handleModerationEvent(event) }
7666
+ );
7667
+ }
7668
+ // ===========================================================================
7669
+ // Event Handlers
7670
+ // ===========================================================================
7671
+ handleGroupEvent(event) {
7672
+ if (this.processedEventIds.has(event.id)) return;
7673
+ const groupId = this.getGroupIdFromEvent(event);
7674
+ if (!groupId) return;
7675
+ const group = this.groups.get(groupId);
7676
+ if (!group) return;
7677
+ const { text: content, senderNametag } = this.unwrapMessageContent(event.content);
7678
+ const message = {
7679
+ id: event.id,
7680
+ groupId,
7681
+ content,
7682
+ timestamp: event.created_at * 1e3,
7683
+ senderPubkey: event.pubkey,
7684
+ senderNametag: senderNametag || void 0,
7685
+ replyToId: this.extractReplyTo(event),
7686
+ previousIds: this.extractPreviousIds(event)
7687
+ };
7688
+ this.saveMessageToMemory(message);
7689
+ this.addProcessedEventId(event.id);
7690
+ if (senderNametag) {
7691
+ this.updateMemberNametag(groupId, event.pubkey, senderNametag, event.created_at * 1e3);
7692
+ }
7693
+ this.updateGroupLastMessage(groupId, content.slice(0, 100), message.timestamp);
7694
+ const myPubkey = this.getMyPublicKey();
7695
+ if (event.pubkey !== myPubkey) {
7696
+ group.unreadCount = (group.unreadCount || 0) + 1;
7697
+ }
7698
+ this.deps.emitEvent("groupchat:message", message);
7699
+ this.deps.emitEvent("groupchat:updated", {});
7700
+ for (const handler of this.messageHandlers) {
7701
+ try {
7702
+ handler(message);
7703
+ } catch {
7704
+ }
7705
+ }
7706
+ this.schedulePersist();
7707
+ }
7708
+ handleMetadataEvent(event) {
7709
+ const groupId = this.getGroupIdFromMetadataEvent(event);
7710
+ if (!groupId) return;
7711
+ const group = this.groups.get(groupId);
7712
+ if (!group) return;
7713
+ if (event.kind === NIP29_KINDS.GROUP_METADATA) {
7714
+ if (!event.content || event.content.trim() === "") return;
7715
+ try {
7716
+ const metadata = JSON.parse(event.content);
7717
+ group.name = metadata.name || group.name;
7718
+ group.description = metadata.about || group.description;
7719
+ group.picture = metadata.picture || group.picture;
7720
+ group.updatedAt = event.created_at * 1e3;
7721
+ this.groups.set(groupId, group);
7722
+ this.persistGroups();
7723
+ } catch {
7724
+ }
7725
+ } else if (event.kind === NIP29_KINDS.GROUP_MEMBERS) {
7726
+ this.updateMembersFromEvent(groupId, event);
7727
+ } else if (event.kind === NIP29_KINDS.GROUP_ADMINS) {
7728
+ this.updateAdminsFromEvent(groupId, event);
7729
+ }
7730
+ }
7731
+ handleModerationEvent(event) {
7732
+ const groupId = this.getGroupIdFromEvent(event);
7733
+ if (!groupId) return;
7734
+ const group = this.groups.get(groupId);
7735
+ if (!group) return;
7736
+ if (event.kind === NIP29_KINDS.DELETE_EVENT) {
7737
+ const eTags = event.tags.filter((t) => t[0] === "e");
7738
+ for (const tag of eTags) {
7739
+ const messageId = tag[1];
7740
+ if (messageId) {
7741
+ this.deleteMessageFromMemory(groupId, messageId);
7742
+ }
7743
+ }
7744
+ this.deps.emitEvent("groupchat:updated", {});
7745
+ this.persistMessages();
7746
+ } else if (event.kind === NIP29_KINDS.REMOVE_USER) {
7747
+ if (this.processedEventIds.has(event.id)) return;
7748
+ const eventTimestampMs = event.created_at * 1e3;
7749
+ if (group.localJoinedAt && eventTimestampMs < group.localJoinedAt) {
7750
+ this.addProcessedEventId(event.id);
7751
+ return;
7752
+ }
7753
+ this.addProcessedEventId(event.id);
7754
+ const pTags = event.tags.filter((t) => t[0] === "p");
7755
+ const myPubkey = this.getMyPublicKey();
7756
+ for (const tag of pTags) {
7757
+ const removedPubkey = tag[1];
7758
+ if (!removedPubkey) continue;
7759
+ if (removedPubkey === myPubkey) {
7760
+ if (this.pendingLeaves.has(groupId)) {
7761
+ this.pendingLeaves.delete(groupId);
7762
+ this.deps.emitEvent("groupchat:updated", {});
7763
+ } else {
7764
+ const groupName = group.name || groupId;
7765
+ this.removeGroupFromMemory(groupId);
7766
+ this.deps.emitEvent("groupchat:kicked", { groupId, groupName });
7767
+ this.deps.emitEvent("groupchat:updated", {});
7768
+ }
7769
+ } else {
7770
+ this.removeMemberFromMemory(groupId, removedPubkey);
7771
+ }
7772
+ }
7773
+ this.schedulePersist();
7774
+ } else if (event.kind === NIP29_KINDS.DELETE_GROUP) {
7775
+ if (this.processedEventIds.has(event.id)) return;
7776
+ const deleteTimestampMs = event.created_at * 1e3;
7777
+ if (deleteTimestampMs < group.createdAt) {
7778
+ this.addProcessedEventId(event.id);
7779
+ return;
7780
+ }
7781
+ this.addProcessedEventId(event.id);
7782
+ const groupName = group.name || groupId;
7783
+ this.removeGroupFromMemory(groupId);
7784
+ this.deps.emitEvent("groupchat:group_deleted", { groupId, groupName });
7785
+ this.deps.emitEvent("groupchat:updated", {});
7786
+ this.schedulePersist();
7787
+ }
7788
+ }
7789
+ updateMembersFromEvent(groupId, event) {
7790
+ const pTags = event.tags.filter((t) => t[0] === "p");
7791
+ const existingMembers = this.members.get(groupId) || [];
7792
+ for (const tag of pTags) {
7793
+ const pubkey = tag[1];
7794
+ const roleFromTag = tag[3];
7795
+ const existing = existingMembers.find((m) => m.pubkey === pubkey);
7796
+ const role = roleFromTag || existing?.role || GroupRole.MEMBER;
7797
+ const member = {
7798
+ pubkey,
7799
+ groupId,
7800
+ role,
7801
+ nametag: existing?.nametag,
7802
+ joinedAt: existing?.joinedAt || event.created_at * 1e3
7803
+ };
7804
+ this.saveMemberToMemory(member);
7805
+ }
7806
+ this.persistMembers();
7807
+ }
7808
+ updateAdminsFromEvent(groupId, event) {
7809
+ const pTags = event.tags.filter((t) => t[0] === "p");
7810
+ const existingMembers = this.members.get(groupId) || [];
7811
+ for (const tag of pTags) {
7812
+ const pubkey = tag[1];
7813
+ const existing = existingMembers.find((m) => m.pubkey === pubkey);
7814
+ if (existing) {
7815
+ existing.role = GroupRole.ADMIN;
7816
+ this.saveMemberToMemory(existing);
7817
+ } else {
7818
+ this.saveMemberToMemory({
7819
+ pubkey,
7820
+ groupId,
7821
+ role: GroupRole.ADMIN,
7822
+ joinedAt: event.created_at * 1e3
7823
+ });
7824
+ }
7825
+ }
7826
+ this.persistMembers();
7827
+ }
7828
+ // ===========================================================================
7829
+ // Group Membership Restoration
7830
+ // ===========================================================================
7831
+ async restoreJoinedGroups() {
7832
+ if (!this.client) return [];
7833
+ const myPubkey = this.getMyPublicKey();
7834
+ if (!myPubkey) return [];
7835
+ const groupIdsWithMembership = /* @__PURE__ */ new Set();
7836
+ await this.oneshotSubscription(
7837
+ new Filter({ kinds: [NIP29_KINDS.GROUP_MEMBERS] }),
7838
+ {
7839
+ onEvent: (event) => {
7840
+ const groupId = this.getGroupIdFromMetadataEvent(event);
7841
+ if (!groupId) return;
7842
+ const pTags = event.tags.filter((t) => t[0] === "p");
7843
+ if (pTags.some((tag) => tag[1] === myPubkey)) {
7844
+ groupIdsWithMembership.add(groupId);
7845
+ }
7846
+ },
7847
+ onComplete: () => {
7848
+ },
7849
+ timeoutMs: 15e3
7850
+ }
7851
+ );
7852
+ if (groupIdsWithMembership.size === 0) return [];
7853
+ const restoredGroups = [];
7854
+ for (const groupId of groupIdsWithMembership) {
7855
+ if (this.groups.has(groupId)) continue;
7856
+ try {
7857
+ const group = await this.fetchGroupMetadataInternal(groupId);
7858
+ if (group) {
7859
+ this.groups.set(groupId, group);
7860
+ restoredGroups.push(group);
7861
+ await Promise.all([
7862
+ this.fetchAndSaveMembers(groupId),
7863
+ this.fetchMessages(groupId)
7864
+ ]);
7865
+ }
7866
+ } catch {
7867
+ }
7868
+ }
7869
+ if (restoredGroups.length > 0) {
7870
+ await this.subscribeToJoinedGroups();
7871
+ this.deps.emitEvent("groupchat:updated", {});
7872
+ this.schedulePersist();
7873
+ }
7874
+ return restoredGroups;
7875
+ }
7876
+ // ===========================================================================
7877
+ // Public API — Groups
7878
+ // ===========================================================================
7879
+ async fetchAvailableGroups() {
7880
+ await this.ensureConnected();
7881
+ if (!this.client) return [];
7882
+ const groupsMap = /* @__PURE__ */ new Map();
7883
+ const memberCountsMap = /* @__PURE__ */ new Map();
7884
+ await Promise.all([
7885
+ this.oneshotSubscription(
7886
+ new Filter({ kinds: [NIP29_KINDS.GROUP_METADATA] }),
7887
+ {
7888
+ onEvent: (event) => {
7889
+ const group = this.parseGroupMetadata(event);
7890
+ if (group && group.visibility === GroupVisibility.PUBLIC) {
7891
+ const existing = groupsMap.get(group.id);
7892
+ if (!existing || group.createdAt > existing.createdAt) {
7893
+ groupsMap.set(group.id, group);
7894
+ }
7895
+ }
7896
+ },
7897
+ onComplete: () => {
7898
+ },
7899
+ timeoutMs: 1e4
7900
+ }
7901
+ ),
7902
+ this.oneshotSubscription(
7903
+ new Filter({ kinds: [NIP29_KINDS.GROUP_MEMBERS] }),
7904
+ {
7905
+ onEvent: (event) => {
7906
+ const groupId = this.getGroupIdFromMetadataEvent(event);
7907
+ if (groupId) {
7908
+ const pTags = event.tags.filter((t) => t[0] === "p");
7909
+ memberCountsMap.set(groupId, pTags.length);
7910
+ }
7911
+ },
7912
+ onComplete: () => {
7913
+ },
7914
+ timeoutMs: 1e4
7915
+ }
7916
+ )
7917
+ ]);
7918
+ for (const [groupId, count] of memberCountsMap) {
7919
+ const group = groupsMap.get(groupId);
7920
+ if (group) group.memberCount = count;
7921
+ }
7922
+ return Array.from(groupsMap.values());
7923
+ }
7924
+ async joinGroup(groupId, inviteCode) {
7925
+ await this.ensureConnected();
7926
+ if (!this.client) return false;
7927
+ try {
7928
+ let group = await this.fetchGroupMetadataInternal(groupId);
7929
+ if (!group && !inviteCode) return false;
7930
+ const tags = [["h", groupId]];
7931
+ if (inviteCode) tags.push(["code", inviteCode]);
7932
+ const eventId = await this.client.createAndPublishEvent({
7933
+ kind: NIP29_KINDS.JOIN_REQUEST,
7934
+ tags,
7935
+ content: ""
7936
+ });
7937
+ if (eventId) {
7938
+ if (!group) {
7939
+ group = await this.fetchGroupMetadataInternal(groupId);
7940
+ if (!group) return false;
7941
+ }
7942
+ group.localJoinedAt = Date.now();
7943
+ this.groups.set(groupId, group);
7944
+ this.subscribeToGroup(groupId);
7945
+ await Promise.all([
7946
+ this.fetchMessages(groupId),
7947
+ this.fetchAndSaveMembers(groupId)
7948
+ ]);
7949
+ this.deps.emitEvent("groupchat:joined", { groupId, groupName: group.name });
7950
+ this.deps.emitEvent("groupchat:updated", {});
7951
+ this.persistAll();
7952
+ return true;
7953
+ }
7954
+ return false;
7955
+ } catch (error) {
7956
+ const msg = error instanceof Error ? error.message : String(error);
7957
+ if (msg.includes("already a member")) {
7958
+ const group = await this.fetchGroupMetadataInternal(groupId);
7959
+ if (group) {
7960
+ group.localJoinedAt = Date.now();
7961
+ this.groups.set(groupId, group);
7962
+ this.subscribeToGroup(groupId);
7963
+ await Promise.all([
7964
+ this.fetchMessages(groupId),
7965
+ this.fetchAndSaveMembers(groupId)
7966
+ ]);
7967
+ this.deps.emitEvent("groupchat:joined", { groupId, groupName: group.name });
7968
+ this.deps.emitEvent("groupchat:updated", {});
7969
+ this.persistAll();
7970
+ return true;
7971
+ }
7972
+ }
7973
+ console.error("[GroupChat] Failed to join group", error);
7974
+ return false;
7975
+ }
7976
+ }
7977
+ async leaveGroup(groupId) {
7978
+ await this.ensureConnected();
7979
+ if (!this.client) return false;
7980
+ try {
7981
+ this.pendingLeaves.add(groupId);
7982
+ const eventId = await this.client.createAndPublishEvent({
7983
+ kind: NIP29_KINDS.LEAVE_REQUEST,
7984
+ tags: [["h", groupId]],
7985
+ content: ""
7986
+ });
7987
+ if (eventId) {
7988
+ this.removeGroupFromMemory(groupId);
7989
+ this.deps.emitEvent("groupchat:left", { groupId });
7990
+ this.deps.emitEvent("groupchat:updated", {});
7991
+ this.persistAll();
7992
+ return true;
7993
+ }
7994
+ this.pendingLeaves.delete(groupId);
7995
+ return false;
7996
+ } catch (error) {
7997
+ const msg = error instanceof Error ? error.message : String(error);
7998
+ if (msg.includes("group not found") || msg.includes("not a member")) {
7999
+ this.removeGroupFromMemory(groupId);
8000
+ this.persistAll();
8001
+ return true;
8002
+ }
8003
+ console.error("[GroupChat] Failed to leave group", error);
8004
+ return false;
8005
+ }
8006
+ }
8007
+ async createGroup(options) {
8008
+ await this.ensureConnected();
8009
+ if (!this.client) return null;
8010
+ const creatorPubkey = this.getMyPublicKey();
8011
+ if (!creatorPubkey) return null;
8012
+ const proposedGroupId = options.name.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 20) || this.randomId();
8013
+ try {
8014
+ const isPrivate = options.visibility === GroupVisibility.PRIVATE;
8015
+ const eventId = await this.client.createAndPublishEvent({
8016
+ kind: NIP29_KINDS.CREATE_GROUP,
8017
+ tags: [["h", proposedGroupId]],
8018
+ content: JSON.stringify({
8019
+ name: options.name,
8020
+ about: options.description,
8021
+ picture: options.picture,
8022
+ closed: true,
8023
+ private: isPrivate,
8024
+ hidden: isPrivate
8025
+ })
8026
+ });
8027
+ if (!eventId) return null;
8028
+ let group = await this.fetchGroupMetadataInternal(proposedGroupId);
8029
+ if (!group) {
8030
+ group = {
8031
+ id: proposedGroupId,
8032
+ relayUrl: this.config.relays[0] || "",
8033
+ name: options.name,
8034
+ description: options.description,
8035
+ visibility: options.visibility || GroupVisibility.PUBLIC,
8036
+ createdAt: Date.now(),
8037
+ memberCount: 1
8038
+ };
8039
+ }
8040
+ if (!group.name || group.name === "Unnamed Group") {
8041
+ group.name = options.name;
8042
+ }
8043
+ if (options.description && !group.description) {
8044
+ group.description = options.description;
8045
+ }
8046
+ group.visibility = options.visibility || GroupVisibility.PUBLIC;
8047
+ group.memberCount = 1;
8048
+ this.groups.set(group.id, group);
8049
+ this.subscribeToGroup(group.id);
8050
+ this.client.createAndPublishEvent({
8051
+ kind: NIP29_KINDS.JOIN_REQUEST,
8052
+ tags: [["h", group.id]],
8053
+ content: ""
8054
+ }).catch(() => {
8055
+ });
8056
+ await this.fetchAndSaveMembers(group.id).catch(() => {
8057
+ });
8058
+ this.saveMemberToMemory({
8059
+ pubkey: creatorPubkey,
8060
+ groupId: group.id,
8061
+ role: GroupRole.ADMIN,
8062
+ joinedAt: Date.now()
8063
+ });
8064
+ this.deps.emitEvent("groupchat:joined", { groupId: group.id, groupName: group.name });
8065
+ this.deps.emitEvent("groupchat:updated", {});
8066
+ this.schedulePersist();
8067
+ return group;
8068
+ } catch (error) {
8069
+ console.error("[GroupChat] Failed to create group", error);
8070
+ return null;
8071
+ }
8072
+ }
8073
+ async deleteGroup(groupId) {
8074
+ await this.ensureConnected();
8075
+ if (!this.client) return false;
8076
+ const group = this.groups.get(groupId);
8077
+ if (!group) return false;
8078
+ const canDelete = await this.canModerateGroup(groupId);
8079
+ if (!canDelete) return false;
8080
+ try {
8081
+ const eventId = await this.client.createAndPublishEvent({
8082
+ kind: NIP29_KINDS.DELETE_GROUP,
8083
+ tags: [["h", groupId]],
8084
+ content: ""
8085
+ });
8086
+ if (eventId) {
8087
+ const groupName = group.name || groupId;
8088
+ this.removeGroupFromMemory(groupId);
8089
+ this.deps.emitEvent("groupchat:group_deleted", { groupId, groupName });
8090
+ this.deps.emitEvent("groupchat:updated", {});
8091
+ this.persistAll();
8092
+ return true;
8093
+ }
8094
+ return false;
8095
+ } catch (error) {
8096
+ console.error("[GroupChat] Failed to delete group", error);
8097
+ return false;
8098
+ }
8099
+ }
8100
+ async createInvite(groupId) {
8101
+ await this.ensureConnected();
8102
+ if (!this.client) return null;
8103
+ if (!this.isCurrentUserAdmin(groupId)) return null;
8104
+ try {
8105
+ const inviteCode = this.randomId();
8106
+ const eventId = await this.client.createAndPublishEvent({
8107
+ kind: NIP29_KINDS.CREATE_INVITE,
8108
+ tags: [
8109
+ ["h", groupId],
8110
+ ["code", inviteCode]
8111
+ ],
8112
+ content: ""
8113
+ });
8114
+ return eventId ? inviteCode : null;
8115
+ } catch (error) {
8116
+ console.error("[GroupChat] Failed to create invite", error);
8117
+ return null;
8118
+ }
8119
+ }
8120
+ // ===========================================================================
8121
+ // Public API — Messages
8122
+ // ===========================================================================
8123
+ async sendMessage(groupId, content, replyToId) {
8124
+ await this.ensureConnected();
8125
+ if (!this.client) return null;
8126
+ const group = this.groups.get(groupId);
8127
+ if (!group) return null;
8128
+ try {
8129
+ const senderNametag = this.deps.identity.nametag || null;
8130
+ const kind = replyToId ? NIP29_KINDS.THREAD_REPLY : NIP29_KINDS.CHAT_MESSAGE;
8131
+ const tags = [["h", groupId]];
8132
+ const groupMessages = this.messages.get(groupId) || [];
8133
+ const recentIds = groupMessages.slice(-this.config.maxPreviousTags).map((m) => (m.id || "").slice(0, 8)).filter(Boolean);
8134
+ if (recentIds.length > 0) {
8135
+ tags.push(["previous", ...recentIds]);
8136
+ }
8137
+ if (replyToId) {
8138
+ tags.push(["e", replyToId, "", "reply"]);
8139
+ }
8140
+ const wrappedContent = this.wrapMessageContent(content, senderNametag);
8141
+ const eventId = await this.client.createAndPublishEvent({
8142
+ kind,
8143
+ tags,
8144
+ content: wrappedContent
8145
+ });
8146
+ if (eventId) {
8147
+ const myPubkey = this.getMyPublicKey();
8148
+ const message = {
8149
+ id: eventId,
8150
+ groupId,
8151
+ content,
8152
+ timestamp: Date.now(),
8153
+ senderPubkey: myPubkey || "",
8154
+ senderNametag: senderNametag || void 0,
8155
+ replyToId,
8156
+ previousIds: recentIds
8157
+ };
8158
+ this.saveMessageToMemory(message);
8159
+ this.addProcessedEventId(eventId);
8160
+ this.updateGroupLastMessage(groupId, content.slice(0, 100), message.timestamp);
8161
+ this.persistAll();
8162
+ return message;
8163
+ }
8164
+ return null;
8165
+ } catch (error) {
8166
+ console.error("[GroupChat] Failed to send message", error);
8167
+ return null;
8168
+ }
8169
+ }
8170
+ async fetchMessages(groupId, since, limit) {
8171
+ await this.ensureConnected();
8172
+ if (!this.client) return [];
8173
+ const fetchedMessages = [];
8174
+ const filterData = {
8175
+ kinds: [NIP29_KINDS.CHAT_MESSAGE, NIP29_KINDS.THREAD_ROOT, NIP29_KINDS.THREAD_REPLY],
8176
+ "#h": [groupId]
8177
+ };
8178
+ if (since) filterData.since = Math.floor(since / 1e3);
8179
+ if (limit) filterData.limit = limit;
8180
+ if (!limit && !since) filterData.limit = this.config.defaultMessageLimit;
8181
+ return this.oneshotSubscription(createNip29Filter(filterData), {
8182
+ onEvent: (event) => {
8183
+ const { text: content, senderNametag } = this.unwrapMessageContent(event.content);
8184
+ const message = {
8185
+ id: event.id,
8186
+ groupId,
8187
+ content,
8188
+ timestamp: event.created_at * 1e3,
8189
+ senderPubkey: event.pubkey,
8190
+ senderNametag: senderNametag || void 0,
8191
+ replyToId: this.extractReplyTo(event),
8192
+ previousIds: this.extractPreviousIds(event)
8193
+ };
8194
+ fetchedMessages.push(message);
8195
+ this.saveMessageToMemory(message);
8196
+ this.addProcessedEventId(event.id);
8197
+ if (senderNametag) {
8198
+ this.updateMemberNametag(groupId, event.pubkey, senderNametag, event.created_at * 1e3);
8199
+ }
8200
+ },
8201
+ onComplete: () => {
8202
+ this.schedulePersist();
8203
+ return fetchedMessages;
8204
+ },
8205
+ timeoutMs: 1e4
8206
+ });
8207
+ }
8208
+ // ===========================================================================
8209
+ // Public API — Queries (from local state)
8210
+ // ===========================================================================
8211
+ getGroups() {
8212
+ return Array.from(this.groups.values()).sort((a, b) => (b.lastMessageTime || 0) - (a.lastMessageTime || 0));
8213
+ }
8214
+ getGroup(groupId) {
8215
+ return this.groups.get(groupId) || null;
8216
+ }
8217
+ getMessages(groupId) {
8218
+ return (this.messages.get(groupId) || []).sort((a, b) => a.timestamp - b.timestamp);
8219
+ }
8220
+ getMembers(groupId) {
8221
+ return (this.members.get(groupId) || []).sort((a, b) => a.joinedAt - b.joinedAt);
8222
+ }
8223
+ getMember(groupId, pubkey) {
8224
+ const members = this.members.get(groupId) || [];
8225
+ return members.find((m) => m.pubkey === pubkey) || null;
8226
+ }
8227
+ getTotalUnreadCount() {
8228
+ let total = 0;
8229
+ for (const group of this.groups.values()) {
8230
+ total += group.unreadCount || 0;
8231
+ }
8232
+ return total;
8233
+ }
8234
+ markGroupAsRead(groupId) {
8235
+ const group = this.groups.get(groupId);
8236
+ if (group && (group.unreadCount || 0) > 0) {
8237
+ group.unreadCount = 0;
8238
+ this.groups.set(groupId, group);
8239
+ this.persistGroups();
8240
+ }
8241
+ }
8242
+ // ===========================================================================
8243
+ // Public API — Admin
8244
+ // ===========================================================================
8245
+ async kickUser(groupId, userPubkey, reason) {
8246
+ await this.ensureConnected();
8247
+ if (!this.client) return false;
8248
+ const canModerate = await this.canModerateGroup(groupId);
8249
+ if (!canModerate) return false;
8250
+ const myPubkey = this.getMyPublicKey();
8251
+ if (myPubkey === userPubkey) return false;
8252
+ try {
8253
+ const eventId = await this.client.createAndPublishEvent({
8254
+ kind: NIP29_KINDS.REMOVE_USER,
8255
+ tags: [["h", groupId], ["p", userPubkey]],
8256
+ content: reason || ""
8257
+ });
8258
+ if (eventId) {
8259
+ this.removeMemberFromMemory(groupId, userPubkey);
8260
+ this.deps.emitEvent("groupchat:updated", {});
8261
+ this.persistMembers();
8262
+ return true;
8263
+ }
8264
+ return false;
8265
+ } catch (error) {
8266
+ console.error("[GroupChat] Failed to kick user", error);
8267
+ return false;
8268
+ }
8269
+ }
8270
+ async deleteMessage(groupId, messageId) {
8271
+ await this.ensureConnected();
8272
+ if (!this.client) return false;
8273
+ const canModerate = await this.canModerateGroup(groupId);
8274
+ if (!canModerate) return false;
8275
+ try {
8276
+ const eventId = await this.client.createAndPublishEvent({
8277
+ kind: NIP29_KINDS.DELETE_EVENT,
8278
+ tags: [["h", groupId], ["e", messageId]],
8279
+ content: ""
8280
+ });
8281
+ if (eventId) {
8282
+ this.deleteMessageFromMemory(groupId, messageId);
8283
+ this.deps.emitEvent("groupchat:updated", {});
8284
+ this.persistMessages();
8285
+ return true;
8286
+ }
8287
+ return false;
8288
+ } catch (error) {
8289
+ console.error("[GroupChat] Failed to delete message", error);
8290
+ return false;
8291
+ }
8292
+ }
8293
+ isCurrentUserAdmin(groupId) {
8294
+ const myPubkey = this.getMyPublicKey();
8295
+ if (!myPubkey) return false;
8296
+ const member = this.getMember(groupId, myPubkey);
8297
+ return member?.role === GroupRole.ADMIN;
8298
+ }
8299
+ isCurrentUserModerator(groupId) {
8300
+ const myPubkey = this.getMyPublicKey();
8301
+ if (!myPubkey) return false;
8302
+ const member = this.getMember(groupId, myPubkey);
8303
+ return member?.role === GroupRole.ADMIN || member?.role === GroupRole.MODERATOR;
8304
+ }
8305
+ /**
8306
+ * Check if current user can moderate a group:
8307
+ * - Group admin/moderator can always moderate their group
8308
+ * - Relay admins can moderate public groups
8309
+ */
8310
+ async canModerateGroup(groupId) {
8311
+ if (this.isCurrentUserAdmin(groupId) || this.isCurrentUserModerator(groupId)) {
8312
+ return true;
8313
+ }
8314
+ const group = this.groups.get(groupId);
8315
+ if (group && group.visibility === GroupVisibility.PUBLIC) {
8316
+ return this.isCurrentUserRelayAdmin();
8317
+ }
8318
+ return false;
8319
+ }
8320
+ async isCurrentUserRelayAdmin() {
8321
+ const myPubkey = this.getMyPublicKey();
8322
+ if (!myPubkey) return false;
8323
+ const admins = await this.fetchRelayAdmins();
8324
+ return admins.has(myPubkey);
8325
+ }
8326
+ getCurrentUserRole(groupId) {
8327
+ const myPubkey = this.getMyPublicKey();
8328
+ if (!myPubkey) return null;
8329
+ const member = this.getMember(groupId, myPubkey);
8330
+ return member?.role || null;
8331
+ }
8332
+ // ===========================================================================
8333
+ // Public API — Listeners
8334
+ // ===========================================================================
8335
+ onMessage(handler) {
8336
+ this.messageHandlers.add(handler);
8337
+ return () => this.messageHandlers.delete(handler);
8338
+ }
8339
+ // ===========================================================================
8340
+ // Public API — Utilities
8341
+ // ===========================================================================
8342
+ getRelayUrls() {
8343
+ return this.config.relays;
8344
+ }
8345
+ getMyPublicKey() {
8346
+ return this.keyManager?.getPublicKeyHex() || null;
8347
+ }
8348
+ // ===========================================================================
8349
+ // Private — Relay Admin
8350
+ // ===========================================================================
8351
+ async fetchRelayAdmins() {
8352
+ if (this.relayAdminPubkeys) return this.relayAdminPubkeys;
8353
+ if (this.relayAdminFetchPromise) return this.relayAdminFetchPromise;
8354
+ this.relayAdminFetchPromise = this.doFetchRelayAdmins();
8355
+ const result = await this.relayAdminFetchPromise;
8356
+ this.relayAdminFetchPromise = null;
8357
+ return result;
8358
+ }
8359
+ async doFetchRelayAdmins() {
8360
+ await this.ensureConnected();
8361
+ if (!this.client) return /* @__PURE__ */ new Set();
8362
+ const adminPubkeys = /* @__PURE__ */ new Set();
8363
+ return this.oneshotSubscription(
8364
+ new Filter({ kinds: [NIP29_KINDS.GROUP_ADMINS], "#d": ["", "_"] }),
8365
+ {
8366
+ onEvent: (event) => {
8367
+ const pTags = event.tags.filter((t) => t[0] === "p");
8368
+ for (const tag of pTags) {
8369
+ if (tag[1]) adminPubkeys.add(tag[1]);
8370
+ }
8371
+ },
8372
+ onComplete: () => {
8373
+ this.relayAdminPubkeys = adminPubkeys;
8374
+ return adminPubkeys;
8375
+ }
8376
+ }
8377
+ );
8378
+ }
8379
+ // ===========================================================================
8380
+ // Private — Fetch Helpers
8381
+ // ===========================================================================
8382
+ async fetchGroupMetadataInternal(groupId) {
8383
+ if (!this.client) return null;
8384
+ let result = null;
8385
+ return this.oneshotSubscription(
8386
+ new Filter({ kinds: [NIP29_KINDS.GROUP_METADATA], "#d": [groupId] }),
8387
+ {
8388
+ onEvent: (event) => {
8389
+ if (!result) result = this.parseGroupMetadata(event);
8390
+ },
8391
+ onComplete: () => result
8392
+ }
8393
+ );
8394
+ }
8395
+ async fetchAndSaveMembers(groupId) {
8396
+ const [members, adminPubkeys] = await Promise.all([
8397
+ this.fetchGroupMembersInternal(groupId),
8398
+ this.fetchGroupAdminsInternal(groupId)
8399
+ ]);
8400
+ for (const member of members) {
8401
+ if (adminPubkeys.includes(member.pubkey)) {
8402
+ member.role = GroupRole.ADMIN;
8403
+ }
8404
+ this.saveMemberToMemory(member);
8405
+ }
8406
+ for (const pubkey of adminPubkeys) {
8407
+ const existing = (this.members.get(groupId) || []).find((m) => m.pubkey === pubkey);
8408
+ if (!existing) {
8409
+ this.saveMemberToMemory({
8410
+ pubkey,
8411
+ groupId,
8412
+ role: GroupRole.ADMIN,
8413
+ joinedAt: Date.now()
8414
+ });
8415
+ }
8416
+ }
8417
+ this.persistMembers();
8418
+ }
8419
+ async fetchGroupMembersInternal(groupId) {
8420
+ if (!this.client) return [];
8421
+ const members = [];
8422
+ return this.oneshotSubscription(
8423
+ new Filter({ kinds: [NIP29_KINDS.GROUP_MEMBERS], "#d": [groupId] }),
8424
+ {
8425
+ onEvent: (event) => {
8426
+ const pTags = event.tags.filter((t) => t[0] === "p");
8427
+ for (const tag of pTags) {
8428
+ members.push({
8429
+ pubkey: tag[1],
8430
+ groupId,
8431
+ role: tag[3] || GroupRole.MEMBER,
8432
+ joinedAt: event.created_at * 1e3
8433
+ });
8434
+ }
8435
+ },
8436
+ onComplete: () => members
8437
+ }
8438
+ );
8439
+ }
8440
+ async fetchGroupAdminsInternal(groupId) {
8441
+ if (!this.client) return [];
8442
+ const adminPubkeys = [];
8443
+ return this.oneshotSubscription(
8444
+ new Filter({ kinds: [NIP29_KINDS.GROUP_ADMINS], "#d": [groupId] }),
8445
+ {
8446
+ onEvent: (event) => {
8447
+ const pTags = event.tags.filter((t) => t[0] === "p");
8448
+ for (const tag of pTags) {
8449
+ if (tag[1] && !adminPubkeys.includes(tag[1])) {
8450
+ adminPubkeys.push(tag[1]);
8451
+ }
8452
+ }
8453
+ },
8454
+ onComplete: () => adminPubkeys
8455
+ }
8456
+ );
8457
+ }
8458
+ // ===========================================================================
8459
+ // Private — In-Memory State Helpers
7256
8460
  // ===========================================================================
7257
- /**
7258
- * Send direct message
7259
- */
7260
- async sendDM(recipient, content) {
7261
- this.ensureInitialized();
7262
- const recipientPubkey = await this.resolveRecipient(recipient);
7263
- const eventId = await this.deps.transport.sendMessage(recipientPubkey, content);
7264
- const message = {
7265
- id: eventId,
7266
- senderPubkey: this.deps.identity.chainPubkey,
7267
- senderNametag: this.deps.identity.nametag,
7268
- recipientPubkey,
7269
- content,
7270
- timestamp: Date.now(),
7271
- isRead: true
7272
- };
7273
- this.messages.set(message.id, message);
7274
- if (this.config.autoSave) {
7275
- await this.save();
8461
+ saveMessageToMemory(message) {
8462
+ const groupId = message.groupId;
8463
+ if (!this.messages.has(groupId)) {
8464
+ this.messages.set(groupId, []);
8465
+ }
8466
+ const msgs = this.messages.get(groupId);
8467
+ const idx = msgs.findIndex((m) => m.id === message.id);
8468
+ if (idx >= 0) {
8469
+ msgs[idx] = message;
8470
+ } else {
8471
+ msgs.push(message);
8472
+ const maxMessages = this.config.defaultMessageLimit * 2;
8473
+ if (msgs.length > maxMessages) {
8474
+ msgs.splice(0, msgs.length - maxMessages);
8475
+ }
7276
8476
  }
7277
- return message;
7278
8477
  }
7279
- /**
7280
- * Get conversation with peer
7281
- */
7282
- getConversation(peerPubkey) {
7283
- return Array.from(this.messages.values()).filter(
7284
- (m) => m.senderPubkey === peerPubkey || m.recipientPubkey === peerPubkey
7285
- ).sort((a, b) => a.timestamp - b.timestamp);
8478
+ deleteMessageFromMemory(groupId, messageId) {
8479
+ const msgs = this.messages.get(groupId);
8480
+ if (msgs) {
8481
+ const idx = msgs.findIndex((m) => m.id === messageId);
8482
+ if (idx >= 0) msgs.splice(idx, 1);
8483
+ }
7286
8484
  }
7287
- /**
7288
- * Get all conversations grouped by peer
7289
- */
7290
- getConversations() {
7291
- const conversations = /* @__PURE__ */ new Map();
7292
- for (const message of this.messages.values()) {
7293
- const peer = message.senderPubkey === this.deps?.identity.chainPubkey ? message.recipientPubkey : message.senderPubkey;
7294
- if (!conversations.has(peer)) {
7295
- conversations.set(peer, []);
7296
- }
7297
- conversations.get(peer).push(message);
8485
+ saveMemberToMemory(member) {
8486
+ const groupId = member.groupId;
8487
+ if (!this.members.has(groupId)) {
8488
+ this.members.set(groupId, []);
7298
8489
  }
7299
- for (const msgs of conversations.values()) {
7300
- msgs.sort((a, b) => a.timestamp - b.timestamp);
8490
+ const mems = this.members.get(groupId);
8491
+ const idx = mems.findIndex((m) => m.pubkey === member.pubkey);
8492
+ if (idx >= 0) {
8493
+ mems[idx] = member;
8494
+ } else {
8495
+ mems.push(member);
7301
8496
  }
7302
- return conversations;
7303
8497
  }
7304
- /**
7305
- * Mark messages as read
7306
- */
7307
- async markAsRead(messageIds) {
7308
- for (const id of messageIds) {
7309
- const msg = this.messages.get(id);
7310
- if (msg) {
7311
- msg.isRead = true;
7312
- }
8498
+ removeMemberFromMemory(groupId, pubkey) {
8499
+ const mems = this.members.get(groupId);
8500
+ if (mems) {
8501
+ const idx = mems.findIndex((m) => m.pubkey === pubkey);
8502
+ if (idx >= 0) mems.splice(idx, 1);
7313
8503
  }
7314
- if (this.config.autoSave) {
7315
- await this.save();
8504
+ const group = this.groups.get(groupId);
8505
+ if (group) {
8506
+ group.memberCount = (this.members.get(groupId) || []).length;
8507
+ this.groups.set(groupId, group);
7316
8508
  }
7317
8509
  }
7318
- /**
7319
- * Get unread count
7320
- */
7321
- getUnreadCount(peerPubkey) {
7322
- let messages = Array.from(this.messages.values()).filter(
7323
- (m) => !m.isRead && m.senderPubkey !== this.deps?.identity.chainPubkey
7324
- );
7325
- if (peerPubkey) {
7326
- messages = messages.filter((m) => m.senderPubkey === peerPubkey);
8510
+ removeGroupFromMemory(groupId) {
8511
+ this.groups.delete(groupId);
8512
+ this.messages.delete(groupId);
8513
+ this.members.delete(groupId);
8514
+ }
8515
+ updateGroupLastMessage(groupId, text, timestamp) {
8516
+ const group = this.groups.get(groupId);
8517
+ if (group && timestamp >= (group.lastMessageTime || 0)) {
8518
+ group.lastMessageText = text;
8519
+ group.lastMessageTime = timestamp;
8520
+ this.groups.set(groupId, group);
7327
8521
  }
7328
- return messages.length;
7329
8522
  }
7330
- /**
7331
- * Subscribe to incoming DMs
7332
- */
7333
- onDirectMessage(handler) {
7334
- this.dmHandlers.add(handler);
7335
- return () => this.dmHandlers.delete(handler);
8523
+ updateMemberNametag(groupId, pubkey, nametag, joinedAt) {
8524
+ const members = this.members.get(groupId) || [];
8525
+ const existing = members.find((m) => m.pubkey === pubkey);
8526
+ if (existing) {
8527
+ if (existing.nametag !== nametag) {
8528
+ existing.nametag = nametag;
8529
+ this.saveMemberToMemory(existing);
8530
+ }
8531
+ } else {
8532
+ this.saveMemberToMemory({
8533
+ pubkey,
8534
+ groupId,
8535
+ role: GroupRole.MEMBER,
8536
+ nametag,
8537
+ joinedAt
8538
+ });
8539
+ }
8540
+ }
8541
+ addProcessedEventId(eventId) {
8542
+ this.processedEventIds.add(eventId);
8543
+ if (this.processedEventIds.size > 1e4) {
8544
+ const arr = Array.from(this.processedEventIds);
8545
+ this.processedEventIds = new Set(arr.slice(arr.length - 1e4));
8546
+ }
7336
8547
  }
7337
8548
  // ===========================================================================
7338
- // Public API - Broadcasts
8549
+ // Private Persistence
7339
8550
  // ===========================================================================
7340
- /**
7341
- * Publish broadcast message
7342
- */
7343
- async broadcast(content, tags) {
7344
- this.ensureInitialized();
7345
- const eventId = await this.deps.transport.publishBroadcast?.(content, tags);
7346
- const message = {
7347
- id: eventId ?? crypto.randomUUID(),
7348
- authorPubkey: this.deps.identity.chainPubkey,
7349
- authorNametag: this.deps.identity.nametag,
7350
- content,
7351
- timestamp: Date.now(),
7352
- tags
7353
- };
7354
- this.broadcasts.set(message.id, message);
7355
- return message;
8551
+ /** Schedule a debounced persist (coalesces rapid event bursts). */
8552
+ schedulePersist() {
8553
+ if (this.persistTimer) return;
8554
+ this.persistTimer = setTimeout(() => {
8555
+ this.persistTimer = null;
8556
+ this.persistPromise = this.doPersistAll().catch((err) => {
8557
+ console.error("[GroupChat] Persistence error:", err);
8558
+ }).finally(() => {
8559
+ this.persistPromise = null;
8560
+ });
8561
+ }, 200);
7356
8562
  }
7357
- /**
7358
- * Subscribe to broadcasts with tags
7359
- */
7360
- subscribeToBroadcasts(tags) {
7361
- this.ensureInitialized();
7362
- const key = tags.sort().join(":");
7363
- if (this.broadcastSubscriptions.has(key)) {
7364
- return () => {
7365
- };
8563
+ /** Persist immediately (for explicit flush points). */
8564
+ async persistAll() {
8565
+ if (this.persistTimer) {
8566
+ clearTimeout(this.persistTimer);
8567
+ this.persistTimer = null;
7366
8568
  }
7367
- const unsub = this.deps.transport.subscribeToBroadcast?.(tags, (broadcast2) => {
7368
- this.handleIncomingBroadcast(broadcast2);
7369
- });
7370
- if (unsub) {
7371
- this.broadcastSubscriptions.set(key, unsub);
8569
+ if (this.persistPromise) {
8570
+ await this.persistPromise;
7372
8571
  }
7373
- return () => {
7374
- const sub = this.broadcastSubscriptions.get(key);
7375
- if (sub) {
7376
- sub();
7377
- this.broadcastSubscriptions.delete(key);
7378
- }
7379
- };
8572
+ await this.doPersistAll();
7380
8573
  }
7381
- /**
7382
- * Get broadcasts
7383
- */
7384
- getBroadcasts(limit) {
7385
- const messages = Array.from(this.broadcasts.values()).sort((a, b) => b.timestamp - a.timestamp);
7386
- return limit ? messages.slice(0, limit) : messages;
8574
+ async doPersistAll() {
8575
+ await Promise.all([
8576
+ this.persistGroups(),
8577
+ this.persistMessages(),
8578
+ this.persistMembers(),
8579
+ this.persistProcessedEvents()
8580
+ ]);
7387
8581
  }
7388
- /**
7389
- * Subscribe to incoming broadcasts
7390
- */
7391
- onBroadcast(handler) {
7392
- this.broadcastHandlers.add(handler);
7393
- return () => this.broadcastHandlers.delete(handler);
8582
+ async persistGroups() {
8583
+ if (!this.deps) return;
8584
+ const data = Array.from(this.groups.values());
8585
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_GROUPS, JSON.stringify(data));
8586
+ }
8587
+ async persistMessages() {
8588
+ if (!this.deps) return;
8589
+ const allMessages = [];
8590
+ for (const msgs of this.messages.values()) {
8591
+ allMessages.push(...msgs);
8592
+ }
8593
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MESSAGES, JSON.stringify(allMessages));
8594
+ }
8595
+ async persistMembers() {
8596
+ if (!this.deps) return;
8597
+ const allMembers = [];
8598
+ for (const mems of this.members.values()) {
8599
+ allMembers.push(...mems);
8600
+ }
8601
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MEMBERS, JSON.stringify(allMembers));
8602
+ }
8603
+ async persistProcessedEvents() {
8604
+ if (!this.deps) return;
8605
+ const arr = Array.from(this.processedEventIds);
8606
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_PROCESSED_EVENTS, JSON.stringify(arr));
7394
8607
  }
7395
8608
  // ===========================================================================
7396
- // Private: Message Handling
8609
+ // Private Relay URL Change Detection
7397
8610
  // ===========================================================================
7398
- handleIncomingMessage(msg) {
7399
- if (msg.senderTransportPubkey === this.deps?.identity.chainPubkey) return;
7400
- const message = {
7401
- id: msg.id,
7402
- senderPubkey: msg.senderTransportPubkey,
7403
- senderNametag: msg.senderNametag,
7404
- recipientPubkey: this.deps.identity.chainPubkey,
7405
- content: msg.content,
7406
- timestamp: msg.timestamp,
7407
- isRead: false
7408
- };
7409
- this.messages.set(message.id, message);
7410
- this.deps.emitEvent("message:dm", message);
7411
- for (const handler of this.dmHandlers) {
7412
- try {
7413
- handler(message);
7414
- } catch (error) {
7415
- console.error("[Communications] Handler error:", error);
8611
+ async checkAndClearOnRelayChange(currentRelayUrl) {
8612
+ if (!this.deps) return;
8613
+ const stored = await this.deps.storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_RELAY_URL);
8614
+ if (stored && stored !== currentRelayUrl) {
8615
+ this.groups.clear();
8616
+ this.messages.clear();
8617
+ this.members.clear();
8618
+ this.processedEventIds.clear();
8619
+ await this.persistAll();
8620
+ }
8621
+ if (!stored) {
8622
+ for (const group of this.groups.values()) {
8623
+ if (group.relayUrl && group.relayUrl !== currentRelayUrl) {
8624
+ this.groups.clear();
8625
+ this.messages.clear();
8626
+ this.members.clear();
8627
+ this.processedEventIds.clear();
8628
+ await this.persistAll();
8629
+ break;
8630
+ }
7416
8631
  }
7417
8632
  }
7418
- if (this.config.autoSave) {
7419
- this.save();
8633
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_RELAY_URL, currentRelayUrl);
8634
+ }
8635
+ // ===========================================================================
8636
+ // Private — Message Content Wrapping
8637
+ // ===========================================================================
8638
+ wrapMessageContent(content, senderNametag) {
8639
+ if (senderNametag) {
8640
+ return JSON.stringify({ senderNametag, text: content });
7420
8641
  }
7421
- this.pruneIfNeeded();
8642
+ return content;
7422
8643
  }
7423
- handleIncomingBroadcast(incoming) {
7424
- const message = {
7425
- id: incoming.id,
7426
- authorPubkey: incoming.authorTransportPubkey,
7427
- content: incoming.content,
7428
- timestamp: incoming.timestamp,
7429
- tags: incoming.tags
7430
- };
7431
- this.broadcasts.set(message.id, message);
7432
- this.deps.emitEvent("message:broadcast", message);
7433
- for (const handler of this.broadcastHandlers) {
7434
- try {
7435
- handler(message);
7436
- } catch (error) {
7437
- console.error("[Communications] Handler error:", error);
8644
+ unwrapMessageContent(content) {
8645
+ try {
8646
+ const parsed = JSON.parse(content);
8647
+ if (typeof parsed === "object" && parsed.text !== void 0) {
8648
+ return { text: parsed.text, senderNametag: parsed.senderNametag || null };
7438
8649
  }
8650
+ } catch {
7439
8651
  }
8652
+ return { text: content, senderNametag: null };
7440
8653
  }
7441
8654
  // ===========================================================================
7442
- // Private: Storage
8655
+ // Private — Event Tag Helpers
7443
8656
  // ===========================================================================
7444
- async save() {
7445
- const messages = Array.from(this.messages.values());
7446
- await this.deps.storage.set("direct_messages", JSON.stringify(messages));
8657
+ getGroupIdFromEvent(event) {
8658
+ const hTag = event.tags.find((t) => t[0] === "h");
8659
+ return hTag ? hTag[1] : null;
7447
8660
  }
7448
- pruneIfNeeded() {
7449
- if (this.messages.size <= this.config.maxMessages) return;
7450
- const sorted = Array.from(this.messages.entries()).sort(([, a], [, b]) => a.timestamp - b.timestamp);
7451
- const toRemove = sorted.slice(0, sorted.length - this.config.maxMessages);
7452
- for (const [id] of toRemove) {
7453
- this.messages.delete(id);
8661
+ getGroupIdFromMetadataEvent(event) {
8662
+ const dTag = event.tags.find((t) => t[0] === "d");
8663
+ if (dTag?.[1]) return dTag[1];
8664
+ const hTag = event.tags.find((t) => t[0] === "h");
8665
+ return hTag?.[1] ?? null;
8666
+ }
8667
+ extractReplyTo(event) {
8668
+ const eTag = event.tags.find((t) => t[0] === "e" && t[3] === "reply");
8669
+ return eTag ? eTag[1] : void 0;
8670
+ }
8671
+ extractPreviousIds(event) {
8672
+ const previousTag = event.tags.find((t) => t[0] === "previous");
8673
+ return previousTag ? previousTag.slice(1) : void 0;
8674
+ }
8675
+ parseGroupMetadata(event) {
8676
+ try {
8677
+ const groupId = this.getGroupIdFromMetadataEvent(event);
8678
+ if (!groupId) return null;
8679
+ let name = "Unnamed Group";
8680
+ let description;
8681
+ let picture;
8682
+ let isPrivate = false;
8683
+ if (event.content && event.content.trim()) {
8684
+ try {
8685
+ const metadata = JSON.parse(event.content);
8686
+ name = metadata.name || name;
8687
+ description = metadata.about || metadata.description;
8688
+ picture = metadata.picture;
8689
+ isPrivate = metadata.private === true;
8690
+ } catch {
8691
+ }
8692
+ }
8693
+ for (const tag of event.tags) {
8694
+ if (tag[0] === "name" && tag[1]) name = tag[1];
8695
+ if (tag[0] === "about" && tag[1]) description = tag[1];
8696
+ if (tag[0] === "picture" && tag[1]) picture = tag[1];
8697
+ if (tag[0] === "private") isPrivate = true;
8698
+ if (tag[0] === "public" && tag[1] === "false") isPrivate = true;
8699
+ }
8700
+ return {
8701
+ id: groupId,
8702
+ relayUrl: this.config.relays[0] || "",
8703
+ name,
8704
+ description,
8705
+ picture,
8706
+ visibility: isPrivate ? GroupVisibility.PRIVATE : GroupVisibility.PUBLIC,
8707
+ createdAt: event.created_at * 1e3
8708
+ };
8709
+ } catch {
8710
+ return null;
7454
8711
  }
7455
8712
  }
7456
8713
  // ===========================================================================
7457
- // Private: Helpers
8714
+ // Private — Utility
7458
8715
  // ===========================================================================
7459
- async resolveRecipient(recipient) {
7460
- if (recipient.startsWith("@")) {
7461
- const pubkey = await this.deps.transport.resolveNametag?.(recipient.slice(1));
7462
- if (!pubkey) {
7463
- throw new Error(`Nametag not found: ${recipient}`);
7464
- }
7465
- return pubkey;
7466
- }
7467
- return recipient;
8716
+ /** Subscribe and track the subscription ID for cleanup. */
8717
+ trackSubscription(filter, handlers) {
8718
+ const subId = this.client.subscribe(filter, {
8719
+ onEvent: handlers.onEvent,
8720
+ onEndOfStoredEvents: handlers.onEndOfStoredEvents ?? (() => {
8721
+ })
8722
+ });
8723
+ this.subscriptionIds.push(subId);
8724
+ return subId;
8725
+ }
8726
+ /** Subscribe for a one-shot fetch, auto-unsubscribe on EOSE or timeout. */
8727
+ oneshotSubscription(filter, opts) {
8728
+ return new Promise((resolve) => {
8729
+ let done = false;
8730
+ let subId;
8731
+ const finish = () => {
8732
+ if (done) return;
8733
+ done = true;
8734
+ if (subId) {
8735
+ try {
8736
+ this.client.unsubscribe(subId);
8737
+ } catch {
8738
+ }
8739
+ const idx = this.subscriptionIds.indexOf(subId);
8740
+ if (idx >= 0) this.subscriptionIds.splice(idx, 1);
8741
+ }
8742
+ resolve(opts.onComplete());
8743
+ };
8744
+ subId = this.client.subscribe(filter, {
8745
+ onEvent: (event) => {
8746
+ if (!done) opts.onEvent(event);
8747
+ },
8748
+ onEndOfStoredEvents: finish
8749
+ });
8750
+ this.subscriptionIds.push(subId);
8751
+ setTimeout(finish, opts.timeoutMs ?? 5e3);
8752
+ });
7468
8753
  }
7469
8754
  ensureInitialized() {
7470
8755
  if (!this.deps) {
7471
- throw new Error("CommunicationsModule not initialized");
8756
+ throw new Error("GroupChatModule not initialized");
8757
+ }
8758
+ }
8759
+ async ensureConnected() {
8760
+ if (!this.connected) {
8761
+ await this.connect();
8762
+ }
8763
+ }
8764
+ randomId() {
8765
+ const bytes = new Uint8Array(8);
8766
+ if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.getRandomValues) {
8767
+ globalThis.crypto.getRandomValues(bytes);
8768
+ } else {
8769
+ for (let i = 0; i < bytes.length; i++) {
8770
+ bytes[i] = Math.floor(Math.random() * 256);
8771
+ }
7472
8772
  }
8773
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
7473
8774
  }
7474
8775
  };
7475
- function createCommunicationsModule(config) {
7476
- return new CommunicationsModule(config);
8776
+ function createGroupChatModule(config) {
8777
+ return new GroupChatModule(config);
7477
8778
  }
7478
8779
 
7479
8780
  // core/encryption.ts
@@ -8389,12 +9690,13 @@ var Sphere = class _Sphere {
8389
9690
  // Modules
8390
9691
  _payments;
8391
9692
  _communications;
9693
+ _groupChat = null;
8392
9694
  // Events
8393
9695
  eventHandlers = /* @__PURE__ */ new Map();
8394
9696
  // ===========================================================================
8395
9697
  // Constructor (private)
8396
9698
  // ===========================================================================
8397
- constructor(storage, transport, oracle, tokenStorage, l1Config, priceProvider) {
9699
+ constructor(storage, transport, oracle, tokenStorage, l1Config, priceProvider, groupChatConfig) {
8398
9700
  this._storage = storage;
8399
9701
  this._transport = transport;
8400
9702
  this._oracle = oracle;
@@ -8404,6 +9706,7 @@ var Sphere = class _Sphere {
8404
9706
  }
8405
9707
  this._payments = createPaymentsModule({ l1: l1Config });
8406
9708
  this._communications = createCommunicationsModule();
9709
+ this._groupChat = groupChatConfig ? createGroupChatModule(groupChatConfig) : null;
8407
9710
  }
8408
9711
  // ===========================================================================
8409
9712
  // Static Methods - Wallet Management
@@ -8451,6 +9754,7 @@ var Sphere = class _Sphere {
8451
9754
  * ```
8452
9755
  */
8453
9756
  static async init(options) {
9757
+ const groupChat = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
8454
9758
  const walletExists = await _Sphere.exists(options.storage);
8455
9759
  if (walletExists) {
8456
9760
  const sphere2 = await _Sphere.load({
@@ -8459,7 +9763,8 @@ var Sphere = class _Sphere {
8459
9763
  oracle: options.oracle,
8460
9764
  tokenStorage: options.tokenStorage,
8461
9765
  l1: options.l1,
8462
- price: options.price
9766
+ price: options.price,
9767
+ groupChat
8463
9768
  });
8464
9769
  return { sphere: sphere2, created: false };
8465
9770
  }
@@ -8484,10 +9789,34 @@ var Sphere = class _Sphere {
8484
9789
  derivationPath: options.derivationPath,
8485
9790
  nametag: options.nametag,
8486
9791
  l1: options.l1,
8487
- price: options.price
9792
+ price: options.price,
9793
+ groupChat
8488
9794
  });
8489
9795
  return { sphere, created: true, generatedMnemonic };
8490
9796
  }
9797
+ /**
9798
+ * Resolve groupChat config from init/create/load options.
9799
+ * - `true` → use network-default relays
9800
+ * - `GroupChatModuleConfig` → pass through
9801
+ * - `undefined` → no groupchat
9802
+ */
9803
+ /**
9804
+ * Resolve GroupChat config from Sphere.init() options.
9805
+ * Note: impl/shared/resolvers.ts has a similar resolver for provider-level config
9806
+ * (different input shape: { enabled?, relays? }). Both fill relay URLs from network defaults.
9807
+ */
9808
+ static resolveGroupChatConfig(config, network) {
9809
+ if (!config) return void 0;
9810
+ if (config === true) {
9811
+ const netConfig = network ? NETWORKS[network] : NETWORKS.mainnet;
9812
+ return { relays: [...netConfig.groupRelays] };
9813
+ }
9814
+ if (!config.relays || config.relays.length === 0) {
9815
+ const netConfig = network ? NETWORKS[network] : NETWORKS.mainnet;
9816
+ return { ...config, relays: [...netConfig.groupRelays] };
9817
+ }
9818
+ return config;
9819
+ }
8491
9820
  /**
8492
9821
  * Create new wallet with mnemonic
8493
9822
  */
@@ -8498,13 +9827,15 @@ var Sphere = class _Sphere {
8498
9827
  if (await _Sphere.exists(options.storage)) {
8499
9828
  throw new Error("Wallet already exists. Use Sphere.load() or Sphere.clear() first.");
8500
9829
  }
9830
+ const groupChatConfig = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
8501
9831
  const sphere = new _Sphere(
8502
9832
  options.storage,
8503
9833
  options.transport,
8504
9834
  options.oracle,
8505
9835
  options.tokenStorage,
8506
9836
  options.l1,
8507
- options.price
9837
+ options.price,
9838
+ groupChatConfig
8508
9839
  );
8509
9840
  await sphere.storeMnemonic(options.mnemonic, options.derivationPath);
8510
9841
  await sphere.initializeIdentityFromMnemonic(options.mnemonic, options.derivationPath);
@@ -8529,13 +9860,15 @@ var Sphere = class _Sphere {
8529
9860
  if (!await _Sphere.exists(options.storage)) {
8530
9861
  throw new Error("No wallet found. Use Sphere.create() to create a new wallet.");
8531
9862
  }
9863
+ const groupChatConfig = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
8532
9864
  const sphere = new _Sphere(
8533
9865
  options.storage,
8534
9866
  options.transport,
8535
9867
  options.oracle,
8536
9868
  options.tokenStorage,
8537
9869
  options.l1,
8538
- options.price
9870
+ options.price,
9871
+ groupChatConfig
8539
9872
  );
8540
9873
  await sphere.loadIdentityFromStorage();
8541
9874
  await sphere.initializeProviders();
@@ -8574,13 +9907,15 @@ var Sphere = class _Sphere {
8574
9907
  await options.storage.connect();
8575
9908
  console.log("[Sphere.import] Storage reconnected");
8576
9909
  }
9910
+ const groupChatConfig = _Sphere.resolveGroupChatConfig(options.groupChat);
8577
9911
  const sphere = new _Sphere(
8578
9912
  options.storage,
8579
9913
  options.transport,
8580
9914
  options.oracle,
8581
9915
  options.tokenStorage,
8582
9916
  options.l1,
8583
- options.price
9917
+ options.price,
9918
+ groupChatConfig
8584
9919
  );
8585
9920
  if (options.mnemonic) {
8586
9921
  if (!_Sphere.validateMnemonic(options.mnemonic)) {
@@ -8740,6 +10075,10 @@ var Sphere = class _Sphere {
8740
10075
  this.ensureReady();
8741
10076
  return this._communications;
8742
10077
  }
10078
+ /** Group chat module (NIP-29). Null if not configured. */
10079
+ get groupChat() {
10080
+ return this._groupChat;
10081
+ }
8743
10082
  // ===========================================================================
8744
10083
  // Public Properties - State
8745
10084
  // ===========================================================================
@@ -9610,8 +10949,14 @@ var Sphere = class _Sphere {
9610
10949
  transport: this._transport,
9611
10950
  emitEvent
9612
10951
  });
10952
+ this._groupChat?.initialize({
10953
+ identity: this._identity,
10954
+ storage: this._storage,
10955
+ emitEvent
10956
+ });
9613
10957
  await this._payments.load();
9614
10958
  await this._communications.load();
10959
+ await this._groupChat?.load();
9615
10960
  }
9616
10961
  /**
9617
10962
  * Derive address at a specific index
@@ -10214,6 +11559,7 @@ var Sphere = class _Sphere {
10214
11559
  async destroy() {
10215
11560
  this._payments.destroy();
10216
11561
  this._communications.destroy();
11562
+ this._groupChat?.destroy();
10217
11563
  await this._transport.disconnect();
10218
11564
  await this._storage.disconnect();
10219
11565
  await this._oracle.disconnect();
@@ -10434,8 +11780,14 @@ var Sphere = class _Sphere {
10434
11780
  transport: this._transport,
10435
11781
  emitEvent
10436
11782
  });
11783
+ this._groupChat?.initialize({
11784
+ identity: this._identity,
11785
+ storage: this._storage,
11786
+ emitEvent
11787
+ });
10437
11788
  await this._payments.load();
10438
11789
  await this._communications.load();
11790
+ await this._groupChat?.load();
10439
11791
  }
10440
11792
  // ===========================================================================
10441
11793
  // Private: Helpers