@unicitylabs/sphere-sdk 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,57 +1,63 @@
1
- // constants.ts
2
- var STORAGE_KEYS_GLOBAL = {
3
- /** Encrypted BIP39 mnemonic */
4
- MNEMONIC: "mnemonic",
5
- /** Encrypted master private key */
6
- MASTER_KEY: "master_key",
7
- /** BIP32 chain code */
8
- CHAIN_CODE: "chain_code",
9
- /** HD derivation path (full path like m/44'/0'/0'/0/0) */
10
- DERIVATION_PATH: "derivation_path",
11
- /** Base derivation path (like m/44'/0'/0' without chain/index) */
12
- BASE_PATH: "base_path",
13
- /** Derivation mode: bip32, wif_hmac, legacy_hmac */
14
- DERIVATION_MODE: "derivation_mode",
15
- /** Wallet source: mnemonic, file, unknown */
16
- WALLET_SOURCE: "wallet_source",
17
- /** Wallet existence flag */
18
- WALLET_EXISTS: "wallet_exists",
19
- /** Current active address index */
20
- CURRENT_ADDRESS_INDEX: "current_address_index",
21
- /** Nametag cache per address (separate from tracked addresses registry) */
22
- ADDRESS_NAMETAGS: "address_nametags",
23
- /** Active addresses registry (JSON: TrackedAddressesStorage) */
24
- TRACKED_ADDRESSES: "tracked_addresses"
25
- };
26
- var STORAGE_KEYS_ADDRESS = {
27
- /** Pending transfers for this address */
28
- PENDING_TRANSFERS: "pending_transfers",
29
- /** Transfer outbox for this address */
30
- OUTBOX: "outbox",
31
- /** Conversations for this address */
32
- CONVERSATIONS: "conversations",
33
- /** Messages for this address */
34
- MESSAGES: "messages",
35
- /** Transaction history for this address */
36
- TRANSACTION_HISTORY: "transaction_history"
1
+ // impl/shared/ipfs/ipfs-error-types.ts
2
+ var IpfsError = class extends Error {
3
+ category;
4
+ gateway;
5
+ cause;
6
+ constructor(message, category, gateway, cause) {
7
+ super(message);
8
+ this.name = "IpfsError";
9
+ this.category = category;
10
+ this.gateway = gateway;
11
+ this.cause = cause;
12
+ }
13
+ /** Whether this error should trigger the circuit breaker */
14
+ get shouldTriggerCircuitBreaker() {
15
+ return this.category !== "NOT_FOUND" && this.category !== "SEQUENCE_DOWNGRADE";
16
+ }
37
17
  };
38
- var STORAGE_KEYS = {
39
- ...STORAGE_KEYS_GLOBAL,
40
- ...STORAGE_KEYS_ADDRESS
18
+ function classifyFetchError(error) {
19
+ if (error instanceof DOMException && error.name === "AbortError") {
20
+ return "TIMEOUT";
21
+ }
22
+ if (error instanceof TypeError) {
23
+ return "NETWORK_ERROR";
24
+ }
25
+ if (error instanceof Error && error.name === "TimeoutError") {
26
+ return "TIMEOUT";
27
+ }
28
+ return "NETWORK_ERROR";
29
+ }
30
+ function classifyHttpStatus(status, responseBody) {
31
+ if (status === 404) {
32
+ return "NOT_FOUND";
33
+ }
34
+ if (status === 500 && responseBody) {
35
+ if (/routing:\s*not\s*found/i.test(responseBody)) {
36
+ return "NOT_FOUND";
37
+ }
38
+ }
39
+ if (status >= 500) {
40
+ return "GATEWAY_ERROR";
41
+ }
42
+ if (status >= 400) {
43
+ return "GATEWAY_ERROR";
44
+ }
45
+ return "GATEWAY_ERROR";
46
+ }
47
+
48
+ // impl/shared/ipfs/ipfs-state-persistence.ts
49
+ var InMemoryIpfsStatePersistence = class {
50
+ states = /* @__PURE__ */ new Map();
51
+ async load(ipnsName) {
52
+ return this.states.get(ipnsName) ?? null;
53
+ }
54
+ async save(ipnsName, state) {
55
+ this.states.set(ipnsName, { ...state });
56
+ }
57
+ async clear(ipnsName) {
58
+ this.states.delete(ipnsName);
59
+ }
41
60
  };
42
- var DEFAULT_IPFS_GATEWAYS = [
43
- "https://ipfs.unicity.network",
44
- "https://dweb.link",
45
- "https://ipfs.io"
46
- ];
47
- var DEFAULT_IPFS_BOOTSTRAP_PEERS = [
48
- "/dns4/unicity-ipfs2.dyndns.org/tcp/4001/p2p/12D3KooWLNi5NDPPHbrfJakAQqwBqymYTTwMQXQKEWuCrJNDdmfh",
49
- "/dns4/unicity-ipfs3.dyndns.org/tcp/4001/p2p/12D3KooWQ4aujVE4ShLjdusNZBdffq3TbzrwT2DuWZY9H1Gxhwn6",
50
- "/dns4/unicity-ipfs4.dyndns.org/tcp/4001/p2p/12D3KooWJ1ByPfUzUrpYvgxKU8NZrR8i6PU1tUgMEbQX9Hh2DEn1",
51
- "/dns4/unicity-ipfs5.dyndns.org/tcp/4001/p2p/12D3KooWB1MdZZGHN5B8TvWXntbycfe7Cjcz7n6eZ9eykZadvmDv"
52
- ];
53
- var DEFAULT_BASE_PATH = "m/44'/0'/0'";
54
- var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
55
61
 
56
62
  // node_modules/@noble/hashes/utils.js
57
63
  function isBytes(a) {
@@ -489,583 +495,1941 @@ var sha256 = /* @__PURE__ */ createHasher(
489
495
  /* @__PURE__ */ oidNist(1)
490
496
  );
491
497
 
492
- // impl/browser/storage/IpfsStorageProvider.ts
493
- var heliaModule = null;
494
- var heliaJsonModule = null;
495
- var libp2pBootstrapModule = null;
498
+ // core/crypto.ts
499
+ import * as bip39 from "bip39";
500
+ import CryptoJS from "crypto-js";
501
+ import elliptic from "elliptic";
502
+ var ec = new elliptic.ec("secp256k1");
503
+ var CURVE_ORDER = BigInt(
504
+ "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"
505
+ );
506
+ function hexToBytes(hex) {
507
+ const matches = hex.match(/../g);
508
+ if (!matches) {
509
+ return new Uint8Array(0);
510
+ }
511
+ return Uint8Array.from(matches.map((x) => parseInt(x, 16)));
512
+ }
513
+
514
+ // impl/shared/ipfs/ipns-key-derivation.ts
515
+ var IPNS_HKDF_INFO = "ipfs-storage-ed25519-v1";
496
516
  var libp2pCryptoModule = null;
497
517
  var libp2pPeerIdModule = null;
498
- var multiformatsCidModule = null;
499
- async function loadHeliaModules() {
500
- if (!heliaModule) {
501
- [
502
- heliaModule,
503
- heliaJsonModule,
504
- libp2pBootstrapModule,
505
- libp2pCryptoModule,
506
- libp2pPeerIdModule,
507
- multiformatsCidModule
508
- ] = await Promise.all([
509
- import("helia"),
510
- import("@helia/json"),
511
- import("@libp2p/bootstrap"),
518
+ async function loadLibp2pModules() {
519
+ if (!libp2pCryptoModule) {
520
+ [libp2pCryptoModule, libp2pPeerIdModule] = await Promise.all([
512
521
  import("@libp2p/crypto/keys"),
513
- import("@libp2p/peer-id"),
514
- import("multiformats/cid")
522
+ import("@libp2p/peer-id")
515
523
  ]);
516
524
  }
517
525
  return {
518
- createHelia: heliaModule.createHelia,
519
- json: heliaJsonModule.json,
520
- bootstrap: libp2pBootstrapModule.bootstrap,
521
526
  generateKeyPairFromSeed: libp2pCryptoModule.generateKeyPairFromSeed,
522
- peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey,
523
- CID: multiformatsCidModule.CID
527
+ peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey
524
528
  };
525
529
  }
526
- var HKDF_INFO = new TextEncoder().encode("ipfs-storage-key");
527
- var IpfsStorageProvider = class {
528
- id = "ipfs";
529
- name = "IPFS Storage";
530
- type = "p2p";
531
- description = "Decentralized storage via IPFS/IPNS";
532
- config;
533
- identity = null;
534
- status = "disconnected";
535
- ipnsName = null;
536
- lastCid = null;
537
- eventCallbacks = /* @__PURE__ */ new Set();
538
- // Helia instance for browser-based IPFS
539
- helia = null;
540
- heliaJson = null;
541
- ipnsKeyPair = null;
542
- /** Get the last published CID */
543
- getLastCid() {
544
- return this.lastCid;
545
- }
546
- // Local cache for faster loads
547
- localCache = null;
548
- cacheTimestamp = 0;
549
- constructor(config) {
550
- this.config = {
551
- gateways: config?.gateways ?? [...DEFAULT_IPFS_GATEWAYS],
552
- bootstrapPeers: config?.bootstrapPeers ?? [...DEFAULT_IPFS_BOOTSTRAP_PEERS],
553
- enableIpns: config?.enableIpns ?? true,
554
- ipnsTimeout: config?.ipnsTimeout ?? 3e4,
555
- fetchTimeout: config?.fetchTimeout ?? 15e3,
556
- debug: config?.debug ?? false
530
+ function deriveEd25519KeyMaterial(privateKeyHex, info = IPNS_HKDF_INFO) {
531
+ const walletSecret = hexToBytes(privateKeyHex);
532
+ const infoBytes = new TextEncoder().encode(info);
533
+ return hkdf(sha256, walletSecret, void 0, infoBytes, 32);
534
+ }
535
+ async function deriveIpnsIdentity(privateKeyHex) {
536
+ const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadLibp2pModules();
537
+ const derivedKey = deriveEd25519KeyMaterial(privateKeyHex);
538
+ const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
539
+ const peerId = peerIdFromPrivateKey(keyPair);
540
+ return {
541
+ keyPair,
542
+ ipnsName: peerId.toString()
543
+ };
544
+ }
545
+
546
+ // impl/shared/ipfs/ipns-record-manager.ts
547
+ var DEFAULT_LIFETIME_MS = 99 * 365 * 24 * 60 * 60 * 1e3;
548
+ var ipnsModule = null;
549
+ async function loadIpnsModule() {
550
+ if (!ipnsModule) {
551
+ const mod = await import("ipns");
552
+ ipnsModule = {
553
+ createIPNSRecord: mod.createIPNSRecord,
554
+ marshalIPNSRecord: mod.marshalIPNSRecord,
555
+ unmarshalIPNSRecord: mod.unmarshalIPNSRecord
557
556
  };
558
557
  }
559
- // ===========================================================================
560
- // BaseProvider Implementation
561
- // ===========================================================================
562
- async connect() {
563
- if (this.status === "connected") return;
564
- this.status = "connecting";
558
+ return ipnsModule;
559
+ }
560
+ async function createSignedRecord(keyPair, cid, sequenceNumber, lifetimeMs = DEFAULT_LIFETIME_MS) {
561
+ const { createIPNSRecord, marshalIPNSRecord } = await loadIpnsModule();
562
+ const record = await createIPNSRecord(
563
+ keyPair,
564
+ `/ipfs/${cid}`,
565
+ sequenceNumber,
566
+ lifetimeMs
567
+ );
568
+ return marshalIPNSRecord(record);
569
+ }
570
+ async function parseRoutingApiResponse(responseText) {
571
+ const { unmarshalIPNSRecord } = await loadIpnsModule();
572
+ const lines = responseText.trim().split("\n");
573
+ for (const line of lines) {
574
+ if (!line.trim()) continue;
565
575
  try {
566
- await this.testGatewayConnectivity();
567
- await this.initializeHelia();
568
- this.status = "connected";
569
- this.log("Connected to IPFS gateways and Helia initialized");
570
- } catch (error) {
571
- this.status = "error";
572
- throw new Error(`IPFS connection failed: ${error}`);
576
+ const obj = JSON.parse(line);
577
+ if (obj.Extra) {
578
+ const recordData = base64ToUint8Array(obj.Extra);
579
+ const record = unmarshalIPNSRecord(recordData);
580
+ const valueBytes = typeof record.value === "string" ? new TextEncoder().encode(record.value) : record.value;
581
+ const valueStr = new TextDecoder().decode(valueBytes);
582
+ const cidMatch = valueStr.match(/\/ipfs\/([a-zA-Z0-9]+)/);
583
+ if (cidMatch) {
584
+ return {
585
+ cid: cidMatch[1],
586
+ sequence: record.sequence,
587
+ recordData
588
+ };
589
+ }
590
+ }
591
+ } catch {
592
+ continue;
593
+ }
594
+ }
595
+ return null;
596
+ }
597
+ function base64ToUint8Array(base64) {
598
+ const binary = atob(base64);
599
+ const bytes = new Uint8Array(binary.length);
600
+ for (let i = 0; i < binary.length; i++) {
601
+ bytes[i] = binary.charCodeAt(i);
602
+ }
603
+ return bytes;
604
+ }
605
+
606
+ // impl/shared/ipfs/ipfs-cache.ts
607
+ var DEFAULT_IPNS_TTL_MS = 6e4;
608
+ var DEFAULT_FAILURE_COOLDOWN_MS = 6e4;
609
+ var DEFAULT_FAILURE_THRESHOLD = 3;
610
+ var DEFAULT_KNOWN_FRESH_WINDOW_MS = 3e4;
611
+ var IpfsCache = class {
612
+ ipnsRecords = /* @__PURE__ */ new Map();
613
+ content = /* @__PURE__ */ new Map();
614
+ gatewayFailures = /* @__PURE__ */ new Map();
615
+ knownFreshTimestamps = /* @__PURE__ */ new Map();
616
+ ipnsTtlMs;
617
+ failureCooldownMs;
618
+ failureThreshold;
619
+ knownFreshWindowMs;
620
+ constructor(config) {
621
+ this.ipnsTtlMs = config?.ipnsTtlMs ?? DEFAULT_IPNS_TTL_MS;
622
+ this.failureCooldownMs = config?.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;
623
+ this.failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
624
+ this.knownFreshWindowMs = config?.knownFreshWindowMs ?? DEFAULT_KNOWN_FRESH_WINDOW_MS;
625
+ }
626
+ // ---------------------------------------------------------------------------
627
+ // IPNS Record Cache (60s TTL)
628
+ // ---------------------------------------------------------------------------
629
+ getIpnsRecord(ipnsName) {
630
+ const entry = this.ipnsRecords.get(ipnsName);
631
+ if (!entry) return null;
632
+ if (Date.now() - entry.timestamp > this.ipnsTtlMs) {
633
+ this.ipnsRecords.delete(ipnsName);
634
+ return null;
573
635
  }
636
+ return entry.data;
574
637
  }
575
638
  /**
576
- * Initialize Helia browser IPFS node
639
+ * Get cached IPNS record ignoring TTL (for known-fresh optimization).
577
640
  */
578
- async initializeHelia() {
579
- if (this.helia) return;
580
- try {
581
- this.log("Initializing Helia with bootstrap peers...");
582
- const { createHelia, json, bootstrap } = await loadHeliaModules();
583
- this.helia = await createHelia({
584
- libp2p: {
585
- peerDiscovery: [
586
- bootstrap({ list: this.config.bootstrapPeers })
587
- ],
588
- connectionManager: {
589
- maxConnections: 10
590
- }
591
- }
592
- });
593
- this.heliaJson = json(this.helia);
594
- const peerId = this.helia.libp2p.peerId.toString();
595
- this.log("Helia initialized, browser peer ID:", peerId.slice(0, 20) + "...");
596
- setTimeout(() => {
597
- const connections = this.helia?.libp2p.getConnections() || [];
598
- this.log(`Active Helia connections: ${connections.length}`);
599
- }, 3e3);
600
- } catch (error) {
601
- this.log("Helia initialization failed (will use HTTP only):", error);
602
- }
641
+ getIpnsRecordIgnoreTtl(ipnsName) {
642
+ const entry = this.ipnsRecords.get(ipnsName);
643
+ return entry?.data ?? null;
603
644
  }
604
- async disconnect() {
605
- if (this.helia) {
606
- try {
607
- await this.helia.stop();
608
- } catch (error) {
609
- this.log("Error stopping Helia:", error);
610
- }
611
- this.helia = null;
612
- this.heliaJson = null;
613
- }
614
- this.status = "disconnected";
615
- this.localCache = null;
616
- this.ipnsKeyPair = null;
617
- this.log("Disconnected from IPFS");
645
+ setIpnsRecord(ipnsName, result) {
646
+ this.ipnsRecords.set(ipnsName, {
647
+ data: result,
648
+ timestamp: Date.now()
649
+ });
618
650
  }
619
- isConnected() {
620
- return this.status === "connected";
651
+ invalidateIpns(ipnsName) {
652
+ this.ipnsRecords.delete(ipnsName);
621
653
  }
622
- getStatus() {
623
- return this.status;
654
+ // ---------------------------------------------------------------------------
655
+ // Content Cache (infinite TTL - content is immutable by CID)
656
+ // ---------------------------------------------------------------------------
657
+ getContent(cid) {
658
+ const entry = this.content.get(cid);
659
+ return entry?.data ?? null;
624
660
  }
625
- // ===========================================================================
626
- // TokenStorageProvider Implementation
627
- // ===========================================================================
628
- async setIdentity(identity) {
629
- this.identity = identity;
630
- try {
631
- const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadHeliaModules();
632
- const walletSecret = this.hexToBytes(identity.privateKey);
633
- const derivedKey = hkdf(sha256, walletSecret, void 0, HKDF_INFO, 32);
634
- this.ipnsKeyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
635
- const peerId = peerIdFromPrivateKey(this.ipnsKeyPair);
636
- this.ipnsName = peerId.toString();
637
- this.log("Identity set, IPNS name:", this.ipnsName);
638
- } catch {
639
- this.ipnsName = identity.ipnsName ?? this.deriveIpnsNameSimple(identity.privateKey);
640
- this.log("Identity set with fallback IPNS name:", this.ipnsName);
641
- }
661
+ setContent(cid, data) {
662
+ this.content.set(cid, {
663
+ data,
664
+ timestamp: Date.now()
665
+ });
642
666
  }
643
- async initialize() {
644
- if (!this.identity) {
645
- throw new Error("Identity must be set before initialization");
667
+ // ---------------------------------------------------------------------------
668
+ // Gateway Failure Tracking (Circuit Breaker)
669
+ // ---------------------------------------------------------------------------
670
+ /**
671
+ * Record a gateway failure. After threshold consecutive failures,
672
+ * the gateway enters cooldown and is considered unhealthy.
673
+ */
674
+ recordGatewayFailure(gateway) {
675
+ const existing = this.gatewayFailures.get(gateway);
676
+ this.gatewayFailures.set(gateway, {
677
+ count: (existing?.count ?? 0) + 1,
678
+ lastFailure: Date.now()
679
+ });
680
+ }
681
+ /** Reset failure count for a gateway (on successful request) */
682
+ recordGatewaySuccess(gateway) {
683
+ this.gatewayFailures.delete(gateway);
684
+ }
685
+ /**
686
+ * Check if a gateway is currently in circuit breaker cooldown.
687
+ * A gateway is considered unhealthy if it has had >= threshold
688
+ * consecutive failures and the cooldown period hasn't elapsed.
689
+ */
690
+ isGatewayInCooldown(gateway) {
691
+ const failure = this.gatewayFailures.get(gateway);
692
+ if (!failure) return false;
693
+ if (failure.count < this.failureThreshold) return false;
694
+ const elapsed = Date.now() - failure.lastFailure;
695
+ if (elapsed >= this.failureCooldownMs) {
696
+ this.gatewayFailures.delete(gateway);
697
+ return false;
646
698
  }
647
- try {
648
- await this.connect();
649
- return true;
650
- } catch {
699
+ return true;
700
+ }
701
+ // ---------------------------------------------------------------------------
702
+ // Known-Fresh Flag (FAST mode optimization)
703
+ // ---------------------------------------------------------------------------
704
+ /**
705
+ * Mark IPNS cache as "known-fresh" (after local publish or push notification).
706
+ * Within the fresh window, we can skip network resolution.
707
+ */
708
+ markIpnsFresh(ipnsName) {
709
+ this.knownFreshTimestamps.set(ipnsName, Date.now());
710
+ }
711
+ /**
712
+ * Check if the cache is known-fresh (within the fresh window).
713
+ */
714
+ isIpnsKnownFresh(ipnsName) {
715
+ const timestamp = this.knownFreshTimestamps.get(ipnsName);
716
+ if (!timestamp) return false;
717
+ if (Date.now() - timestamp > this.knownFreshWindowMs) {
718
+ this.knownFreshTimestamps.delete(ipnsName);
651
719
  return false;
652
720
  }
721
+ return true;
653
722
  }
654
- async shutdown() {
655
- await this.disconnect();
723
+ // ---------------------------------------------------------------------------
724
+ // Cache Management
725
+ // ---------------------------------------------------------------------------
726
+ clear() {
727
+ this.ipnsRecords.clear();
728
+ this.content.clear();
729
+ this.gatewayFailures.clear();
730
+ this.knownFreshTimestamps.clear();
656
731
  }
657
- async save(data) {
658
- this.ensureReady();
659
- this.emitEvent({ type: "storage:saving", timestamp: Date.now() });
732
+ };
733
+
734
+ // impl/shared/ipfs/ipfs-http-client.ts
735
+ var DEFAULT_CONNECTIVITY_TIMEOUT_MS = 5e3;
736
+ var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
737
+ var DEFAULT_RESOLVE_TIMEOUT_MS = 1e4;
738
+ var DEFAULT_PUBLISH_TIMEOUT_MS = 3e4;
739
+ var DEFAULT_GATEWAY_PATH_TIMEOUT_MS = 3e3;
740
+ var DEFAULT_ROUTING_API_TIMEOUT_MS = 2e3;
741
+ var IpfsHttpClient = class {
742
+ gateways;
743
+ fetchTimeoutMs;
744
+ resolveTimeoutMs;
745
+ publishTimeoutMs;
746
+ connectivityTimeoutMs;
747
+ debug;
748
+ cache;
749
+ constructor(config, cache) {
750
+ this.gateways = config.gateways;
751
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
752
+ this.resolveTimeoutMs = config.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;
753
+ this.publishTimeoutMs = config.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS;
754
+ this.connectivityTimeoutMs = config.connectivityTimeoutMs ?? DEFAULT_CONNECTIVITY_TIMEOUT_MS;
755
+ this.debug = config.debug ?? false;
756
+ this.cache = cache;
757
+ }
758
+ // ---------------------------------------------------------------------------
759
+ // Public Accessors
760
+ // ---------------------------------------------------------------------------
761
+ /**
762
+ * Get configured gateway URLs.
763
+ */
764
+ getGateways() {
765
+ return [...this.gateways];
766
+ }
767
+ // ---------------------------------------------------------------------------
768
+ // Gateway Health
769
+ // ---------------------------------------------------------------------------
770
+ /**
771
+ * Test connectivity to a single gateway.
772
+ */
773
+ async testConnectivity(gateway) {
774
+ const start = Date.now();
660
775
  try {
661
- const dataToSave = {
662
- ...data,
663
- _meta: {
664
- ...data._meta,
665
- updatedAt: Date.now(),
666
- ipnsName: this.ipnsName ?? void 0
667
- }
668
- };
669
- const cid = await this.publishToGateways(dataToSave);
670
- if (this.config.enableIpns && this.ipnsName) {
671
- await this.publishIpns(cid);
776
+ const response = await this.fetchWithTimeout(
777
+ `${gateway}/api/v0/version`,
778
+ this.connectivityTimeoutMs,
779
+ { method: "POST" }
780
+ );
781
+ if (!response.ok) {
782
+ return { gateway, healthy: false, error: `HTTP ${response.status}` };
672
783
  }
673
- this.localCache = dataToSave;
674
- this.cacheTimestamp = Date.now();
675
- this.lastCid = cid;
676
- this.emitEvent({ type: "storage:saved", timestamp: Date.now(), data: { cid } });
677
784
  return {
678
- success: true,
679
- cid,
680
- timestamp: Date.now()
785
+ gateway,
786
+ healthy: true,
787
+ responseTimeMs: Date.now() - start
681
788
  };
682
789
  } catch (error) {
683
- const errorMsg = error instanceof Error ? error.message : String(error);
684
- this.emitEvent({ type: "storage:error", timestamp: Date.now(), error: errorMsg });
685
790
  return {
686
- success: false,
687
- error: errorMsg,
688
- timestamp: Date.now()
791
+ gateway,
792
+ healthy: false,
793
+ error: error instanceof Error ? error.message : String(error)
689
794
  };
690
795
  }
691
796
  }
692
- async load(identifier) {
693
- this.ensureReady();
694
- this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
695
- try {
696
- const cacheAge = Date.now() - this.cacheTimestamp;
697
- if (this.localCache && cacheAge < 6e4) {
698
- this.log("Returning cached data");
699
- return {
700
- success: true,
701
- data: this.localCache,
702
- source: "cache",
703
- timestamp: Date.now()
704
- };
797
+ /**
798
+ * Find healthy gateways from the configured list.
799
+ */
800
+ async findHealthyGateways() {
801
+ const results = await Promise.allSettled(
802
+ this.gateways.map((gw) => this.testConnectivity(gw))
803
+ );
804
+ return results.filter((r) => r.status === "fulfilled" && r.value.healthy).map((r) => r.value.gateway);
805
+ }
806
+ /**
807
+ * Get gateways that are not in circuit breaker cooldown.
808
+ */
809
+ getAvailableGateways() {
810
+ return this.gateways.filter((gw) => !this.cache.isGatewayInCooldown(gw));
811
+ }
812
+ // ---------------------------------------------------------------------------
813
+ // Content Upload
814
+ // ---------------------------------------------------------------------------
815
+ /**
816
+ * Upload JSON content to IPFS.
817
+ * Tries all gateways in parallel, returns first success.
818
+ */
819
+ async upload(data, gateways) {
820
+ const targets = gateways ?? this.getAvailableGateways();
821
+ if (targets.length === 0) {
822
+ throw new IpfsError("No gateways available for upload", "NETWORK_ERROR");
823
+ }
824
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(data));
825
+ const promises = targets.map(async (gateway) => {
826
+ try {
827
+ const formData = new FormData();
828
+ formData.append("file", new Blob([jsonBytes], { type: "application/json" }), "data.json");
829
+ const response = await this.fetchWithTimeout(
830
+ `${gateway}/api/v0/add?pin=true&cid-version=1`,
831
+ this.publishTimeoutMs,
832
+ { method: "POST", body: formData }
833
+ );
834
+ if (!response.ok) {
835
+ throw new IpfsError(
836
+ `Upload failed: HTTP ${response.status}`,
837
+ classifyHttpStatus(response.status),
838
+ gateway
839
+ );
840
+ }
841
+ const result = await response.json();
842
+ this.cache.recordGatewaySuccess(gateway);
843
+ this.log(`Uploaded to ${gateway}: CID=${result.Hash}`);
844
+ return { cid: result.Hash, gateway };
845
+ } catch (error) {
846
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
847
+ this.cache.recordGatewayFailure(gateway);
848
+ }
849
+ throw error;
705
850
  }
706
- let cid = identifier ?? null;
707
- if (!cid && this.config.enableIpns && this.ipnsName) {
708
- cid = await this.resolveIpns(this.ipnsName);
851
+ });
852
+ try {
853
+ const result = await Promise.any(promises);
854
+ return { cid: result.cid };
855
+ } catch (error) {
856
+ if (error instanceof AggregateError) {
857
+ throw new IpfsError(
858
+ `Upload failed on all gateways: ${error.errors.map((e) => e.message).join("; ")}`,
859
+ "NETWORK_ERROR"
860
+ );
709
861
  }
710
- if (!cid) {
711
- return {
712
- success: true,
713
- data: void 0,
714
- source: "remote",
715
- timestamp: Date.now()
716
- };
862
+ throw error;
863
+ }
864
+ }
865
+ // ---------------------------------------------------------------------------
866
+ // Content Fetch
867
+ // ---------------------------------------------------------------------------
868
+ /**
869
+ * Fetch content by CID from IPFS gateways.
870
+ * Checks content cache first. Races all gateways for fastest response.
871
+ */
872
+ async fetchContent(cid, gateways) {
873
+ const cached = this.cache.getContent(cid);
874
+ if (cached) {
875
+ this.log(`Content cache hit for CID=${cid}`);
876
+ return cached;
877
+ }
878
+ const targets = gateways ?? this.getAvailableGateways();
879
+ if (targets.length === 0) {
880
+ throw new IpfsError("No gateways available for fetch", "NETWORK_ERROR");
881
+ }
882
+ const promises = targets.map(async (gateway) => {
883
+ try {
884
+ const response = await this.fetchWithTimeout(
885
+ `${gateway}/ipfs/${cid}`,
886
+ this.fetchTimeoutMs,
887
+ { headers: { Accept: "application/octet-stream" } }
888
+ );
889
+ if (!response.ok) {
890
+ const body = await response.text().catch(() => "");
891
+ throw new IpfsError(
892
+ `Fetch failed: HTTP ${response.status}`,
893
+ classifyHttpStatus(response.status, body),
894
+ gateway
895
+ );
896
+ }
897
+ const data = await response.json();
898
+ this.cache.recordGatewaySuccess(gateway);
899
+ this.cache.setContent(cid, data);
900
+ this.log(`Fetched from ${gateway}: CID=${cid}`);
901
+ return data;
902
+ } catch (error) {
903
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
904
+ this.cache.recordGatewayFailure(gateway);
905
+ }
906
+ throw error;
717
907
  }
718
- const data = await this.fetchFromGateways(cid);
719
- this.localCache = data;
720
- this.cacheTimestamp = Date.now();
721
- this.lastCid = cid;
722
- this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
723
- return {
724
- success: true,
725
- data,
726
- source: "remote",
727
- timestamp: Date.now()
728
- };
908
+ });
909
+ try {
910
+ return await Promise.any(promises);
729
911
  } catch (error) {
730
- const errorMsg = error instanceof Error ? error.message : String(error);
731
- this.emitEvent({ type: "storage:error", timestamp: Date.now(), error: errorMsg });
732
- if (this.localCache) {
733
- return {
734
- success: true,
735
- data: this.localCache,
736
- source: "cache",
737
- timestamp: Date.now()
738
- };
912
+ if (error instanceof AggregateError) {
913
+ throw new IpfsError(
914
+ `Fetch failed on all gateways for CID=${cid}`,
915
+ "NETWORK_ERROR"
916
+ );
739
917
  }
740
- return {
741
- success: false,
742
- error: errorMsg,
743
- source: "remote",
744
- timestamp: Date.now()
745
- };
918
+ throw error;
746
919
  }
747
920
  }
748
- async sync(localData) {
749
- this.ensureReady();
750
- this.emitEvent({ type: "sync:started", timestamp: Date.now() });
921
+ // ---------------------------------------------------------------------------
922
+ // IPNS Resolution
923
+ // ---------------------------------------------------------------------------
924
+ /**
925
+ * Resolve IPNS via Routing API (returns record with sequence number).
926
+ * POST /api/v0/routing/get?arg=/ipns/{name}
927
+ */
928
+ async resolveIpnsViaRoutingApi(gateway, ipnsName, timeoutMs = DEFAULT_ROUTING_API_TIMEOUT_MS) {
751
929
  try {
752
- const remoteResult = await this.load();
753
- const remoteData = remoteResult.data;
754
- if (!remoteData) {
755
- await this.save(localData);
756
- this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
757
- return {
758
- success: true,
759
- merged: localData,
760
- added: 0,
761
- removed: 0,
762
- conflicts: 0
763
- };
930
+ const response = await this.fetchWithTimeout(
931
+ `${gateway}/api/v0/routing/get?arg=/ipns/${ipnsName}`,
932
+ timeoutMs,
933
+ { method: "POST" }
934
+ );
935
+ if (!response.ok) {
936
+ const body = await response.text().catch(() => "");
937
+ const category = classifyHttpStatus(response.status, body);
938
+ if (category === "NOT_FOUND") return null;
939
+ throw new IpfsError(`Routing API: HTTP ${response.status}`, category, gateway);
764
940
  }
765
- const mergeResult = this.mergeData(localData, remoteData);
766
- await this.save(mergeResult.merged);
767
- this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
941
+ const text = await response.text();
942
+ const parsed = await parseRoutingApiResponse(text);
943
+ if (!parsed) return null;
944
+ this.cache.recordGatewaySuccess(gateway);
768
945
  return {
769
- success: true,
770
- merged: mergeResult.merged,
771
- added: mergeResult.added,
772
- removed: mergeResult.removed,
773
- conflicts: mergeResult.conflicts
946
+ cid: parsed.cid,
947
+ sequence: parsed.sequence,
948
+ gateway,
949
+ recordData: parsed.recordData
774
950
  };
775
951
  } catch (error) {
776
- const errorMsg = error instanceof Error ? error.message : String(error);
777
- this.emitEvent({ type: "sync:error", timestamp: Date.now(), error: errorMsg });
778
- return {
779
- success: false,
780
- added: 0,
781
- removed: 0,
782
- conflicts: 0,
783
- error: errorMsg
784
- };
952
+ if (error instanceof IpfsError) throw error;
953
+ const category = classifyFetchError(error);
954
+ if (category !== "NOT_FOUND") {
955
+ this.cache.recordGatewayFailure(gateway);
956
+ }
957
+ return null;
785
958
  }
786
959
  }
787
- async exists() {
788
- if (!this.ipnsName) return false;
960
+ /**
961
+ * Resolve IPNS via gateway path (simpler, no sequence number).
962
+ * GET /ipns/{name}?format=dag-json
963
+ */
964
+ async resolveIpnsViaGatewayPath(gateway, ipnsName, timeoutMs = DEFAULT_GATEWAY_PATH_TIMEOUT_MS) {
789
965
  try {
790
- const cid = await this.resolveIpns(this.ipnsName);
791
- return cid !== null;
966
+ const response = await this.fetchWithTimeout(
967
+ `${gateway}/ipns/${ipnsName}`,
968
+ timeoutMs,
969
+ { headers: { Accept: "application/json" } }
970
+ );
971
+ if (!response.ok) return null;
972
+ const content = await response.json();
973
+ const cidHeader = response.headers.get("X-Ipfs-Path");
974
+ if (cidHeader) {
975
+ const match = cidHeader.match(/\/ipfs\/([a-zA-Z0-9]+)/);
976
+ if (match) {
977
+ this.cache.recordGatewaySuccess(gateway);
978
+ return { cid: match[1], content };
979
+ }
980
+ }
981
+ return { cid: "", content };
792
982
  } catch {
793
- return false;
794
- }
795
- }
796
- async clear() {
797
- const emptyData = {
798
- _meta: {
799
- version: 0,
800
- address: this.identity?.l1Address ?? "",
801
- formatVersion: "2.0",
802
- updatedAt: Date.now()
803
- },
804
- _tombstones: []
805
- };
806
- const result = await this.save(emptyData);
807
- return result.success;
808
- }
809
- onEvent(callback) {
810
- this.eventCallbacks.add(callback);
811
- return () => this.eventCallbacks.delete(callback);
812
- }
813
- // ===========================================================================
814
- // Private: IPFS Operations
815
- // ===========================================================================
816
- async testGatewayConnectivity() {
817
- const gateway = this.config.gateways[0];
818
- const controller = new AbortController();
819
- const timeout = setTimeout(() => controller.abort(), 5e3);
820
- try {
821
- const response = await fetch(`${gateway}/api/v0/version`, {
822
- method: "POST",
823
- signal: controller.signal
824
- });
825
- if (!response.ok) throw new Error("Gateway not responding");
826
- } finally {
827
- clearTimeout(timeout);
983
+ return null;
828
984
  }
829
985
  }
830
- async publishToGateways(data) {
831
- const content = JSON.stringify(data);
832
- const blob = new Blob([content], { type: "application/json" });
833
- const promises = [];
834
- for (const gateway of this.config.gateways) {
835
- promises.push(this.publishToGateway(gateway, blob));
986
+ /**
987
+ * Progressive IPNS resolution across all gateways.
988
+ * Queries all gateways in parallel, returns highest sequence number.
989
+ */
990
+ async resolveIpns(ipnsName, gateways) {
991
+ const targets = gateways ?? this.getAvailableGateways();
992
+ if (targets.length === 0) {
993
+ return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 };
836
994
  }
837
- if (this.heliaJson) {
838
- promises.push(this.publishToHelia(data));
995
+ const results = [];
996
+ let respondedCount = 0;
997
+ const promises = targets.map(async (gateway) => {
998
+ const result = await this.resolveIpnsViaRoutingApi(
999
+ gateway,
1000
+ ipnsName,
1001
+ this.resolveTimeoutMs
1002
+ );
1003
+ if (result) results.push(result);
1004
+ respondedCount++;
1005
+ return result;
1006
+ });
1007
+ await Promise.race([
1008
+ Promise.allSettled(promises),
1009
+ new Promise((resolve) => setTimeout(resolve, this.resolveTimeoutMs + 1e3))
1010
+ ]);
1011
+ let best = null;
1012
+ for (const result of results) {
1013
+ if (!best || result.sequence > best.sequence) {
1014
+ best = result;
1015
+ }
839
1016
  }
840
- try {
841
- const cid = await Promise.any(promises);
842
- this.log("Published to IPFS, CID:", cid);
843
- return cid;
844
- } catch {
845
- throw new Error("All publish attempts failed");
1017
+ if (best) {
1018
+ this.cache.setIpnsRecord(ipnsName, best);
846
1019
  }
1020
+ return {
1021
+ best,
1022
+ allResults: results,
1023
+ respondedCount,
1024
+ totalGateways: targets.length
1025
+ };
847
1026
  }
1027
+ // ---------------------------------------------------------------------------
1028
+ // IPNS Publishing
1029
+ // ---------------------------------------------------------------------------
848
1030
  /**
849
- * Publish data via Helia (browser DHT)
1031
+ * Publish IPNS record to a single gateway via routing API.
850
1032
  */
851
- async publishToHelia(data) {
852
- if (!this.heliaJson) {
853
- throw new Error("Helia not initialized");
854
- }
855
- const cid = await this.heliaJson.add(data);
856
- this.log("Published via Helia, CID:", cid.toString());
857
- return cid.toString();
858
- }
859
- async publishToGateway(gateway, blob) {
860
- const formData = new FormData();
861
- formData.append("file", blob);
862
- const controller = new AbortController();
863
- const timeout = setTimeout(() => controller.abort(), this.config.fetchTimeout);
1033
+ async publishIpnsViaRoutingApi(gateway, ipnsName, marshalledRecord, timeoutMs = DEFAULT_PUBLISH_TIMEOUT_MS) {
864
1034
  try {
865
- const response = await fetch(`${gateway}/api/v0/add?pin=true`, {
866
- method: "POST",
867
- body: formData,
868
- signal: controller.signal
869
- });
1035
+ const formData = new FormData();
1036
+ formData.append(
1037
+ "file",
1038
+ new Blob([new Uint8Array(marshalledRecord)]),
1039
+ "record"
1040
+ );
1041
+ const response = await this.fetchWithTimeout(
1042
+ `${gateway}/api/v0/routing/put?arg=/ipns/${ipnsName}&allow-offline=true`,
1043
+ timeoutMs,
1044
+ { method: "POST", body: formData }
1045
+ );
870
1046
  if (!response.ok) {
871
- throw new Error(`Gateway ${gateway} returned ${response.status}`);
1047
+ const errorText = await response.text().catch(() => "");
1048
+ throw new IpfsError(
1049
+ `IPNS publish: HTTP ${response.status}: ${errorText.slice(0, 100)}`,
1050
+ classifyHttpStatus(response.status, errorText),
1051
+ gateway
1052
+ );
872
1053
  }
873
- const result = await response.json();
874
- return result.Hash;
875
- } finally {
876
- clearTimeout(timeout);
877
- }
878
- }
879
- async publishIpns(cid) {
880
- if (!this.identity) return;
881
- const promises = this.config.gateways.map(
882
- (gateway) => this.publishIpnsToGateway(gateway, cid).catch(() => null)
883
- );
884
- await Promise.allSettled(promises);
885
- this.log("Published IPNS:", this.ipnsName, "->", cid);
1054
+ this.cache.recordGatewaySuccess(gateway);
1055
+ this.log(`IPNS published to ${gateway}: ${ipnsName}`);
1056
+ return true;
1057
+ } catch (error) {
1058
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
1059
+ this.cache.recordGatewayFailure(gateway);
1060
+ }
1061
+ this.log(`IPNS publish to ${gateway} failed: ${error}`);
1062
+ return false;
1063
+ }
886
1064
  }
887
- async publishIpnsToGateway(gateway, cid) {
1065
+ /**
1066
+ * Publish IPNS record to all gateways in parallel.
1067
+ */
1068
+ async publishIpns(ipnsName, marshalledRecord, gateways) {
1069
+ const targets = gateways ?? this.getAvailableGateways();
1070
+ if (targets.length === 0) {
1071
+ return { success: false, error: "No gateways available" };
1072
+ }
1073
+ const results = await Promise.allSettled(
1074
+ targets.map((gw) => this.publishIpnsViaRoutingApi(gw, ipnsName, marshalledRecord, this.publishTimeoutMs))
1075
+ );
1076
+ const successfulGateways = [];
1077
+ results.forEach((result, index) => {
1078
+ if (result.status === "fulfilled" && result.value) {
1079
+ successfulGateways.push(targets[index]);
1080
+ }
1081
+ });
1082
+ return {
1083
+ success: successfulGateways.length > 0,
1084
+ ipnsName,
1085
+ successfulGateways,
1086
+ error: successfulGateways.length === 0 ? "All gateways failed" : void 0
1087
+ };
1088
+ }
1089
+ // ---------------------------------------------------------------------------
1090
+ // IPNS Verification
1091
+ // ---------------------------------------------------------------------------
1092
+ /**
1093
+ * Verify IPNS record persistence after publishing.
1094
+ * Retries resolution to confirm the record was accepted.
1095
+ */
1096
+ async verifyIpnsRecord(ipnsName, expectedSeq, expectedCid, retries = 3, delayMs = 1e3) {
1097
+ for (let i = 0; i < retries; i++) {
1098
+ if (i > 0) {
1099
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1100
+ }
1101
+ const { best } = await this.resolveIpns(ipnsName);
1102
+ if (best && best.sequence >= expectedSeq && best.cid === expectedCid) {
1103
+ return true;
1104
+ }
1105
+ }
1106
+ return false;
1107
+ }
1108
+ // ---------------------------------------------------------------------------
1109
+ // Helpers
1110
+ // ---------------------------------------------------------------------------
1111
+ async fetchWithTimeout(url, timeoutMs, options) {
888
1112
  const controller = new AbortController();
889
- const timeout = setTimeout(() => controller.abort(), this.config.ipnsTimeout);
1113
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
890
1114
  try {
891
- const response = await fetch(
892
- `${gateway}/api/v0/name/publish?arg=${cid}&key=${this.ipnsName}`,
893
- {
894
- method: "POST",
895
- signal: controller.signal
1115
+ return await fetch(url, {
1116
+ ...options,
1117
+ signal: controller.signal
1118
+ });
1119
+ } finally {
1120
+ clearTimeout(timer);
1121
+ }
1122
+ }
1123
+ log(message) {
1124
+ if (this.debug) {
1125
+ console.log(`[IPFS-HTTP] ${message}`);
1126
+ }
1127
+ }
1128
+ };
1129
+
1130
+ // impl/shared/ipfs/txf-merge.ts
1131
+ function mergeTxfData(local, remote) {
1132
+ let added = 0;
1133
+ let removed = 0;
1134
+ let conflicts = 0;
1135
+ const localVersion = local._meta?.version ?? 0;
1136
+ const remoteVersion = remote._meta?.version ?? 0;
1137
+ const baseMeta = localVersion >= remoteVersion ? local._meta : remote._meta;
1138
+ const mergedMeta = {
1139
+ ...baseMeta,
1140
+ version: Math.max(localVersion, remoteVersion) + 1,
1141
+ updatedAt: Date.now()
1142
+ };
1143
+ const mergedTombstones = mergeTombstones(
1144
+ local._tombstones ?? [],
1145
+ remote._tombstones ?? []
1146
+ );
1147
+ const tombstoneKeys = new Set(
1148
+ mergedTombstones.map((t) => `${t.tokenId}:${t.stateHash}`)
1149
+ );
1150
+ const localTokenKeys = getTokenKeys(local);
1151
+ const remoteTokenKeys = getTokenKeys(remote);
1152
+ const allTokenKeys = /* @__PURE__ */ new Set([...localTokenKeys, ...remoteTokenKeys]);
1153
+ const mergedTokens = {};
1154
+ for (const key of allTokenKeys) {
1155
+ const tokenId = key.startsWith("_") ? key.slice(1) : key;
1156
+ const localToken = local[key];
1157
+ const remoteToken = remote[key];
1158
+ if (isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys)) {
1159
+ if (localTokenKeys.has(key)) removed++;
1160
+ continue;
1161
+ }
1162
+ if (localToken && !remoteToken) {
1163
+ mergedTokens[key] = localToken;
1164
+ } else if (!localToken && remoteToken) {
1165
+ mergedTokens[key] = remoteToken;
1166
+ added++;
1167
+ } else if (localToken && remoteToken) {
1168
+ mergedTokens[key] = localToken;
1169
+ conflicts++;
1170
+ }
1171
+ }
1172
+ const mergedOutbox = mergeArrayById(
1173
+ local._outbox ?? [],
1174
+ remote._outbox ?? [],
1175
+ "id"
1176
+ );
1177
+ const mergedSent = mergeArrayById(
1178
+ local._sent ?? [],
1179
+ remote._sent ?? [],
1180
+ "tokenId"
1181
+ );
1182
+ const mergedInvalid = mergeArrayById(
1183
+ local._invalid ?? [],
1184
+ remote._invalid ?? [],
1185
+ "tokenId"
1186
+ );
1187
+ const merged = {
1188
+ _meta: mergedMeta,
1189
+ _tombstones: mergedTombstones.length > 0 ? mergedTombstones : void 0,
1190
+ _outbox: mergedOutbox.length > 0 ? mergedOutbox : void 0,
1191
+ _sent: mergedSent.length > 0 ? mergedSent : void 0,
1192
+ _invalid: mergedInvalid.length > 0 ? mergedInvalid : void 0,
1193
+ ...mergedTokens
1194
+ };
1195
+ return { merged, added, removed, conflicts };
1196
+ }
1197
+ function mergeTombstones(local, remote) {
1198
+ const merged = /* @__PURE__ */ new Map();
1199
+ for (const tombstone of [...local, ...remote]) {
1200
+ const key = `${tombstone.tokenId}:${tombstone.stateHash}`;
1201
+ const existing = merged.get(key);
1202
+ if (!existing || tombstone.timestamp > existing.timestamp) {
1203
+ merged.set(key, tombstone);
1204
+ }
1205
+ }
1206
+ return Array.from(merged.values());
1207
+ }
1208
+ function getTokenKeys(data) {
1209
+ const reservedKeys = /* @__PURE__ */ new Set([
1210
+ "_meta",
1211
+ "_tombstones",
1212
+ "_outbox",
1213
+ "_sent",
1214
+ "_invalid",
1215
+ "_nametag",
1216
+ "_mintOutbox",
1217
+ "_invalidatedNametags",
1218
+ "_integrity"
1219
+ ]);
1220
+ const keys = /* @__PURE__ */ new Set();
1221
+ for (const key of Object.keys(data)) {
1222
+ if (reservedKeys.has(key)) continue;
1223
+ if (key.startsWith("archived-") || key.startsWith("_forked_") || key.startsWith("nametag-")) continue;
1224
+ keys.add(key);
1225
+ }
1226
+ return keys;
1227
+ }
1228
+ function isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys) {
1229
+ for (const key of tombstoneKeys) {
1230
+ if (key.startsWith(`${tokenId}:`)) {
1231
+ return true;
1232
+ }
1233
+ }
1234
+ void localToken;
1235
+ void remoteToken;
1236
+ return false;
1237
+ }
1238
+ function mergeArrayById(local, remote, idField) {
1239
+ const seen = /* @__PURE__ */ new Map();
1240
+ for (const item of local) {
1241
+ const id = item[idField];
1242
+ if (id !== void 0) {
1243
+ seen.set(id, item);
1244
+ }
1245
+ }
1246
+ for (const item of remote) {
1247
+ const id = item[idField];
1248
+ if (id !== void 0 && !seen.has(id)) {
1249
+ seen.set(id, item);
1250
+ }
1251
+ }
1252
+ return Array.from(seen.values());
1253
+ }
1254
+
1255
+ // transport/websocket.ts
1256
+ var WebSocketReadyState = {
1257
+ CONNECTING: 0,
1258
+ OPEN: 1,
1259
+ CLOSING: 2,
1260
+ CLOSED: 3
1261
+ };
1262
+
1263
+ // impl/shared/ipfs/ipns-subscription-client.ts
1264
+ var IpnsSubscriptionClient = class {
1265
+ ws = null;
1266
+ subscriptions = /* @__PURE__ */ new Map();
1267
+ reconnectTimeout = null;
1268
+ pingInterval = null;
1269
+ fallbackPollInterval = null;
1270
+ wsUrl;
1271
+ createWebSocket;
1272
+ pingIntervalMs;
1273
+ initialReconnectDelayMs;
1274
+ maxReconnectDelayMs;
1275
+ debugEnabled;
1276
+ reconnectAttempts = 0;
1277
+ isConnecting = false;
1278
+ connectionOpenedAt = 0;
1279
+ destroyed = false;
1280
+ /** Minimum stable connection time before resetting backoff (30 seconds) */
1281
+ minStableConnectionMs = 3e4;
1282
+ fallbackPollFn = null;
1283
+ fallbackPollIntervalMs = 0;
1284
+ constructor(config) {
1285
+ this.wsUrl = config.wsUrl;
1286
+ this.createWebSocket = config.createWebSocket;
1287
+ this.pingIntervalMs = config.pingIntervalMs ?? 3e4;
1288
+ this.initialReconnectDelayMs = config.reconnectDelayMs ?? 5e3;
1289
+ this.maxReconnectDelayMs = config.maxReconnectDelayMs ?? 6e4;
1290
+ this.debugEnabled = config.debug ?? false;
1291
+ }
1292
+ // ---------------------------------------------------------------------------
1293
+ // Public API
1294
+ // ---------------------------------------------------------------------------
1295
+ /**
1296
+ * Subscribe to IPNS updates for a specific name.
1297
+ * Automatically connects the WebSocket if not already connected.
1298
+ * If WebSocket is connecting, the name will be subscribed once connected.
1299
+ */
1300
+ subscribe(ipnsName, callback) {
1301
+ if (!ipnsName || typeof ipnsName !== "string") {
1302
+ this.log("Invalid IPNS name for subscription");
1303
+ return () => {
1304
+ };
1305
+ }
1306
+ const isNewSubscription = !this.subscriptions.has(ipnsName);
1307
+ if (isNewSubscription) {
1308
+ this.subscriptions.set(ipnsName, /* @__PURE__ */ new Set());
1309
+ }
1310
+ this.subscriptions.get(ipnsName).add(callback);
1311
+ if (isNewSubscription && this.ws?.readyState === WebSocketReadyState.OPEN) {
1312
+ this.sendSubscribe([ipnsName]);
1313
+ }
1314
+ if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) {
1315
+ this.connect();
1316
+ }
1317
+ return () => {
1318
+ const callbacks = this.subscriptions.get(ipnsName);
1319
+ if (callbacks) {
1320
+ callbacks.delete(callback);
1321
+ if (callbacks.size === 0) {
1322
+ this.subscriptions.delete(ipnsName);
1323
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
1324
+ this.sendUnsubscribe([ipnsName]);
1325
+ }
1326
+ if (this.subscriptions.size === 0) {
1327
+ this.disconnect();
1328
+ }
896
1329
  }
897
- );
898
- if (!response.ok) {
899
- throw new Error(`IPNS publish failed: ${response.status}`);
900
1330
  }
901
- } finally {
902
- clearTimeout(timeout);
1331
+ };
1332
+ }
1333
+ /**
1334
+ * Register a convenience update callback for all subscriptions.
1335
+ * Returns an unsubscribe function.
1336
+ */
1337
+ onUpdate(callback) {
1338
+ if (!this.subscriptions.has("*")) {
1339
+ this.subscriptions.set("*", /* @__PURE__ */ new Set());
1340
+ }
1341
+ this.subscriptions.get("*").add(callback);
1342
+ return () => {
1343
+ const callbacks = this.subscriptions.get("*");
1344
+ if (callbacks) {
1345
+ callbacks.delete(callback);
1346
+ if (callbacks.size === 0) {
1347
+ this.subscriptions.delete("*");
1348
+ }
1349
+ }
1350
+ };
1351
+ }
1352
+ /**
1353
+ * Set a fallback poll function to use when WebSocket is disconnected.
1354
+ * The poll function will be called at the specified interval while WS is down.
1355
+ */
1356
+ setFallbackPoll(fn, intervalMs) {
1357
+ this.fallbackPollFn = fn;
1358
+ this.fallbackPollIntervalMs = intervalMs;
1359
+ if (!this.isConnected()) {
1360
+ this.startFallbackPolling();
903
1361
  }
904
1362
  }
905
- async resolveIpns(name) {
906
- for (const gateway of this.config.gateways) {
907
- try {
908
- return await this.resolveIpnsFromGateway(gateway, name);
909
- } catch {
910
- continue;
1363
+ /**
1364
+ * Connect to the WebSocket server.
1365
+ */
1366
+ connect() {
1367
+ if (this.destroyed) return;
1368
+ if (this.ws?.readyState === WebSocketReadyState.OPEN || this.isConnecting) {
1369
+ return;
1370
+ }
1371
+ this.isConnecting = true;
1372
+ try {
1373
+ this.log(`Connecting to ${this.wsUrl}...`);
1374
+ this.ws = this.createWebSocket(this.wsUrl);
1375
+ this.ws.onopen = () => {
1376
+ this.log("WebSocket connected");
1377
+ this.isConnecting = false;
1378
+ this.connectionOpenedAt = Date.now();
1379
+ const names = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
1380
+ if (names.length > 0) {
1381
+ this.sendSubscribe(names);
1382
+ }
1383
+ this.startPingInterval();
1384
+ this.stopFallbackPolling();
1385
+ };
1386
+ this.ws.onmessage = (event) => {
1387
+ this.handleMessage(event.data);
1388
+ };
1389
+ this.ws.onclose = () => {
1390
+ const connectionDuration = this.connectionOpenedAt > 0 ? Date.now() - this.connectionOpenedAt : 0;
1391
+ const wasStable = connectionDuration >= this.minStableConnectionMs;
1392
+ this.log(`WebSocket closed (duration: ${Math.round(connectionDuration / 1e3)}s)`);
1393
+ this.isConnecting = false;
1394
+ this.connectionOpenedAt = 0;
1395
+ this.stopPingInterval();
1396
+ if (wasStable) {
1397
+ this.reconnectAttempts = 0;
1398
+ }
1399
+ this.startFallbackPolling();
1400
+ this.scheduleReconnect();
1401
+ };
1402
+ this.ws.onerror = () => {
1403
+ this.log("WebSocket error");
1404
+ this.isConnecting = false;
1405
+ };
1406
+ } catch (e) {
1407
+ this.log(`Failed to connect: ${e}`);
1408
+ this.isConnecting = false;
1409
+ this.startFallbackPolling();
1410
+ this.scheduleReconnect();
1411
+ }
1412
+ }
1413
+ /**
1414
+ * Disconnect from the WebSocket server and clean up.
1415
+ */
1416
+ disconnect() {
1417
+ this.destroyed = true;
1418
+ if (this.reconnectTimeout) {
1419
+ clearTimeout(this.reconnectTimeout);
1420
+ this.reconnectTimeout = null;
1421
+ }
1422
+ this.stopPingInterval();
1423
+ this.stopFallbackPolling();
1424
+ if (this.ws) {
1425
+ this.ws.onopen = null;
1426
+ this.ws.onclose = null;
1427
+ this.ws.onerror = null;
1428
+ this.ws.onmessage = null;
1429
+ this.ws.close();
1430
+ this.ws = null;
1431
+ }
1432
+ this.isConnecting = false;
1433
+ this.reconnectAttempts = 0;
1434
+ }
1435
+ /**
1436
+ * Check if connected to the WebSocket server.
1437
+ */
1438
+ isConnected() {
1439
+ return this.ws?.readyState === WebSocketReadyState.OPEN;
1440
+ }
1441
+ // ---------------------------------------------------------------------------
1442
+ // Internal: Message Handling
1443
+ // ---------------------------------------------------------------------------
1444
+ handleMessage(data) {
1445
+ try {
1446
+ const message = JSON.parse(data);
1447
+ switch (message.type) {
1448
+ case "update":
1449
+ if (message.name && message.sequence !== void 0) {
1450
+ this.notifySubscribers({
1451
+ type: "update",
1452
+ name: message.name,
1453
+ sequence: message.sequence,
1454
+ cid: message.cid ?? "",
1455
+ timestamp: message.timestamp || (/* @__PURE__ */ new Date()).toISOString()
1456
+ });
1457
+ }
1458
+ break;
1459
+ case "subscribed":
1460
+ this.log(`Subscribed to ${message.names?.length || 0} names`);
1461
+ break;
1462
+ case "unsubscribed":
1463
+ this.log(`Unsubscribed from ${message.names?.length || 0} names`);
1464
+ break;
1465
+ case "pong":
1466
+ break;
1467
+ case "error":
1468
+ this.log(`Server error: ${message.message}`);
1469
+ break;
1470
+ default:
1471
+ break;
911
1472
  }
1473
+ } catch {
1474
+ this.log("Failed to parse message");
912
1475
  }
913
- return null;
914
1476
  }
915
- async resolveIpnsFromGateway(gateway, name) {
916
- const controller = new AbortController();
917
- const timeout = setTimeout(() => controller.abort(), this.config.fetchTimeout);
1477
+ notifySubscribers(update) {
1478
+ const callbacks = this.subscriptions.get(update.name);
1479
+ if (callbacks) {
1480
+ this.log(`Update: ${update.name.slice(0, 16)}... seq=${update.sequence}`);
1481
+ for (const callback of callbacks) {
1482
+ try {
1483
+ callback(update);
1484
+ } catch {
1485
+ }
1486
+ }
1487
+ }
1488
+ const globalCallbacks = this.subscriptions.get("*");
1489
+ if (globalCallbacks) {
1490
+ for (const callback of globalCallbacks) {
1491
+ try {
1492
+ callback(update);
1493
+ } catch {
1494
+ }
1495
+ }
1496
+ }
1497
+ }
1498
+ // ---------------------------------------------------------------------------
1499
+ // Internal: WebSocket Send
1500
+ // ---------------------------------------------------------------------------
1501
+ sendSubscribe(names) {
1502
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
1503
+ this.ws.send(JSON.stringify({ action: "subscribe", names }));
1504
+ }
1505
+ }
1506
+ sendUnsubscribe(names) {
1507
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
1508
+ this.ws.send(JSON.stringify({ action: "unsubscribe", names }));
1509
+ }
1510
+ }
1511
+ // ---------------------------------------------------------------------------
1512
+ // Internal: Reconnection
1513
+ // ---------------------------------------------------------------------------
1514
+ /**
1515
+ * Schedule reconnection with exponential backoff.
1516
+ * Sequence: 5s, 10s, 20s, 40s, 60s (capped)
1517
+ */
1518
+ scheduleReconnect() {
1519
+ if (this.destroyed || this.reconnectTimeout) return;
1520
+ const realSubscriptions = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
1521
+ if (realSubscriptions.length === 0) return;
1522
+ this.reconnectAttempts++;
1523
+ const delay = Math.min(
1524
+ this.initialReconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
1525
+ this.maxReconnectDelayMs
1526
+ );
1527
+ this.log(`Reconnecting in ${(delay / 1e3).toFixed(1)}s (attempt ${this.reconnectAttempts})...`);
1528
+ this.reconnectTimeout = setTimeout(() => {
1529
+ this.reconnectTimeout = null;
1530
+ this.connect();
1531
+ }, delay);
1532
+ }
1533
+ // ---------------------------------------------------------------------------
1534
+ // Internal: Keepalive
1535
+ // ---------------------------------------------------------------------------
1536
+ startPingInterval() {
1537
+ this.stopPingInterval();
1538
+ this.pingInterval = setInterval(() => {
1539
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
1540
+ this.ws.send(JSON.stringify({ action: "ping" }));
1541
+ }
1542
+ }, this.pingIntervalMs);
1543
+ }
1544
+ stopPingInterval() {
1545
+ if (this.pingInterval) {
1546
+ clearInterval(this.pingInterval);
1547
+ this.pingInterval = null;
1548
+ }
1549
+ }
1550
+ // ---------------------------------------------------------------------------
1551
+ // Internal: Fallback Polling
1552
+ // ---------------------------------------------------------------------------
1553
+ startFallbackPolling() {
1554
+ if (this.fallbackPollInterval || !this.fallbackPollFn || this.destroyed) return;
1555
+ this.log(`Starting fallback polling (${this.fallbackPollIntervalMs / 1e3}s interval)`);
1556
+ this.fallbackPollFn().catch(() => {
1557
+ });
1558
+ this.fallbackPollInterval = setInterval(() => {
1559
+ this.fallbackPollFn?.().catch(() => {
1560
+ });
1561
+ }, this.fallbackPollIntervalMs);
1562
+ }
1563
+ stopFallbackPolling() {
1564
+ if (this.fallbackPollInterval) {
1565
+ clearInterval(this.fallbackPollInterval);
1566
+ this.fallbackPollInterval = null;
1567
+ }
1568
+ }
1569
+ // ---------------------------------------------------------------------------
1570
+ // Internal: Logging
1571
+ // ---------------------------------------------------------------------------
1572
+ log(message) {
1573
+ if (this.debugEnabled) {
1574
+ console.log(`[IPNS-WS] ${message}`);
1575
+ }
1576
+ }
1577
+ };
1578
+
1579
+ // impl/shared/ipfs/write-behind-buffer.ts
1580
+ var AsyncSerialQueue = class {
1581
+ tail = Promise.resolve();
1582
+ /** Enqueue an async operation. Returns when it completes. */
1583
+ enqueue(fn) {
1584
+ let resolve;
1585
+ let reject;
1586
+ const promise = new Promise((res, rej) => {
1587
+ resolve = res;
1588
+ reject = rej;
1589
+ });
1590
+ this.tail = this.tail.then(
1591
+ () => fn().then(resolve, reject),
1592
+ () => fn().then(resolve, reject)
1593
+ );
1594
+ return promise;
1595
+ }
1596
+ };
1597
+ var WriteBuffer = class {
1598
+ /** Full TXF data from save() calls — latest wins */
1599
+ txfData = null;
1600
+ /** Individual token mutations: key -> { op: 'save'|'delete', data? } */
1601
+ tokenMutations = /* @__PURE__ */ new Map();
1602
+ get isEmpty() {
1603
+ return this.txfData === null && this.tokenMutations.size === 0;
1604
+ }
1605
+ clear() {
1606
+ this.txfData = null;
1607
+ this.tokenMutations.clear();
1608
+ }
1609
+ /**
1610
+ * Merge another buffer's contents into this one (for rollback).
1611
+ * Existing (newer) mutations in `this` take precedence over `other`.
1612
+ */
1613
+ mergeFrom(other) {
1614
+ if (other.txfData && !this.txfData) {
1615
+ this.txfData = other.txfData;
1616
+ }
1617
+ for (const [id, mutation] of other.tokenMutations) {
1618
+ if (!this.tokenMutations.has(id)) {
1619
+ this.tokenMutations.set(id, mutation);
1620
+ }
1621
+ }
1622
+ }
1623
+ };
1624
+
1625
+ // constants.ts
1626
+ var STORAGE_KEYS_GLOBAL = {
1627
+ /** Encrypted BIP39 mnemonic */
1628
+ MNEMONIC: "mnemonic",
1629
+ /** Encrypted master private key */
1630
+ MASTER_KEY: "master_key",
1631
+ /** BIP32 chain code */
1632
+ CHAIN_CODE: "chain_code",
1633
+ /** HD derivation path (full path like m/44'/0'/0'/0/0) */
1634
+ DERIVATION_PATH: "derivation_path",
1635
+ /** Base derivation path (like m/44'/0'/0' without chain/index) */
1636
+ BASE_PATH: "base_path",
1637
+ /** Derivation mode: bip32, wif_hmac, legacy_hmac */
1638
+ DERIVATION_MODE: "derivation_mode",
1639
+ /** Wallet source: mnemonic, file, unknown */
1640
+ WALLET_SOURCE: "wallet_source",
1641
+ /** Wallet existence flag */
1642
+ WALLET_EXISTS: "wallet_exists",
1643
+ /** Current active address index */
1644
+ CURRENT_ADDRESS_INDEX: "current_address_index",
1645
+ /** Nametag cache per address (separate from tracked addresses registry) */
1646
+ ADDRESS_NAMETAGS: "address_nametags",
1647
+ /** Active addresses registry (JSON: TrackedAddressesStorage) */
1648
+ TRACKED_ADDRESSES: "tracked_addresses",
1649
+ /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
1650
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
1651
+ };
1652
+ var STORAGE_KEYS_ADDRESS = {
1653
+ /** Pending transfers for this address */
1654
+ PENDING_TRANSFERS: "pending_transfers",
1655
+ /** Transfer outbox for this address */
1656
+ OUTBOX: "outbox",
1657
+ /** Conversations for this address */
1658
+ CONVERSATIONS: "conversations",
1659
+ /** Messages for this address */
1660
+ MESSAGES: "messages",
1661
+ /** Transaction history for this address */
1662
+ TRANSACTION_HISTORY: "transaction_history",
1663
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
1664
+ PENDING_V5_TOKENS: "pending_v5_tokens"
1665
+ };
1666
+ var STORAGE_KEYS = {
1667
+ ...STORAGE_KEYS_GLOBAL,
1668
+ ...STORAGE_KEYS_ADDRESS
1669
+ };
1670
+ var UNICITY_IPFS_NODES = [
1671
+ {
1672
+ host: "unicity-ipfs1.dyndns.org",
1673
+ peerId: "12D3KooWDKJqEMAhH4nsSSiKtK1VLcas5coUqSPZAfbWbZpxtL4u",
1674
+ httpPort: 9080,
1675
+ httpsPort: 443
1676
+ }
1677
+ ];
1678
+ function getIpfsGatewayUrls(isSecure) {
1679
+ return UNICITY_IPFS_NODES.map(
1680
+ (node) => isSecure !== false ? `https://${node.host}` : `http://${node.host}:${node.httpPort}`
1681
+ );
1682
+ }
1683
+ var DEFAULT_BASE_PATH = "m/44'/0'/0'";
1684
+ var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
1685
+
1686
+ // impl/shared/ipfs/ipfs-storage-provider.ts
1687
+ var IpfsStorageProvider = class {
1688
+ id = "ipfs";
1689
+ name = "IPFS Storage";
1690
+ type = "p2p";
1691
+ status = "disconnected";
1692
+ identity = null;
1693
+ ipnsKeyPair = null;
1694
+ ipnsName = null;
1695
+ ipnsSequenceNumber = 0n;
1696
+ lastCid = null;
1697
+ lastKnownRemoteSequence = 0n;
1698
+ dataVersion = 0;
1699
+ /**
1700
+ * The CID currently stored on the sidecar for this IPNS name.
1701
+ * Used as `_meta.lastCid` in the next save to satisfy chain validation.
1702
+ * - null for bootstrap (first-ever save)
1703
+ * - set after every successful save() or load()
1704
+ */
1705
+ remoteCid = null;
1706
+ cache;
1707
+ httpClient;
1708
+ statePersistence;
1709
+ eventCallbacks = /* @__PURE__ */ new Set();
1710
+ debug;
1711
+ ipnsLifetimeMs;
1712
+ /** WebSocket factory for push subscriptions */
1713
+ createWebSocket;
1714
+ /** Override WS URL */
1715
+ wsUrl;
1716
+ /** Fallback poll interval (default: 90000) */
1717
+ fallbackPollIntervalMs;
1718
+ /** IPNS subscription client for push notifications */
1719
+ subscriptionClient = null;
1720
+ /** Unsubscribe function from subscription client */
1721
+ subscriptionUnsubscribe = null;
1722
+ /** In-memory buffer for individual token save/delete calls */
1723
+ tokenBuffer = /* @__PURE__ */ new Map();
1724
+ deletedTokenIds = /* @__PURE__ */ new Set();
1725
+ /** Write-behind buffer: serializes flush / sync / shutdown */
1726
+ flushQueue = new AsyncSerialQueue();
1727
+ /** Pending mutations not yet flushed to IPFS */
1728
+ pendingBuffer = new WriteBuffer();
1729
+ /** Debounce timer for background flush */
1730
+ flushTimer = null;
1731
+ /** Debounce interval in ms */
1732
+ flushDebounceMs;
1733
+ /** Set to true during shutdown to prevent new flushes */
1734
+ isShuttingDown = false;
1735
+ constructor(config, statePersistence) {
1736
+ const gateways = config?.gateways ?? getIpfsGatewayUrls();
1737
+ this.debug = config?.debug ?? false;
1738
+ this.ipnsLifetimeMs = config?.ipnsLifetimeMs ?? 99 * 365 * 24 * 60 * 60 * 1e3;
1739
+ this.flushDebounceMs = config?.flushDebounceMs ?? 2e3;
1740
+ this.cache = new IpfsCache({
1741
+ ipnsTtlMs: config?.ipnsCacheTtlMs,
1742
+ failureCooldownMs: config?.circuitBreakerCooldownMs,
1743
+ failureThreshold: config?.circuitBreakerThreshold,
1744
+ knownFreshWindowMs: config?.knownFreshWindowMs
1745
+ });
1746
+ this.httpClient = new IpfsHttpClient({
1747
+ gateways,
1748
+ fetchTimeoutMs: config?.fetchTimeoutMs,
1749
+ resolveTimeoutMs: config?.resolveTimeoutMs,
1750
+ publishTimeoutMs: config?.publishTimeoutMs,
1751
+ connectivityTimeoutMs: config?.connectivityTimeoutMs,
1752
+ debug: this.debug
1753
+ }, this.cache);
1754
+ this.statePersistence = statePersistence ?? new InMemoryIpfsStatePersistence();
1755
+ this.createWebSocket = config?.createWebSocket;
1756
+ this.wsUrl = config?.wsUrl;
1757
+ this.fallbackPollIntervalMs = config?.fallbackPollIntervalMs ?? 9e4;
1758
+ }
1759
+ // ---------------------------------------------------------------------------
1760
+ // BaseProvider interface
1761
+ // ---------------------------------------------------------------------------
1762
+ async connect() {
1763
+ await this.initialize();
1764
+ }
1765
+ async disconnect() {
1766
+ await this.shutdown();
1767
+ }
1768
+ isConnected() {
1769
+ return this.status === "connected";
1770
+ }
1771
+ getStatus() {
1772
+ return this.status;
1773
+ }
1774
+ // ---------------------------------------------------------------------------
1775
+ // Identity & Initialization
1776
+ // ---------------------------------------------------------------------------
1777
+ setIdentity(identity) {
1778
+ this.identity = identity;
1779
+ }
1780
+ async initialize() {
1781
+ if (!this.identity) {
1782
+ this.log("Cannot initialize: no identity set");
1783
+ return false;
1784
+ }
1785
+ this.status = "connecting";
1786
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
918
1787
  try {
919
- const response = await fetch(`${gateway}/api/v0/name/resolve?arg=${name}`, {
920
- method: "POST",
921
- signal: controller.signal
1788
+ const { keyPair, ipnsName } = await deriveIpnsIdentity(this.identity.privateKey);
1789
+ this.ipnsKeyPair = keyPair;
1790
+ this.ipnsName = ipnsName;
1791
+ this.log(`IPNS name derived: ${ipnsName}`);
1792
+ const persisted = await this.statePersistence.load(ipnsName);
1793
+ if (persisted) {
1794
+ this.ipnsSequenceNumber = BigInt(persisted.sequenceNumber);
1795
+ this.lastCid = persisted.lastCid;
1796
+ this.remoteCid = persisted.lastCid;
1797
+ this.dataVersion = persisted.version;
1798
+ this.log(`Loaded persisted state: seq=${this.ipnsSequenceNumber}, cid=${this.lastCid}`);
1799
+ }
1800
+ if (this.createWebSocket) {
1801
+ try {
1802
+ const wsUrlFinal = this.wsUrl ?? this.deriveWsUrl();
1803
+ if (wsUrlFinal) {
1804
+ this.subscriptionClient = new IpnsSubscriptionClient({
1805
+ wsUrl: wsUrlFinal,
1806
+ createWebSocket: this.createWebSocket,
1807
+ debug: this.debug
1808
+ });
1809
+ this.subscriptionUnsubscribe = this.subscriptionClient.subscribe(
1810
+ ipnsName,
1811
+ (update) => {
1812
+ this.log(`Push update: seq=${update.sequence}, cid=${update.cid}`);
1813
+ this.emitEvent({
1814
+ type: "storage:remote-updated",
1815
+ timestamp: Date.now(),
1816
+ data: { name: update.name, sequence: update.sequence, cid: update.cid }
1817
+ });
1818
+ }
1819
+ );
1820
+ this.subscriptionClient.setFallbackPoll(
1821
+ () => this.pollForRemoteChanges(),
1822
+ this.fallbackPollIntervalMs
1823
+ );
1824
+ this.subscriptionClient.connect();
1825
+ }
1826
+ } catch (wsError) {
1827
+ this.log(`Failed to set up IPNS subscription: ${wsError}`);
1828
+ }
1829
+ }
1830
+ this.httpClient.findHealthyGateways().then((healthy) => {
1831
+ if (healthy.length > 0) {
1832
+ this.log(`${healthy.length} healthy gateway(s) found`);
1833
+ } else {
1834
+ this.log("Warning: no healthy gateways found");
1835
+ }
1836
+ }).catch(() => {
922
1837
  });
923
- if (!response.ok) {
924
- throw new Error(`IPNS resolve failed: ${response.status}`);
1838
+ this.isShuttingDown = false;
1839
+ this.status = "connected";
1840
+ this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
1841
+ return true;
1842
+ } catch (error) {
1843
+ this.status = "error";
1844
+ this.emitEvent({
1845
+ type: "storage:error",
1846
+ timestamp: Date.now(),
1847
+ error: error instanceof Error ? error.message : String(error)
1848
+ });
1849
+ return false;
1850
+ }
1851
+ }
1852
+ async shutdown() {
1853
+ this.isShuttingDown = true;
1854
+ if (this.flushTimer) {
1855
+ clearTimeout(this.flushTimer);
1856
+ this.flushTimer = null;
1857
+ }
1858
+ await this.flushQueue.enqueue(async () => {
1859
+ if (!this.pendingBuffer.isEmpty) {
1860
+ try {
1861
+ await this.executeFlush();
1862
+ } catch {
1863
+ this.log("Final flush on shutdown failed (data may be lost)");
1864
+ }
925
1865
  }
926
- const result = await response.json();
927
- return result.Path.replace("/ipfs/", "");
928
- } finally {
929
- clearTimeout(timeout);
1866
+ });
1867
+ if (this.subscriptionUnsubscribe) {
1868
+ this.subscriptionUnsubscribe();
1869
+ this.subscriptionUnsubscribe = null;
1870
+ }
1871
+ if (this.subscriptionClient) {
1872
+ this.subscriptionClient.disconnect();
1873
+ this.subscriptionClient = null;
930
1874
  }
1875
+ this.cache.clear();
1876
+ this.status = "disconnected";
1877
+ }
1878
+ // ---------------------------------------------------------------------------
1879
+ // Save (non-blocking — buffers data for async flush)
1880
+ // ---------------------------------------------------------------------------
1881
+ async save(data) {
1882
+ if (!this.ipnsKeyPair || !this.ipnsName) {
1883
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
1884
+ }
1885
+ this.pendingBuffer.txfData = data;
1886
+ this.scheduleFlush();
1887
+ return { success: true, timestamp: Date.now() };
931
1888
  }
932
- async fetchFromGateways(cid) {
933
- const promises = [];
934
- for (const gateway of this.config.gateways) {
935
- promises.push(this.fetchFromGateway(gateway, cid));
1889
+ // ---------------------------------------------------------------------------
1890
+ // Internal: Blocking save (used by sync and executeFlush)
1891
+ // ---------------------------------------------------------------------------
1892
+ /**
1893
+ * Perform the actual upload + IPNS publish synchronously.
1894
+ * Called by executeFlush() and sync() — never by public save().
1895
+ */
1896
+ async _doSave(data) {
1897
+ if (!this.ipnsKeyPair || !this.ipnsName) {
1898
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
936
1899
  }
937
- if (this.heliaJson) {
938
- promises.push(this.fetchFromHelia(cid));
1900
+ this.emitEvent({ type: "storage:saving", timestamp: Date.now() });
1901
+ try {
1902
+ this.dataVersion++;
1903
+ const metaUpdate = {
1904
+ ...data._meta,
1905
+ version: this.dataVersion,
1906
+ ipnsName: this.ipnsName,
1907
+ updatedAt: Date.now()
1908
+ };
1909
+ if (this.remoteCid) {
1910
+ metaUpdate.lastCid = this.remoteCid;
1911
+ }
1912
+ const updatedData = { ...data, _meta: metaUpdate };
1913
+ for (const [tokenId, tokenData] of this.tokenBuffer) {
1914
+ if (!this.deletedTokenIds.has(tokenId)) {
1915
+ updatedData[tokenId] = tokenData;
1916
+ }
1917
+ }
1918
+ for (const tokenId of this.deletedTokenIds) {
1919
+ delete updatedData[tokenId];
1920
+ }
1921
+ const { cid } = await this.httpClient.upload(updatedData);
1922
+ this.log(`Content uploaded: CID=${cid}`);
1923
+ const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence ? this.ipnsSequenceNumber : this.lastKnownRemoteSequence;
1924
+ const newSeq = baseSeq + 1n;
1925
+ const marshalledRecord = await createSignedRecord(
1926
+ this.ipnsKeyPair,
1927
+ cid,
1928
+ newSeq,
1929
+ this.ipnsLifetimeMs
1930
+ );
1931
+ const publishResult = await this.httpClient.publishIpns(
1932
+ this.ipnsName,
1933
+ marshalledRecord
1934
+ );
1935
+ if (!publishResult.success) {
1936
+ this.dataVersion--;
1937
+ this.log(`IPNS publish failed: ${publishResult.error}`);
1938
+ return {
1939
+ success: false,
1940
+ error: publishResult.error ?? "IPNS publish failed",
1941
+ timestamp: Date.now()
1942
+ };
1943
+ }
1944
+ this.ipnsSequenceNumber = newSeq;
1945
+ this.lastCid = cid;
1946
+ this.remoteCid = cid;
1947
+ this.cache.setIpnsRecord(this.ipnsName, {
1948
+ cid,
1949
+ sequence: newSeq,
1950
+ gateway: "local"
1951
+ });
1952
+ this.cache.setContent(cid, updatedData);
1953
+ this.cache.markIpnsFresh(this.ipnsName);
1954
+ await this.statePersistence.save(this.ipnsName, {
1955
+ sequenceNumber: newSeq.toString(),
1956
+ lastCid: cid,
1957
+ version: this.dataVersion
1958
+ });
1959
+ this.deletedTokenIds.clear();
1960
+ this.emitEvent({
1961
+ type: "storage:saved",
1962
+ timestamp: Date.now(),
1963
+ data: { cid, sequence: newSeq.toString() }
1964
+ });
1965
+ this.log(`Saved: CID=${cid}, seq=${newSeq}`);
1966
+ return { success: true, cid, timestamp: Date.now() };
1967
+ } catch (error) {
1968
+ this.dataVersion--;
1969
+ const errorMessage = error instanceof Error ? error.message : String(error);
1970
+ this.emitEvent({
1971
+ type: "storage:error",
1972
+ timestamp: Date.now(),
1973
+ error: errorMessage
1974
+ });
1975
+ return { success: false, error: errorMessage, timestamp: Date.now() };
939
1976
  }
940
- return Promise.any(promises);
1977
+ }
1978
+ // ---------------------------------------------------------------------------
1979
+ // Write-behind buffer: scheduling and flushing
1980
+ // ---------------------------------------------------------------------------
1981
+ /**
1982
+ * Schedule a debounced background flush.
1983
+ * Resets the timer on each call so rapid mutations coalesce.
1984
+ */
1985
+ scheduleFlush() {
1986
+ if (this.isShuttingDown) return;
1987
+ if (this.flushTimer) clearTimeout(this.flushTimer);
1988
+ this.flushTimer = setTimeout(() => {
1989
+ this.flushTimer = null;
1990
+ this.flushQueue.enqueue(() => this.executeFlush()).catch((err) => {
1991
+ this.log(`Background flush failed: ${err}`);
1992
+ });
1993
+ }, this.flushDebounceMs);
941
1994
  }
942
1995
  /**
943
- * Fetch content via Helia (browser DHT)
1996
+ * Execute a flush of the pending buffer to IPFS.
1997
+ * Runs inside AsyncSerialQueue for concurrency safety.
944
1998
  */
945
- async fetchFromHelia(cidString) {
946
- if (!this.heliaJson) {
947
- throw new Error("Helia not initialized");
1999
+ async executeFlush() {
2000
+ if (this.pendingBuffer.isEmpty) return;
2001
+ const active = this.pendingBuffer;
2002
+ this.pendingBuffer = new WriteBuffer();
2003
+ try {
2004
+ const baseData = active.txfData ?? {
2005
+ _meta: { version: 0, address: this.identity?.directAddress ?? "", formatVersion: "2.0", updatedAt: 0 }
2006
+ };
2007
+ const result = await this._doSave(baseData);
2008
+ if (!result.success) {
2009
+ throw new Error(result.error ?? "Save failed");
2010
+ }
2011
+ this.log(`Flushed successfully: CID=${result.cid}`);
2012
+ } catch (error) {
2013
+ this.pendingBuffer.mergeFrom(active);
2014
+ const msg = error instanceof Error ? error.message : String(error);
2015
+ this.log(`Flush failed (will retry): ${msg}`);
2016
+ this.scheduleFlush();
2017
+ throw error;
948
2018
  }
949
- const { CID } = await loadHeliaModules();
950
- const cid = CID.parse(cidString);
951
- const data = await this.heliaJson.get(cid);
952
- this.log("Fetched via Helia, CID:", cidString);
953
- return data;
954
2019
  }
955
- async fetchFromGateway(gateway, cid) {
956
- const controller = new AbortController();
957
- const timeout = setTimeout(() => controller.abort(), this.config.fetchTimeout);
2020
+ // ---------------------------------------------------------------------------
2021
+ // Load
2022
+ // ---------------------------------------------------------------------------
2023
+ async load(identifier) {
2024
+ if (!this.ipnsName && !identifier) {
2025
+ return { success: false, error: "Not initialized", source: "local", timestamp: Date.now() };
2026
+ }
2027
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
958
2028
  try {
959
- const response = await fetch(`${gateway}/ipfs/${cid}`, {
960
- signal: controller.signal
2029
+ if (identifier) {
2030
+ const data2 = await this.httpClient.fetchContent(identifier);
2031
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
2032
+ }
2033
+ const ipnsName = this.ipnsName;
2034
+ if (this.cache.isIpnsKnownFresh(ipnsName)) {
2035
+ const cached = this.cache.getIpnsRecordIgnoreTtl(ipnsName);
2036
+ if (cached) {
2037
+ const content = this.cache.getContent(cached.cid);
2038
+ if (content) {
2039
+ this.log("Using known-fresh cached data");
2040
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
2041
+ }
2042
+ }
2043
+ }
2044
+ const cachedRecord = this.cache.getIpnsRecord(ipnsName);
2045
+ if (cachedRecord) {
2046
+ const content = this.cache.getContent(cachedRecord.cid);
2047
+ if (content) {
2048
+ this.log("IPNS cache hit");
2049
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
2050
+ }
2051
+ try {
2052
+ const data2 = await this.httpClient.fetchContent(cachedRecord.cid);
2053
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
2054
+ } catch {
2055
+ }
2056
+ }
2057
+ const { best } = await this.httpClient.resolveIpns(ipnsName);
2058
+ if (!best) {
2059
+ this.log("IPNS record not found (new wallet?)");
2060
+ return { success: false, error: "IPNS record not found", source: "remote", timestamp: Date.now() };
2061
+ }
2062
+ if (best.sequence > this.lastKnownRemoteSequence) {
2063
+ this.lastKnownRemoteSequence = best.sequence;
2064
+ }
2065
+ this.remoteCid = best.cid;
2066
+ const data = await this.httpClient.fetchContent(best.cid);
2067
+ const remoteVersion = data?._meta?.version;
2068
+ if (typeof remoteVersion === "number" && remoteVersion > this.dataVersion) {
2069
+ this.dataVersion = remoteVersion;
2070
+ }
2071
+ this.populateTokenBuffer(data);
2072
+ this.emitEvent({
2073
+ type: "storage:loaded",
2074
+ timestamp: Date.now(),
2075
+ data: { cid: best.cid, sequence: best.sequence.toString() }
961
2076
  });
962
- if (!response.ok) {
963
- throw new Error(`Fetch failed: ${response.status}`);
2077
+ return { success: true, data, source: "remote", timestamp: Date.now() };
2078
+ } catch (error) {
2079
+ if (this.ipnsName) {
2080
+ const cached = this.cache.getIpnsRecordIgnoreTtl(this.ipnsName);
2081
+ if (cached) {
2082
+ const content = this.cache.getContent(cached.cid);
2083
+ if (content) {
2084
+ this.log("Network error, returning stale cache");
2085
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
2086
+ }
2087
+ }
964
2088
  }
965
- return response.json();
966
- } finally {
967
- clearTimeout(timeout);
968
- }
969
- }
970
- // ===========================================================================
971
- // Private: Merge Logic
972
- // ===========================================================================
973
- mergeData(local, remote) {
974
- const localVersion = local._meta?.version ?? 0;
975
- const remoteVersion = remote._meta?.version ?? 0;
976
- const baseMeta = remoteVersion > localVersion ? remote._meta : local._meta;
977
- const tombstones = /* @__PURE__ */ new Map();
978
- for (const t of local._tombstones ?? []) {
979
- tombstones.set(t.tokenId, t);
980
- }
981
- for (const t of remote._tombstones ?? []) {
982
- const existing = tombstones.get(t.tokenId);
983
- if (!existing || t.timestamp > existing.timestamp) {
984
- tombstones.set(t.tokenId, t);
2089
+ const errorMessage = error instanceof Error ? error.message : String(error);
2090
+ this.emitEvent({
2091
+ type: "storage:error",
2092
+ timestamp: Date.now(),
2093
+ error: errorMessage
2094
+ });
2095
+ return { success: false, error: errorMessage, source: "remote", timestamp: Date.now() };
2096
+ }
2097
+ }
2098
+ // ---------------------------------------------------------------------------
2099
+ // Sync (enters serial queue to avoid concurrent IPNS conflicts)
2100
+ // ---------------------------------------------------------------------------
2101
+ async sync(localData) {
2102
+ return this.flushQueue.enqueue(async () => {
2103
+ if (this.flushTimer) {
2104
+ clearTimeout(this.flushTimer);
2105
+ this.flushTimer = null;
985
2106
  }
2107
+ this.emitEvent({ type: "sync:started", timestamp: Date.now() });
2108
+ try {
2109
+ this.pendingBuffer.clear();
2110
+ const remoteResult = await this.load();
2111
+ if (!remoteResult.success || !remoteResult.data) {
2112
+ this.log("No remote data found, uploading local data");
2113
+ const saveResult2 = await this._doSave(localData);
2114
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
2115
+ return {
2116
+ success: saveResult2.success,
2117
+ merged: this.enrichWithTokenBuffer(localData),
2118
+ added: 0,
2119
+ removed: 0,
2120
+ conflicts: 0,
2121
+ error: saveResult2.error
2122
+ };
2123
+ }
2124
+ const remoteData = remoteResult.data;
2125
+ const localVersion = localData._meta?.version ?? 0;
2126
+ const remoteVersion = remoteData._meta?.version ?? 0;
2127
+ if (localVersion === remoteVersion && this.lastCid) {
2128
+ this.log("Data is in sync (same version)");
2129
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
2130
+ return {
2131
+ success: true,
2132
+ merged: this.enrichWithTokenBuffer(localData),
2133
+ added: 0,
2134
+ removed: 0,
2135
+ conflicts: 0
2136
+ };
2137
+ }
2138
+ this.log(`Merging: local v${localVersion} <-> remote v${remoteVersion}`);
2139
+ const { merged, added, removed, conflicts } = mergeTxfData(localData, remoteData);
2140
+ if (conflicts > 0) {
2141
+ this.emitEvent({
2142
+ type: "sync:conflict",
2143
+ timestamp: Date.now(),
2144
+ data: { conflicts }
2145
+ });
2146
+ }
2147
+ const saveResult = await this._doSave(merged);
2148
+ this.emitEvent({
2149
+ type: "sync:completed",
2150
+ timestamp: Date.now(),
2151
+ data: { added, removed, conflicts }
2152
+ });
2153
+ return {
2154
+ success: saveResult.success,
2155
+ merged: this.enrichWithTokenBuffer(merged),
2156
+ added,
2157
+ removed,
2158
+ conflicts,
2159
+ error: saveResult.error
2160
+ };
2161
+ } catch (error) {
2162
+ const errorMessage = error instanceof Error ? error.message : String(error);
2163
+ this.emitEvent({
2164
+ type: "sync:error",
2165
+ timestamp: Date.now(),
2166
+ error: errorMessage
2167
+ });
2168
+ return {
2169
+ success: false,
2170
+ added: 0,
2171
+ removed: 0,
2172
+ conflicts: 0,
2173
+ error: errorMessage
2174
+ };
2175
+ }
2176
+ });
2177
+ }
2178
+ // ---------------------------------------------------------------------------
2179
+ // Private Helpers
2180
+ // ---------------------------------------------------------------------------
2181
+ /**
2182
+ * Enrich TXF data with individually-buffered tokens before returning to caller.
2183
+ * PaymentsModule.createStorageData() passes empty tokens (they're stored via
2184
+ * saveToken()), but loadFromStorageData() needs them in the merged result.
2185
+ */
2186
+ enrichWithTokenBuffer(data) {
2187
+ if (this.tokenBuffer.size === 0) return data;
2188
+ const enriched = { ...data };
2189
+ for (const [tokenId, tokenData] of this.tokenBuffer) {
2190
+ if (!this.deletedTokenIds.has(tokenId)) {
2191
+ enriched[tokenId] = tokenData;
2192
+ }
2193
+ }
2194
+ return enriched;
2195
+ }
2196
+ // ---------------------------------------------------------------------------
2197
+ // Optional Methods
2198
+ // ---------------------------------------------------------------------------
2199
+ async exists() {
2200
+ if (!this.ipnsName) return false;
2201
+ const cached = this.cache.getIpnsRecord(this.ipnsName);
2202
+ if (cached) return true;
2203
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
2204
+ return best !== null;
2205
+ }
2206
+ async clear() {
2207
+ if (!this.ipnsKeyPair || !this.ipnsName) return false;
2208
+ this.pendingBuffer.clear();
2209
+ if (this.flushTimer) {
2210
+ clearTimeout(this.flushTimer);
2211
+ this.flushTimer = null;
986
2212
  }
987
- const merged = {
2213
+ const emptyData = {
988
2214
  _meta: {
989
- ...baseMeta,
990
- version: Math.max(localVersion, remoteVersion) + 1,
2215
+ version: 0,
2216
+ address: this.identity?.directAddress ?? "",
2217
+ ipnsName: this.ipnsName,
2218
+ formatVersion: "2.0",
991
2219
  updatedAt: Date.now()
992
- },
993
- _tombstones: Array.from(tombstones.values())
994
- };
995
- let added = 0;
996
- let conflicts = 0;
997
- const processedKeys = /* @__PURE__ */ new Set();
998
- for (const key of Object.keys(local)) {
999
- if (!key.startsWith("_") || key === "_meta" || key === "_tombstones") continue;
1000
- processedKeys.add(key);
1001
- const tokenId = key.slice(1);
1002
- if (tombstones.has(tokenId)) continue;
1003
- const localToken = local[key];
1004
- const remoteToken = remote[key];
1005
- if (remoteToken) {
1006
- conflicts++;
1007
- merged[key] = localToken;
1008
- } else {
1009
- merged[key] = localToken;
1010
2220
  }
2221
+ };
2222
+ const result = await this._doSave(emptyData);
2223
+ if (result.success) {
2224
+ this.cache.clear();
2225
+ this.tokenBuffer.clear();
2226
+ this.deletedTokenIds.clear();
2227
+ await this.statePersistence.clear(this.ipnsName);
1011
2228
  }
1012
- for (const key of Object.keys(remote)) {
1013
- if (!key.startsWith("_") || key === "_meta" || key === "_tombstones") continue;
1014
- if (processedKeys.has(key)) continue;
1015
- const tokenId = key.slice(1);
1016
- if (tombstones.has(tokenId)) continue;
1017
- merged[key] = remote[key];
1018
- added++;
1019
- }
1020
- return { merged, added, removed: 0, conflicts };
2229
+ return result.success;
1021
2230
  }
1022
- // ===========================================================================
1023
- // Private: Helpers
1024
- // ===========================================================================
1025
- ensureReady() {
1026
- if (this.status !== "connected") {
1027
- throw new Error("IpfsStorageProvider not connected");
1028
- }
1029
- if (!this.identity) {
1030
- throw new Error("Identity not set");
2231
+ onEvent(callback) {
2232
+ this.eventCallbacks.add(callback);
2233
+ return () => {
2234
+ this.eventCallbacks.delete(callback);
2235
+ };
2236
+ }
2237
+ async saveToken(tokenId, tokenData) {
2238
+ this.pendingBuffer.tokenMutations.set(tokenId, { op: "save", data: tokenData });
2239
+ this.tokenBuffer.set(tokenId, tokenData);
2240
+ this.deletedTokenIds.delete(tokenId);
2241
+ this.scheduleFlush();
2242
+ }
2243
+ async getToken(tokenId) {
2244
+ if (this.deletedTokenIds.has(tokenId)) return null;
2245
+ return this.tokenBuffer.get(tokenId) ?? null;
2246
+ }
2247
+ async listTokenIds() {
2248
+ return Array.from(this.tokenBuffer.keys()).filter(
2249
+ (id) => !this.deletedTokenIds.has(id)
2250
+ );
2251
+ }
2252
+ async deleteToken(tokenId) {
2253
+ this.pendingBuffer.tokenMutations.set(tokenId, { op: "delete" });
2254
+ this.tokenBuffer.delete(tokenId);
2255
+ this.deletedTokenIds.add(tokenId);
2256
+ this.scheduleFlush();
2257
+ }
2258
+ // ---------------------------------------------------------------------------
2259
+ // Public Accessors
2260
+ // ---------------------------------------------------------------------------
2261
+ getIpnsName() {
2262
+ return this.ipnsName;
2263
+ }
2264
+ getLastCid() {
2265
+ return this.lastCid;
2266
+ }
2267
+ getSequenceNumber() {
2268
+ return this.ipnsSequenceNumber;
2269
+ }
2270
+ getDataVersion() {
2271
+ return this.dataVersion;
2272
+ }
2273
+ getRemoteCid() {
2274
+ return this.remoteCid;
2275
+ }
2276
+ // ---------------------------------------------------------------------------
2277
+ // Testing helper: wait for pending flush to complete
2278
+ // ---------------------------------------------------------------------------
2279
+ /**
2280
+ * Wait for the pending flush timer to fire and the flush operation to
2281
+ * complete. Useful in tests to await background writes.
2282
+ * Returns immediately if no flush is pending.
2283
+ */
2284
+ async waitForFlush() {
2285
+ if (this.flushTimer) {
2286
+ clearTimeout(this.flushTimer);
2287
+ this.flushTimer = null;
2288
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
2289
+ });
2290
+ } else if (!this.pendingBuffer.isEmpty) {
2291
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
2292
+ });
2293
+ } else {
2294
+ await this.flushQueue.enqueue(async () => {
2295
+ });
1031
2296
  }
1032
2297
  }
2298
+ // ---------------------------------------------------------------------------
2299
+ // Internal: Push Subscription Helpers
2300
+ // ---------------------------------------------------------------------------
1033
2301
  /**
1034
- * Simple IPNS name derivation (fallback when libp2p is unavailable)
2302
+ * Derive WebSocket URL from the first configured gateway.
2303
+ * Converts https://host → wss://host/ws/ipns
1035
2304
  */
1036
- deriveIpnsNameSimple(privateKey) {
1037
- return `12D3KooW${privateKey.slice(0, 40)}`;
2305
+ deriveWsUrl() {
2306
+ const gateways = this.httpClient.getGateways();
2307
+ if (gateways.length === 0) return null;
2308
+ const gateway = gateways[0];
2309
+ const wsProtocol = gateway.startsWith("https://") ? "wss://" : "ws://";
2310
+ const host = gateway.replace(/^https?:\/\//, "");
2311
+ return `${wsProtocol}${host}/ws/ipns`;
1038
2312
  }
1039
2313
  /**
1040
- * Convert hex string to Uint8Array
2314
+ * Poll for remote IPNS changes (fallback when WS is unavailable).
2315
+ * Compares remote sequence number with last known and emits event if changed.
1041
2316
  */
1042
- hexToBytes(hex) {
1043
- const bytes = new Uint8Array(hex.length / 2);
1044
- for (let i = 0; i < bytes.length; i++) {
1045
- bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
2317
+ async pollForRemoteChanges() {
2318
+ if (!this.ipnsName) return;
2319
+ try {
2320
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
2321
+ if (best && best.sequence > this.lastKnownRemoteSequence) {
2322
+ this.log(`Poll detected remote change: seq=${best.sequence} (was ${this.lastKnownRemoteSequence})`);
2323
+ this.lastKnownRemoteSequence = best.sequence;
2324
+ this.emitEvent({
2325
+ type: "storage:remote-updated",
2326
+ timestamp: Date.now(),
2327
+ data: { name: this.ipnsName, sequence: Number(best.sequence), cid: best.cid }
2328
+ });
2329
+ }
2330
+ } catch {
1046
2331
  }
1047
- return bytes;
1048
2332
  }
2333
+ // ---------------------------------------------------------------------------
2334
+ // Internal
2335
+ // ---------------------------------------------------------------------------
1049
2336
  emitEvent(event) {
1050
2337
  for (const callback of this.eventCallbacks) {
1051
2338
  try {
1052
2339
  callback(event);
1053
- } catch (error) {
1054
- console.error("[IpfsStorageProvider] Event callback error:", error);
2340
+ } catch {
2341
+ }
2342
+ }
2343
+ }
2344
+ log(message) {
2345
+ if (this.debug) {
2346
+ console.log(`[IPFS-Storage] ${message}`);
2347
+ }
2348
+ }
2349
+ META_KEYS = /* @__PURE__ */ new Set([
2350
+ "_meta",
2351
+ "_tombstones",
2352
+ "_outbox",
2353
+ "_sent",
2354
+ "_invalid",
2355
+ "_nametag",
2356
+ "_mintOutbox",
2357
+ "_invalidatedNametags",
2358
+ "_integrity"
2359
+ ]);
2360
+ populateTokenBuffer(data) {
2361
+ this.tokenBuffer.clear();
2362
+ this.deletedTokenIds.clear();
2363
+ for (const key of Object.keys(data)) {
2364
+ if (!this.META_KEYS.has(key)) {
2365
+ this.tokenBuffer.set(key, data[key]);
2366
+ }
2367
+ }
2368
+ }
2369
+ };
2370
+
2371
+ // impl/browser/ipfs/browser-ipfs-state-persistence.ts
2372
+ var KEY_PREFIX = "sphere_ipfs_";
2373
+ function seqKey(ipnsName) {
2374
+ return `${KEY_PREFIX}seq_${ipnsName}`;
2375
+ }
2376
+ function cidKey(ipnsName) {
2377
+ return `${KEY_PREFIX}cid_${ipnsName}`;
2378
+ }
2379
+ function verKey(ipnsName) {
2380
+ return `${KEY_PREFIX}ver_${ipnsName}`;
2381
+ }
2382
+ var BrowserIpfsStatePersistence = class {
2383
+ async load(ipnsName) {
2384
+ try {
2385
+ const seq = localStorage.getItem(seqKey(ipnsName));
2386
+ if (!seq) return null;
2387
+ return {
2388
+ sequenceNumber: seq,
2389
+ lastCid: localStorage.getItem(cidKey(ipnsName)),
2390
+ version: parseInt(localStorage.getItem(verKey(ipnsName)) ?? "0", 10)
2391
+ };
2392
+ } catch {
2393
+ return null;
2394
+ }
2395
+ }
2396
+ async save(ipnsName, state) {
2397
+ try {
2398
+ localStorage.setItem(seqKey(ipnsName), state.sequenceNumber);
2399
+ if (state.lastCid) {
2400
+ localStorage.setItem(cidKey(ipnsName), state.lastCid);
2401
+ } else {
2402
+ localStorage.removeItem(cidKey(ipnsName));
1055
2403
  }
2404
+ localStorage.setItem(verKey(ipnsName), String(state.version));
2405
+ } catch {
1056
2406
  }
1057
2407
  }
1058
- log(...args) {
1059
- if (this.config.debug) {
1060
- console.log("[IpfsStorageProvider]", ...args);
2408
+ async clear(ipnsName) {
2409
+ try {
2410
+ localStorage.removeItem(seqKey(ipnsName));
2411
+ localStorage.removeItem(cidKey(ipnsName));
2412
+ localStorage.removeItem(verKey(ipnsName));
2413
+ } catch {
1061
2414
  }
1062
2415
  }
1063
2416
  };
1064
- function createIpfsStorageProvider(config) {
1065
- return new IpfsStorageProvider(config);
2417
+
2418
+ // impl/browser/ipfs/index.ts
2419
+ function createBrowserWebSocket(url) {
2420
+ return new WebSocket(url);
2421
+ }
2422
+ function createBrowserIpfsStorageProvider(config) {
2423
+ return new IpfsStorageProvider(
2424
+ { ...config, createWebSocket: config?.createWebSocket ?? createBrowserWebSocket },
2425
+ new BrowserIpfsStatePersistence()
2426
+ );
1066
2427
  }
2428
+ var createIpfsStorageProvider = createBrowserIpfsStorageProvider;
1067
2429
  export {
2430
+ BrowserIpfsStatePersistence,
1068
2431
  IpfsStorageProvider,
2432
+ createBrowserIpfsStorageProvider,
1069
2433
  createIpfsStorageProvider
1070
2434
  };
1071
2435
  /*! Bundled license information: