@unicitylabs/sphere-sdk 0.2.5 → 0.3.1

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.
@@ -2396,7 +2396,17 @@ var STORAGE_KEYS_GLOBAL = {
2396
2396
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
2397
2397
  TRACKED_ADDRESSES: "tracked_addresses",
2398
2398
  /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
2399
- LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
2399
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts",
2400
+ /** Group chat: joined groups */
2401
+ GROUP_CHAT_GROUPS: "group_chat_groups",
2402
+ /** Group chat: messages */
2403
+ GROUP_CHAT_MESSAGES: "group_chat_messages",
2404
+ /** Group chat: members */
2405
+ GROUP_CHAT_MEMBERS: "group_chat_members",
2406
+ /** Group chat: processed event IDs for deduplication */
2407
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
2408
+ /** Group chat: last used relay URL (stale data detection) */
2409
+ GROUP_CHAT_RELAY_URL: "group_chat_relay_url"
2400
2410
  };
2401
2411
  var STORAGE_KEYS_ADDRESS = {
2402
2412
  /** Pending transfers for this address */
@@ -2427,13 +2437,95 @@ function getAddressId(directAddress) {
2427
2437
  const last = hash.slice(-6).toLowerCase();
2428
2438
  return `DIRECT_${first}_${last}`;
2429
2439
  }
2440
+ var DEFAULT_NOSTR_RELAYS = [
2441
+ "wss://relay.unicity.network",
2442
+ "wss://relay.damus.io",
2443
+ "wss://nos.lol",
2444
+ "wss://relay.nostr.band"
2445
+ ];
2446
+ var NIP29_KINDS = {
2447
+ /** Chat message sent to group */
2448
+ CHAT_MESSAGE: 9,
2449
+ /** Thread root message */
2450
+ THREAD_ROOT: 11,
2451
+ /** Thread reply message */
2452
+ THREAD_REPLY: 12,
2453
+ /** User join request */
2454
+ JOIN_REQUEST: 9021,
2455
+ /** User leave request */
2456
+ LEAVE_REQUEST: 9022,
2457
+ /** Admin: add/update user */
2458
+ PUT_USER: 9e3,
2459
+ /** Admin: remove user */
2460
+ REMOVE_USER: 9001,
2461
+ /** Admin: edit group metadata */
2462
+ EDIT_METADATA: 9002,
2463
+ /** Admin: delete event */
2464
+ DELETE_EVENT: 9005,
2465
+ /** Admin: create group */
2466
+ CREATE_GROUP: 9007,
2467
+ /** Admin: delete group */
2468
+ DELETE_GROUP: 9008,
2469
+ /** Admin: create invite code */
2470
+ CREATE_INVITE: 9009,
2471
+ /** Relay-signed group metadata */
2472
+ GROUP_METADATA: 39e3,
2473
+ /** Relay-signed group admins */
2474
+ GROUP_ADMINS: 39001,
2475
+ /** Relay-signed group members */
2476
+ GROUP_MEMBERS: 39002,
2477
+ /** Relay-signed group roles */
2478
+ GROUP_ROLES: 39003
2479
+ };
2480
+ var DEFAULT_AGGREGATOR_URL = "https://aggregator.unicity.network/rpc";
2481
+ var DEV_AGGREGATOR_URL = "https://dev-aggregator.dyndns.org/rpc";
2482
+ var TEST_AGGREGATOR_URL = "https://goggregator-test.unicity.network";
2483
+ var DEFAULT_IPFS_GATEWAYS = [
2484
+ "https://ipfs.unicity.network",
2485
+ "https://dweb.link",
2486
+ "https://ipfs.io"
2487
+ ];
2430
2488
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
2431
2489
  var DEFAULT_DERIVATION_PATH2 = `${DEFAULT_BASE_PATH}/0/0`;
2490
+ var DEFAULT_ELECTRUM_URL = "wss://fulcrum.alpha.unicity.network:50004";
2491
+ var TEST_ELECTRUM_URL = "wss://fulcrum.alpha.testnet.unicity.network:50004";
2492
+ var TEST_NOSTR_RELAYS = [
2493
+ "wss://nostr-relay.testnet.unicity.network"
2494
+ ];
2495
+ var DEFAULT_GROUP_RELAYS = [
2496
+ "wss://sphere-relay.unicity.network"
2497
+ ];
2498
+ var NETWORKS = {
2499
+ mainnet: {
2500
+ name: "Mainnet",
2501
+ aggregatorUrl: DEFAULT_AGGREGATOR_URL,
2502
+ nostrRelays: DEFAULT_NOSTR_RELAYS,
2503
+ ipfsGateways: DEFAULT_IPFS_GATEWAYS,
2504
+ electrumUrl: DEFAULT_ELECTRUM_URL,
2505
+ groupRelays: DEFAULT_GROUP_RELAYS
2506
+ },
2507
+ testnet: {
2508
+ name: "Testnet",
2509
+ aggregatorUrl: TEST_AGGREGATOR_URL,
2510
+ nostrRelays: TEST_NOSTR_RELAYS,
2511
+ ipfsGateways: DEFAULT_IPFS_GATEWAYS,
2512
+ electrumUrl: TEST_ELECTRUM_URL,
2513
+ groupRelays: DEFAULT_GROUP_RELAYS
2514
+ },
2515
+ dev: {
2516
+ name: "Development",
2517
+ aggregatorUrl: DEV_AGGREGATOR_URL,
2518
+ nostrRelays: TEST_NOSTR_RELAYS,
2519
+ ipfsGateways: DEFAULT_IPFS_GATEWAYS,
2520
+ electrumUrl: TEST_ELECTRUM_URL,
2521
+ groupRelays: DEFAULT_GROUP_RELAYS
2522
+ }
2523
+ };
2432
2524
 
2433
2525
  // types/txf.ts
2434
2526
  var ARCHIVED_PREFIX = "archived-";
2435
2527
  var FORKED_PREFIX = "_forked_";
2436
- var RESERVED_KEYS = ["_meta", "_nametag", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2528
+ var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2437
2529
  function isTokenKey(key) {
2438
2530
  return key.startsWith("_") && !key.startsWith(ARCHIVED_PREFIX) && !key.startsWith(FORKED_PREFIX) && !RESERVED_KEYS.includes(key);
2439
2531
  }
@@ -2469,94 +2561,388 @@ function parseForkedKey(key) {
2469
2561
  };
2470
2562
  }
2471
2563
 
2472
- // serialization/txf-serializer.ts
2473
- function bytesToHex3(bytes) {
2474
- const arr = Array.isArray(bytes) ? bytes : Array.from(bytes);
2475
- return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
2476
- }
2477
- function normalizeToHex(value) {
2478
- if (typeof value === "string") {
2479
- return value;
2564
+ // registry/token-registry.testnet.json
2565
+ var token_registry_testnet_default = [
2566
+ {
2567
+ network: "unicity:testnet",
2568
+ assetKind: "non-fungible",
2569
+ name: "unicity",
2570
+ description: "Unicity testnet token type",
2571
+ id: "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"
2572
+ },
2573
+ {
2574
+ network: "unicity:testnet",
2575
+ assetKind: "fungible",
2576
+ name: "unicity",
2577
+ symbol: "UCT",
2578
+ decimals: 18,
2579
+ description: "Unicity testnet native coin",
2580
+ icons: [
2581
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity_logo_32.png" }
2582
+ ],
2583
+ id: "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"
2584
+ },
2585
+ {
2586
+ network: "unicity:testnet",
2587
+ assetKind: "fungible",
2588
+ name: "unicity-usd",
2589
+ symbol: "USDU",
2590
+ decimals: 6,
2591
+ description: "Unicity testnet USD stablecoin",
2592
+ icons: [
2593
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/usdu_logo_32.png" }
2594
+ ],
2595
+ id: "8f0f3d7a5e7297be0ee98c63b81bcebb2740f43f616566fc290f9823a54f52d7"
2596
+ },
2597
+ {
2598
+ network: "unicity:testnet",
2599
+ assetKind: "fungible",
2600
+ name: "unicity-eur",
2601
+ symbol: "EURU",
2602
+ decimals: 6,
2603
+ description: "Unicity testnet EUR stablecoin",
2604
+ icons: [
2605
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/euru_logo_32.png" }
2606
+ ],
2607
+ id: "5e160d5e9fdbb03b553fb9c3f6e6c30efa41fa807be39fb4f18e43776e492925"
2608
+ },
2609
+ {
2610
+ network: "unicity:testnet",
2611
+ assetKind: "fungible",
2612
+ name: "solana",
2613
+ symbol: "SOL",
2614
+ decimals: 9,
2615
+ description: "Solana testnet coin on Unicity",
2616
+ icons: [
2617
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/sol.svg" },
2618
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/sol.png" }
2619
+ ],
2620
+ id: "dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93"
2621
+ },
2622
+ {
2623
+ network: "unicity:testnet",
2624
+ assetKind: "fungible",
2625
+ name: "bitcoin",
2626
+ symbol: "BTC",
2627
+ decimals: 8,
2628
+ description: "Bitcoin testnet coin on Unicity",
2629
+ icons: [
2630
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/btc.svg" },
2631
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/btc.png" }
2632
+ ],
2633
+ id: "86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa"
2634
+ },
2635
+ {
2636
+ network: "unicity:testnet",
2637
+ assetKind: "fungible",
2638
+ name: "ethereum",
2639
+ symbol: "ETH",
2640
+ decimals: 18,
2641
+ description: "Ethereum testnet coin on Unicity",
2642
+ icons: [
2643
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/eth.svg" },
2644
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/eth.png" }
2645
+ ],
2646
+ id: "3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb"
2647
+ },
2648
+ {
2649
+ network: "unicity:testnet",
2650
+ assetKind: "fungible",
2651
+ name: "alpha_test",
2652
+ symbol: "ALPHT",
2653
+ decimals: 8,
2654
+ description: "ALPHA testnet coin on Unicity",
2655
+ icons: [
2656
+ { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/alpha_coin.png" }
2657
+ ],
2658
+ id: "cde78ded16ef65818a51f43138031c4284e519300ab0cb60c30a8f9078080e5f"
2659
+ },
2660
+ {
2661
+ network: "unicity:testnet",
2662
+ assetKind: "fungible",
2663
+ name: "tether",
2664
+ symbol: "USDT",
2665
+ decimals: 6,
2666
+ description: "Tether (Ethereum) testnet coin on Unicity",
2667
+ icons: [
2668
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdt.svg" },
2669
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdt.png" }
2670
+ ],
2671
+ id: "40d25444648418fe7efd433e147187a3a6adf049ac62bc46038bda5b960bf690"
2672
+ },
2673
+ {
2674
+ network: "unicity:testnet",
2675
+ assetKind: "fungible",
2676
+ name: "usd-coin",
2677
+ symbol: "USDC",
2678
+ decimals: 6,
2679
+ description: "USDC (Ethereum) testnet coin on Unicity",
2680
+ icons: [
2681
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdc.svg" },
2682
+ { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdc.png" }
2683
+ ],
2684
+ id: "2265121770fa6f41131dd9a6cc571e28679263d09a53eb2642e145b5b9a5b0a2"
2480
2685
  }
2481
- if (value && typeof value === "object") {
2482
- const obj = value;
2483
- if ("bytes" in obj && (Array.isArray(obj.bytes) || obj.bytes instanceof Uint8Array)) {
2484
- return bytesToHex3(obj.bytes);
2485
- }
2486
- if (obj.type === "Buffer" && Array.isArray(obj.data)) {
2487
- return bytesToHex3(obj.data);
2488
- }
2686
+ ];
2687
+
2688
+ // registry/TokenRegistry.ts
2689
+ var TokenRegistry = class _TokenRegistry {
2690
+ static instance = null;
2691
+ definitionsById;
2692
+ definitionsBySymbol;
2693
+ definitionsByName;
2694
+ constructor() {
2695
+ this.definitionsById = /* @__PURE__ */ new Map();
2696
+ this.definitionsBySymbol = /* @__PURE__ */ new Map();
2697
+ this.definitionsByName = /* @__PURE__ */ new Map();
2698
+ this.loadRegistry();
2489
2699
  }
2490
- return String(value);
2491
- }
2492
- function normalizeSdkTokenToStorage(sdkTokenJson) {
2493
- const txf = JSON.parse(JSON.stringify(sdkTokenJson));
2494
- if (txf.genesis?.data) {
2495
- const data = txf.genesis.data;
2496
- if (data.tokenId !== void 0) {
2497
- data.tokenId = normalizeToHex(data.tokenId);
2498
- }
2499
- if (data.tokenType !== void 0) {
2500
- data.tokenType = normalizeToHex(data.tokenType);
2501
- }
2502
- if (data.salt !== void 0) {
2503
- data.salt = normalizeToHex(data.salt);
2700
+ /**
2701
+ * Get singleton instance of TokenRegistry
2702
+ */
2703
+ static getInstance() {
2704
+ if (!_TokenRegistry.instance) {
2705
+ _TokenRegistry.instance = new _TokenRegistry();
2504
2706
  }
2707
+ return _TokenRegistry.instance;
2505
2708
  }
2506
- if (txf.genesis?.inclusionProof?.authenticator) {
2507
- const auth = txf.genesis.inclusionProof.authenticator;
2508
- if (auth.publicKey !== void 0) {
2509
- auth.publicKey = normalizeToHex(auth.publicKey);
2510
- }
2511
- if (auth.signature !== void 0) {
2512
- auth.signature = normalizeToHex(auth.signature);
2513
- }
2709
+ /**
2710
+ * Reset the singleton instance (useful for testing)
2711
+ */
2712
+ static resetInstance() {
2713
+ _TokenRegistry.instance = null;
2514
2714
  }
2515
- if (Array.isArray(txf.transactions)) {
2516
- for (const tx of txf.transactions) {
2517
- if (tx.inclusionProof?.authenticator) {
2518
- const auth = tx.inclusionProof.authenticator;
2519
- if (auth.publicKey !== void 0) {
2520
- auth.publicKey = normalizeToHex(auth.publicKey);
2521
- }
2522
- if (auth.signature !== void 0) {
2523
- auth.signature = normalizeToHex(auth.signature);
2524
- }
2715
+ /**
2716
+ * Load registry data from bundled JSON
2717
+ */
2718
+ loadRegistry() {
2719
+ const definitions = token_registry_testnet_default;
2720
+ for (const def of definitions) {
2721
+ const idLower = def.id.toLowerCase();
2722
+ this.definitionsById.set(idLower, def);
2723
+ if (def.symbol) {
2724
+ this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
2525
2725
  }
2726
+ this.definitionsByName.set(def.name.toLowerCase(), def);
2526
2727
  }
2527
2728
  }
2528
- return txf;
2529
- }
2530
- function tokenToTxf(token) {
2531
- const jsonData = token.sdkData;
2532
- if (!jsonData) {
2533
- return null;
2534
- }
2535
- try {
2536
- const txfData = normalizeSdkTokenToStorage(JSON.parse(jsonData));
2537
- if (!txfData.genesis || !txfData.state) {
2538
- return null;
2539
- }
2540
- if (!txfData.version) {
2541
- txfData.version = "2.0";
2542
- }
2543
- if (!txfData.transactions) {
2544
- txfData.transactions = [];
2545
- }
2546
- if (!txfData.nametags) {
2547
- txfData.nametags = [];
2548
- }
2549
- if (!txfData._integrity) {
2550
- txfData._integrity = {
2551
- genesisDataJSONHash: "0000" + "0".repeat(60)
2552
- };
2553
- }
2554
- return txfData;
2555
- } catch {
2556
- return null;
2729
+ // ===========================================================================
2730
+ // Lookup Methods
2731
+ // ===========================================================================
2732
+ /**
2733
+ * Get token definition by hex coin ID
2734
+ * @param coinId - 64-character hex string
2735
+ * @returns Token definition or undefined if not found
2736
+ */
2737
+ getDefinition(coinId) {
2738
+ if (!coinId) return void 0;
2739
+ return this.definitionsById.get(coinId.toLowerCase());
2557
2740
  }
2558
- }
2559
- function determineTokenStatus(txf) {
2741
+ /**
2742
+ * Get token definition by symbol (e.g., "UCT", "BTC")
2743
+ * @param symbol - Token symbol (case-insensitive)
2744
+ * @returns Token definition or undefined if not found
2745
+ */
2746
+ getDefinitionBySymbol(symbol) {
2747
+ if (!symbol) return void 0;
2748
+ return this.definitionsBySymbol.get(symbol.toUpperCase());
2749
+ }
2750
+ /**
2751
+ * Get token definition by name (e.g., "bitcoin", "ethereum")
2752
+ * @param name - Token name (case-insensitive)
2753
+ * @returns Token definition or undefined if not found
2754
+ */
2755
+ getDefinitionByName(name) {
2756
+ if (!name) return void 0;
2757
+ return this.definitionsByName.get(name.toLowerCase());
2758
+ }
2759
+ /**
2760
+ * Get token symbol for a coin ID
2761
+ * @param coinId - 64-character hex string
2762
+ * @returns Symbol (e.g., "UCT") or truncated ID if not found
2763
+ */
2764
+ getSymbol(coinId) {
2765
+ const def = this.getDefinition(coinId);
2766
+ if (def?.symbol) {
2767
+ return def.symbol;
2768
+ }
2769
+ return coinId.slice(0, 6).toUpperCase();
2770
+ }
2771
+ /**
2772
+ * Get token name for a coin ID
2773
+ * @param coinId - 64-character hex string
2774
+ * @returns Name (e.g., "Bitcoin") or coin ID if not found
2775
+ */
2776
+ getName(coinId) {
2777
+ const def = this.getDefinition(coinId);
2778
+ if (def?.name) {
2779
+ return def.name.charAt(0).toUpperCase() + def.name.slice(1);
2780
+ }
2781
+ return coinId;
2782
+ }
2783
+ /**
2784
+ * Get decimal places for a coin ID
2785
+ * @param coinId - 64-character hex string
2786
+ * @returns Decimals or 0 if not found
2787
+ */
2788
+ getDecimals(coinId) {
2789
+ const def = this.getDefinition(coinId);
2790
+ return def?.decimals ?? 0;
2791
+ }
2792
+ /**
2793
+ * Get icon URL for a coin ID
2794
+ * @param coinId - 64-character hex string
2795
+ * @param preferPng - Prefer PNG format over SVG
2796
+ * @returns Icon URL or null if not found
2797
+ */
2798
+ getIconUrl(coinId, preferPng = true) {
2799
+ const def = this.getDefinition(coinId);
2800
+ if (!def?.icons || def.icons.length === 0) {
2801
+ return null;
2802
+ }
2803
+ if (preferPng) {
2804
+ const pngIcon = def.icons.find((i) => i.url.toLowerCase().includes(".png"));
2805
+ if (pngIcon) return pngIcon.url;
2806
+ }
2807
+ return def.icons[0].url;
2808
+ }
2809
+ /**
2810
+ * Check if a coin ID is known in the registry
2811
+ * @param coinId - 64-character hex string
2812
+ * @returns true if the coin is in the registry
2813
+ */
2814
+ isKnown(coinId) {
2815
+ return this.definitionsById.has(coinId.toLowerCase());
2816
+ }
2817
+ /**
2818
+ * Get all token definitions
2819
+ * @returns Array of all token definitions
2820
+ */
2821
+ getAllDefinitions() {
2822
+ return Array.from(this.definitionsById.values());
2823
+ }
2824
+ /**
2825
+ * Get all fungible token definitions
2826
+ * @returns Array of fungible token definitions
2827
+ */
2828
+ getFungibleTokens() {
2829
+ return this.getAllDefinitions().filter((def) => def.assetKind === "fungible");
2830
+ }
2831
+ /**
2832
+ * Get all non-fungible token definitions
2833
+ * @returns Array of non-fungible token definitions
2834
+ */
2835
+ getNonFungibleTokens() {
2836
+ return this.getAllDefinitions().filter((def) => def.assetKind === "non-fungible");
2837
+ }
2838
+ /**
2839
+ * Get coin ID by symbol
2840
+ * @param symbol - Token symbol (e.g., "UCT")
2841
+ * @returns Coin ID hex string or undefined if not found
2842
+ */
2843
+ getCoinIdBySymbol(symbol) {
2844
+ const def = this.getDefinitionBySymbol(symbol);
2845
+ return def?.id;
2846
+ }
2847
+ /**
2848
+ * Get coin ID by name
2849
+ * @param name - Token name (e.g., "bitcoin")
2850
+ * @returns Coin ID hex string or undefined if not found
2851
+ */
2852
+ getCoinIdByName(name) {
2853
+ const def = this.getDefinitionByName(name);
2854
+ return def?.id;
2855
+ }
2856
+ };
2857
+
2858
+ // serialization/txf-serializer.ts
2859
+ function bytesToHex3(bytes) {
2860
+ const arr = Array.isArray(bytes) ? bytes : Array.from(bytes);
2861
+ return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
2862
+ }
2863
+ function normalizeToHex(value) {
2864
+ if (typeof value === "string") {
2865
+ return value;
2866
+ }
2867
+ if (value && typeof value === "object") {
2868
+ const obj = value;
2869
+ if ("bytes" in obj && (Array.isArray(obj.bytes) || obj.bytes instanceof Uint8Array)) {
2870
+ return bytesToHex3(obj.bytes);
2871
+ }
2872
+ if (obj.type === "Buffer" && Array.isArray(obj.data)) {
2873
+ return bytesToHex3(obj.data);
2874
+ }
2875
+ }
2876
+ return String(value);
2877
+ }
2878
+ function normalizeSdkTokenToStorage(sdkTokenJson) {
2879
+ const txf = JSON.parse(JSON.stringify(sdkTokenJson));
2880
+ if (txf.genesis?.data) {
2881
+ const data = txf.genesis.data;
2882
+ if (data.tokenId !== void 0) {
2883
+ data.tokenId = normalizeToHex(data.tokenId);
2884
+ }
2885
+ if (data.tokenType !== void 0) {
2886
+ data.tokenType = normalizeToHex(data.tokenType);
2887
+ }
2888
+ if (data.salt !== void 0) {
2889
+ data.salt = normalizeToHex(data.salt);
2890
+ }
2891
+ }
2892
+ if (txf.genesis?.inclusionProof?.authenticator) {
2893
+ const auth = txf.genesis.inclusionProof.authenticator;
2894
+ if (auth.publicKey !== void 0) {
2895
+ auth.publicKey = normalizeToHex(auth.publicKey);
2896
+ }
2897
+ if (auth.signature !== void 0) {
2898
+ auth.signature = normalizeToHex(auth.signature);
2899
+ }
2900
+ }
2901
+ if (Array.isArray(txf.transactions)) {
2902
+ for (const tx of txf.transactions) {
2903
+ if (tx.inclusionProof?.authenticator) {
2904
+ const auth = tx.inclusionProof.authenticator;
2905
+ if (auth.publicKey !== void 0) {
2906
+ auth.publicKey = normalizeToHex(auth.publicKey);
2907
+ }
2908
+ if (auth.signature !== void 0) {
2909
+ auth.signature = normalizeToHex(auth.signature);
2910
+ }
2911
+ }
2912
+ }
2913
+ }
2914
+ return txf;
2915
+ }
2916
+ function tokenToTxf(token) {
2917
+ const jsonData = token.sdkData;
2918
+ if (!jsonData) {
2919
+ return null;
2920
+ }
2921
+ try {
2922
+ const txfData = normalizeSdkTokenToStorage(JSON.parse(jsonData));
2923
+ if (!txfData.genesis || !txfData.state) {
2924
+ return null;
2925
+ }
2926
+ if (!txfData.version) {
2927
+ txfData.version = "2.0";
2928
+ }
2929
+ if (!txfData.transactions) {
2930
+ txfData.transactions = [];
2931
+ }
2932
+ if (!txfData.nametags) {
2933
+ txfData.nametags = [];
2934
+ }
2935
+ if (!txfData._integrity) {
2936
+ txfData._integrity = {
2937
+ genesisDataJSONHash: "0000" + "0".repeat(60)
2938
+ };
2939
+ }
2940
+ return txfData;
2941
+ } catch {
2942
+ return null;
2943
+ }
2944
+ }
2945
+ function determineTokenStatus(txf) {
2560
2946
  if (txf.transactions.length > 0) {
2561
2947
  const lastTx = txf.transactions[txf.transactions.length - 1];
2562
2948
  if (lastTx.inclusionProof === null) {
@@ -2580,12 +2966,14 @@ function txfToToken(tokenId, txf) {
2580
2966
  const tokenType = txf.genesis.data.tokenType;
2581
2967
  const isNft = tokenType === "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89";
2582
2968
  const now = Date.now();
2969
+ const registry = TokenRegistry.getInstance();
2970
+ const def = registry.getDefinition(coinId);
2583
2971
  return {
2584
2972
  id: tokenId,
2585
2973
  coinId,
2586
- symbol: isNft ? "NFT" : "UCT",
2587
- name: isNft ? "NFT" : "Token",
2588
- decimals: isNft ? 0 : 8,
2974
+ symbol: isNft ? "NFT" : def?.symbol || coinId.slice(0, 8),
2975
+ name: isNft ? "NFT" : def?.name ? def.name.charAt(0).toUpperCase() + def.name.slice(1) : "Token",
2976
+ decimals: isNft ? 0 : def?.decimals ?? 8,
2589
2977
  amount: totalAmount.toString(),
2590
2978
  status: determineTokenStatus(txf),
2591
2979
  createdAt: now,
@@ -2600,6 +2988,9 @@ async function buildTxfStorageData(tokens, meta, options) {
2600
2988
  formatVersion: "2.0"
2601
2989
  }
2602
2990
  };
2991
+ if (options?.nametags && options.nametags.length > 0) {
2992
+ storageData._nametags = options.nametags;
2993
+ }
2603
2994
  if (options?.tombstones && options.tombstones.length > 0) {
2604
2995
  storageData._tombstones = options.tombstones;
2605
2996
  }
@@ -2638,7 +3029,7 @@ function parseTxfStorageData(data) {
2638
3029
  const result = {
2639
3030
  tokens: [],
2640
3031
  meta: null,
2641
- nametag: null,
3032
+ nametags: [],
2642
3033
  tombstones: [],
2643
3034
  archivedTokens: /* @__PURE__ */ new Map(),
2644
3035
  forkedTokens: /* @__PURE__ */ new Map(),
@@ -2655,8 +3046,20 @@ function parseTxfStorageData(data) {
2655
3046
  if (storageData._meta && typeof storageData._meta === "object") {
2656
3047
  result.meta = storageData._meta;
2657
3048
  }
3049
+ const seenNames = /* @__PURE__ */ new Set();
3050
+ if (Array.isArray(storageData._nametags)) {
3051
+ for (const entry of storageData._nametags) {
3052
+ if (entry && typeof entry === "object" && typeof entry.name === "string") {
3053
+ result.nametags.push(entry);
3054
+ seenNames.add(entry.name);
3055
+ }
3056
+ }
3057
+ }
2658
3058
  if (storageData._nametag && typeof storageData._nametag === "object") {
2659
- result.nametag = storageData._nametag;
3059
+ const legacy = storageData._nametag;
3060
+ if (typeof legacy.name === "string" && !seenNames.has(legacy.name)) {
3061
+ result.nametags.push(legacy);
3062
+ }
2660
3063
  }
2661
3064
  if (storageData._tombstones && Array.isArray(storageData._tombstones)) {
2662
3065
  for (const entry of storageData._tombstones) {
@@ -2756,300 +3159,6 @@ function getCurrentStateHash(txf) {
2756
3159
  return void 0;
2757
3160
  }
2758
3161
 
2759
- // registry/token-registry.testnet.json
2760
- var token_registry_testnet_default = [
2761
- {
2762
- network: "unicity:testnet",
2763
- assetKind: "non-fungible",
2764
- name: "unicity",
2765
- description: "Unicity testnet token type",
2766
- id: "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"
2767
- },
2768
- {
2769
- network: "unicity:testnet",
2770
- assetKind: "fungible",
2771
- name: "unicity",
2772
- symbol: "UCT",
2773
- decimals: 18,
2774
- description: "Unicity testnet native coin",
2775
- icons: [
2776
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity_logo_32.png" }
2777
- ],
2778
- id: "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"
2779
- },
2780
- {
2781
- network: "unicity:testnet",
2782
- assetKind: "fungible",
2783
- name: "unicity-usd",
2784
- symbol: "USDU",
2785
- decimals: 6,
2786
- description: "Unicity testnet USD stablecoin",
2787
- icons: [
2788
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/usdu_logo_32.png" }
2789
- ],
2790
- id: "8f0f3d7a5e7297be0ee98c63b81bcebb2740f43f616566fc290f9823a54f52d7"
2791
- },
2792
- {
2793
- network: "unicity:testnet",
2794
- assetKind: "fungible",
2795
- name: "unicity-eur",
2796
- symbol: "EURU",
2797
- decimals: 6,
2798
- description: "Unicity testnet EUR stablecoin",
2799
- icons: [
2800
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/euru_logo_32.png" }
2801
- ],
2802
- id: "5e160d5e9fdbb03b553fb9c3f6e6c30efa41fa807be39fb4f18e43776e492925"
2803
- },
2804
- {
2805
- network: "unicity:testnet",
2806
- assetKind: "fungible",
2807
- name: "solana",
2808
- symbol: "SOL",
2809
- decimals: 9,
2810
- description: "Solana testnet coin on Unicity",
2811
- icons: [
2812
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/sol.svg" },
2813
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/sol.png" }
2814
- ],
2815
- id: "dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93"
2816
- },
2817
- {
2818
- network: "unicity:testnet",
2819
- assetKind: "fungible",
2820
- name: "bitcoin",
2821
- symbol: "BTC",
2822
- decimals: 8,
2823
- description: "Bitcoin testnet coin on Unicity",
2824
- icons: [
2825
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/btc.svg" },
2826
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/btc.png" }
2827
- ],
2828
- id: "86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa"
2829
- },
2830
- {
2831
- network: "unicity:testnet",
2832
- assetKind: "fungible",
2833
- name: "ethereum",
2834
- symbol: "ETH",
2835
- decimals: 18,
2836
- description: "Ethereum testnet coin on Unicity",
2837
- icons: [
2838
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/eth.svg" },
2839
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/eth.png" }
2840
- ],
2841
- id: "3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb"
2842
- },
2843
- {
2844
- network: "unicity:testnet",
2845
- assetKind: "fungible",
2846
- name: "alpha_test",
2847
- symbol: "ALPHT",
2848
- decimals: 8,
2849
- description: "ALPHA testnet coin on Unicity",
2850
- icons: [
2851
- { url: "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/alpha_coin.png" }
2852
- ],
2853
- id: "cde78ded16ef65818a51f43138031c4284e519300ab0cb60c30a8f9078080e5f"
2854
- },
2855
- {
2856
- network: "unicity:testnet",
2857
- assetKind: "fungible",
2858
- name: "tether",
2859
- symbol: "USDT",
2860
- decimals: 6,
2861
- description: "Tether (Ethereum) testnet coin on Unicity",
2862
- icons: [
2863
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdt.svg" },
2864
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdt.png" }
2865
- ],
2866
- id: "40d25444648418fe7efd433e147187a3a6adf049ac62bc46038bda5b960bf690"
2867
- },
2868
- {
2869
- network: "unicity:testnet",
2870
- assetKind: "fungible",
2871
- name: "usd-coin",
2872
- symbol: "USDC",
2873
- decimals: 6,
2874
- description: "USDC (Ethereum) testnet coin on Unicity",
2875
- icons: [
2876
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/svg/icon/usdc.svg" },
2877
- { url: "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/32/icon/usdc.png" }
2878
- ],
2879
- id: "2265121770fa6f41131dd9a6cc571e28679263d09a53eb2642e145b5b9a5b0a2"
2880
- }
2881
- ];
2882
-
2883
- // registry/TokenRegistry.ts
2884
- var TokenRegistry = class _TokenRegistry {
2885
- static instance = null;
2886
- definitionsById;
2887
- definitionsBySymbol;
2888
- definitionsByName;
2889
- constructor() {
2890
- this.definitionsById = /* @__PURE__ */ new Map();
2891
- this.definitionsBySymbol = /* @__PURE__ */ new Map();
2892
- this.definitionsByName = /* @__PURE__ */ new Map();
2893
- this.loadRegistry();
2894
- }
2895
- /**
2896
- * Get singleton instance of TokenRegistry
2897
- */
2898
- static getInstance() {
2899
- if (!_TokenRegistry.instance) {
2900
- _TokenRegistry.instance = new _TokenRegistry();
2901
- }
2902
- return _TokenRegistry.instance;
2903
- }
2904
- /**
2905
- * Reset the singleton instance (useful for testing)
2906
- */
2907
- static resetInstance() {
2908
- _TokenRegistry.instance = null;
2909
- }
2910
- /**
2911
- * Load registry data from bundled JSON
2912
- */
2913
- loadRegistry() {
2914
- const definitions = token_registry_testnet_default;
2915
- for (const def of definitions) {
2916
- const idLower = def.id.toLowerCase();
2917
- this.definitionsById.set(idLower, def);
2918
- if (def.symbol) {
2919
- this.definitionsBySymbol.set(def.symbol.toUpperCase(), def);
2920
- }
2921
- this.definitionsByName.set(def.name.toLowerCase(), def);
2922
- }
2923
- }
2924
- // ===========================================================================
2925
- // Lookup Methods
2926
- // ===========================================================================
2927
- /**
2928
- * Get token definition by hex coin ID
2929
- * @param coinId - 64-character hex string
2930
- * @returns Token definition or undefined if not found
2931
- */
2932
- getDefinition(coinId) {
2933
- if (!coinId) return void 0;
2934
- return this.definitionsById.get(coinId.toLowerCase());
2935
- }
2936
- /**
2937
- * Get token definition by symbol (e.g., "UCT", "BTC")
2938
- * @param symbol - Token symbol (case-insensitive)
2939
- * @returns Token definition or undefined if not found
2940
- */
2941
- getDefinitionBySymbol(symbol) {
2942
- if (!symbol) return void 0;
2943
- return this.definitionsBySymbol.get(symbol.toUpperCase());
2944
- }
2945
- /**
2946
- * Get token definition by name (e.g., "bitcoin", "ethereum")
2947
- * @param name - Token name (case-insensitive)
2948
- * @returns Token definition or undefined if not found
2949
- */
2950
- getDefinitionByName(name) {
2951
- if (!name) return void 0;
2952
- return this.definitionsByName.get(name.toLowerCase());
2953
- }
2954
- /**
2955
- * Get token symbol for a coin ID
2956
- * @param coinId - 64-character hex string
2957
- * @returns Symbol (e.g., "UCT") or truncated ID if not found
2958
- */
2959
- getSymbol(coinId) {
2960
- const def = this.getDefinition(coinId);
2961
- if (def?.symbol) {
2962
- return def.symbol;
2963
- }
2964
- return coinId.slice(0, 6).toUpperCase();
2965
- }
2966
- /**
2967
- * Get token name for a coin ID
2968
- * @param coinId - 64-character hex string
2969
- * @returns Name (e.g., "Bitcoin") or coin ID if not found
2970
- */
2971
- getName(coinId) {
2972
- const def = this.getDefinition(coinId);
2973
- if (def?.name) {
2974
- return def.name.charAt(0).toUpperCase() + def.name.slice(1);
2975
- }
2976
- return coinId;
2977
- }
2978
- /**
2979
- * Get decimal places for a coin ID
2980
- * @param coinId - 64-character hex string
2981
- * @returns Decimals or 0 if not found
2982
- */
2983
- getDecimals(coinId) {
2984
- const def = this.getDefinition(coinId);
2985
- return def?.decimals ?? 0;
2986
- }
2987
- /**
2988
- * Get icon URL for a coin ID
2989
- * @param coinId - 64-character hex string
2990
- * @param preferPng - Prefer PNG format over SVG
2991
- * @returns Icon URL or null if not found
2992
- */
2993
- getIconUrl(coinId, preferPng = true) {
2994
- const def = this.getDefinition(coinId);
2995
- if (!def?.icons || def.icons.length === 0) {
2996
- return null;
2997
- }
2998
- if (preferPng) {
2999
- const pngIcon = def.icons.find((i) => i.url.toLowerCase().includes(".png"));
3000
- if (pngIcon) return pngIcon.url;
3001
- }
3002
- return def.icons[0].url;
3003
- }
3004
- /**
3005
- * Check if a coin ID is known in the registry
3006
- * @param coinId - 64-character hex string
3007
- * @returns true if the coin is in the registry
3008
- */
3009
- isKnown(coinId) {
3010
- return this.definitionsById.has(coinId.toLowerCase());
3011
- }
3012
- /**
3013
- * Get all token definitions
3014
- * @returns Array of all token definitions
3015
- */
3016
- getAllDefinitions() {
3017
- return Array.from(this.definitionsById.values());
3018
- }
3019
- /**
3020
- * Get all fungible token definitions
3021
- * @returns Array of fungible token definitions
3022
- */
3023
- getFungibleTokens() {
3024
- return this.getAllDefinitions().filter((def) => def.assetKind === "fungible");
3025
- }
3026
- /**
3027
- * Get all non-fungible token definitions
3028
- * @returns Array of non-fungible token definitions
3029
- */
3030
- getNonFungibleTokens() {
3031
- return this.getAllDefinitions().filter((def) => def.assetKind === "non-fungible");
3032
- }
3033
- /**
3034
- * Get coin ID by symbol
3035
- * @param symbol - Token symbol (e.g., "UCT")
3036
- * @returns Coin ID hex string or undefined if not found
3037
- */
3038
- getCoinIdBySymbol(symbol) {
3039
- const def = this.getDefinitionBySymbol(symbol);
3040
- return def?.id;
3041
- }
3042
- /**
3043
- * Get coin ID by name
3044
- * @param name - Token name (e.g., "bitcoin")
3045
- * @returns Coin ID hex string or undefined if not found
3046
- */
3047
- getCoinIdByName(name) {
3048
- const def = this.getDefinitionByName(name);
3049
- return def?.id;
3050
- }
3051
- };
3052
-
3053
3162
  // modules/payments/InstantSplitExecutor.ts
3054
3163
  var import_Token4 = require("@unicitylabs/state-transition-sdk/lib/token/Token");
3055
3164
  var import_TokenId3 = require("@unicitylabs/state-transition-sdk/lib/token/TokenId");
@@ -4083,7 +4192,7 @@ var PaymentsModule = class _PaymentsModule {
4083
4192
  archivedTokens = /* @__PURE__ */ new Map();
4084
4193
  forkedTokens = /* @__PURE__ */ new Map();
4085
4194
  transactionHistory = [];
4086
- nametag = null;
4195
+ nametags = [];
4087
4196
  // Payment Requests State (Incoming)
4088
4197
  paymentRequests = [];
4089
4198
  paymentRequestHandlers = /* @__PURE__ */ new Set();
@@ -4152,7 +4261,7 @@ var PaymentsModule = class _PaymentsModule {
4152
4261
  this.archivedTokens.clear();
4153
4262
  this.forkedTokens.clear();
4154
4263
  this.transactionHistory = [];
4155
- this.nametag = null;
4264
+ this.nametags = [];
4156
4265
  this.deps = deps;
4157
4266
  this.priceProvider = deps.price ?? null;
4158
4267
  if (this.l1) {
@@ -4201,8 +4310,6 @@ var PaymentsModule = class _PaymentsModule {
4201
4310
  }
4202
4311
  }
4203
4312
  await this.loadPendingV5Tokens();
4204
- await this.loadTokensFromFileStorage();
4205
- await this.loadNametagFromFileStorage();
4206
4313
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
4207
4314
  if (historyData) {
4208
4315
  try {
@@ -4711,9 +4818,10 @@ var PaymentsModule = class _PaymentsModule {
4711
4818
  senderPubkey,
4712
4819
  {
4713
4820
  findNametagToken: async (proxyAddress) => {
4714
- if (this.nametag?.token) {
4821
+ const currentNametag = this.getNametag();
4822
+ if (currentNametag?.token) {
4715
4823
  try {
4716
- const nametagToken = await import_Token6.Token.fromJSON(this.nametag.token);
4824
+ const nametagToken = await import_Token6.Token.fromJSON(currentNametag.token);
4717
4825
  const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
4718
4826
  const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
4719
4827
  if (proxy.address === proxyAddress) {
@@ -5502,7 +5610,6 @@ var PaymentsModule = class _PaymentsModule {
5502
5610
  sdkData: JSON.stringify(finalizedToken.toJSON())
5503
5611
  };
5504
5612
  this.tokens.set(tokenId, confirmedToken);
5505
- await this.saveTokenToFileStorage(confirmedToken);
5506
5613
  await this.addToHistory({
5507
5614
  type: "RECEIVED",
5508
5615
  amount: confirmedToken.amount,
@@ -5591,9 +5698,10 @@ var PaymentsModule = class _PaymentsModule {
5591
5698
  } catch {
5592
5699
  }
5593
5700
  }
5594
- if (nametagTokens.length === 0 && this.nametag?.token) {
5701
+ const localNametag = this.getNametag();
5702
+ if (nametagTokens.length === 0 && localNametag?.token) {
5595
5703
  try {
5596
- const nametagToken = await import_Token6.Token.fromJSON(this.nametag.token);
5704
+ const nametagToken = await import_Token6.Token.fromJSON(localNametag.token);
5597
5705
  const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5598
5706
  const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5599
5707
  if (proxy.address === recipientAddressStr) {
@@ -5754,125 +5862,16 @@ var PaymentsModule = class _PaymentsModule {
5754
5862
  });
5755
5863
  }
5756
5864
  await this.save();
5757
- if (!this.parsePendingFinalization(token.sdkData)) {
5758
- await this.saveTokenToFileStorage(token);
5759
- }
5760
5865
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
5761
5866
  return true;
5762
5867
  }
5763
5868
  /**
5764
- * Save token as individual file to token storage providers
5765
- * Similar to lottery's saveReceivedToken() pattern
5766
- */
5767
- async saveTokenToFileStorage(token) {
5768
- const providers = this.getTokenStorageProviders();
5769
- if (providers.size === 0) return;
5770
- const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
5771
- const tokenIdPrefix = sdkTokenId ? sdkTokenId.slice(0, 16) : token.id.slice(0, 16);
5772
- const filename = `token-${tokenIdPrefix}-${Date.now()}`;
5773
- const tokenData = {
5774
- token: token.sdkData ? JSON.parse(token.sdkData) : null,
5775
- receivedAt: Date.now(),
5776
- meta: {
5777
- id: token.id,
5778
- coinId: token.coinId,
5779
- symbol: token.symbol,
5780
- amount: token.amount,
5781
- status: token.status
5782
- }
5783
- };
5784
- for (const [providerId, provider] of providers) {
5785
- try {
5786
- if (provider.saveToken) {
5787
- await provider.saveToken(filename, tokenData);
5788
- this.log(`Saved token file ${filename} to ${providerId}`);
5789
- }
5790
- } catch (error) {
5791
- console.warn(`[Payments] Failed to save token to ${providerId}:`, error);
5792
- }
5793
- }
5794
- }
5795
- /**
5796
- * Load tokens from file storage providers (lottery compatibility)
5797
- * This loads tokens from file-based storage that may have been saved
5798
- * by other applications using the same storage directory.
5799
- */
5800
- async loadTokensFromFileStorage() {
5801
- const providers = this.getTokenStorageProviders();
5802
- if (providers.size === 0) return;
5803
- for (const [providerId, provider] of providers) {
5804
- if (!provider.listTokenIds || !provider.getToken) continue;
5805
- try {
5806
- const allIds = await provider.listTokenIds();
5807
- const tokenIds = allIds.filter((id) => id.startsWith("token-"));
5808
- this.log(`Found ${tokenIds.length} token files in ${providerId}`);
5809
- for (const tokenId of tokenIds) {
5810
- try {
5811
- const fileData = await provider.getToken(tokenId);
5812
- if (!fileData || typeof fileData !== "object") continue;
5813
- const data = fileData;
5814
- const tokenJson = data.token;
5815
- if (!tokenJson) continue;
5816
- if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
5817
- continue;
5818
- }
5819
- let sdkTokenId;
5820
- if (typeof tokenJson === "object" && tokenJson !== null) {
5821
- const tokenObj = tokenJson;
5822
- const genesis = tokenObj.genesis;
5823
- const genesisData = genesis?.data;
5824
- sdkTokenId = genesisData?.tokenId;
5825
- }
5826
- if (sdkTokenId) {
5827
- let exists = false;
5828
- for (const existing of this.tokens.values()) {
5829
- const existingId = extractTokenIdFromSdkData(existing.sdkData);
5830
- if (existingId === sdkTokenId) {
5831
- exists = true;
5832
- break;
5833
- }
5834
- }
5835
- if (exists) continue;
5836
- }
5837
- const tokenInfo = await parseTokenInfo(tokenJson);
5838
- const token = {
5839
- id: tokenInfo.tokenId ?? tokenId,
5840
- coinId: tokenInfo.coinId,
5841
- symbol: tokenInfo.symbol,
5842
- name: tokenInfo.name,
5843
- decimals: tokenInfo.decimals,
5844
- iconUrl: tokenInfo.iconUrl,
5845
- amount: tokenInfo.amount,
5846
- status: "confirmed",
5847
- createdAt: data.receivedAt || Date.now(),
5848
- updatedAt: Date.now(),
5849
- sdkData: typeof tokenJson === "string" ? tokenJson : JSON.stringify(tokenJson)
5850
- };
5851
- const loadedTokenId = extractTokenIdFromSdkData(token.sdkData);
5852
- const loadedStateHash = extractStateHashFromSdkData(token.sdkData);
5853
- if (loadedTokenId && loadedStateHash && this.isStateTombstoned(loadedTokenId, loadedStateHash)) {
5854
- this.log(`Skipping tombstoned token file ${tokenId} (${loadedTokenId.slice(0, 8)}...)`);
5855
- continue;
5856
- }
5857
- this.tokens.set(token.id, token);
5858
- this.log(`Loaded token from file: ${tokenId}`);
5859
- } catch (tokenError) {
5860
- console.warn(`[Payments] Failed to load token ${tokenId}:`, tokenError);
5861
- }
5862
- }
5863
- } catch (error) {
5864
- console.warn(`[Payments] Failed to load tokens from ${providerId}:`, error);
5865
- }
5866
- }
5867
- this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5868
- }
5869
- /**
5870
- * Update an existing token or add it if not found.
5871
- *
5872
- * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5873
- * `token.id`. If no match is found, falls back to {@link addToken}.
5874
- *
5875
- * @param token - The token with updated data. Must include a valid `id`.
5869
+ * Update an existing token or add it if not found.
5870
+ *
5871
+ * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5872
+ * `token.id`. If no match is found, falls back to {@link addToken}.
5873
+ *
5874
+ * @param token - The token with updated data. Must include a valid `id`.
5876
5875
  */
5877
5876
  async updateToken(token) {
5878
5877
  this.ensureInitialized();
@@ -5924,7 +5923,6 @@ var PaymentsModule = class _PaymentsModule {
5924
5923
  this.log(`Warning: Could not create tombstone for token ${tokenId.slice(0, 8)}... (missing tokenId or stateHash)`);
5925
5924
  }
5926
5925
  this.tokens.delete(tokenId);
5927
- await this.deleteTokenFiles(token);
5928
5926
  if (!skipHistory && token.coinId && token.amount) {
5929
5927
  await this.addToHistory({
5930
5928
  type: "SENT",
@@ -5937,31 +5935,6 @@ var PaymentsModule = class _PaymentsModule {
5937
5935
  }
5938
5936
  await this.save();
5939
5937
  }
5940
- /**
5941
- * Delete physical token file(s) from all storage providers.
5942
- * Finds files by matching the SDK token ID prefix in the filename.
5943
- */
5944
- async deleteTokenFiles(token) {
5945
- const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
5946
- if (!sdkTokenId) return;
5947
- const tokenIdPrefix = sdkTokenId.slice(0, 16);
5948
- const providers = this.getTokenStorageProviders();
5949
- for (const [providerId, provider] of providers) {
5950
- if (!provider.listTokenIds || !provider.deleteToken) continue;
5951
- try {
5952
- const allIds = await provider.listTokenIds();
5953
- const matchingFiles = allIds.filter(
5954
- (id) => id.startsWith(`token-${tokenIdPrefix}`)
5955
- );
5956
- for (const fileId of matchingFiles) {
5957
- await provider.deleteToken(fileId);
5958
- this.log(`Deleted token file ${fileId} from ${providerId}`);
5959
- }
5960
- } catch (error) {
5961
- console.warn(`[Payments] Failed to delete token files from ${providerId}:`, error);
5962
- }
5963
- }
5964
- }
5965
5938
  // ===========================================================================
5966
5939
  // Public API - Tombstones
5967
5940
  // ===========================================================================
@@ -6217,18 +6190,30 @@ var PaymentsModule = class _PaymentsModule {
6217
6190
  */
6218
6191
  async setNametag(nametag) {
6219
6192
  this.ensureInitialized();
6220
- this.nametag = nametag;
6193
+ const idx = this.nametags.findIndex((n) => n.name === nametag.name);
6194
+ if (idx >= 0) {
6195
+ this.nametags[idx] = nametag;
6196
+ } else {
6197
+ this.nametags.push(nametag);
6198
+ }
6221
6199
  await this.save();
6222
- await this.saveNametagToFileStorage(nametag);
6223
6200
  this.log(`Nametag set: ${nametag.name}`);
6224
6201
  }
6225
6202
  /**
6226
- * Get the current nametag data.
6203
+ * Get the current (first) nametag data.
6227
6204
  *
6228
6205
  * @returns The nametag data, or `null` if no nametag is set.
6229
6206
  */
6230
6207
  getNametag() {
6231
- return this.nametag;
6208
+ return this.nametags[0] ?? null;
6209
+ }
6210
+ /**
6211
+ * Get all nametag data entries.
6212
+ *
6213
+ * @returns A copy of the nametags array.
6214
+ */
6215
+ getNametags() {
6216
+ return [...this.nametags];
6232
6217
  }
6233
6218
  /**
6234
6219
  * Check whether a nametag is currently set.
@@ -6236,77 +6221,16 @@ var PaymentsModule = class _PaymentsModule {
6236
6221
  * @returns `true` if nametag data is present.
6237
6222
  */
6238
6223
  hasNametag() {
6239
- return this.nametag !== null;
6224
+ return this.nametags.length > 0;
6240
6225
  }
6241
6226
  /**
6242
- * Remove the current nametag data from memory and storage.
6227
+ * Remove all nametag data from memory and storage.
6243
6228
  */
6244
6229
  async clearNametag() {
6245
6230
  this.ensureInitialized();
6246
- this.nametag = null;
6231
+ this.nametags = [];
6247
6232
  await this.save();
6248
6233
  }
6249
- /**
6250
- * Save nametag to file storage for lottery compatibility
6251
- * Creates file: nametag-{name}.json
6252
- */
6253
- async saveNametagToFileStorage(nametag) {
6254
- const providers = this.getTokenStorageProviders();
6255
- if (providers.size === 0) return;
6256
- const filename = `nametag-${nametag.name}`;
6257
- const fileData = {
6258
- nametag: nametag.name,
6259
- token: nametag.token,
6260
- timestamp: nametag.timestamp || Date.now()
6261
- };
6262
- for (const [providerId, provider] of providers) {
6263
- try {
6264
- if (provider.saveToken) {
6265
- await provider.saveToken(filename, fileData);
6266
- this.log(`Saved nametag file ${filename} to ${providerId}`);
6267
- }
6268
- } catch (error) {
6269
- console.warn(`[Payments] Failed to save nametag to ${providerId}:`, error);
6270
- }
6271
- }
6272
- }
6273
- /**
6274
- * Load nametag from file storage (lottery compatibility)
6275
- * Looks for file: nametag-{name}.json
6276
- */
6277
- async loadNametagFromFileStorage() {
6278
- if (this.nametag) return;
6279
- const providers = this.getTokenStorageProviders();
6280
- if (providers.size === 0) return;
6281
- for (const [providerId, provider] of providers) {
6282
- if (!provider.listTokenIds || !provider.getToken) continue;
6283
- try {
6284
- const tokenIds = await provider.listTokenIds();
6285
- const nametagFiles = tokenIds.filter((id) => id.startsWith("nametag-"));
6286
- for (const nametagFile of nametagFiles) {
6287
- try {
6288
- const fileData = await provider.getToken(nametagFile);
6289
- if (!fileData || typeof fileData !== "object") continue;
6290
- const data = fileData;
6291
- if (!data.token || !data.nametag) continue;
6292
- this.nametag = {
6293
- name: data.nametag,
6294
- token: data.token,
6295
- timestamp: data.timestamp || Date.now(),
6296
- format: "lottery",
6297
- version: "1.0"
6298
- };
6299
- this.log(`Loaded nametag from file: ${nametagFile}`);
6300
- return;
6301
- } catch (fileError) {
6302
- console.warn(`[Payments] Failed to load nametag file ${nametagFile}:`, fileError);
6303
- }
6304
- }
6305
- } catch (error) {
6306
- console.warn(`[Payments] Failed to search nametag files in ${providerId}:`, error);
6307
- }
6308
- }
6309
- }
6310
6234
  /**
6311
6235
  * Mint a nametag token on-chain (like Sphere wallet and lottery)
6312
6236
  * This creates the nametag token required for receiving tokens via PROXY addresses
@@ -6794,10 +6718,11 @@ var PaymentsModule = class _PaymentsModule {
6794
6718
  let nametagTokens = [];
6795
6719
  if (addressScheme === import_AddressScheme.AddressScheme.PROXY) {
6796
6720
  const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
6797
- if (!this.nametag?.token) {
6721
+ const proxyNametag = this.getNametag();
6722
+ if (!proxyNametag?.token) {
6798
6723
  throw new Error("Cannot finalize PROXY transfer - no nametag token");
6799
6724
  }
6800
- const nametagToken = await import_Token6.Token.fromJSON(this.nametag.token);
6725
+ const nametagToken = await import_Token6.Token.fromJSON(proxyNametag.token);
6801
6726
  const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
6802
6727
  if (proxy.address !== recipientAddress.address) {
6803
6728
  throw new Error(
@@ -6858,7 +6783,6 @@ var PaymentsModule = class _PaymentsModule {
6858
6783
  };
6859
6784
  this.tokens.set(tokenId, finalizedToken);
6860
6785
  await this.save();
6861
- await this.saveTokenToFileStorage(finalizedToken);
6862
6786
  this.log(`NOSTR-FIRST: Token ${tokenId.slice(0, 8)}... finalized and confirmed`);
6863
6787
  this.deps.emitEvent("transfer:confirmed", {
6864
6788
  id: crypto.randomUUID(),
@@ -6902,9 +6826,6 @@ var PaymentsModule = class _PaymentsModule {
6902
6826
  if (instantBundle) {
6903
6827
  this.log("Processing INSTANT_SPLIT bundle...");
6904
6828
  try {
6905
- if (!this.nametag) {
6906
- await this.loadNametagFromFileStorage();
6907
- }
6908
6829
  const result = await this.processInstantSplitBundle(
6909
6830
  instantBundle,
6910
6831
  transfer.senderTransportPubkey
@@ -7104,6 +7025,7 @@ var PaymentsModule = class _PaymentsModule {
7104
7025
  ipnsName: this.deps.identity.ipnsName ?? ""
7105
7026
  },
7106
7027
  {
7028
+ nametags: this.nametags,
7107
7029
  tombstones: this.tombstones,
7108
7030
  archivedTokens: this.archivedTokens,
7109
7031
  forkedTokens: this.forkedTokens
@@ -7125,9 +7047,7 @@ var PaymentsModule = class _PaymentsModule {
7125
7047
  }
7126
7048
  this.archivedTokens = parsed.archivedTokens;
7127
7049
  this.forkedTokens = parsed.forkedTokens;
7128
- if (parsed.nametag !== null) {
7129
- this.nametag = parsed.nametag;
7130
- }
7050
+ this.nametags = parsed.nametags;
7131
7051
  }
7132
7052
  // ===========================================================================
7133
7053
  // Private: NOSTR-FIRST Proof Polling
@@ -7345,228 +7265,1605 @@ var CommunicationsModule = class {
7345
7265
  this.broadcastSubscriptions.clear();
7346
7266
  }
7347
7267
  // ===========================================================================
7348
- // Public API - Direct Messages
7268
+ // Public API - Direct Messages
7269
+ // ===========================================================================
7270
+ /**
7271
+ * Send direct message
7272
+ */
7273
+ async sendDM(recipient, content) {
7274
+ this.ensureInitialized();
7275
+ const recipientPubkey = await this.resolveRecipient(recipient);
7276
+ const eventId = await this.deps.transport.sendMessage(recipientPubkey, content);
7277
+ const message = {
7278
+ id: eventId,
7279
+ senderPubkey: this.deps.identity.chainPubkey,
7280
+ senderNametag: this.deps.identity.nametag,
7281
+ recipientPubkey,
7282
+ content,
7283
+ timestamp: Date.now(),
7284
+ isRead: true
7285
+ };
7286
+ this.messages.set(message.id, message);
7287
+ if (this.config.autoSave) {
7288
+ await this.save();
7289
+ }
7290
+ return message;
7291
+ }
7292
+ /**
7293
+ * Get conversation with peer
7294
+ */
7295
+ getConversation(peerPubkey) {
7296
+ return Array.from(this.messages.values()).filter(
7297
+ (m) => m.senderPubkey === peerPubkey || m.recipientPubkey === peerPubkey
7298
+ ).sort((a, b) => a.timestamp - b.timestamp);
7299
+ }
7300
+ /**
7301
+ * Get all conversations grouped by peer
7302
+ */
7303
+ getConversations() {
7304
+ const conversations = /* @__PURE__ */ new Map();
7305
+ for (const message of this.messages.values()) {
7306
+ const peer = message.senderPubkey === this.deps?.identity.chainPubkey ? message.recipientPubkey : message.senderPubkey;
7307
+ if (!conversations.has(peer)) {
7308
+ conversations.set(peer, []);
7309
+ }
7310
+ conversations.get(peer).push(message);
7311
+ }
7312
+ for (const msgs of conversations.values()) {
7313
+ msgs.sort((a, b) => a.timestamp - b.timestamp);
7314
+ }
7315
+ return conversations;
7316
+ }
7317
+ /**
7318
+ * Mark messages as read
7319
+ */
7320
+ async markAsRead(messageIds) {
7321
+ for (const id of messageIds) {
7322
+ const msg = this.messages.get(id);
7323
+ if (msg) {
7324
+ msg.isRead = true;
7325
+ }
7326
+ }
7327
+ if (this.config.autoSave) {
7328
+ await this.save();
7329
+ }
7330
+ }
7331
+ /**
7332
+ * Get unread count
7333
+ */
7334
+ getUnreadCount(peerPubkey) {
7335
+ let messages = Array.from(this.messages.values()).filter(
7336
+ (m) => !m.isRead && m.senderPubkey !== this.deps?.identity.chainPubkey
7337
+ );
7338
+ if (peerPubkey) {
7339
+ messages = messages.filter((m) => m.senderPubkey === peerPubkey);
7340
+ }
7341
+ return messages.length;
7342
+ }
7343
+ /**
7344
+ * Subscribe to incoming DMs
7345
+ */
7346
+ onDirectMessage(handler) {
7347
+ this.dmHandlers.add(handler);
7348
+ return () => this.dmHandlers.delete(handler);
7349
+ }
7350
+ // ===========================================================================
7351
+ // Public API - Broadcasts
7352
+ // ===========================================================================
7353
+ /**
7354
+ * Publish broadcast message
7355
+ */
7356
+ async broadcast(content, tags) {
7357
+ this.ensureInitialized();
7358
+ const eventId = await this.deps.transport.publishBroadcast?.(content, tags);
7359
+ const message = {
7360
+ id: eventId ?? crypto.randomUUID(),
7361
+ authorPubkey: this.deps.identity.chainPubkey,
7362
+ authorNametag: this.deps.identity.nametag,
7363
+ content,
7364
+ timestamp: Date.now(),
7365
+ tags
7366
+ };
7367
+ this.broadcasts.set(message.id, message);
7368
+ return message;
7369
+ }
7370
+ /**
7371
+ * Subscribe to broadcasts with tags
7372
+ */
7373
+ subscribeToBroadcasts(tags) {
7374
+ this.ensureInitialized();
7375
+ const key = tags.sort().join(":");
7376
+ if (this.broadcastSubscriptions.has(key)) {
7377
+ return () => {
7378
+ };
7379
+ }
7380
+ const unsub = this.deps.transport.subscribeToBroadcast?.(tags, (broadcast2) => {
7381
+ this.handleIncomingBroadcast(broadcast2);
7382
+ });
7383
+ if (unsub) {
7384
+ this.broadcastSubscriptions.set(key, unsub);
7385
+ }
7386
+ return () => {
7387
+ const sub = this.broadcastSubscriptions.get(key);
7388
+ if (sub) {
7389
+ sub();
7390
+ this.broadcastSubscriptions.delete(key);
7391
+ }
7392
+ };
7393
+ }
7394
+ /**
7395
+ * Get broadcasts
7396
+ */
7397
+ getBroadcasts(limit) {
7398
+ const messages = Array.from(this.broadcasts.values()).sort((a, b) => b.timestamp - a.timestamp);
7399
+ return limit ? messages.slice(0, limit) : messages;
7400
+ }
7401
+ /**
7402
+ * Subscribe to incoming broadcasts
7403
+ */
7404
+ onBroadcast(handler) {
7405
+ this.broadcastHandlers.add(handler);
7406
+ return () => this.broadcastHandlers.delete(handler);
7407
+ }
7408
+ // ===========================================================================
7409
+ // Private: Message Handling
7410
+ // ===========================================================================
7411
+ handleIncomingMessage(msg) {
7412
+ if (msg.senderTransportPubkey === this.deps?.identity.chainPubkey) return;
7413
+ const message = {
7414
+ id: msg.id,
7415
+ senderPubkey: msg.senderTransportPubkey,
7416
+ senderNametag: msg.senderNametag,
7417
+ recipientPubkey: this.deps.identity.chainPubkey,
7418
+ content: msg.content,
7419
+ timestamp: msg.timestamp,
7420
+ isRead: false
7421
+ };
7422
+ this.messages.set(message.id, message);
7423
+ this.deps.emitEvent("message:dm", message);
7424
+ for (const handler of this.dmHandlers) {
7425
+ try {
7426
+ handler(message);
7427
+ } catch (error) {
7428
+ console.error("[Communications] Handler error:", error);
7429
+ }
7430
+ }
7431
+ if (this.config.autoSave) {
7432
+ this.save();
7433
+ }
7434
+ this.pruneIfNeeded();
7435
+ }
7436
+ handleIncomingBroadcast(incoming) {
7437
+ const message = {
7438
+ id: incoming.id,
7439
+ authorPubkey: incoming.authorTransportPubkey,
7440
+ content: incoming.content,
7441
+ timestamp: incoming.timestamp,
7442
+ tags: incoming.tags
7443
+ };
7444
+ this.broadcasts.set(message.id, message);
7445
+ this.deps.emitEvent("message:broadcast", message);
7446
+ for (const handler of this.broadcastHandlers) {
7447
+ try {
7448
+ handler(message);
7449
+ } catch (error) {
7450
+ console.error("[Communications] Handler error:", error);
7451
+ }
7452
+ }
7453
+ }
7454
+ // ===========================================================================
7455
+ // Private: Storage
7456
+ // ===========================================================================
7457
+ async save() {
7458
+ const messages = Array.from(this.messages.values());
7459
+ await this.deps.storage.set("direct_messages", JSON.stringify(messages));
7460
+ }
7461
+ pruneIfNeeded() {
7462
+ if (this.messages.size <= this.config.maxMessages) return;
7463
+ const sorted = Array.from(this.messages.entries()).sort(([, a], [, b]) => a.timestamp - b.timestamp);
7464
+ const toRemove = sorted.slice(0, sorted.length - this.config.maxMessages);
7465
+ for (const [id] of toRemove) {
7466
+ this.messages.delete(id);
7467
+ }
7468
+ }
7469
+ // ===========================================================================
7470
+ // Private: Helpers
7471
+ // ===========================================================================
7472
+ async resolveRecipient(recipient) {
7473
+ if (recipient.startsWith("@")) {
7474
+ const pubkey = await this.deps.transport.resolveNametag?.(recipient.slice(1));
7475
+ if (!pubkey) {
7476
+ throw new Error(`Nametag not found: ${recipient}`);
7477
+ }
7478
+ return pubkey;
7479
+ }
7480
+ return recipient;
7481
+ }
7482
+ ensureInitialized() {
7483
+ if (!this.deps) {
7484
+ throw new Error("CommunicationsModule not initialized");
7485
+ }
7486
+ }
7487
+ };
7488
+ function createCommunicationsModule(config) {
7489
+ return new CommunicationsModule(config);
7490
+ }
7491
+
7492
+ // modules/groupchat/GroupChatModule.ts
7493
+ var import_nostr_js_sdk2 = require("@unicitylabs/nostr-js-sdk");
7494
+
7495
+ // modules/groupchat/types.ts
7496
+ var GroupRole = {
7497
+ ADMIN: "ADMIN",
7498
+ MODERATOR: "MODERATOR",
7499
+ MEMBER: "MEMBER"
7500
+ };
7501
+ var GroupVisibility = {
7502
+ PUBLIC: "PUBLIC",
7503
+ PRIVATE: "PRIVATE"
7504
+ };
7505
+
7506
+ // modules/groupchat/GroupChatModule.ts
7507
+ function createNip29Filter(data) {
7508
+ return new import_nostr_js_sdk2.Filter(data);
7509
+ }
7510
+ var GroupChatModule = class {
7511
+ config;
7512
+ deps = null;
7513
+ // Nostr connection (separate from wallet relay)
7514
+ client = null;
7515
+ keyManager = null;
7516
+ connected = false;
7517
+ connecting = false;
7518
+ connectPromise = null;
7519
+ reconnectAttempts = 0;
7520
+ reconnectTimer = null;
7521
+ // Subscription tracking (for cleanup)
7522
+ subscriptionIds = [];
7523
+ // In-memory state
7524
+ groups = /* @__PURE__ */ new Map();
7525
+ messages = /* @__PURE__ */ new Map();
7526
+ // groupId -> messages
7527
+ members = /* @__PURE__ */ new Map();
7528
+ // groupId -> members
7529
+ processedEventIds = /* @__PURE__ */ new Set();
7530
+ pendingLeaves = /* @__PURE__ */ new Set();
7531
+ // Persistence debounce
7532
+ persistTimer = null;
7533
+ persistPromise = null;
7534
+ // Relay admin cache
7535
+ relayAdminPubkeys = null;
7536
+ relayAdminFetchPromise = null;
7537
+ // Listeners
7538
+ messageHandlers = /* @__PURE__ */ new Set();
7539
+ constructor(config) {
7540
+ this.config = {
7541
+ relays: config?.relays ?? [],
7542
+ defaultMessageLimit: config?.defaultMessageLimit ?? 50,
7543
+ maxPreviousTags: config?.maxPreviousTags ?? 3,
7544
+ reconnectDelayMs: config?.reconnectDelayMs ?? 3e3,
7545
+ maxReconnectAttempts: config?.maxReconnectAttempts ?? 5
7546
+ };
7547
+ }
7548
+ // ===========================================================================
7549
+ // Lifecycle
7550
+ // ===========================================================================
7551
+ initialize(deps) {
7552
+ if (this.deps) {
7553
+ this.destroyConnection();
7554
+ }
7555
+ this.deps = deps;
7556
+ const secretKey = Buffer.from(deps.identity.privateKey, "hex");
7557
+ this.keyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(secretKey);
7558
+ }
7559
+ async load() {
7560
+ this.ensureInitialized();
7561
+ const storage = this.deps.storage;
7562
+ const groupsJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_GROUPS);
7563
+ if (groupsJson) {
7564
+ try {
7565
+ const parsed = JSON.parse(groupsJson);
7566
+ this.groups.clear();
7567
+ for (const g of parsed) {
7568
+ this.groups.set(g.id, g);
7569
+ }
7570
+ } catch {
7571
+ }
7572
+ }
7573
+ const messagesJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MESSAGES);
7574
+ if (messagesJson) {
7575
+ try {
7576
+ const parsed = JSON.parse(messagesJson);
7577
+ this.messages.clear();
7578
+ for (const m of parsed) {
7579
+ const groupId = m.groupId;
7580
+ if (!this.messages.has(groupId)) {
7581
+ this.messages.set(groupId, []);
7582
+ }
7583
+ this.messages.get(groupId).push(m);
7584
+ }
7585
+ } catch {
7586
+ }
7587
+ }
7588
+ const membersJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MEMBERS);
7589
+ if (membersJson) {
7590
+ try {
7591
+ const parsed = JSON.parse(membersJson);
7592
+ this.members.clear();
7593
+ for (const m of parsed) {
7594
+ const groupId = m.groupId;
7595
+ if (!this.members.has(groupId)) {
7596
+ this.members.set(groupId, []);
7597
+ }
7598
+ this.members.get(groupId).push(m);
7599
+ }
7600
+ } catch {
7601
+ }
7602
+ }
7603
+ const processedJson = await storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_PROCESSED_EVENTS);
7604
+ if (processedJson) {
7605
+ try {
7606
+ const parsed = JSON.parse(processedJson);
7607
+ this.processedEventIds = new Set(parsed);
7608
+ } catch {
7609
+ }
7610
+ }
7611
+ }
7612
+ destroy() {
7613
+ this.destroyConnection();
7614
+ this.groups.clear();
7615
+ this.messages.clear();
7616
+ this.members.clear();
7617
+ this.processedEventIds.clear();
7618
+ this.pendingLeaves.clear();
7619
+ this.messageHandlers.clear();
7620
+ this.relayAdminPubkeys = null;
7621
+ this.relayAdminFetchPromise = null;
7622
+ if (this.persistTimer) {
7623
+ clearTimeout(this.persistTimer);
7624
+ this.persistTimer = null;
7625
+ }
7626
+ this.deps = null;
7627
+ }
7628
+ destroyConnection() {
7629
+ if (this.reconnectTimer) {
7630
+ clearTimeout(this.reconnectTimer);
7631
+ this.reconnectTimer = null;
7632
+ }
7633
+ if (this.client) {
7634
+ for (const subId of this.subscriptionIds) {
7635
+ try {
7636
+ this.client.unsubscribe(subId);
7637
+ } catch {
7638
+ }
7639
+ }
7640
+ this.subscriptionIds = [];
7641
+ try {
7642
+ this.client.disconnect();
7643
+ } catch {
7644
+ }
7645
+ this.client = null;
7646
+ }
7647
+ this.connected = false;
7648
+ this.connecting = false;
7649
+ this.connectPromise = null;
7650
+ this.reconnectAttempts = 0;
7651
+ this.keyManager = null;
7652
+ }
7653
+ // ===========================================================================
7654
+ // Connection
7655
+ // ===========================================================================
7656
+ async connect() {
7657
+ if (this.connected) return;
7658
+ if (this.connectPromise) {
7659
+ return this.connectPromise;
7660
+ }
7661
+ this.connecting = true;
7662
+ this.connectPromise = this.doConnect().finally(() => {
7663
+ this.connecting = false;
7664
+ this.connectPromise = null;
7665
+ });
7666
+ return this.connectPromise;
7667
+ }
7668
+ getConnectionStatus() {
7669
+ return this.connected;
7670
+ }
7671
+ async doConnect() {
7672
+ this.ensureInitialized();
7673
+ if (!this.keyManager) {
7674
+ const secretKey = Buffer.from(this.deps.identity.privateKey, "hex");
7675
+ this.keyManager = import_nostr_js_sdk2.NostrKeyManager.fromPrivateKey(secretKey);
7676
+ }
7677
+ const primaryRelay = this.config.relays[0];
7678
+ if (primaryRelay) {
7679
+ await this.checkAndClearOnRelayChange(primaryRelay);
7680
+ }
7681
+ this.client = new import_nostr_js_sdk2.NostrClient(this.keyManager);
7682
+ try {
7683
+ await this.client.connect(...this.config.relays);
7684
+ this.connected = true;
7685
+ this.reconnectAttempts = 0;
7686
+ this.deps.emitEvent("groupchat:connection", { connected: true });
7687
+ if (this.groups.size === 0) {
7688
+ await this.restoreJoinedGroups();
7689
+ } else {
7690
+ await this.subscribeToJoinedGroups();
7691
+ }
7692
+ } catch (error) {
7693
+ console.error("[GroupChat] Failed to connect to relays", error);
7694
+ this.deps.emitEvent("groupchat:connection", { connected: false });
7695
+ this.scheduleReconnect();
7696
+ }
7697
+ }
7698
+ scheduleReconnect() {
7699
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
7700
+ console.error("[GroupChat] Max reconnection attempts reached");
7701
+ return;
7702
+ }
7703
+ this.reconnectAttempts++;
7704
+ this.reconnectTimer = setTimeout(() => {
7705
+ this.reconnectTimer = null;
7706
+ if (this.deps) {
7707
+ this.connect().catch(console.error);
7708
+ }
7709
+ }, this.config.reconnectDelayMs);
7710
+ }
7711
+ // ===========================================================================
7712
+ // Subscription Management
7713
+ // ===========================================================================
7714
+ async subscribeToJoinedGroups() {
7715
+ if (!this.client) return;
7716
+ const groupIds = Array.from(this.groups.keys());
7717
+ if (groupIds.length === 0) return;
7718
+ this.trackSubscription(
7719
+ createNip29Filter({
7720
+ kinds: [NIP29_KINDS.CHAT_MESSAGE, NIP29_KINDS.THREAD_ROOT, NIP29_KINDS.THREAD_REPLY],
7721
+ "#h": groupIds
7722
+ }),
7723
+ { onEvent: (event) => this.handleGroupEvent(event) }
7724
+ );
7725
+ this.trackSubscription(
7726
+ createNip29Filter({
7727
+ kinds: [NIP29_KINDS.GROUP_METADATA, NIP29_KINDS.GROUP_MEMBERS, NIP29_KINDS.GROUP_ADMINS],
7728
+ "#d": groupIds
7729
+ }),
7730
+ { onEvent: (event) => this.handleMetadataEvent(event) }
7731
+ );
7732
+ this.trackSubscription(
7733
+ createNip29Filter({
7734
+ kinds: [NIP29_KINDS.DELETE_EVENT, NIP29_KINDS.REMOVE_USER, NIP29_KINDS.DELETE_GROUP],
7735
+ "#h": groupIds
7736
+ }),
7737
+ { onEvent: (event) => this.handleModerationEvent(event) }
7738
+ );
7739
+ }
7740
+ subscribeToGroup(groupId) {
7741
+ if (!this.client) return;
7742
+ this.trackSubscription(
7743
+ createNip29Filter({
7744
+ kinds: [NIP29_KINDS.CHAT_MESSAGE, NIP29_KINDS.THREAD_ROOT, NIP29_KINDS.THREAD_REPLY],
7745
+ "#h": [groupId]
7746
+ }),
7747
+ { onEvent: (event) => this.handleGroupEvent(event) }
7748
+ );
7749
+ this.trackSubscription(
7750
+ createNip29Filter({
7751
+ kinds: [NIP29_KINDS.DELETE_EVENT, NIP29_KINDS.REMOVE_USER, NIP29_KINDS.DELETE_GROUP],
7752
+ "#h": [groupId]
7753
+ }),
7754
+ { onEvent: (event) => this.handleModerationEvent(event) }
7755
+ );
7756
+ }
7757
+ // ===========================================================================
7758
+ // Event Handlers
7759
+ // ===========================================================================
7760
+ handleGroupEvent(event) {
7761
+ if (this.processedEventIds.has(event.id)) return;
7762
+ const groupId = this.getGroupIdFromEvent(event);
7763
+ if (!groupId) return;
7764
+ const group = this.groups.get(groupId);
7765
+ if (!group) return;
7766
+ const { text: content, senderNametag } = this.unwrapMessageContent(event.content);
7767
+ const message = {
7768
+ id: event.id,
7769
+ groupId,
7770
+ content,
7771
+ timestamp: event.created_at * 1e3,
7772
+ senderPubkey: event.pubkey,
7773
+ senderNametag: senderNametag || void 0,
7774
+ replyToId: this.extractReplyTo(event),
7775
+ previousIds: this.extractPreviousIds(event)
7776
+ };
7777
+ this.saveMessageToMemory(message);
7778
+ this.addProcessedEventId(event.id);
7779
+ if (senderNametag) {
7780
+ this.updateMemberNametag(groupId, event.pubkey, senderNametag, event.created_at * 1e3);
7781
+ }
7782
+ this.updateGroupLastMessage(groupId, content.slice(0, 100), message.timestamp);
7783
+ const myPubkey = this.getMyPublicKey();
7784
+ if (event.pubkey !== myPubkey) {
7785
+ group.unreadCount = (group.unreadCount || 0) + 1;
7786
+ }
7787
+ this.deps.emitEvent("groupchat:message", message);
7788
+ this.deps.emitEvent("groupchat:updated", {});
7789
+ for (const handler of this.messageHandlers) {
7790
+ try {
7791
+ handler(message);
7792
+ } catch {
7793
+ }
7794
+ }
7795
+ this.schedulePersist();
7796
+ }
7797
+ handleMetadataEvent(event) {
7798
+ const groupId = this.getGroupIdFromMetadataEvent(event);
7799
+ if (!groupId) return;
7800
+ const group = this.groups.get(groupId);
7801
+ if (!group) return;
7802
+ if (event.kind === NIP29_KINDS.GROUP_METADATA) {
7803
+ if (!event.content || event.content.trim() === "") return;
7804
+ try {
7805
+ const metadata = JSON.parse(event.content);
7806
+ group.name = metadata.name || group.name;
7807
+ group.description = metadata.about || group.description;
7808
+ group.picture = metadata.picture || group.picture;
7809
+ group.updatedAt = event.created_at * 1e3;
7810
+ this.groups.set(groupId, group);
7811
+ this.persistGroups();
7812
+ } catch {
7813
+ }
7814
+ } else if (event.kind === NIP29_KINDS.GROUP_MEMBERS) {
7815
+ this.updateMembersFromEvent(groupId, event);
7816
+ } else if (event.kind === NIP29_KINDS.GROUP_ADMINS) {
7817
+ this.updateAdminsFromEvent(groupId, event);
7818
+ }
7819
+ }
7820
+ handleModerationEvent(event) {
7821
+ const groupId = this.getGroupIdFromEvent(event);
7822
+ if (!groupId) return;
7823
+ const group = this.groups.get(groupId);
7824
+ if (!group) return;
7825
+ if (event.kind === NIP29_KINDS.DELETE_EVENT) {
7826
+ const eTags = event.tags.filter((t) => t[0] === "e");
7827
+ for (const tag of eTags) {
7828
+ const messageId = tag[1];
7829
+ if (messageId) {
7830
+ this.deleteMessageFromMemory(groupId, messageId);
7831
+ }
7832
+ }
7833
+ this.deps.emitEvent("groupchat:updated", {});
7834
+ this.persistMessages();
7835
+ } else if (event.kind === NIP29_KINDS.REMOVE_USER) {
7836
+ if (this.processedEventIds.has(event.id)) return;
7837
+ const eventTimestampMs = event.created_at * 1e3;
7838
+ if (group.localJoinedAt && eventTimestampMs < group.localJoinedAt) {
7839
+ this.addProcessedEventId(event.id);
7840
+ return;
7841
+ }
7842
+ this.addProcessedEventId(event.id);
7843
+ const pTags = event.tags.filter((t) => t[0] === "p");
7844
+ const myPubkey = this.getMyPublicKey();
7845
+ for (const tag of pTags) {
7846
+ const removedPubkey = tag[1];
7847
+ if (!removedPubkey) continue;
7848
+ if (removedPubkey === myPubkey) {
7849
+ if (this.pendingLeaves.has(groupId)) {
7850
+ this.pendingLeaves.delete(groupId);
7851
+ this.deps.emitEvent("groupchat:updated", {});
7852
+ } else {
7853
+ const groupName = group.name || groupId;
7854
+ this.removeGroupFromMemory(groupId);
7855
+ this.deps.emitEvent("groupchat:kicked", { groupId, groupName });
7856
+ this.deps.emitEvent("groupchat:updated", {});
7857
+ }
7858
+ } else {
7859
+ this.removeMemberFromMemory(groupId, removedPubkey);
7860
+ }
7861
+ }
7862
+ this.schedulePersist();
7863
+ } else if (event.kind === NIP29_KINDS.DELETE_GROUP) {
7864
+ if (this.processedEventIds.has(event.id)) return;
7865
+ const deleteTimestampMs = event.created_at * 1e3;
7866
+ if (deleteTimestampMs < group.createdAt) {
7867
+ this.addProcessedEventId(event.id);
7868
+ return;
7869
+ }
7870
+ this.addProcessedEventId(event.id);
7871
+ const groupName = group.name || groupId;
7872
+ this.removeGroupFromMemory(groupId);
7873
+ this.deps.emitEvent("groupchat:group_deleted", { groupId, groupName });
7874
+ this.deps.emitEvent("groupchat:updated", {});
7875
+ this.schedulePersist();
7876
+ }
7877
+ }
7878
+ updateMembersFromEvent(groupId, event) {
7879
+ const pTags = event.tags.filter((t) => t[0] === "p");
7880
+ const existingMembers = this.members.get(groupId) || [];
7881
+ for (const tag of pTags) {
7882
+ const pubkey = tag[1];
7883
+ const roleFromTag = tag[3];
7884
+ const existing = existingMembers.find((m) => m.pubkey === pubkey);
7885
+ const role = roleFromTag || existing?.role || GroupRole.MEMBER;
7886
+ const member = {
7887
+ pubkey,
7888
+ groupId,
7889
+ role,
7890
+ nametag: existing?.nametag,
7891
+ joinedAt: existing?.joinedAt || event.created_at * 1e3
7892
+ };
7893
+ this.saveMemberToMemory(member);
7894
+ }
7895
+ this.persistMembers();
7896
+ }
7897
+ updateAdminsFromEvent(groupId, event) {
7898
+ const pTags = event.tags.filter((t) => t[0] === "p");
7899
+ const existingMembers = this.members.get(groupId) || [];
7900
+ for (const tag of pTags) {
7901
+ const pubkey = tag[1];
7902
+ const existing = existingMembers.find((m) => m.pubkey === pubkey);
7903
+ if (existing) {
7904
+ existing.role = GroupRole.ADMIN;
7905
+ this.saveMemberToMemory(existing);
7906
+ } else {
7907
+ this.saveMemberToMemory({
7908
+ pubkey,
7909
+ groupId,
7910
+ role: GroupRole.ADMIN,
7911
+ joinedAt: event.created_at * 1e3
7912
+ });
7913
+ }
7914
+ }
7915
+ this.persistMembers();
7916
+ }
7917
+ // ===========================================================================
7918
+ // Group Membership Restoration
7919
+ // ===========================================================================
7920
+ async restoreJoinedGroups() {
7921
+ if (!this.client) return [];
7922
+ const myPubkey = this.getMyPublicKey();
7923
+ if (!myPubkey) return [];
7924
+ const groupIdsWithMembership = /* @__PURE__ */ new Set();
7925
+ await this.oneshotSubscription(
7926
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_MEMBERS] }),
7927
+ {
7928
+ onEvent: (event) => {
7929
+ const groupId = this.getGroupIdFromMetadataEvent(event);
7930
+ if (!groupId) return;
7931
+ const pTags = event.tags.filter((t) => t[0] === "p");
7932
+ if (pTags.some((tag) => tag[1] === myPubkey)) {
7933
+ groupIdsWithMembership.add(groupId);
7934
+ }
7935
+ },
7936
+ onComplete: () => {
7937
+ },
7938
+ timeoutMs: 15e3
7939
+ }
7940
+ );
7941
+ if (groupIdsWithMembership.size === 0) return [];
7942
+ const restoredGroups = [];
7943
+ for (const groupId of groupIdsWithMembership) {
7944
+ if (this.groups.has(groupId)) continue;
7945
+ try {
7946
+ const group = await this.fetchGroupMetadataInternal(groupId);
7947
+ if (group) {
7948
+ this.groups.set(groupId, group);
7949
+ restoredGroups.push(group);
7950
+ await Promise.all([
7951
+ this.fetchAndSaveMembers(groupId),
7952
+ this.fetchMessages(groupId)
7953
+ ]);
7954
+ }
7955
+ } catch {
7956
+ }
7957
+ }
7958
+ if (restoredGroups.length > 0) {
7959
+ await this.subscribeToJoinedGroups();
7960
+ this.deps.emitEvent("groupchat:updated", {});
7961
+ this.schedulePersist();
7962
+ }
7963
+ return restoredGroups;
7964
+ }
7965
+ // ===========================================================================
7966
+ // Public API — Groups
7967
+ // ===========================================================================
7968
+ async fetchAvailableGroups() {
7969
+ await this.ensureConnected();
7970
+ if (!this.client) return [];
7971
+ const groupsMap = /* @__PURE__ */ new Map();
7972
+ const memberCountsMap = /* @__PURE__ */ new Map();
7973
+ await Promise.all([
7974
+ this.oneshotSubscription(
7975
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_METADATA] }),
7976
+ {
7977
+ onEvent: (event) => {
7978
+ const group = this.parseGroupMetadata(event);
7979
+ if (group && group.visibility === GroupVisibility.PUBLIC) {
7980
+ const existing = groupsMap.get(group.id);
7981
+ if (!existing || group.createdAt > existing.createdAt) {
7982
+ groupsMap.set(group.id, group);
7983
+ }
7984
+ }
7985
+ },
7986
+ onComplete: () => {
7987
+ },
7988
+ timeoutMs: 1e4
7989
+ }
7990
+ ),
7991
+ this.oneshotSubscription(
7992
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_MEMBERS] }),
7993
+ {
7994
+ onEvent: (event) => {
7995
+ const groupId = this.getGroupIdFromMetadataEvent(event);
7996
+ if (groupId) {
7997
+ const pTags = event.tags.filter((t) => t[0] === "p");
7998
+ memberCountsMap.set(groupId, pTags.length);
7999
+ }
8000
+ },
8001
+ onComplete: () => {
8002
+ },
8003
+ timeoutMs: 1e4
8004
+ }
8005
+ )
8006
+ ]);
8007
+ for (const [groupId, count] of memberCountsMap) {
8008
+ const group = groupsMap.get(groupId);
8009
+ if (group) group.memberCount = count;
8010
+ }
8011
+ return Array.from(groupsMap.values());
8012
+ }
8013
+ async joinGroup(groupId, inviteCode) {
8014
+ await this.ensureConnected();
8015
+ if (!this.client) return false;
8016
+ try {
8017
+ let group = await this.fetchGroupMetadataInternal(groupId);
8018
+ if (!group && !inviteCode) return false;
8019
+ const tags = [["h", groupId]];
8020
+ if (inviteCode) tags.push(["code", inviteCode]);
8021
+ const eventId = await this.client.createAndPublishEvent({
8022
+ kind: NIP29_KINDS.JOIN_REQUEST,
8023
+ tags,
8024
+ content: ""
8025
+ });
8026
+ if (eventId) {
8027
+ if (!group) {
8028
+ group = await this.fetchGroupMetadataInternal(groupId);
8029
+ if (!group) return false;
8030
+ }
8031
+ group.localJoinedAt = Date.now();
8032
+ this.groups.set(groupId, group);
8033
+ this.subscribeToGroup(groupId);
8034
+ await Promise.all([
8035
+ this.fetchMessages(groupId),
8036
+ this.fetchAndSaveMembers(groupId)
8037
+ ]);
8038
+ this.deps.emitEvent("groupchat:joined", { groupId, groupName: group.name });
8039
+ this.deps.emitEvent("groupchat:updated", {});
8040
+ this.persistAll();
8041
+ return true;
8042
+ }
8043
+ return false;
8044
+ } catch (error) {
8045
+ const msg = error instanceof Error ? error.message : String(error);
8046
+ if (msg.includes("already a member")) {
8047
+ const group = await this.fetchGroupMetadataInternal(groupId);
8048
+ if (group) {
8049
+ group.localJoinedAt = Date.now();
8050
+ this.groups.set(groupId, group);
8051
+ this.subscribeToGroup(groupId);
8052
+ await Promise.all([
8053
+ this.fetchMessages(groupId),
8054
+ this.fetchAndSaveMembers(groupId)
8055
+ ]);
8056
+ this.deps.emitEvent("groupchat:joined", { groupId, groupName: group.name });
8057
+ this.deps.emitEvent("groupchat:updated", {});
8058
+ this.persistAll();
8059
+ return true;
8060
+ }
8061
+ }
8062
+ console.error("[GroupChat] Failed to join group", error);
8063
+ return false;
8064
+ }
8065
+ }
8066
+ async leaveGroup(groupId) {
8067
+ await this.ensureConnected();
8068
+ if (!this.client) return false;
8069
+ try {
8070
+ this.pendingLeaves.add(groupId);
8071
+ const eventId = await this.client.createAndPublishEvent({
8072
+ kind: NIP29_KINDS.LEAVE_REQUEST,
8073
+ tags: [["h", groupId]],
8074
+ content: ""
8075
+ });
8076
+ if (eventId) {
8077
+ this.removeGroupFromMemory(groupId);
8078
+ this.deps.emitEvent("groupchat:left", { groupId });
8079
+ this.deps.emitEvent("groupchat:updated", {});
8080
+ this.persistAll();
8081
+ return true;
8082
+ }
8083
+ this.pendingLeaves.delete(groupId);
8084
+ return false;
8085
+ } catch (error) {
8086
+ const msg = error instanceof Error ? error.message : String(error);
8087
+ if (msg.includes("group not found") || msg.includes("not a member")) {
8088
+ this.removeGroupFromMemory(groupId);
8089
+ this.persistAll();
8090
+ return true;
8091
+ }
8092
+ console.error("[GroupChat] Failed to leave group", error);
8093
+ return false;
8094
+ }
8095
+ }
8096
+ async createGroup(options) {
8097
+ await this.ensureConnected();
8098
+ if (!this.client) return null;
8099
+ const creatorPubkey = this.getMyPublicKey();
8100
+ if (!creatorPubkey) return null;
8101
+ const proposedGroupId = options.name.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 20) || this.randomId();
8102
+ try {
8103
+ const isPrivate = options.visibility === GroupVisibility.PRIVATE;
8104
+ const eventId = await this.client.createAndPublishEvent({
8105
+ kind: NIP29_KINDS.CREATE_GROUP,
8106
+ tags: [["h", proposedGroupId]],
8107
+ content: JSON.stringify({
8108
+ name: options.name,
8109
+ about: options.description,
8110
+ picture: options.picture,
8111
+ closed: true,
8112
+ private: isPrivate,
8113
+ hidden: isPrivate
8114
+ })
8115
+ });
8116
+ if (!eventId) return null;
8117
+ let group = await this.fetchGroupMetadataInternal(proposedGroupId);
8118
+ if (!group) {
8119
+ group = {
8120
+ id: proposedGroupId,
8121
+ relayUrl: this.config.relays[0] || "",
8122
+ name: options.name,
8123
+ description: options.description,
8124
+ visibility: options.visibility || GroupVisibility.PUBLIC,
8125
+ createdAt: Date.now(),
8126
+ memberCount: 1
8127
+ };
8128
+ }
8129
+ if (!group.name || group.name === "Unnamed Group") {
8130
+ group.name = options.name;
8131
+ }
8132
+ if (options.description && !group.description) {
8133
+ group.description = options.description;
8134
+ }
8135
+ group.visibility = options.visibility || GroupVisibility.PUBLIC;
8136
+ group.memberCount = 1;
8137
+ this.groups.set(group.id, group);
8138
+ this.subscribeToGroup(group.id);
8139
+ this.client.createAndPublishEvent({
8140
+ kind: NIP29_KINDS.JOIN_REQUEST,
8141
+ tags: [["h", group.id]],
8142
+ content: ""
8143
+ }).catch(() => {
8144
+ });
8145
+ await this.fetchAndSaveMembers(group.id).catch(() => {
8146
+ });
8147
+ this.saveMemberToMemory({
8148
+ pubkey: creatorPubkey,
8149
+ groupId: group.id,
8150
+ role: GroupRole.ADMIN,
8151
+ joinedAt: Date.now()
8152
+ });
8153
+ this.deps.emitEvent("groupchat:joined", { groupId: group.id, groupName: group.name });
8154
+ this.deps.emitEvent("groupchat:updated", {});
8155
+ this.schedulePersist();
8156
+ return group;
8157
+ } catch (error) {
8158
+ console.error("[GroupChat] Failed to create group", error);
8159
+ return null;
8160
+ }
8161
+ }
8162
+ async deleteGroup(groupId) {
8163
+ await this.ensureConnected();
8164
+ if (!this.client) return false;
8165
+ const group = this.groups.get(groupId);
8166
+ if (!group) return false;
8167
+ const canDelete = await this.canModerateGroup(groupId);
8168
+ if (!canDelete) return false;
8169
+ try {
8170
+ const eventId = await this.client.createAndPublishEvent({
8171
+ kind: NIP29_KINDS.DELETE_GROUP,
8172
+ tags: [["h", groupId]],
8173
+ content: ""
8174
+ });
8175
+ if (eventId) {
8176
+ const groupName = group.name || groupId;
8177
+ this.removeGroupFromMemory(groupId);
8178
+ this.deps.emitEvent("groupchat:group_deleted", { groupId, groupName });
8179
+ this.deps.emitEvent("groupchat:updated", {});
8180
+ this.persistAll();
8181
+ return true;
8182
+ }
8183
+ return false;
8184
+ } catch (error) {
8185
+ console.error("[GroupChat] Failed to delete group", error);
8186
+ return false;
8187
+ }
8188
+ }
8189
+ async createInvite(groupId) {
8190
+ await this.ensureConnected();
8191
+ if (!this.client) return null;
8192
+ if (!this.isCurrentUserAdmin(groupId)) return null;
8193
+ try {
8194
+ const inviteCode = this.randomId();
8195
+ const eventId = await this.client.createAndPublishEvent({
8196
+ kind: NIP29_KINDS.CREATE_INVITE,
8197
+ tags: [
8198
+ ["h", groupId],
8199
+ ["code", inviteCode]
8200
+ ],
8201
+ content: ""
8202
+ });
8203
+ return eventId ? inviteCode : null;
8204
+ } catch (error) {
8205
+ console.error("[GroupChat] Failed to create invite", error);
8206
+ return null;
8207
+ }
8208
+ }
8209
+ // ===========================================================================
8210
+ // Public API — Messages
8211
+ // ===========================================================================
8212
+ async sendMessage(groupId, content, replyToId) {
8213
+ await this.ensureConnected();
8214
+ if (!this.client) return null;
8215
+ const group = this.groups.get(groupId);
8216
+ if (!group) return null;
8217
+ try {
8218
+ const senderNametag = this.deps.identity.nametag || null;
8219
+ const kind = replyToId ? NIP29_KINDS.THREAD_REPLY : NIP29_KINDS.CHAT_MESSAGE;
8220
+ const tags = [["h", groupId]];
8221
+ const groupMessages = this.messages.get(groupId) || [];
8222
+ const recentIds = groupMessages.slice(-this.config.maxPreviousTags).map((m) => (m.id || "").slice(0, 8)).filter(Boolean);
8223
+ if (recentIds.length > 0) {
8224
+ tags.push(["previous", ...recentIds]);
8225
+ }
8226
+ if (replyToId) {
8227
+ tags.push(["e", replyToId, "", "reply"]);
8228
+ }
8229
+ const wrappedContent = this.wrapMessageContent(content, senderNametag);
8230
+ const eventId = await this.client.createAndPublishEvent({
8231
+ kind,
8232
+ tags,
8233
+ content: wrappedContent
8234
+ });
8235
+ if (eventId) {
8236
+ const myPubkey = this.getMyPublicKey();
8237
+ const message = {
8238
+ id: eventId,
8239
+ groupId,
8240
+ content,
8241
+ timestamp: Date.now(),
8242
+ senderPubkey: myPubkey || "",
8243
+ senderNametag: senderNametag || void 0,
8244
+ replyToId,
8245
+ previousIds: recentIds
8246
+ };
8247
+ this.saveMessageToMemory(message);
8248
+ this.addProcessedEventId(eventId);
8249
+ this.updateGroupLastMessage(groupId, content.slice(0, 100), message.timestamp);
8250
+ this.persistAll();
8251
+ return message;
8252
+ }
8253
+ return null;
8254
+ } catch (error) {
8255
+ console.error("[GroupChat] Failed to send message", error);
8256
+ return null;
8257
+ }
8258
+ }
8259
+ async fetchMessages(groupId, since, limit) {
8260
+ await this.ensureConnected();
8261
+ if (!this.client) return [];
8262
+ const fetchedMessages = [];
8263
+ const filterData = {
8264
+ kinds: [NIP29_KINDS.CHAT_MESSAGE, NIP29_KINDS.THREAD_ROOT, NIP29_KINDS.THREAD_REPLY],
8265
+ "#h": [groupId]
8266
+ };
8267
+ if (since) filterData.since = Math.floor(since / 1e3);
8268
+ if (limit) filterData.limit = limit;
8269
+ if (!limit && !since) filterData.limit = this.config.defaultMessageLimit;
8270
+ return this.oneshotSubscription(createNip29Filter(filterData), {
8271
+ onEvent: (event) => {
8272
+ const { text: content, senderNametag } = this.unwrapMessageContent(event.content);
8273
+ const message = {
8274
+ id: event.id,
8275
+ groupId,
8276
+ content,
8277
+ timestamp: event.created_at * 1e3,
8278
+ senderPubkey: event.pubkey,
8279
+ senderNametag: senderNametag || void 0,
8280
+ replyToId: this.extractReplyTo(event),
8281
+ previousIds: this.extractPreviousIds(event)
8282
+ };
8283
+ fetchedMessages.push(message);
8284
+ this.saveMessageToMemory(message);
8285
+ this.addProcessedEventId(event.id);
8286
+ if (senderNametag) {
8287
+ this.updateMemberNametag(groupId, event.pubkey, senderNametag, event.created_at * 1e3);
8288
+ }
8289
+ },
8290
+ onComplete: () => {
8291
+ this.schedulePersist();
8292
+ return fetchedMessages;
8293
+ },
8294
+ timeoutMs: 1e4
8295
+ });
8296
+ }
8297
+ // ===========================================================================
8298
+ // Public API — Queries (from local state)
8299
+ // ===========================================================================
8300
+ getGroups() {
8301
+ return Array.from(this.groups.values()).sort((a, b) => (b.lastMessageTime || 0) - (a.lastMessageTime || 0));
8302
+ }
8303
+ getGroup(groupId) {
8304
+ return this.groups.get(groupId) || null;
8305
+ }
8306
+ getMessages(groupId) {
8307
+ return (this.messages.get(groupId) || []).sort((a, b) => a.timestamp - b.timestamp);
8308
+ }
8309
+ getMembers(groupId) {
8310
+ return (this.members.get(groupId) || []).sort((a, b) => a.joinedAt - b.joinedAt);
8311
+ }
8312
+ getMember(groupId, pubkey) {
8313
+ const members = this.members.get(groupId) || [];
8314
+ return members.find((m) => m.pubkey === pubkey) || null;
8315
+ }
8316
+ getTotalUnreadCount() {
8317
+ let total = 0;
8318
+ for (const group of this.groups.values()) {
8319
+ total += group.unreadCount || 0;
8320
+ }
8321
+ return total;
8322
+ }
8323
+ markGroupAsRead(groupId) {
8324
+ const group = this.groups.get(groupId);
8325
+ if (group && (group.unreadCount || 0) > 0) {
8326
+ group.unreadCount = 0;
8327
+ this.groups.set(groupId, group);
8328
+ this.persistGroups();
8329
+ }
8330
+ }
8331
+ // ===========================================================================
8332
+ // Public API — Admin
8333
+ // ===========================================================================
8334
+ async kickUser(groupId, userPubkey, reason) {
8335
+ await this.ensureConnected();
8336
+ if (!this.client) return false;
8337
+ const canModerate = await this.canModerateGroup(groupId);
8338
+ if (!canModerate) return false;
8339
+ const myPubkey = this.getMyPublicKey();
8340
+ if (myPubkey === userPubkey) return false;
8341
+ try {
8342
+ const eventId = await this.client.createAndPublishEvent({
8343
+ kind: NIP29_KINDS.REMOVE_USER,
8344
+ tags: [["h", groupId], ["p", userPubkey]],
8345
+ content: reason || ""
8346
+ });
8347
+ if (eventId) {
8348
+ this.removeMemberFromMemory(groupId, userPubkey);
8349
+ this.deps.emitEvent("groupchat:updated", {});
8350
+ this.persistMembers();
8351
+ return true;
8352
+ }
8353
+ return false;
8354
+ } catch (error) {
8355
+ console.error("[GroupChat] Failed to kick user", error);
8356
+ return false;
8357
+ }
8358
+ }
8359
+ async deleteMessage(groupId, messageId) {
8360
+ await this.ensureConnected();
8361
+ if (!this.client) return false;
8362
+ const canModerate = await this.canModerateGroup(groupId);
8363
+ if (!canModerate) return false;
8364
+ try {
8365
+ const eventId = await this.client.createAndPublishEvent({
8366
+ kind: NIP29_KINDS.DELETE_EVENT,
8367
+ tags: [["h", groupId], ["e", messageId]],
8368
+ content: ""
8369
+ });
8370
+ if (eventId) {
8371
+ this.deleteMessageFromMemory(groupId, messageId);
8372
+ this.deps.emitEvent("groupchat:updated", {});
8373
+ this.persistMessages();
8374
+ return true;
8375
+ }
8376
+ return false;
8377
+ } catch (error) {
8378
+ console.error("[GroupChat] Failed to delete message", error);
8379
+ return false;
8380
+ }
8381
+ }
8382
+ isCurrentUserAdmin(groupId) {
8383
+ const myPubkey = this.getMyPublicKey();
8384
+ if (!myPubkey) return false;
8385
+ const member = this.getMember(groupId, myPubkey);
8386
+ return member?.role === GroupRole.ADMIN;
8387
+ }
8388
+ isCurrentUserModerator(groupId) {
8389
+ const myPubkey = this.getMyPublicKey();
8390
+ if (!myPubkey) return false;
8391
+ const member = this.getMember(groupId, myPubkey);
8392
+ return member?.role === GroupRole.ADMIN || member?.role === GroupRole.MODERATOR;
8393
+ }
8394
+ /**
8395
+ * Check if current user can moderate a group:
8396
+ * - Group admin/moderator can always moderate their group
8397
+ * - Relay admins can moderate public groups
8398
+ */
8399
+ async canModerateGroup(groupId) {
8400
+ if (this.isCurrentUserAdmin(groupId) || this.isCurrentUserModerator(groupId)) {
8401
+ return true;
8402
+ }
8403
+ const group = this.groups.get(groupId);
8404
+ if (group && group.visibility === GroupVisibility.PUBLIC) {
8405
+ return this.isCurrentUserRelayAdmin();
8406
+ }
8407
+ return false;
8408
+ }
8409
+ async isCurrentUserRelayAdmin() {
8410
+ const myPubkey = this.getMyPublicKey();
8411
+ if (!myPubkey) return false;
8412
+ const admins = await this.fetchRelayAdmins();
8413
+ return admins.has(myPubkey);
8414
+ }
8415
+ getCurrentUserRole(groupId) {
8416
+ const myPubkey = this.getMyPublicKey();
8417
+ if (!myPubkey) return null;
8418
+ const member = this.getMember(groupId, myPubkey);
8419
+ return member?.role || null;
8420
+ }
8421
+ // ===========================================================================
8422
+ // Public API — Listeners
8423
+ // ===========================================================================
8424
+ onMessage(handler) {
8425
+ this.messageHandlers.add(handler);
8426
+ return () => this.messageHandlers.delete(handler);
8427
+ }
8428
+ // ===========================================================================
8429
+ // Public API — Utilities
8430
+ // ===========================================================================
8431
+ getRelayUrls() {
8432
+ return this.config.relays;
8433
+ }
8434
+ getMyPublicKey() {
8435
+ return this.keyManager?.getPublicKeyHex() || null;
8436
+ }
8437
+ // ===========================================================================
8438
+ // Private — Relay Admin
8439
+ // ===========================================================================
8440
+ async fetchRelayAdmins() {
8441
+ if (this.relayAdminPubkeys) return this.relayAdminPubkeys;
8442
+ if (this.relayAdminFetchPromise) return this.relayAdminFetchPromise;
8443
+ this.relayAdminFetchPromise = this.doFetchRelayAdmins();
8444
+ const result = await this.relayAdminFetchPromise;
8445
+ this.relayAdminFetchPromise = null;
8446
+ return result;
8447
+ }
8448
+ async doFetchRelayAdmins() {
8449
+ await this.ensureConnected();
8450
+ if (!this.client) return /* @__PURE__ */ new Set();
8451
+ const adminPubkeys = /* @__PURE__ */ new Set();
8452
+ return this.oneshotSubscription(
8453
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_ADMINS], "#d": ["", "_"] }),
8454
+ {
8455
+ onEvent: (event) => {
8456
+ const pTags = event.tags.filter((t) => t[0] === "p");
8457
+ for (const tag of pTags) {
8458
+ if (tag[1]) adminPubkeys.add(tag[1]);
8459
+ }
8460
+ },
8461
+ onComplete: () => {
8462
+ this.relayAdminPubkeys = adminPubkeys;
8463
+ return adminPubkeys;
8464
+ }
8465
+ }
8466
+ );
8467
+ }
8468
+ // ===========================================================================
8469
+ // Private — Fetch Helpers
8470
+ // ===========================================================================
8471
+ async fetchGroupMetadataInternal(groupId) {
8472
+ if (!this.client) return null;
8473
+ let result = null;
8474
+ return this.oneshotSubscription(
8475
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_METADATA], "#d": [groupId] }),
8476
+ {
8477
+ onEvent: (event) => {
8478
+ if (!result) result = this.parseGroupMetadata(event);
8479
+ },
8480
+ onComplete: () => result
8481
+ }
8482
+ );
8483
+ }
8484
+ async fetchAndSaveMembers(groupId) {
8485
+ const [members, adminPubkeys] = await Promise.all([
8486
+ this.fetchGroupMembersInternal(groupId),
8487
+ this.fetchGroupAdminsInternal(groupId)
8488
+ ]);
8489
+ for (const member of members) {
8490
+ if (adminPubkeys.includes(member.pubkey)) {
8491
+ member.role = GroupRole.ADMIN;
8492
+ }
8493
+ this.saveMemberToMemory(member);
8494
+ }
8495
+ for (const pubkey of adminPubkeys) {
8496
+ const existing = (this.members.get(groupId) || []).find((m) => m.pubkey === pubkey);
8497
+ if (!existing) {
8498
+ this.saveMemberToMemory({
8499
+ pubkey,
8500
+ groupId,
8501
+ role: GroupRole.ADMIN,
8502
+ joinedAt: Date.now()
8503
+ });
8504
+ }
8505
+ }
8506
+ this.persistMembers();
8507
+ }
8508
+ async fetchGroupMembersInternal(groupId) {
8509
+ if (!this.client) return [];
8510
+ const members = [];
8511
+ return this.oneshotSubscription(
8512
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_MEMBERS], "#d": [groupId] }),
8513
+ {
8514
+ onEvent: (event) => {
8515
+ const pTags = event.tags.filter((t) => t[0] === "p");
8516
+ for (const tag of pTags) {
8517
+ members.push({
8518
+ pubkey: tag[1],
8519
+ groupId,
8520
+ role: tag[3] || GroupRole.MEMBER,
8521
+ joinedAt: event.created_at * 1e3
8522
+ });
8523
+ }
8524
+ },
8525
+ onComplete: () => members
8526
+ }
8527
+ );
8528
+ }
8529
+ async fetchGroupAdminsInternal(groupId) {
8530
+ if (!this.client) return [];
8531
+ const adminPubkeys = [];
8532
+ return this.oneshotSubscription(
8533
+ new import_nostr_js_sdk2.Filter({ kinds: [NIP29_KINDS.GROUP_ADMINS], "#d": [groupId] }),
8534
+ {
8535
+ onEvent: (event) => {
8536
+ const pTags = event.tags.filter((t) => t[0] === "p");
8537
+ for (const tag of pTags) {
8538
+ if (tag[1] && !adminPubkeys.includes(tag[1])) {
8539
+ adminPubkeys.push(tag[1]);
8540
+ }
8541
+ }
8542
+ },
8543
+ onComplete: () => adminPubkeys
8544
+ }
8545
+ );
8546
+ }
8547
+ // ===========================================================================
8548
+ // Private — In-Memory State Helpers
7349
8549
  // ===========================================================================
7350
- /**
7351
- * Send direct message
7352
- */
7353
- async sendDM(recipient, content) {
7354
- this.ensureInitialized();
7355
- const recipientPubkey = await this.resolveRecipient(recipient);
7356
- const eventId = await this.deps.transport.sendMessage(recipientPubkey, content);
7357
- const message = {
7358
- id: eventId,
7359
- senderPubkey: this.deps.identity.chainPubkey,
7360
- senderNametag: this.deps.identity.nametag,
7361
- recipientPubkey,
7362
- content,
7363
- timestamp: Date.now(),
7364
- isRead: true
7365
- };
7366
- this.messages.set(message.id, message);
7367
- if (this.config.autoSave) {
7368
- await this.save();
8550
+ saveMessageToMemory(message) {
8551
+ const groupId = message.groupId;
8552
+ if (!this.messages.has(groupId)) {
8553
+ this.messages.set(groupId, []);
8554
+ }
8555
+ const msgs = this.messages.get(groupId);
8556
+ const idx = msgs.findIndex((m) => m.id === message.id);
8557
+ if (idx >= 0) {
8558
+ msgs[idx] = message;
8559
+ } else {
8560
+ msgs.push(message);
8561
+ const maxMessages = this.config.defaultMessageLimit * 2;
8562
+ if (msgs.length > maxMessages) {
8563
+ msgs.splice(0, msgs.length - maxMessages);
8564
+ }
7369
8565
  }
7370
- return message;
7371
8566
  }
7372
- /**
7373
- * Get conversation with peer
7374
- */
7375
- getConversation(peerPubkey) {
7376
- return Array.from(this.messages.values()).filter(
7377
- (m) => m.senderPubkey === peerPubkey || m.recipientPubkey === peerPubkey
7378
- ).sort((a, b) => a.timestamp - b.timestamp);
8567
+ deleteMessageFromMemory(groupId, messageId) {
8568
+ const msgs = this.messages.get(groupId);
8569
+ if (msgs) {
8570
+ const idx = msgs.findIndex((m) => m.id === messageId);
8571
+ if (idx >= 0) msgs.splice(idx, 1);
8572
+ }
7379
8573
  }
7380
- /**
7381
- * Get all conversations grouped by peer
7382
- */
7383
- getConversations() {
7384
- const conversations = /* @__PURE__ */ new Map();
7385
- for (const message of this.messages.values()) {
7386
- const peer = message.senderPubkey === this.deps?.identity.chainPubkey ? message.recipientPubkey : message.senderPubkey;
7387
- if (!conversations.has(peer)) {
7388
- conversations.set(peer, []);
7389
- }
7390
- conversations.get(peer).push(message);
8574
+ saveMemberToMemory(member) {
8575
+ const groupId = member.groupId;
8576
+ if (!this.members.has(groupId)) {
8577
+ this.members.set(groupId, []);
7391
8578
  }
7392
- for (const msgs of conversations.values()) {
7393
- msgs.sort((a, b) => a.timestamp - b.timestamp);
8579
+ const mems = this.members.get(groupId);
8580
+ const idx = mems.findIndex((m) => m.pubkey === member.pubkey);
8581
+ if (idx >= 0) {
8582
+ mems[idx] = member;
8583
+ } else {
8584
+ mems.push(member);
7394
8585
  }
7395
- return conversations;
7396
8586
  }
7397
- /**
7398
- * Mark messages as read
7399
- */
7400
- async markAsRead(messageIds) {
7401
- for (const id of messageIds) {
7402
- const msg = this.messages.get(id);
7403
- if (msg) {
7404
- msg.isRead = true;
7405
- }
8587
+ removeMemberFromMemory(groupId, pubkey) {
8588
+ const mems = this.members.get(groupId);
8589
+ if (mems) {
8590
+ const idx = mems.findIndex((m) => m.pubkey === pubkey);
8591
+ if (idx >= 0) mems.splice(idx, 1);
7406
8592
  }
7407
- if (this.config.autoSave) {
7408
- await this.save();
8593
+ const group = this.groups.get(groupId);
8594
+ if (group) {
8595
+ group.memberCount = (this.members.get(groupId) || []).length;
8596
+ this.groups.set(groupId, group);
7409
8597
  }
7410
8598
  }
7411
- /**
7412
- * Get unread count
7413
- */
7414
- getUnreadCount(peerPubkey) {
7415
- let messages = Array.from(this.messages.values()).filter(
7416
- (m) => !m.isRead && m.senderPubkey !== this.deps?.identity.chainPubkey
7417
- );
7418
- if (peerPubkey) {
7419
- messages = messages.filter((m) => m.senderPubkey === peerPubkey);
8599
+ removeGroupFromMemory(groupId) {
8600
+ this.groups.delete(groupId);
8601
+ this.messages.delete(groupId);
8602
+ this.members.delete(groupId);
8603
+ }
8604
+ updateGroupLastMessage(groupId, text, timestamp) {
8605
+ const group = this.groups.get(groupId);
8606
+ if (group && timestamp >= (group.lastMessageTime || 0)) {
8607
+ group.lastMessageText = text;
8608
+ group.lastMessageTime = timestamp;
8609
+ this.groups.set(groupId, group);
7420
8610
  }
7421
- return messages.length;
7422
8611
  }
7423
- /**
7424
- * Subscribe to incoming DMs
7425
- */
7426
- onDirectMessage(handler) {
7427
- this.dmHandlers.add(handler);
7428
- return () => this.dmHandlers.delete(handler);
8612
+ updateMemberNametag(groupId, pubkey, nametag, joinedAt) {
8613
+ const members = this.members.get(groupId) || [];
8614
+ const existing = members.find((m) => m.pubkey === pubkey);
8615
+ if (existing) {
8616
+ if (existing.nametag !== nametag) {
8617
+ existing.nametag = nametag;
8618
+ this.saveMemberToMemory(existing);
8619
+ }
8620
+ } else {
8621
+ this.saveMemberToMemory({
8622
+ pubkey,
8623
+ groupId,
8624
+ role: GroupRole.MEMBER,
8625
+ nametag,
8626
+ joinedAt
8627
+ });
8628
+ }
8629
+ }
8630
+ addProcessedEventId(eventId) {
8631
+ this.processedEventIds.add(eventId);
8632
+ if (this.processedEventIds.size > 1e4) {
8633
+ const arr = Array.from(this.processedEventIds);
8634
+ this.processedEventIds = new Set(arr.slice(arr.length - 1e4));
8635
+ }
7429
8636
  }
7430
8637
  // ===========================================================================
7431
- // Public API - Broadcasts
8638
+ // Private Persistence
7432
8639
  // ===========================================================================
7433
- /**
7434
- * Publish broadcast message
7435
- */
7436
- async broadcast(content, tags) {
7437
- this.ensureInitialized();
7438
- const eventId = await this.deps.transport.publishBroadcast?.(content, tags);
7439
- const message = {
7440
- id: eventId ?? crypto.randomUUID(),
7441
- authorPubkey: this.deps.identity.chainPubkey,
7442
- authorNametag: this.deps.identity.nametag,
7443
- content,
7444
- timestamp: Date.now(),
7445
- tags
7446
- };
7447
- this.broadcasts.set(message.id, message);
7448
- return message;
8640
+ /** Schedule a debounced persist (coalesces rapid event bursts). */
8641
+ schedulePersist() {
8642
+ if (this.persistTimer) return;
8643
+ this.persistTimer = setTimeout(() => {
8644
+ this.persistTimer = null;
8645
+ this.persistPromise = this.doPersistAll().catch((err) => {
8646
+ console.error("[GroupChat] Persistence error:", err);
8647
+ }).finally(() => {
8648
+ this.persistPromise = null;
8649
+ });
8650
+ }, 200);
7449
8651
  }
7450
- /**
7451
- * Subscribe to broadcasts with tags
7452
- */
7453
- subscribeToBroadcasts(tags) {
7454
- this.ensureInitialized();
7455
- const key = tags.sort().join(":");
7456
- if (this.broadcastSubscriptions.has(key)) {
7457
- return () => {
7458
- };
8652
+ /** Persist immediately (for explicit flush points). */
8653
+ async persistAll() {
8654
+ if (this.persistTimer) {
8655
+ clearTimeout(this.persistTimer);
8656
+ this.persistTimer = null;
7459
8657
  }
7460
- const unsub = this.deps.transport.subscribeToBroadcast?.(tags, (broadcast2) => {
7461
- this.handleIncomingBroadcast(broadcast2);
7462
- });
7463
- if (unsub) {
7464
- this.broadcastSubscriptions.set(key, unsub);
8658
+ if (this.persistPromise) {
8659
+ await this.persistPromise;
7465
8660
  }
7466
- return () => {
7467
- const sub = this.broadcastSubscriptions.get(key);
7468
- if (sub) {
7469
- sub();
7470
- this.broadcastSubscriptions.delete(key);
7471
- }
7472
- };
8661
+ await this.doPersistAll();
7473
8662
  }
7474
- /**
7475
- * Get broadcasts
7476
- */
7477
- getBroadcasts(limit) {
7478
- const messages = Array.from(this.broadcasts.values()).sort((a, b) => b.timestamp - a.timestamp);
7479
- return limit ? messages.slice(0, limit) : messages;
8663
+ async doPersistAll() {
8664
+ await Promise.all([
8665
+ this.persistGroups(),
8666
+ this.persistMessages(),
8667
+ this.persistMembers(),
8668
+ this.persistProcessedEvents()
8669
+ ]);
7480
8670
  }
7481
- /**
7482
- * Subscribe to incoming broadcasts
7483
- */
7484
- onBroadcast(handler) {
7485
- this.broadcastHandlers.add(handler);
7486
- return () => this.broadcastHandlers.delete(handler);
8671
+ async persistGroups() {
8672
+ if (!this.deps) return;
8673
+ const data = Array.from(this.groups.values());
8674
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_GROUPS, JSON.stringify(data));
8675
+ }
8676
+ async persistMessages() {
8677
+ if (!this.deps) return;
8678
+ const allMessages = [];
8679
+ for (const msgs of this.messages.values()) {
8680
+ allMessages.push(...msgs);
8681
+ }
8682
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MESSAGES, JSON.stringify(allMessages));
8683
+ }
8684
+ async persistMembers() {
8685
+ if (!this.deps) return;
8686
+ const allMembers = [];
8687
+ for (const mems of this.members.values()) {
8688
+ allMembers.push(...mems);
8689
+ }
8690
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_MEMBERS, JSON.stringify(allMembers));
8691
+ }
8692
+ async persistProcessedEvents() {
8693
+ if (!this.deps) return;
8694
+ const arr = Array.from(this.processedEventIds);
8695
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_PROCESSED_EVENTS, JSON.stringify(arr));
7487
8696
  }
7488
8697
  // ===========================================================================
7489
- // Private: Message Handling
8698
+ // Private Relay URL Change Detection
7490
8699
  // ===========================================================================
7491
- handleIncomingMessage(msg) {
7492
- if (msg.senderTransportPubkey === this.deps?.identity.chainPubkey) return;
7493
- const message = {
7494
- id: msg.id,
7495
- senderPubkey: msg.senderTransportPubkey,
7496
- senderNametag: msg.senderNametag,
7497
- recipientPubkey: this.deps.identity.chainPubkey,
7498
- content: msg.content,
7499
- timestamp: msg.timestamp,
7500
- isRead: false
7501
- };
7502
- this.messages.set(message.id, message);
7503
- this.deps.emitEvent("message:dm", message);
7504
- for (const handler of this.dmHandlers) {
7505
- try {
7506
- handler(message);
7507
- } catch (error) {
7508
- console.error("[Communications] Handler error:", error);
8700
+ async checkAndClearOnRelayChange(currentRelayUrl) {
8701
+ if (!this.deps) return;
8702
+ const stored = await this.deps.storage.get(STORAGE_KEYS_GLOBAL.GROUP_CHAT_RELAY_URL);
8703
+ if (stored && stored !== currentRelayUrl) {
8704
+ this.groups.clear();
8705
+ this.messages.clear();
8706
+ this.members.clear();
8707
+ this.processedEventIds.clear();
8708
+ await this.persistAll();
8709
+ }
8710
+ if (!stored) {
8711
+ for (const group of this.groups.values()) {
8712
+ if (group.relayUrl && group.relayUrl !== currentRelayUrl) {
8713
+ this.groups.clear();
8714
+ this.messages.clear();
8715
+ this.members.clear();
8716
+ this.processedEventIds.clear();
8717
+ await this.persistAll();
8718
+ break;
8719
+ }
7509
8720
  }
7510
8721
  }
7511
- if (this.config.autoSave) {
7512
- this.save();
8722
+ await this.deps.storage.set(STORAGE_KEYS_GLOBAL.GROUP_CHAT_RELAY_URL, currentRelayUrl);
8723
+ }
8724
+ // ===========================================================================
8725
+ // Private — Message Content Wrapping
8726
+ // ===========================================================================
8727
+ wrapMessageContent(content, senderNametag) {
8728
+ if (senderNametag) {
8729
+ return JSON.stringify({ senderNametag, text: content });
7513
8730
  }
7514
- this.pruneIfNeeded();
8731
+ return content;
7515
8732
  }
7516
- handleIncomingBroadcast(incoming) {
7517
- const message = {
7518
- id: incoming.id,
7519
- authorPubkey: incoming.authorTransportPubkey,
7520
- content: incoming.content,
7521
- timestamp: incoming.timestamp,
7522
- tags: incoming.tags
7523
- };
7524
- this.broadcasts.set(message.id, message);
7525
- this.deps.emitEvent("message:broadcast", message);
7526
- for (const handler of this.broadcastHandlers) {
7527
- try {
7528
- handler(message);
7529
- } catch (error) {
7530
- console.error("[Communications] Handler error:", error);
8733
+ unwrapMessageContent(content) {
8734
+ try {
8735
+ const parsed = JSON.parse(content);
8736
+ if (typeof parsed === "object" && parsed.text !== void 0) {
8737
+ return { text: parsed.text, senderNametag: parsed.senderNametag || null };
7531
8738
  }
8739
+ } catch {
7532
8740
  }
8741
+ return { text: content, senderNametag: null };
7533
8742
  }
7534
8743
  // ===========================================================================
7535
- // Private: Storage
8744
+ // Private — Event Tag Helpers
7536
8745
  // ===========================================================================
7537
- async save() {
7538
- const messages = Array.from(this.messages.values());
7539
- await this.deps.storage.set("direct_messages", JSON.stringify(messages));
8746
+ getGroupIdFromEvent(event) {
8747
+ const hTag = event.tags.find((t) => t[0] === "h");
8748
+ return hTag ? hTag[1] : null;
7540
8749
  }
7541
- pruneIfNeeded() {
7542
- if (this.messages.size <= this.config.maxMessages) return;
7543
- const sorted = Array.from(this.messages.entries()).sort(([, a], [, b]) => a.timestamp - b.timestamp);
7544
- const toRemove = sorted.slice(0, sorted.length - this.config.maxMessages);
7545
- for (const [id] of toRemove) {
7546
- this.messages.delete(id);
8750
+ getGroupIdFromMetadataEvent(event) {
8751
+ const dTag = event.tags.find((t) => t[0] === "d");
8752
+ if (dTag?.[1]) return dTag[1];
8753
+ const hTag = event.tags.find((t) => t[0] === "h");
8754
+ return hTag?.[1] ?? null;
8755
+ }
8756
+ extractReplyTo(event) {
8757
+ const eTag = event.tags.find((t) => t[0] === "e" && t[3] === "reply");
8758
+ return eTag ? eTag[1] : void 0;
8759
+ }
8760
+ extractPreviousIds(event) {
8761
+ const previousTag = event.tags.find((t) => t[0] === "previous");
8762
+ return previousTag ? previousTag.slice(1) : void 0;
8763
+ }
8764
+ parseGroupMetadata(event) {
8765
+ try {
8766
+ const groupId = this.getGroupIdFromMetadataEvent(event);
8767
+ if (!groupId) return null;
8768
+ let name = "Unnamed Group";
8769
+ let description;
8770
+ let picture;
8771
+ let isPrivate = false;
8772
+ if (event.content && event.content.trim()) {
8773
+ try {
8774
+ const metadata = JSON.parse(event.content);
8775
+ name = metadata.name || name;
8776
+ description = metadata.about || metadata.description;
8777
+ picture = metadata.picture;
8778
+ isPrivate = metadata.private === true;
8779
+ } catch {
8780
+ }
8781
+ }
8782
+ for (const tag of event.tags) {
8783
+ if (tag[0] === "name" && tag[1]) name = tag[1];
8784
+ if (tag[0] === "about" && tag[1]) description = tag[1];
8785
+ if (tag[0] === "picture" && tag[1]) picture = tag[1];
8786
+ if (tag[0] === "private") isPrivate = true;
8787
+ if (tag[0] === "public" && tag[1] === "false") isPrivate = true;
8788
+ }
8789
+ return {
8790
+ id: groupId,
8791
+ relayUrl: this.config.relays[0] || "",
8792
+ name,
8793
+ description,
8794
+ picture,
8795
+ visibility: isPrivate ? GroupVisibility.PRIVATE : GroupVisibility.PUBLIC,
8796
+ createdAt: event.created_at * 1e3
8797
+ };
8798
+ } catch {
8799
+ return null;
7547
8800
  }
7548
8801
  }
7549
8802
  // ===========================================================================
7550
- // Private: Helpers
8803
+ // Private — Utility
7551
8804
  // ===========================================================================
7552
- async resolveRecipient(recipient) {
7553
- if (recipient.startsWith("@")) {
7554
- const pubkey = await this.deps.transport.resolveNametag?.(recipient.slice(1));
7555
- if (!pubkey) {
7556
- throw new Error(`Nametag not found: ${recipient}`);
7557
- }
7558
- return pubkey;
7559
- }
7560
- return recipient;
8805
+ /** Subscribe and track the subscription ID for cleanup. */
8806
+ trackSubscription(filter, handlers) {
8807
+ const subId = this.client.subscribe(filter, {
8808
+ onEvent: handlers.onEvent,
8809
+ onEndOfStoredEvents: handlers.onEndOfStoredEvents ?? (() => {
8810
+ })
8811
+ });
8812
+ this.subscriptionIds.push(subId);
8813
+ return subId;
8814
+ }
8815
+ /** Subscribe for a one-shot fetch, auto-unsubscribe on EOSE or timeout. */
8816
+ oneshotSubscription(filter, opts) {
8817
+ return new Promise((resolve) => {
8818
+ let done = false;
8819
+ let subId;
8820
+ const finish = () => {
8821
+ if (done) return;
8822
+ done = true;
8823
+ if (subId) {
8824
+ try {
8825
+ this.client.unsubscribe(subId);
8826
+ } catch {
8827
+ }
8828
+ const idx = this.subscriptionIds.indexOf(subId);
8829
+ if (idx >= 0) this.subscriptionIds.splice(idx, 1);
8830
+ }
8831
+ resolve(opts.onComplete());
8832
+ };
8833
+ subId = this.client.subscribe(filter, {
8834
+ onEvent: (event) => {
8835
+ if (!done) opts.onEvent(event);
8836
+ },
8837
+ onEndOfStoredEvents: finish
8838
+ });
8839
+ this.subscriptionIds.push(subId);
8840
+ setTimeout(finish, opts.timeoutMs ?? 5e3);
8841
+ });
7561
8842
  }
7562
8843
  ensureInitialized() {
7563
8844
  if (!this.deps) {
7564
- throw new Error("CommunicationsModule not initialized");
8845
+ throw new Error("GroupChatModule not initialized");
8846
+ }
8847
+ }
8848
+ async ensureConnected() {
8849
+ if (!this.connected) {
8850
+ await this.connect();
8851
+ }
8852
+ }
8853
+ randomId() {
8854
+ const bytes = new Uint8Array(8);
8855
+ if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.getRandomValues) {
8856
+ globalThis.crypto.getRandomValues(bytes);
8857
+ } else {
8858
+ for (let i = 0; i < bytes.length; i++) {
8859
+ bytes[i] = Math.floor(Math.random() * 256);
8860
+ }
7565
8861
  }
8862
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
7566
8863
  }
7567
8864
  };
7568
- function createCommunicationsModule(config) {
7569
- return new CommunicationsModule(config);
8865
+ function createGroupChatModule(config) {
8866
+ return new GroupChatModule(config);
7570
8867
  }
7571
8868
 
7572
8869
  // core/encryption.ts
@@ -8434,9 +9731,9 @@ var import_SigningService2 = require("@unicitylabs/state-transition-sdk/lib/sign
8434
9731
  var import_TokenType5 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
8435
9732
  var import_HashAlgorithm7 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
8436
9733
  var import_UnmaskedPredicateReference3 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
8437
- var import_nostr_js_sdk2 = require("@unicitylabs/nostr-js-sdk");
9734
+ var import_nostr_js_sdk3 = require("@unicitylabs/nostr-js-sdk");
8438
9735
  function isValidNametag(nametag) {
8439
- if ((0, import_nostr_js_sdk2.isPhoneNumber)(nametag)) return true;
9736
+ if ((0, import_nostr_js_sdk3.isPhoneNumber)(nametag)) return true;
8440
9737
  return /^[a-z0-9_-]{3,20}$/.test(nametag);
8441
9738
  }
8442
9739
  var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
@@ -8482,12 +9779,13 @@ var Sphere = class _Sphere {
8482
9779
  // Modules
8483
9780
  _payments;
8484
9781
  _communications;
9782
+ _groupChat = null;
8485
9783
  // Events
8486
9784
  eventHandlers = /* @__PURE__ */ new Map();
8487
9785
  // ===========================================================================
8488
9786
  // Constructor (private)
8489
9787
  // ===========================================================================
8490
- constructor(storage, transport, oracle, tokenStorage, l1Config, priceProvider) {
9788
+ constructor(storage, transport, oracle, tokenStorage, l1Config, priceProvider, groupChatConfig) {
8491
9789
  this._storage = storage;
8492
9790
  this._transport = transport;
8493
9791
  this._oracle = oracle;
@@ -8497,6 +9795,7 @@ var Sphere = class _Sphere {
8497
9795
  }
8498
9796
  this._payments = createPaymentsModule({ l1: l1Config });
8499
9797
  this._communications = createCommunicationsModule();
9798
+ this._groupChat = groupChatConfig ? createGroupChatModule(groupChatConfig) : null;
8500
9799
  }
8501
9800
  // ===========================================================================
8502
9801
  // Static Methods - Wallet Management
@@ -8544,6 +9843,7 @@ var Sphere = class _Sphere {
8544
9843
  * ```
8545
9844
  */
8546
9845
  static async init(options) {
9846
+ const groupChat = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
8547
9847
  const walletExists = await _Sphere.exists(options.storage);
8548
9848
  if (walletExists) {
8549
9849
  const sphere2 = await _Sphere.load({
@@ -8552,7 +9852,8 @@ var Sphere = class _Sphere {
8552
9852
  oracle: options.oracle,
8553
9853
  tokenStorage: options.tokenStorage,
8554
9854
  l1: options.l1,
8555
- price: options.price
9855
+ price: options.price,
9856
+ groupChat
8556
9857
  });
8557
9858
  return { sphere: sphere2, created: false };
8558
9859
  }
@@ -8577,10 +9878,34 @@ var Sphere = class _Sphere {
8577
9878
  derivationPath: options.derivationPath,
8578
9879
  nametag: options.nametag,
8579
9880
  l1: options.l1,
8580
- price: options.price
9881
+ price: options.price,
9882
+ groupChat
8581
9883
  });
8582
9884
  return { sphere, created: true, generatedMnemonic };
8583
9885
  }
9886
+ /**
9887
+ * Resolve groupChat config from init/create/load options.
9888
+ * - `true` → use network-default relays
9889
+ * - `GroupChatModuleConfig` → pass through
9890
+ * - `undefined` → no groupchat
9891
+ */
9892
+ /**
9893
+ * Resolve GroupChat config from Sphere.init() options.
9894
+ * Note: impl/shared/resolvers.ts has a similar resolver for provider-level config
9895
+ * (different input shape: { enabled?, relays? }). Both fill relay URLs from network defaults.
9896
+ */
9897
+ static resolveGroupChatConfig(config, network) {
9898
+ if (!config) return void 0;
9899
+ if (config === true) {
9900
+ const netConfig = network ? NETWORKS[network] : NETWORKS.mainnet;
9901
+ return { relays: [...netConfig.groupRelays] };
9902
+ }
9903
+ if (!config.relays || config.relays.length === 0) {
9904
+ const netConfig = network ? NETWORKS[network] : NETWORKS.mainnet;
9905
+ return { ...config, relays: [...netConfig.groupRelays] };
9906
+ }
9907
+ return config;
9908
+ }
8584
9909
  /**
8585
9910
  * Create new wallet with mnemonic
8586
9911
  */
@@ -8591,13 +9916,15 @@ var Sphere = class _Sphere {
8591
9916
  if (await _Sphere.exists(options.storage)) {
8592
9917
  throw new Error("Wallet already exists. Use Sphere.load() or Sphere.clear() first.");
8593
9918
  }
9919
+ const groupChatConfig = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
8594
9920
  const sphere = new _Sphere(
8595
9921
  options.storage,
8596
9922
  options.transport,
8597
9923
  options.oracle,
8598
9924
  options.tokenStorage,
8599
9925
  options.l1,
8600
- options.price
9926
+ options.price,
9927
+ groupChatConfig
8601
9928
  );
8602
9929
  await sphere.storeMnemonic(options.mnemonic, options.derivationPath);
8603
9930
  await sphere.initializeIdentityFromMnemonic(options.mnemonic, options.derivationPath);
@@ -8622,13 +9949,15 @@ var Sphere = class _Sphere {
8622
9949
  if (!await _Sphere.exists(options.storage)) {
8623
9950
  throw new Error("No wallet found. Use Sphere.create() to create a new wallet.");
8624
9951
  }
9952
+ const groupChatConfig = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
8625
9953
  const sphere = new _Sphere(
8626
9954
  options.storage,
8627
9955
  options.transport,
8628
9956
  options.oracle,
8629
9957
  options.tokenStorage,
8630
9958
  options.l1,
8631
- options.price
9959
+ options.price,
9960
+ groupChatConfig
8632
9961
  );
8633
9962
  await sphere.loadIdentityFromStorage();
8634
9963
  await sphere.initializeProviders();
@@ -8667,13 +9996,15 @@ var Sphere = class _Sphere {
8667
9996
  await options.storage.connect();
8668
9997
  console.log("[Sphere.import] Storage reconnected");
8669
9998
  }
9999
+ const groupChatConfig = _Sphere.resolveGroupChatConfig(options.groupChat);
8670
10000
  const sphere = new _Sphere(
8671
10001
  options.storage,
8672
10002
  options.transport,
8673
10003
  options.oracle,
8674
10004
  options.tokenStorage,
8675
10005
  options.l1,
8676
- options.price
10006
+ options.price,
10007
+ groupChatConfig
8677
10008
  );
8678
10009
  if (options.mnemonic) {
8679
10010
  if (!_Sphere.validateMnemonic(options.mnemonic)) {
@@ -8833,6 +10164,10 @@ var Sphere = class _Sphere {
8833
10164
  this.ensureReady();
8834
10165
  return this._communications;
8835
10166
  }
10167
+ /** Group chat module (NIP-29). Null if not configured. */
10168
+ get groupChat() {
10169
+ return this._groupChat;
10170
+ }
8836
10171
  // ===========================================================================
8837
10172
  // Public Properties - State
8838
10173
  // ===========================================================================
@@ -9703,8 +11038,14 @@ var Sphere = class _Sphere {
9703
11038
  transport: this._transport,
9704
11039
  emitEvent
9705
11040
  });
11041
+ this._groupChat?.initialize({
11042
+ identity: this._identity,
11043
+ storage: this._storage,
11044
+ emitEvent
11045
+ });
9706
11046
  await this._payments.load();
9707
11047
  await this._communications.load();
11048
+ await this._groupChat?.load();
9708
11049
  }
9709
11050
  /**
9710
11051
  * Derive address at a specific index
@@ -10299,7 +11640,7 @@ var Sphere = class _Sphere {
10299
11640
  */
10300
11641
  cleanNametag(raw) {
10301
11642
  const stripped = raw.startsWith("@") ? raw.slice(1) : raw;
10302
- return (0, import_nostr_js_sdk2.normalizeNametag)(stripped);
11643
+ return (0, import_nostr_js_sdk3.normalizeNametag)(stripped);
10303
11644
  }
10304
11645
  // ===========================================================================
10305
11646
  // Public Methods - Lifecycle
@@ -10307,6 +11648,7 @@ var Sphere = class _Sphere {
10307
11648
  async destroy() {
10308
11649
  this._payments.destroy();
10309
11650
  this._communications.destroy();
11651
+ this._groupChat?.destroy();
10310
11652
  await this._transport.disconnect();
10311
11653
  await this._storage.disconnect();
10312
11654
  await this._oracle.disconnect();
@@ -10527,8 +11869,14 @@ var Sphere = class _Sphere {
10527
11869
  transport: this._transport,
10528
11870
  emitEvent
10529
11871
  });
11872
+ this._groupChat?.initialize({
11873
+ identity: this._identity,
11874
+ storage: this._storage,
11875
+ emitEvent
11876
+ });
10530
11877
  await this._payments.load();
10531
11878
  await this._communications.load();
11879
+ await this._groupChat?.load();
10532
11880
  }
10533
11881
  // ===========================================================================
10534
11882
  // Private: Helpers