@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.
@@ -82,7 +82,9 @@ var STORAGE_KEYS_GLOBAL = {
82
82
  /** Nametag cache per address (separate from tracked addresses registry) */
83
83
  ADDRESS_NAMETAGS: "address_nametags",
84
84
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
85
- TRACKED_ADDRESSES: "tracked_addresses"
85
+ TRACKED_ADDRESSES: "tracked_addresses",
86
+ /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
87
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
86
88
  };
87
89
  var STORAGE_KEYS_ADDRESS = {
88
90
  /** Pending transfers for this address */
@@ -94,7 +96,9 @@ var STORAGE_KEYS_ADDRESS = {
94
96
  /** Messages for this address */
95
97
  MESSAGES: "messages",
96
98
  /** Transaction history for this address */
97
- TRANSACTION_HISTORY: "transaction_history"
99
+ TRANSACTION_HISTORY: "transaction_history",
100
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
101
+ PENDING_V5_TOKENS: "pending_v5_tokens"
98
102
  };
99
103
  var STORAGE_KEYS = {
100
104
  ...STORAGE_KEYS_GLOBAL,
@@ -141,6 +145,19 @@ var DEFAULT_IPFS_GATEWAYS = [
141
145
  "https://dweb.link",
142
146
  "https://ipfs.io"
143
147
  ];
148
+ var UNICITY_IPFS_NODES = [
149
+ {
150
+ host: "unicity-ipfs1.dyndns.org",
151
+ peerId: "12D3KooWDKJqEMAhH4nsSSiKtK1VLcas5coUqSPZAfbWbZpxtL4u",
152
+ httpPort: 9080,
153
+ httpsPort: 443
154
+ }
155
+ ];
156
+ function getIpfsGatewayUrls(isSecure) {
157
+ return UNICITY_IPFS_NODES.map(
158
+ (node) => isSecure !== false ? `https://${node.host}` : `http://${node.host}:${node.httpPort}`
159
+ );
160
+ }
144
161
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
145
162
  var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
146
163
  var DEFAULT_ELECTRUM_URL = "wss://fulcrum.alpha.unicity.network:50004";
@@ -555,6 +572,13 @@ var IndexedDBTokenStorageProvider = class {
555
572
  this.db = null;
556
573
  }
557
574
  this.status = "disconnected";
575
+ const CLEAR_TIMEOUT = 1500;
576
+ const withTimeout = (promise, ms, label) => Promise.race([
577
+ promise,
578
+ new Promise(
579
+ (_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
580
+ )
581
+ ]);
558
582
  const deleteDb = (name) => new Promise((resolve) => {
559
583
  const req = indexedDB.deleteDatabase(name);
560
584
  req.onsuccess = () => resolve();
@@ -563,7 +587,11 @@ var IndexedDBTokenStorageProvider = class {
563
587
  });
564
588
  try {
565
589
  if (typeof indexedDB.databases === "function") {
566
- const dbs = await indexedDB.databases();
590
+ const dbs = await withTimeout(
591
+ indexedDB.databases(),
592
+ CLEAR_TIMEOUT,
593
+ "indexedDB.databases()"
594
+ );
567
595
  await Promise.all(
568
596
  dbs.filter((db) => db.name?.startsWith(this.dbNamePrefix)).map((db) => deleteDb(db.name))
569
597
  );
@@ -571,7 +599,8 @@ var IndexedDBTokenStorageProvider = class {
571
599
  await deleteDb(this.dbName);
572
600
  }
573
601
  return true;
574
- } catch {
602
+ } catch (err) {
603
+ console.warn("[IndexedDBTokenStorage] clear() failed:", err);
575
604
  return false;
576
605
  }
577
606
  }
@@ -1241,6 +1270,13 @@ function publicKeyToAddress(publicKey, prefix = "alpha", witnessVersion = 0) {
1241
1270
  const programBytes = hash160ToBytes(pubKeyHash);
1242
1271
  return encodeBech32(prefix, witnessVersion, programBytes);
1243
1272
  }
1273
+ function hexToBytes(hex) {
1274
+ const matches = hex.match(/../g);
1275
+ if (!matches) {
1276
+ return new Uint8Array(0);
1277
+ }
1278
+ return Uint8Array.from(matches.map((x) => parseInt(x, 16)));
1279
+ }
1244
1280
 
1245
1281
  // transport/websocket.ts
1246
1282
  var WebSocketReadyState = {
@@ -1325,6 +1361,9 @@ var NostrTransportProvider = class {
1325
1361
  type = "p2p";
1326
1362
  description = "P2P messaging via Nostr protocol";
1327
1363
  config;
1364
+ storage = null;
1365
+ /** In-memory max event timestamp to avoid read-before-write races in updateLastEventTimestamp. */
1366
+ lastEventTs = 0;
1328
1367
  identity = null;
1329
1368
  keyManager = null;
1330
1369
  status = "disconnected";
@@ -1333,6 +1372,7 @@ var NostrTransportProvider = class {
1333
1372
  nostrClient = null;
1334
1373
  mainSubscriptionId = null;
1335
1374
  // Event handlers
1375
+ processedEventIds = /* @__PURE__ */ new Set();
1336
1376
  messageHandlers = /* @__PURE__ */ new Set();
1337
1377
  transferHandlers = /* @__PURE__ */ new Set();
1338
1378
  paymentRequestHandlers = /* @__PURE__ */ new Set();
@@ -1350,6 +1390,7 @@ var NostrTransportProvider = class {
1350
1390
  createWebSocket: config.createWebSocket,
1351
1391
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
1352
1392
  };
1393
+ this.storage = config.storage ?? null;
1353
1394
  }
1354
1395
  // ===========================================================================
1355
1396
  // BaseProvider Implementation
@@ -1388,7 +1429,14 @@ var NostrTransportProvider = class {
1388
1429
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
1389
1430
  }
1390
1431
  });
1391
- await this.nostrClient.connect(...this.config.relays);
1432
+ await Promise.race([
1433
+ this.nostrClient.connect(...this.config.relays),
1434
+ new Promise(
1435
+ (_, reject) => setTimeout(() => reject(new Error(
1436
+ `Transport connection timed out after ${this.config.timeout}ms`
1437
+ )), this.config.timeout)
1438
+ )
1439
+ ]);
1392
1440
  if (!this.nostrClient.isConnected()) {
1393
1441
  throw new Error("Failed to connect to any relay");
1394
1442
  }
@@ -1396,7 +1444,7 @@ var NostrTransportProvider = class {
1396
1444
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
1397
1445
  this.log("Connected to", this.nostrClient.getConnectedRelays().size, "relays");
1398
1446
  if (this.identity) {
1399
- this.subscribeToEvents();
1447
+ await this.subscribeToEvents();
1400
1448
  }
1401
1449
  } catch (error) {
1402
1450
  this.status = "error";
@@ -1549,11 +1597,18 @@ var NostrTransportProvider = class {
1549
1597
  this.log("NostrClient reconnected to relay:", url);
1550
1598
  }
1551
1599
  });
1552
- await this.nostrClient.connect(...this.config.relays);
1553
- this.subscribeToEvents();
1600
+ await Promise.race([
1601
+ this.nostrClient.connect(...this.config.relays),
1602
+ new Promise(
1603
+ (_, reject) => setTimeout(() => reject(new Error(
1604
+ `Transport reconnection timed out after ${this.config.timeout}ms`
1605
+ )), this.config.timeout)
1606
+ )
1607
+ ]);
1608
+ await this.subscribeToEvents();
1554
1609
  oldClient.disconnect();
1555
1610
  } else if (this.isConnected()) {
1556
- this.subscribeToEvents();
1611
+ await this.subscribeToEvents();
1557
1612
  }
1558
1613
  }
1559
1614
  /**
@@ -2058,6 +2113,12 @@ var NostrTransportProvider = class {
2058
2113
  // Private: Message Handling
2059
2114
  // ===========================================================================
2060
2115
  async handleEvent(event) {
2116
+ if (event.id && this.processedEventIds.has(event.id)) {
2117
+ return;
2118
+ }
2119
+ if (event.id) {
2120
+ this.processedEventIds.add(event.id);
2121
+ }
2061
2122
  this.log("Processing event kind:", event.kind, "id:", event.id?.slice(0, 12));
2062
2123
  try {
2063
2124
  switch (event.kind) {
@@ -2081,10 +2142,31 @@ var NostrTransportProvider = class {
2081
2142
  this.handleBroadcast(event);
2082
2143
  break;
2083
2144
  }
2145
+ if (event.created_at && this.storage && this.keyManager) {
2146
+ const kind = event.kind;
2147
+ if (kind === EVENT_KINDS.DIRECT_MESSAGE || kind === EVENT_KINDS.TOKEN_TRANSFER || kind === EVENT_KINDS.PAYMENT_REQUEST || kind === EVENT_KINDS.PAYMENT_REQUEST_RESPONSE) {
2148
+ this.updateLastEventTimestamp(event.created_at);
2149
+ }
2150
+ }
2084
2151
  } catch (error) {
2085
2152
  this.log("Failed to handle event:", error);
2086
2153
  }
2087
2154
  }
2155
+ /**
2156
+ * Save the max event timestamp to storage (fire-and-forget, no await needed by caller).
2157
+ * Uses in-memory `lastEventTs` to avoid read-before-write race conditions
2158
+ * when multiple events arrive in quick succession.
2159
+ */
2160
+ updateLastEventTimestamp(createdAt) {
2161
+ if (!this.storage || !this.keyManager) return;
2162
+ if (createdAt <= this.lastEventTs) return;
2163
+ this.lastEventTs = createdAt;
2164
+ const pubkey = this.keyManager.getPublicKeyHex();
2165
+ const storageKey = `${STORAGE_KEYS_GLOBAL.LAST_WALLET_EVENT_TS}_${pubkey.slice(0, 16)}`;
2166
+ this.storage.set(storageKey, createdAt.toString()).catch((err) => {
2167
+ this.log("Failed to save last event timestamp:", err);
2168
+ });
2169
+ }
2088
2170
  async handleDirectMessage(event) {
2089
2171
  this.log("Ignoring NIP-04 kind 4 event (DMs use NIP-17):", event.id?.slice(0, 12));
2090
2172
  }
@@ -2149,7 +2231,7 @@ var NostrTransportProvider = class {
2149
2231
  this.emitEvent({ type: "transfer:received", timestamp: Date.now() });
2150
2232
  for (const handler of this.transferHandlers) {
2151
2233
  try {
2152
- handler(transfer);
2234
+ await handler(transfer);
2153
2235
  } catch (error) {
2154
2236
  this.log("Transfer handler error:", error);
2155
2237
  }
@@ -2163,6 +2245,7 @@ var NostrTransportProvider = class {
2163
2245
  const request = {
2164
2246
  id: event.id,
2165
2247
  senderTransportPubkey: event.pubkey,
2248
+ senderNametag: requestData.recipientNametag,
2166
2249
  request: {
2167
2250
  requestId: requestData.requestId,
2168
2251
  amount: requestData.amount,
@@ -2278,6 +2361,49 @@ var NostrTransportProvider = class {
2278
2361
  const sdkEvent = import_nostr_js_sdk.Event.fromJSON(event);
2279
2362
  await this.nostrClient.publishEvent(sdkEvent);
2280
2363
  }
2364
+ async fetchPendingEvents() {
2365
+ if (!this.nostrClient?.isConnected() || !this.keyManager) {
2366
+ throw new Error("Transport not connected");
2367
+ }
2368
+ const nostrPubkey = this.keyManager.getPublicKeyHex();
2369
+ const walletFilter = new import_nostr_js_sdk.Filter();
2370
+ walletFilter.kinds = [
2371
+ EVENT_KINDS.DIRECT_MESSAGE,
2372
+ EVENT_KINDS.TOKEN_TRANSFER,
2373
+ EVENT_KINDS.PAYMENT_REQUEST,
2374
+ EVENT_KINDS.PAYMENT_REQUEST_RESPONSE
2375
+ ];
2376
+ walletFilter["#p"] = [nostrPubkey];
2377
+ walletFilter.since = Math.floor(Date.now() / 1e3) - 86400;
2378
+ const events = [];
2379
+ await new Promise((resolve) => {
2380
+ const timeout = setTimeout(() => {
2381
+ if (subId) this.nostrClient?.unsubscribe(subId);
2382
+ resolve();
2383
+ }, 5e3);
2384
+ const subId = this.nostrClient.subscribe(walletFilter, {
2385
+ onEvent: (event) => {
2386
+ events.push({
2387
+ id: event.id,
2388
+ kind: event.kind,
2389
+ content: event.content,
2390
+ tags: event.tags,
2391
+ pubkey: event.pubkey,
2392
+ created_at: event.created_at,
2393
+ sig: event.sig
2394
+ });
2395
+ },
2396
+ onEndOfStoredEvents: () => {
2397
+ clearTimeout(timeout);
2398
+ this.nostrClient?.unsubscribe(subId);
2399
+ resolve();
2400
+ }
2401
+ });
2402
+ });
2403
+ for (const event of events) {
2404
+ await this.handleEvent(event);
2405
+ }
2406
+ }
2281
2407
  async queryEvents(filterObj) {
2282
2408
  if (!this.nostrClient || !this.nostrClient.isConnected()) {
2283
2409
  throw new Error("No connected relays");
@@ -2317,7 +2443,7 @@ var NostrTransportProvider = class {
2317
2443
  // Track subscription IDs for cleanup
2318
2444
  walletSubscriptionId = null;
2319
2445
  chatSubscriptionId = null;
2320
- subscribeToEvents() {
2446
+ async subscribeToEvents() {
2321
2447
  this.log("subscribeToEvents called, identity:", !!this.identity, "keyManager:", !!this.keyManager, "nostrClient:", !!this.nostrClient);
2322
2448
  if (!this.identity || !this.keyManager || !this.nostrClient) {
2323
2449
  this.log("subscribeToEvents: skipped - no identity, keyManager, or nostrClient");
@@ -2337,6 +2463,27 @@ var NostrTransportProvider = class {
2337
2463
  }
2338
2464
  const nostrPubkey = this.keyManager.getPublicKeyHex();
2339
2465
  this.log("Subscribing with Nostr pubkey:", nostrPubkey);
2466
+ let since;
2467
+ if (this.storage) {
2468
+ const storageKey = `${STORAGE_KEYS_GLOBAL.LAST_WALLET_EVENT_TS}_${nostrPubkey.slice(0, 16)}`;
2469
+ try {
2470
+ const stored = await this.storage.get(storageKey);
2471
+ if (stored) {
2472
+ since = parseInt(stored, 10);
2473
+ this.lastEventTs = since;
2474
+ this.log("Resuming from stored event timestamp:", since);
2475
+ } else {
2476
+ since = Math.floor(Date.now() / 1e3);
2477
+ this.log("No stored timestamp, starting from now:", since);
2478
+ }
2479
+ } catch (err) {
2480
+ this.log("Failed to read last event timestamp, falling back to now:", err);
2481
+ since = Math.floor(Date.now() / 1e3);
2482
+ }
2483
+ } else {
2484
+ since = Math.floor(Date.now() / 1e3) - 86400;
2485
+ this.log("No storage adapter, using 24h fallback");
2486
+ }
2340
2487
  const walletFilter = new import_nostr_js_sdk.Filter();
2341
2488
  walletFilter.kinds = [
2342
2489
  EVENT_KINDS.DIRECT_MESSAGE,
@@ -2345,7 +2492,7 @@ var NostrTransportProvider = class {
2345
2492
  EVENT_KINDS.PAYMENT_REQUEST_RESPONSE
2346
2493
  ];
2347
2494
  walletFilter["#p"] = [nostrPubkey];
2348
- walletFilter.since = Math.floor(Date.now() / 1e3) - 86400;
2495
+ walletFilter.since = since;
2349
2496
  this.walletSubscriptionId = this.nostrClient.subscribe(walletFilter, {
2350
2497
  onEvent: (event) => {
2351
2498
  this.log("Received wallet event kind:", event.kind, "id:", event.id?.slice(0, 12));
@@ -3019,6 +3166,1913 @@ async function readFileAsUint8Array(file) {
3019
3166
  return new Uint8Array(buffer);
3020
3167
  }
3021
3168
 
3169
+ // impl/shared/ipfs/ipfs-error-types.ts
3170
+ var IpfsError = class extends Error {
3171
+ category;
3172
+ gateway;
3173
+ cause;
3174
+ constructor(message, category, gateway, cause) {
3175
+ super(message);
3176
+ this.name = "IpfsError";
3177
+ this.category = category;
3178
+ this.gateway = gateway;
3179
+ this.cause = cause;
3180
+ }
3181
+ /** Whether this error should trigger the circuit breaker */
3182
+ get shouldTriggerCircuitBreaker() {
3183
+ return this.category !== "NOT_FOUND" && this.category !== "SEQUENCE_DOWNGRADE";
3184
+ }
3185
+ };
3186
+ function classifyFetchError(error) {
3187
+ if (error instanceof DOMException && error.name === "AbortError") {
3188
+ return "TIMEOUT";
3189
+ }
3190
+ if (error instanceof TypeError) {
3191
+ return "NETWORK_ERROR";
3192
+ }
3193
+ if (error instanceof Error && error.name === "TimeoutError") {
3194
+ return "TIMEOUT";
3195
+ }
3196
+ return "NETWORK_ERROR";
3197
+ }
3198
+ function classifyHttpStatus(status, responseBody) {
3199
+ if (status === 404) {
3200
+ return "NOT_FOUND";
3201
+ }
3202
+ if (status === 500 && responseBody) {
3203
+ if (/routing:\s*not\s*found/i.test(responseBody)) {
3204
+ return "NOT_FOUND";
3205
+ }
3206
+ }
3207
+ if (status >= 500) {
3208
+ return "GATEWAY_ERROR";
3209
+ }
3210
+ if (status >= 400) {
3211
+ return "GATEWAY_ERROR";
3212
+ }
3213
+ return "GATEWAY_ERROR";
3214
+ }
3215
+
3216
+ // impl/shared/ipfs/ipfs-state-persistence.ts
3217
+ var InMemoryIpfsStatePersistence = class {
3218
+ states = /* @__PURE__ */ new Map();
3219
+ async load(ipnsName) {
3220
+ return this.states.get(ipnsName) ?? null;
3221
+ }
3222
+ async save(ipnsName, state) {
3223
+ this.states.set(ipnsName, { ...state });
3224
+ }
3225
+ async clear(ipnsName) {
3226
+ this.states.delete(ipnsName);
3227
+ }
3228
+ };
3229
+
3230
+ // impl/shared/ipfs/ipns-key-derivation.ts
3231
+ var IPNS_HKDF_INFO = "ipfs-storage-ed25519-v1";
3232
+ var libp2pCryptoModule = null;
3233
+ var libp2pPeerIdModule = null;
3234
+ async function loadLibp2pModules() {
3235
+ if (!libp2pCryptoModule) {
3236
+ [libp2pCryptoModule, libp2pPeerIdModule] = await Promise.all([
3237
+ import("@libp2p/crypto/keys"),
3238
+ import("@libp2p/peer-id")
3239
+ ]);
3240
+ }
3241
+ return {
3242
+ generateKeyPairFromSeed: libp2pCryptoModule.generateKeyPairFromSeed,
3243
+ peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey
3244
+ };
3245
+ }
3246
+ function deriveEd25519KeyMaterial(privateKeyHex, info = IPNS_HKDF_INFO) {
3247
+ const walletSecret = hexToBytes(privateKeyHex);
3248
+ const infoBytes = new TextEncoder().encode(info);
3249
+ return hkdf(sha256, walletSecret, void 0, infoBytes, 32);
3250
+ }
3251
+ async function deriveIpnsIdentity(privateKeyHex) {
3252
+ const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadLibp2pModules();
3253
+ const derivedKey = deriveEd25519KeyMaterial(privateKeyHex);
3254
+ const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
3255
+ const peerId = peerIdFromPrivateKey(keyPair);
3256
+ return {
3257
+ keyPair,
3258
+ ipnsName: peerId.toString()
3259
+ };
3260
+ }
3261
+
3262
+ // impl/shared/ipfs/ipns-record-manager.ts
3263
+ var DEFAULT_LIFETIME_MS = 99 * 365 * 24 * 60 * 60 * 1e3;
3264
+ var ipnsModule = null;
3265
+ async function loadIpnsModule() {
3266
+ if (!ipnsModule) {
3267
+ const mod = await import("ipns");
3268
+ ipnsModule = {
3269
+ createIPNSRecord: mod.createIPNSRecord,
3270
+ marshalIPNSRecord: mod.marshalIPNSRecord,
3271
+ unmarshalIPNSRecord: mod.unmarshalIPNSRecord
3272
+ };
3273
+ }
3274
+ return ipnsModule;
3275
+ }
3276
+ async function createSignedRecord(keyPair, cid, sequenceNumber, lifetimeMs = DEFAULT_LIFETIME_MS) {
3277
+ const { createIPNSRecord, marshalIPNSRecord } = await loadIpnsModule();
3278
+ const record = await createIPNSRecord(
3279
+ keyPair,
3280
+ `/ipfs/${cid}`,
3281
+ sequenceNumber,
3282
+ lifetimeMs
3283
+ );
3284
+ return marshalIPNSRecord(record);
3285
+ }
3286
+ async function parseRoutingApiResponse(responseText) {
3287
+ const { unmarshalIPNSRecord } = await loadIpnsModule();
3288
+ const lines = responseText.trim().split("\n");
3289
+ for (const line of lines) {
3290
+ if (!line.trim()) continue;
3291
+ try {
3292
+ const obj = JSON.parse(line);
3293
+ if (obj.Extra) {
3294
+ const recordData = base64ToUint8Array(obj.Extra);
3295
+ const record = unmarshalIPNSRecord(recordData);
3296
+ const valueBytes = typeof record.value === "string" ? new TextEncoder().encode(record.value) : record.value;
3297
+ const valueStr = new TextDecoder().decode(valueBytes);
3298
+ const cidMatch = valueStr.match(/\/ipfs\/([a-zA-Z0-9]+)/);
3299
+ if (cidMatch) {
3300
+ return {
3301
+ cid: cidMatch[1],
3302
+ sequence: record.sequence,
3303
+ recordData
3304
+ };
3305
+ }
3306
+ }
3307
+ } catch {
3308
+ continue;
3309
+ }
3310
+ }
3311
+ return null;
3312
+ }
3313
+ function base64ToUint8Array(base64) {
3314
+ const binary = atob(base64);
3315
+ const bytes = new Uint8Array(binary.length);
3316
+ for (let i = 0; i < binary.length; i++) {
3317
+ bytes[i] = binary.charCodeAt(i);
3318
+ }
3319
+ return bytes;
3320
+ }
3321
+
3322
+ // impl/shared/ipfs/ipfs-cache.ts
3323
+ var DEFAULT_IPNS_TTL_MS = 6e4;
3324
+ var DEFAULT_FAILURE_COOLDOWN_MS = 6e4;
3325
+ var DEFAULT_FAILURE_THRESHOLD = 3;
3326
+ var DEFAULT_KNOWN_FRESH_WINDOW_MS = 3e4;
3327
+ var IpfsCache = class {
3328
+ ipnsRecords = /* @__PURE__ */ new Map();
3329
+ content = /* @__PURE__ */ new Map();
3330
+ gatewayFailures = /* @__PURE__ */ new Map();
3331
+ knownFreshTimestamps = /* @__PURE__ */ new Map();
3332
+ ipnsTtlMs;
3333
+ failureCooldownMs;
3334
+ failureThreshold;
3335
+ knownFreshWindowMs;
3336
+ constructor(config) {
3337
+ this.ipnsTtlMs = config?.ipnsTtlMs ?? DEFAULT_IPNS_TTL_MS;
3338
+ this.failureCooldownMs = config?.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;
3339
+ this.failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
3340
+ this.knownFreshWindowMs = config?.knownFreshWindowMs ?? DEFAULT_KNOWN_FRESH_WINDOW_MS;
3341
+ }
3342
+ // ---------------------------------------------------------------------------
3343
+ // IPNS Record Cache (60s TTL)
3344
+ // ---------------------------------------------------------------------------
3345
+ getIpnsRecord(ipnsName) {
3346
+ const entry = this.ipnsRecords.get(ipnsName);
3347
+ if (!entry) return null;
3348
+ if (Date.now() - entry.timestamp > this.ipnsTtlMs) {
3349
+ this.ipnsRecords.delete(ipnsName);
3350
+ return null;
3351
+ }
3352
+ return entry.data;
3353
+ }
3354
+ /**
3355
+ * Get cached IPNS record ignoring TTL (for known-fresh optimization).
3356
+ */
3357
+ getIpnsRecordIgnoreTtl(ipnsName) {
3358
+ const entry = this.ipnsRecords.get(ipnsName);
3359
+ return entry?.data ?? null;
3360
+ }
3361
+ setIpnsRecord(ipnsName, result) {
3362
+ this.ipnsRecords.set(ipnsName, {
3363
+ data: result,
3364
+ timestamp: Date.now()
3365
+ });
3366
+ }
3367
+ invalidateIpns(ipnsName) {
3368
+ this.ipnsRecords.delete(ipnsName);
3369
+ }
3370
+ // ---------------------------------------------------------------------------
3371
+ // Content Cache (infinite TTL - content is immutable by CID)
3372
+ // ---------------------------------------------------------------------------
3373
+ getContent(cid) {
3374
+ const entry = this.content.get(cid);
3375
+ return entry?.data ?? null;
3376
+ }
3377
+ setContent(cid, data) {
3378
+ this.content.set(cid, {
3379
+ data,
3380
+ timestamp: Date.now()
3381
+ });
3382
+ }
3383
+ // ---------------------------------------------------------------------------
3384
+ // Gateway Failure Tracking (Circuit Breaker)
3385
+ // ---------------------------------------------------------------------------
3386
+ /**
3387
+ * Record a gateway failure. After threshold consecutive failures,
3388
+ * the gateway enters cooldown and is considered unhealthy.
3389
+ */
3390
+ recordGatewayFailure(gateway) {
3391
+ const existing = this.gatewayFailures.get(gateway);
3392
+ this.gatewayFailures.set(gateway, {
3393
+ count: (existing?.count ?? 0) + 1,
3394
+ lastFailure: Date.now()
3395
+ });
3396
+ }
3397
+ /** Reset failure count for a gateway (on successful request) */
3398
+ recordGatewaySuccess(gateway) {
3399
+ this.gatewayFailures.delete(gateway);
3400
+ }
3401
+ /**
3402
+ * Check if a gateway is currently in circuit breaker cooldown.
3403
+ * A gateway is considered unhealthy if it has had >= threshold
3404
+ * consecutive failures and the cooldown period hasn't elapsed.
3405
+ */
3406
+ isGatewayInCooldown(gateway) {
3407
+ const failure = this.gatewayFailures.get(gateway);
3408
+ if (!failure) return false;
3409
+ if (failure.count < this.failureThreshold) return false;
3410
+ const elapsed = Date.now() - failure.lastFailure;
3411
+ if (elapsed >= this.failureCooldownMs) {
3412
+ this.gatewayFailures.delete(gateway);
3413
+ return false;
3414
+ }
3415
+ return true;
3416
+ }
3417
+ // ---------------------------------------------------------------------------
3418
+ // Known-Fresh Flag (FAST mode optimization)
3419
+ // ---------------------------------------------------------------------------
3420
+ /**
3421
+ * Mark IPNS cache as "known-fresh" (after local publish or push notification).
3422
+ * Within the fresh window, we can skip network resolution.
3423
+ */
3424
+ markIpnsFresh(ipnsName) {
3425
+ this.knownFreshTimestamps.set(ipnsName, Date.now());
3426
+ }
3427
+ /**
3428
+ * Check if the cache is known-fresh (within the fresh window).
3429
+ */
3430
+ isIpnsKnownFresh(ipnsName) {
3431
+ const timestamp = this.knownFreshTimestamps.get(ipnsName);
3432
+ if (!timestamp) return false;
3433
+ if (Date.now() - timestamp > this.knownFreshWindowMs) {
3434
+ this.knownFreshTimestamps.delete(ipnsName);
3435
+ return false;
3436
+ }
3437
+ return true;
3438
+ }
3439
+ // ---------------------------------------------------------------------------
3440
+ // Cache Management
3441
+ // ---------------------------------------------------------------------------
3442
+ clear() {
3443
+ this.ipnsRecords.clear();
3444
+ this.content.clear();
3445
+ this.gatewayFailures.clear();
3446
+ this.knownFreshTimestamps.clear();
3447
+ }
3448
+ };
3449
+
3450
+ // impl/shared/ipfs/ipfs-http-client.ts
3451
+ var DEFAULT_CONNECTIVITY_TIMEOUT_MS = 5e3;
3452
+ var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
3453
+ var DEFAULT_RESOLVE_TIMEOUT_MS = 1e4;
3454
+ var DEFAULT_PUBLISH_TIMEOUT_MS = 3e4;
3455
+ var DEFAULT_GATEWAY_PATH_TIMEOUT_MS = 3e3;
3456
+ var DEFAULT_ROUTING_API_TIMEOUT_MS = 2e3;
3457
+ var IpfsHttpClient = class {
3458
+ gateways;
3459
+ fetchTimeoutMs;
3460
+ resolveTimeoutMs;
3461
+ publishTimeoutMs;
3462
+ connectivityTimeoutMs;
3463
+ debug;
3464
+ cache;
3465
+ constructor(config, cache) {
3466
+ this.gateways = config.gateways;
3467
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3468
+ this.resolveTimeoutMs = config.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;
3469
+ this.publishTimeoutMs = config.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS;
3470
+ this.connectivityTimeoutMs = config.connectivityTimeoutMs ?? DEFAULT_CONNECTIVITY_TIMEOUT_MS;
3471
+ this.debug = config.debug ?? false;
3472
+ this.cache = cache;
3473
+ }
3474
+ // ---------------------------------------------------------------------------
3475
+ // Public Accessors
3476
+ // ---------------------------------------------------------------------------
3477
+ /**
3478
+ * Get configured gateway URLs.
3479
+ */
3480
+ getGateways() {
3481
+ return [...this.gateways];
3482
+ }
3483
+ // ---------------------------------------------------------------------------
3484
+ // Gateway Health
3485
+ // ---------------------------------------------------------------------------
3486
+ /**
3487
+ * Test connectivity to a single gateway.
3488
+ */
3489
+ async testConnectivity(gateway) {
3490
+ const start = Date.now();
3491
+ try {
3492
+ const response = await this.fetchWithTimeout(
3493
+ `${gateway}/api/v0/version`,
3494
+ this.connectivityTimeoutMs,
3495
+ { method: "POST" }
3496
+ );
3497
+ if (!response.ok) {
3498
+ return { gateway, healthy: false, error: `HTTP ${response.status}` };
3499
+ }
3500
+ return {
3501
+ gateway,
3502
+ healthy: true,
3503
+ responseTimeMs: Date.now() - start
3504
+ };
3505
+ } catch (error) {
3506
+ return {
3507
+ gateway,
3508
+ healthy: false,
3509
+ error: error instanceof Error ? error.message : String(error)
3510
+ };
3511
+ }
3512
+ }
3513
+ /**
3514
+ * Find healthy gateways from the configured list.
3515
+ */
3516
+ async findHealthyGateways() {
3517
+ const results = await Promise.allSettled(
3518
+ this.gateways.map((gw) => this.testConnectivity(gw))
3519
+ );
3520
+ return results.filter((r) => r.status === "fulfilled" && r.value.healthy).map((r) => r.value.gateway);
3521
+ }
3522
+ /**
3523
+ * Get gateways that are not in circuit breaker cooldown.
3524
+ */
3525
+ getAvailableGateways() {
3526
+ return this.gateways.filter((gw) => !this.cache.isGatewayInCooldown(gw));
3527
+ }
3528
+ // ---------------------------------------------------------------------------
3529
+ // Content Upload
3530
+ // ---------------------------------------------------------------------------
3531
+ /**
3532
+ * Upload JSON content to IPFS.
3533
+ * Tries all gateways in parallel, returns first success.
3534
+ */
3535
+ async upload(data, gateways) {
3536
+ const targets = gateways ?? this.getAvailableGateways();
3537
+ if (targets.length === 0) {
3538
+ throw new IpfsError("No gateways available for upload", "NETWORK_ERROR");
3539
+ }
3540
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(data));
3541
+ const promises = targets.map(async (gateway) => {
3542
+ try {
3543
+ const formData = new FormData();
3544
+ formData.append("file", new Blob([jsonBytes], { type: "application/json" }), "data.json");
3545
+ const response = await this.fetchWithTimeout(
3546
+ `${gateway}/api/v0/add?pin=true&cid-version=1`,
3547
+ this.publishTimeoutMs,
3548
+ { method: "POST", body: formData }
3549
+ );
3550
+ if (!response.ok) {
3551
+ throw new IpfsError(
3552
+ `Upload failed: HTTP ${response.status}`,
3553
+ classifyHttpStatus(response.status),
3554
+ gateway
3555
+ );
3556
+ }
3557
+ const result = await response.json();
3558
+ this.cache.recordGatewaySuccess(gateway);
3559
+ this.log(`Uploaded to ${gateway}: CID=${result.Hash}`);
3560
+ return { cid: result.Hash, gateway };
3561
+ } catch (error) {
3562
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3563
+ this.cache.recordGatewayFailure(gateway);
3564
+ }
3565
+ throw error;
3566
+ }
3567
+ });
3568
+ try {
3569
+ const result = await Promise.any(promises);
3570
+ return { cid: result.cid };
3571
+ } catch (error) {
3572
+ if (error instanceof AggregateError) {
3573
+ throw new IpfsError(
3574
+ `Upload failed on all gateways: ${error.errors.map((e) => e.message).join("; ")}`,
3575
+ "NETWORK_ERROR"
3576
+ );
3577
+ }
3578
+ throw error;
3579
+ }
3580
+ }
3581
+ // ---------------------------------------------------------------------------
3582
+ // Content Fetch
3583
+ // ---------------------------------------------------------------------------
3584
+ /**
3585
+ * Fetch content by CID from IPFS gateways.
3586
+ * Checks content cache first. Races all gateways for fastest response.
3587
+ */
3588
+ async fetchContent(cid, gateways) {
3589
+ const cached = this.cache.getContent(cid);
3590
+ if (cached) {
3591
+ this.log(`Content cache hit for CID=${cid}`);
3592
+ return cached;
3593
+ }
3594
+ const targets = gateways ?? this.getAvailableGateways();
3595
+ if (targets.length === 0) {
3596
+ throw new IpfsError("No gateways available for fetch", "NETWORK_ERROR");
3597
+ }
3598
+ const promises = targets.map(async (gateway) => {
3599
+ try {
3600
+ const response = await this.fetchWithTimeout(
3601
+ `${gateway}/ipfs/${cid}`,
3602
+ this.fetchTimeoutMs,
3603
+ { headers: { Accept: "application/octet-stream" } }
3604
+ );
3605
+ if (!response.ok) {
3606
+ const body = await response.text().catch(() => "");
3607
+ throw new IpfsError(
3608
+ `Fetch failed: HTTP ${response.status}`,
3609
+ classifyHttpStatus(response.status, body),
3610
+ gateway
3611
+ );
3612
+ }
3613
+ const data = await response.json();
3614
+ this.cache.recordGatewaySuccess(gateway);
3615
+ this.cache.setContent(cid, data);
3616
+ this.log(`Fetched from ${gateway}: CID=${cid}`);
3617
+ return data;
3618
+ } catch (error) {
3619
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3620
+ this.cache.recordGatewayFailure(gateway);
3621
+ }
3622
+ throw error;
3623
+ }
3624
+ });
3625
+ try {
3626
+ return await Promise.any(promises);
3627
+ } catch (error) {
3628
+ if (error instanceof AggregateError) {
3629
+ throw new IpfsError(
3630
+ `Fetch failed on all gateways for CID=${cid}`,
3631
+ "NETWORK_ERROR"
3632
+ );
3633
+ }
3634
+ throw error;
3635
+ }
3636
+ }
3637
+ // ---------------------------------------------------------------------------
3638
+ // IPNS Resolution
3639
+ // ---------------------------------------------------------------------------
3640
+ /**
3641
+ * Resolve IPNS via Routing API (returns record with sequence number).
3642
+ * POST /api/v0/routing/get?arg=/ipns/{name}
3643
+ */
3644
+ async resolveIpnsViaRoutingApi(gateway, ipnsName, timeoutMs = DEFAULT_ROUTING_API_TIMEOUT_MS) {
3645
+ try {
3646
+ const response = await this.fetchWithTimeout(
3647
+ `${gateway}/api/v0/routing/get?arg=/ipns/${ipnsName}`,
3648
+ timeoutMs,
3649
+ { method: "POST" }
3650
+ );
3651
+ if (!response.ok) {
3652
+ const body = await response.text().catch(() => "");
3653
+ const category = classifyHttpStatus(response.status, body);
3654
+ if (category === "NOT_FOUND") return null;
3655
+ throw new IpfsError(`Routing API: HTTP ${response.status}`, category, gateway);
3656
+ }
3657
+ const text = await response.text();
3658
+ const parsed = await parseRoutingApiResponse(text);
3659
+ if (!parsed) return null;
3660
+ this.cache.recordGatewaySuccess(gateway);
3661
+ return {
3662
+ cid: parsed.cid,
3663
+ sequence: parsed.sequence,
3664
+ gateway,
3665
+ recordData: parsed.recordData
3666
+ };
3667
+ } catch (error) {
3668
+ if (error instanceof IpfsError) throw error;
3669
+ const category = classifyFetchError(error);
3670
+ if (category !== "NOT_FOUND") {
3671
+ this.cache.recordGatewayFailure(gateway);
3672
+ }
3673
+ return null;
3674
+ }
3675
+ }
3676
+ /**
3677
+ * Resolve IPNS via gateway path (simpler, no sequence number).
3678
+ * GET /ipns/{name}?format=dag-json
3679
+ */
3680
+ async resolveIpnsViaGatewayPath(gateway, ipnsName, timeoutMs = DEFAULT_GATEWAY_PATH_TIMEOUT_MS) {
3681
+ try {
3682
+ const response = await this.fetchWithTimeout(
3683
+ `${gateway}/ipns/${ipnsName}`,
3684
+ timeoutMs,
3685
+ { headers: { Accept: "application/json" } }
3686
+ );
3687
+ if (!response.ok) return null;
3688
+ const content = await response.json();
3689
+ const cidHeader = response.headers.get("X-Ipfs-Path");
3690
+ if (cidHeader) {
3691
+ const match = cidHeader.match(/\/ipfs\/([a-zA-Z0-9]+)/);
3692
+ if (match) {
3693
+ this.cache.recordGatewaySuccess(gateway);
3694
+ return { cid: match[1], content };
3695
+ }
3696
+ }
3697
+ return { cid: "", content };
3698
+ } catch {
3699
+ return null;
3700
+ }
3701
+ }
3702
+ /**
3703
+ * Progressive IPNS resolution across all gateways.
3704
+ * Queries all gateways in parallel, returns highest sequence number.
3705
+ */
3706
+ async resolveIpns(ipnsName, gateways) {
3707
+ const targets = gateways ?? this.getAvailableGateways();
3708
+ if (targets.length === 0) {
3709
+ return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 };
3710
+ }
3711
+ const results = [];
3712
+ let respondedCount = 0;
3713
+ const promises = targets.map(async (gateway) => {
3714
+ const result = await this.resolveIpnsViaRoutingApi(
3715
+ gateway,
3716
+ ipnsName,
3717
+ this.resolveTimeoutMs
3718
+ );
3719
+ if (result) results.push(result);
3720
+ respondedCount++;
3721
+ return result;
3722
+ });
3723
+ await Promise.race([
3724
+ Promise.allSettled(promises),
3725
+ new Promise((resolve) => setTimeout(resolve, this.resolveTimeoutMs + 1e3))
3726
+ ]);
3727
+ let best = null;
3728
+ for (const result of results) {
3729
+ if (!best || result.sequence > best.sequence) {
3730
+ best = result;
3731
+ }
3732
+ }
3733
+ if (best) {
3734
+ this.cache.setIpnsRecord(ipnsName, best);
3735
+ }
3736
+ return {
3737
+ best,
3738
+ allResults: results,
3739
+ respondedCount,
3740
+ totalGateways: targets.length
3741
+ };
3742
+ }
3743
+ // ---------------------------------------------------------------------------
3744
+ // IPNS Publishing
3745
+ // ---------------------------------------------------------------------------
3746
+ /**
3747
+ * Publish IPNS record to a single gateway via routing API.
3748
+ */
3749
+ async publishIpnsViaRoutingApi(gateway, ipnsName, marshalledRecord, timeoutMs = DEFAULT_PUBLISH_TIMEOUT_MS) {
3750
+ try {
3751
+ const formData = new FormData();
3752
+ formData.append(
3753
+ "file",
3754
+ new Blob([new Uint8Array(marshalledRecord)]),
3755
+ "record"
3756
+ );
3757
+ const response = await this.fetchWithTimeout(
3758
+ `${gateway}/api/v0/routing/put?arg=/ipns/${ipnsName}&allow-offline=true`,
3759
+ timeoutMs,
3760
+ { method: "POST", body: formData }
3761
+ );
3762
+ if (!response.ok) {
3763
+ const errorText = await response.text().catch(() => "");
3764
+ throw new IpfsError(
3765
+ `IPNS publish: HTTP ${response.status}: ${errorText.slice(0, 100)}`,
3766
+ classifyHttpStatus(response.status, errorText),
3767
+ gateway
3768
+ );
3769
+ }
3770
+ this.cache.recordGatewaySuccess(gateway);
3771
+ this.log(`IPNS published to ${gateway}: ${ipnsName}`);
3772
+ return true;
3773
+ } catch (error) {
3774
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3775
+ this.cache.recordGatewayFailure(gateway);
3776
+ }
3777
+ this.log(`IPNS publish to ${gateway} failed: ${error}`);
3778
+ return false;
3779
+ }
3780
+ }
3781
+ /**
3782
+ * Publish IPNS record to all gateways in parallel.
3783
+ */
3784
+ async publishIpns(ipnsName, marshalledRecord, gateways) {
3785
+ const targets = gateways ?? this.getAvailableGateways();
3786
+ if (targets.length === 0) {
3787
+ return { success: false, error: "No gateways available" };
3788
+ }
3789
+ const results = await Promise.allSettled(
3790
+ targets.map((gw) => this.publishIpnsViaRoutingApi(gw, ipnsName, marshalledRecord, this.publishTimeoutMs))
3791
+ );
3792
+ const successfulGateways = [];
3793
+ results.forEach((result, index) => {
3794
+ if (result.status === "fulfilled" && result.value) {
3795
+ successfulGateways.push(targets[index]);
3796
+ }
3797
+ });
3798
+ return {
3799
+ success: successfulGateways.length > 0,
3800
+ ipnsName,
3801
+ successfulGateways,
3802
+ error: successfulGateways.length === 0 ? "All gateways failed" : void 0
3803
+ };
3804
+ }
3805
+ // ---------------------------------------------------------------------------
3806
+ // IPNS Verification
3807
+ // ---------------------------------------------------------------------------
3808
+ /**
3809
+ * Verify IPNS record persistence after publishing.
3810
+ * Retries resolution to confirm the record was accepted.
3811
+ */
3812
+ async verifyIpnsRecord(ipnsName, expectedSeq, expectedCid, retries = 3, delayMs = 1e3) {
3813
+ for (let i = 0; i < retries; i++) {
3814
+ if (i > 0) {
3815
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
3816
+ }
3817
+ const { best } = await this.resolveIpns(ipnsName);
3818
+ if (best && best.sequence >= expectedSeq && best.cid === expectedCid) {
3819
+ return true;
3820
+ }
3821
+ }
3822
+ return false;
3823
+ }
3824
+ // ---------------------------------------------------------------------------
3825
+ // Helpers
3826
+ // ---------------------------------------------------------------------------
3827
+ async fetchWithTimeout(url, timeoutMs, options) {
3828
+ const controller = new AbortController();
3829
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3830
+ try {
3831
+ return await fetch(url, {
3832
+ ...options,
3833
+ signal: controller.signal
3834
+ });
3835
+ } finally {
3836
+ clearTimeout(timer);
3837
+ }
3838
+ }
3839
+ log(message) {
3840
+ if (this.debug) {
3841
+ console.log(`[IPFS-HTTP] ${message}`);
3842
+ }
3843
+ }
3844
+ };
3845
+
3846
+ // impl/shared/ipfs/txf-merge.ts
3847
+ function mergeTxfData(local, remote) {
3848
+ let added = 0;
3849
+ let removed = 0;
3850
+ let conflicts = 0;
3851
+ const localVersion = local._meta?.version ?? 0;
3852
+ const remoteVersion = remote._meta?.version ?? 0;
3853
+ const baseMeta = localVersion >= remoteVersion ? local._meta : remote._meta;
3854
+ const mergedMeta = {
3855
+ ...baseMeta,
3856
+ version: Math.max(localVersion, remoteVersion) + 1,
3857
+ updatedAt: Date.now()
3858
+ };
3859
+ const mergedTombstones = mergeTombstones(
3860
+ local._tombstones ?? [],
3861
+ remote._tombstones ?? []
3862
+ );
3863
+ const tombstoneKeys = new Set(
3864
+ mergedTombstones.map((t) => `${t.tokenId}:${t.stateHash}`)
3865
+ );
3866
+ const localTokenKeys = getTokenKeys(local);
3867
+ const remoteTokenKeys = getTokenKeys(remote);
3868
+ const allTokenKeys = /* @__PURE__ */ new Set([...localTokenKeys, ...remoteTokenKeys]);
3869
+ const mergedTokens = {};
3870
+ for (const key of allTokenKeys) {
3871
+ const tokenId = key.startsWith("_") ? key.slice(1) : key;
3872
+ const localToken = local[key];
3873
+ const remoteToken = remote[key];
3874
+ if (isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys)) {
3875
+ if (localTokenKeys.has(key)) removed++;
3876
+ continue;
3877
+ }
3878
+ if (localToken && !remoteToken) {
3879
+ mergedTokens[key] = localToken;
3880
+ } else if (!localToken && remoteToken) {
3881
+ mergedTokens[key] = remoteToken;
3882
+ added++;
3883
+ } else if (localToken && remoteToken) {
3884
+ mergedTokens[key] = localToken;
3885
+ conflicts++;
3886
+ }
3887
+ }
3888
+ const mergedOutbox = mergeArrayById(
3889
+ local._outbox ?? [],
3890
+ remote._outbox ?? [],
3891
+ "id"
3892
+ );
3893
+ const mergedSent = mergeArrayById(
3894
+ local._sent ?? [],
3895
+ remote._sent ?? [],
3896
+ "tokenId"
3897
+ );
3898
+ const mergedInvalid = mergeArrayById(
3899
+ local._invalid ?? [],
3900
+ remote._invalid ?? [],
3901
+ "tokenId"
3902
+ );
3903
+ const merged = {
3904
+ _meta: mergedMeta,
3905
+ _tombstones: mergedTombstones.length > 0 ? mergedTombstones : void 0,
3906
+ _outbox: mergedOutbox.length > 0 ? mergedOutbox : void 0,
3907
+ _sent: mergedSent.length > 0 ? mergedSent : void 0,
3908
+ _invalid: mergedInvalid.length > 0 ? mergedInvalid : void 0,
3909
+ ...mergedTokens
3910
+ };
3911
+ return { merged, added, removed, conflicts };
3912
+ }
3913
+ function mergeTombstones(local, remote) {
3914
+ const merged = /* @__PURE__ */ new Map();
3915
+ for (const tombstone of [...local, ...remote]) {
3916
+ const key = `${tombstone.tokenId}:${tombstone.stateHash}`;
3917
+ const existing = merged.get(key);
3918
+ if (!existing || tombstone.timestamp > existing.timestamp) {
3919
+ merged.set(key, tombstone);
3920
+ }
3921
+ }
3922
+ return Array.from(merged.values());
3923
+ }
3924
+ function getTokenKeys(data) {
3925
+ const reservedKeys = /* @__PURE__ */ new Set([
3926
+ "_meta",
3927
+ "_tombstones",
3928
+ "_outbox",
3929
+ "_sent",
3930
+ "_invalid",
3931
+ "_nametag",
3932
+ "_mintOutbox",
3933
+ "_invalidatedNametags",
3934
+ "_integrity"
3935
+ ]);
3936
+ const keys = /* @__PURE__ */ new Set();
3937
+ for (const key of Object.keys(data)) {
3938
+ if (reservedKeys.has(key)) continue;
3939
+ if (key.startsWith("archived-") || key.startsWith("_forked_") || key.startsWith("nametag-")) continue;
3940
+ keys.add(key);
3941
+ }
3942
+ return keys;
3943
+ }
3944
+ function isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys) {
3945
+ for (const key of tombstoneKeys) {
3946
+ if (key.startsWith(`${tokenId}:`)) {
3947
+ return true;
3948
+ }
3949
+ }
3950
+ void localToken;
3951
+ void remoteToken;
3952
+ return false;
3953
+ }
3954
+ function mergeArrayById(local, remote, idField) {
3955
+ const seen = /* @__PURE__ */ new Map();
3956
+ for (const item of local) {
3957
+ const id = item[idField];
3958
+ if (id !== void 0) {
3959
+ seen.set(id, item);
3960
+ }
3961
+ }
3962
+ for (const item of remote) {
3963
+ const id = item[idField];
3964
+ if (id !== void 0 && !seen.has(id)) {
3965
+ seen.set(id, item);
3966
+ }
3967
+ }
3968
+ return Array.from(seen.values());
3969
+ }
3970
+
3971
+ // impl/shared/ipfs/ipns-subscription-client.ts
3972
+ var IpnsSubscriptionClient = class {
3973
+ ws = null;
3974
+ subscriptions = /* @__PURE__ */ new Map();
3975
+ reconnectTimeout = null;
3976
+ pingInterval = null;
3977
+ fallbackPollInterval = null;
3978
+ wsUrl;
3979
+ createWebSocket;
3980
+ pingIntervalMs;
3981
+ initialReconnectDelayMs;
3982
+ maxReconnectDelayMs;
3983
+ debugEnabled;
3984
+ reconnectAttempts = 0;
3985
+ isConnecting = false;
3986
+ connectionOpenedAt = 0;
3987
+ destroyed = false;
3988
+ /** Minimum stable connection time before resetting backoff (30 seconds) */
3989
+ minStableConnectionMs = 3e4;
3990
+ fallbackPollFn = null;
3991
+ fallbackPollIntervalMs = 0;
3992
+ constructor(config) {
3993
+ this.wsUrl = config.wsUrl;
3994
+ this.createWebSocket = config.createWebSocket;
3995
+ this.pingIntervalMs = config.pingIntervalMs ?? 3e4;
3996
+ this.initialReconnectDelayMs = config.reconnectDelayMs ?? 5e3;
3997
+ this.maxReconnectDelayMs = config.maxReconnectDelayMs ?? 6e4;
3998
+ this.debugEnabled = config.debug ?? false;
3999
+ }
4000
+ // ---------------------------------------------------------------------------
4001
+ // Public API
4002
+ // ---------------------------------------------------------------------------
4003
+ /**
4004
+ * Subscribe to IPNS updates for a specific name.
4005
+ * Automatically connects the WebSocket if not already connected.
4006
+ * If WebSocket is connecting, the name will be subscribed once connected.
4007
+ */
4008
+ subscribe(ipnsName, callback) {
4009
+ if (!ipnsName || typeof ipnsName !== "string") {
4010
+ this.log("Invalid IPNS name for subscription");
4011
+ return () => {
4012
+ };
4013
+ }
4014
+ const isNewSubscription = !this.subscriptions.has(ipnsName);
4015
+ if (isNewSubscription) {
4016
+ this.subscriptions.set(ipnsName, /* @__PURE__ */ new Set());
4017
+ }
4018
+ this.subscriptions.get(ipnsName).add(callback);
4019
+ if (isNewSubscription && this.ws?.readyState === WebSocketReadyState.OPEN) {
4020
+ this.sendSubscribe([ipnsName]);
4021
+ }
4022
+ if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) {
4023
+ this.connect();
4024
+ }
4025
+ return () => {
4026
+ const callbacks = this.subscriptions.get(ipnsName);
4027
+ if (callbacks) {
4028
+ callbacks.delete(callback);
4029
+ if (callbacks.size === 0) {
4030
+ this.subscriptions.delete(ipnsName);
4031
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4032
+ this.sendUnsubscribe([ipnsName]);
4033
+ }
4034
+ if (this.subscriptions.size === 0) {
4035
+ this.disconnect();
4036
+ }
4037
+ }
4038
+ }
4039
+ };
4040
+ }
4041
+ /**
4042
+ * Register a convenience update callback for all subscriptions.
4043
+ * Returns an unsubscribe function.
4044
+ */
4045
+ onUpdate(callback) {
4046
+ if (!this.subscriptions.has("*")) {
4047
+ this.subscriptions.set("*", /* @__PURE__ */ new Set());
4048
+ }
4049
+ this.subscriptions.get("*").add(callback);
4050
+ return () => {
4051
+ const callbacks = this.subscriptions.get("*");
4052
+ if (callbacks) {
4053
+ callbacks.delete(callback);
4054
+ if (callbacks.size === 0) {
4055
+ this.subscriptions.delete("*");
4056
+ }
4057
+ }
4058
+ };
4059
+ }
4060
+ /**
4061
+ * Set a fallback poll function to use when WebSocket is disconnected.
4062
+ * The poll function will be called at the specified interval while WS is down.
4063
+ */
4064
+ setFallbackPoll(fn, intervalMs) {
4065
+ this.fallbackPollFn = fn;
4066
+ this.fallbackPollIntervalMs = intervalMs;
4067
+ if (!this.isConnected()) {
4068
+ this.startFallbackPolling();
4069
+ }
4070
+ }
4071
+ /**
4072
+ * Connect to the WebSocket server.
4073
+ */
4074
+ connect() {
4075
+ if (this.destroyed) return;
4076
+ if (this.ws?.readyState === WebSocketReadyState.OPEN || this.isConnecting) {
4077
+ return;
4078
+ }
4079
+ this.isConnecting = true;
4080
+ try {
4081
+ this.log(`Connecting to ${this.wsUrl}...`);
4082
+ this.ws = this.createWebSocket(this.wsUrl);
4083
+ this.ws.onopen = () => {
4084
+ this.log("WebSocket connected");
4085
+ this.isConnecting = false;
4086
+ this.connectionOpenedAt = Date.now();
4087
+ const names = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
4088
+ if (names.length > 0) {
4089
+ this.sendSubscribe(names);
4090
+ }
4091
+ this.startPingInterval();
4092
+ this.stopFallbackPolling();
4093
+ };
4094
+ this.ws.onmessage = (event) => {
4095
+ this.handleMessage(event.data);
4096
+ };
4097
+ this.ws.onclose = () => {
4098
+ const connectionDuration = this.connectionOpenedAt > 0 ? Date.now() - this.connectionOpenedAt : 0;
4099
+ const wasStable = connectionDuration >= this.minStableConnectionMs;
4100
+ this.log(`WebSocket closed (duration: ${Math.round(connectionDuration / 1e3)}s)`);
4101
+ this.isConnecting = false;
4102
+ this.connectionOpenedAt = 0;
4103
+ this.stopPingInterval();
4104
+ if (wasStable) {
4105
+ this.reconnectAttempts = 0;
4106
+ }
4107
+ this.startFallbackPolling();
4108
+ this.scheduleReconnect();
4109
+ };
4110
+ this.ws.onerror = () => {
4111
+ this.log("WebSocket error");
4112
+ this.isConnecting = false;
4113
+ };
4114
+ } catch (e) {
4115
+ this.log(`Failed to connect: ${e}`);
4116
+ this.isConnecting = false;
4117
+ this.startFallbackPolling();
4118
+ this.scheduleReconnect();
4119
+ }
4120
+ }
4121
+ /**
4122
+ * Disconnect from the WebSocket server and clean up.
4123
+ */
4124
+ disconnect() {
4125
+ this.destroyed = true;
4126
+ if (this.reconnectTimeout) {
4127
+ clearTimeout(this.reconnectTimeout);
4128
+ this.reconnectTimeout = null;
4129
+ }
4130
+ this.stopPingInterval();
4131
+ this.stopFallbackPolling();
4132
+ if (this.ws) {
4133
+ this.ws.onopen = null;
4134
+ this.ws.onclose = null;
4135
+ this.ws.onerror = null;
4136
+ this.ws.onmessage = null;
4137
+ this.ws.close();
4138
+ this.ws = null;
4139
+ }
4140
+ this.isConnecting = false;
4141
+ this.reconnectAttempts = 0;
4142
+ }
4143
+ /**
4144
+ * Check if connected to the WebSocket server.
4145
+ */
4146
+ isConnected() {
4147
+ return this.ws?.readyState === WebSocketReadyState.OPEN;
4148
+ }
4149
+ // ---------------------------------------------------------------------------
4150
+ // Internal: Message Handling
4151
+ // ---------------------------------------------------------------------------
4152
+ handleMessage(data) {
4153
+ try {
4154
+ const message = JSON.parse(data);
4155
+ switch (message.type) {
4156
+ case "update":
4157
+ if (message.name && message.sequence !== void 0) {
4158
+ this.notifySubscribers({
4159
+ type: "update",
4160
+ name: message.name,
4161
+ sequence: message.sequence,
4162
+ cid: message.cid ?? "",
4163
+ timestamp: message.timestamp || (/* @__PURE__ */ new Date()).toISOString()
4164
+ });
4165
+ }
4166
+ break;
4167
+ case "subscribed":
4168
+ this.log(`Subscribed to ${message.names?.length || 0} names`);
4169
+ break;
4170
+ case "unsubscribed":
4171
+ this.log(`Unsubscribed from ${message.names?.length || 0} names`);
4172
+ break;
4173
+ case "pong":
4174
+ break;
4175
+ case "error":
4176
+ this.log(`Server error: ${message.message}`);
4177
+ break;
4178
+ default:
4179
+ break;
4180
+ }
4181
+ } catch {
4182
+ this.log("Failed to parse message");
4183
+ }
4184
+ }
4185
+ notifySubscribers(update) {
4186
+ const callbacks = this.subscriptions.get(update.name);
4187
+ if (callbacks) {
4188
+ this.log(`Update: ${update.name.slice(0, 16)}... seq=${update.sequence}`);
4189
+ for (const callback of callbacks) {
4190
+ try {
4191
+ callback(update);
4192
+ } catch {
4193
+ }
4194
+ }
4195
+ }
4196
+ const globalCallbacks = this.subscriptions.get("*");
4197
+ if (globalCallbacks) {
4198
+ for (const callback of globalCallbacks) {
4199
+ try {
4200
+ callback(update);
4201
+ } catch {
4202
+ }
4203
+ }
4204
+ }
4205
+ }
4206
+ // ---------------------------------------------------------------------------
4207
+ // Internal: WebSocket Send
4208
+ // ---------------------------------------------------------------------------
4209
+ sendSubscribe(names) {
4210
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4211
+ this.ws.send(JSON.stringify({ action: "subscribe", names }));
4212
+ }
4213
+ }
4214
+ sendUnsubscribe(names) {
4215
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4216
+ this.ws.send(JSON.stringify({ action: "unsubscribe", names }));
4217
+ }
4218
+ }
4219
+ // ---------------------------------------------------------------------------
4220
+ // Internal: Reconnection
4221
+ // ---------------------------------------------------------------------------
4222
+ /**
4223
+ * Schedule reconnection with exponential backoff.
4224
+ * Sequence: 5s, 10s, 20s, 40s, 60s (capped)
4225
+ */
4226
+ scheduleReconnect() {
4227
+ if (this.destroyed || this.reconnectTimeout) return;
4228
+ const realSubscriptions = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
4229
+ if (realSubscriptions.length === 0) return;
4230
+ this.reconnectAttempts++;
4231
+ const delay = Math.min(
4232
+ this.initialReconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
4233
+ this.maxReconnectDelayMs
4234
+ );
4235
+ this.log(`Reconnecting in ${(delay / 1e3).toFixed(1)}s (attempt ${this.reconnectAttempts})...`);
4236
+ this.reconnectTimeout = setTimeout(() => {
4237
+ this.reconnectTimeout = null;
4238
+ this.connect();
4239
+ }, delay);
4240
+ }
4241
+ // ---------------------------------------------------------------------------
4242
+ // Internal: Keepalive
4243
+ // ---------------------------------------------------------------------------
4244
+ startPingInterval() {
4245
+ this.stopPingInterval();
4246
+ this.pingInterval = setInterval(() => {
4247
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4248
+ this.ws.send(JSON.stringify({ action: "ping" }));
4249
+ }
4250
+ }, this.pingIntervalMs);
4251
+ }
4252
+ stopPingInterval() {
4253
+ if (this.pingInterval) {
4254
+ clearInterval(this.pingInterval);
4255
+ this.pingInterval = null;
4256
+ }
4257
+ }
4258
+ // ---------------------------------------------------------------------------
4259
+ // Internal: Fallback Polling
4260
+ // ---------------------------------------------------------------------------
4261
+ startFallbackPolling() {
4262
+ if (this.fallbackPollInterval || !this.fallbackPollFn || this.destroyed) return;
4263
+ this.log(`Starting fallback polling (${this.fallbackPollIntervalMs / 1e3}s interval)`);
4264
+ this.fallbackPollFn().catch(() => {
4265
+ });
4266
+ this.fallbackPollInterval = setInterval(() => {
4267
+ this.fallbackPollFn?.().catch(() => {
4268
+ });
4269
+ }, this.fallbackPollIntervalMs);
4270
+ }
4271
+ stopFallbackPolling() {
4272
+ if (this.fallbackPollInterval) {
4273
+ clearInterval(this.fallbackPollInterval);
4274
+ this.fallbackPollInterval = null;
4275
+ }
4276
+ }
4277
+ // ---------------------------------------------------------------------------
4278
+ // Internal: Logging
4279
+ // ---------------------------------------------------------------------------
4280
+ log(message) {
4281
+ if (this.debugEnabled) {
4282
+ console.log(`[IPNS-WS] ${message}`);
4283
+ }
4284
+ }
4285
+ };
4286
+
4287
+ // impl/shared/ipfs/write-behind-buffer.ts
4288
+ var AsyncSerialQueue = class {
4289
+ tail = Promise.resolve();
4290
+ /** Enqueue an async operation. Returns when it completes. */
4291
+ enqueue(fn) {
4292
+ let resolve;
4293
+ let reject;
4294
+ const promise = new Promise((res, rej) => {
4295
+ resolve = res;
4296
+ reject = rej;
4297
+ });
4298
+ this.tail = this.tail.then(
4299
+ () => fn().then(resolve, reject),
4300
+ () => fn().then(resolve, reject)
4301
+ );
4302
+ return promise;
4303
+ }
4304
+ };
4305
+ var WriteBuffer = class {
4306
+ /** Full TXF data from save() calls — latest wins */
4307
+ txfData = null;
4308
+ /** Individual token mutations: key -> { op: 'save'|'delete', data? } */
4309
+ tokenMutations = /* @__PURE__ */ new Map();
4310
+ get isEmpty() {
4311
+ return this.txfData === null && this.tokenMutations.size === 0;
4312
+ }
4313
+ clear() {
4314
+ this.txfData = null;
4315
+ this.tokenMutations.clear();
4316
+ }
4317
+ /**
4318
+ * Merge another buffer's contents into this one (for rollback).
4319
+ * Existing (newer) mutations in `this` take precedence over `other`.
4320
+ */
4321
+ mergeFrom(other) {
4322
+ if (other.txfData && !this.txfData) {
4323
+ this.txfData = other.txfData;
4324
+ }
4325
+ for (const [id, mutation] of other.tokenMutations) {
4326
+ if (!this.tokenMutations.has(id)) {
4327
+ this.tokenMutations.set(id, mutation);
4328
+ }
4329
+ }
4330
+ }
4331
+ };
4332
+
4333
+ // impl/shared/ipfs/ipfs-storage-provider.ts
4334
+ var IpfsStorageProvider = class {
4335
+ id = "ipfs";
4336
+ name = "IPFS Storage";
4337
+ type = "p2p";
4338
+ status = "disconnected";
4339
+ identity = null;
4340
+ ipnsKeyPair = null;
4341
+ ipnsName = null;
4342
+ ipnsSequenceNumber = 0n;
4343
+ lastCid = null;
4344
+ lastKnownRemoteSequence = 0n;
4345
+ dataVersion = 0;
4346
+ /**
4347
+ * The CID currently stored on the sidecar for this IPNS name.
4348
+ * Used as `_meta.lastCid` in the next save to satisfy chain validation.
4349
+ * - null for bootstrap (first-ever save)
4350
+ * - set after every successful save() or load()
4351
+ */
4352
+ remoteCid = null;
4353
+ cache;
4354
+ httpClient;
4355
+ statePersistence;
4356
+ eventCallbacks = /* @__PURE__ */ new Set();
4357
+ debug;
4358
+ ipnsLifetimeMs;
4359
+ /** WebSocket factory for push subscriptions */
4360
+ createWebSocket;
4361
+ /** Override WS URL */
4362
+ wsUrl;
4363
+ /** Fallback poll interval (default: 90000) */
4364
+ fallbackPollIntervalMs;
4365
+ /** IPNS subscription client for push notifications */
4366
+ subscriptionClient = null;
4367
+ /** Unsubscribe function from subscription client */
4368
+ subscriptionUnsubscribe = null;
4369
+ /** In-memory buffer for individual token save/delete calls */
4370
+ tokenBuffer = /* @__PURE__ */ new Map();
4371
+ deletedTokenIds = /* @__PURE__ */ new Set();
4372
+ /** Write-behind buffer: serializes flush / sync / shutdown */
4373
+ flushQueue = new AsyncSerialQueue();
4374
+ /** Pending mutations not yet flushed to IPFS */
4375
+ pendingBuffer = new WriteBuffer();
4376
+ /** Debounce timer for background flush */
4377
+ flushTimer = null;
4378
+ /** Debounce interval in ms */
4379
+ flushDebounceMs;
4380
+ /** Set to true during shutdown to prevent new flushes */
4381
+ isShuttingDown = false;
4382
+ constructor(config, statePersistence) {
4383
+ const gateways = config?.gateways ?? getIpfsGatewayUrls();
4384
+ this.debug = config?.debug ?? false;
4385
+ this.ipnsLifetimeMs = config?.ipnsLifetimeMs ?? 99 * 365 * 24 * 60 * 60 * 1e3;
4386
+ this.flushDebounceMs = config?.flushDebounceMs ?? 2e3;
4387
+ this.cache = new IpfsCache({
4388
+ ipnsTtlMs: config?.ipnsCacheTtlMs,
4389
+ failureCooldownMs: config?.circuitBreakerCooldownMs,
4390
+ failureThreshold: config?.circuitBreakerThreshold,
4391
+ knownFreshWindowMs: config?.knownFreshWindowMs
4392
+ });
4393
+ this.httpClient = new IpfsHttpClient({
4394
+ gateways,
4395
+ fetchTimeoutMs: config?.fetchTimeoutMs,
4396
+ resolveTimeoutMs: config?.resolveTimeoutMs,
4397
+ publishTimeoutMs: config?.publishTimeoutMs,
4398
+ connectivityTimeoutMs: config?.connectivityTimeoutMs,
4399
+ debug: this.debug
4400
+ }, this.cache);
4401
+ this.statePersistence = statePersistence ?? new InMemoryIpfsStatePersistence();
4402
+ this.createWebSocket = config?.createWebSocket;
4403
+ this.wsUrl = config?.wsUrl;
4404
+ this.fallbackPollIntervalMs = config?.fallbackPollIntervalMs ?? 9e4;
4405
+ }
4406
+ // ---------------------------------------------------------------------------
4407
+ // BaseProvider interface
4408
+ // ---------------------------------------------------------------------------
4409
+ async connect() {
4410
+ await this.initialize();
4411
+ }
4412
+ async disconnect() {
4413
+ await this.shutdown();
4414
+ }
4415
+ isConnected() {
4416
+ return this.status === "connected";
4417
+ }
4418
+ getStatus() {
4419
+ return this.status;
4420
+ }
4421
+ // ---------------------------------------------------------------------------
4422
+ // Identity & Initialization
4423
+ // ---------------------------------------------------------------------------
4424
+ setIdentity(identity) {
4425
+ this.identity = identity;
4426
+ }
4427
+ async initialize() {
4428
+ if (!this.identity) {
4429
+ this.log("Cannot initialize: no identity set");
4430
+ return false;
4431
+ }
4432
+ this.status = "connecting";
4433
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
4434
+ try {
4435
+ const { keyPair, ipnsName } = await deriveIpnsIdentity(this.identity.privateKey);
4436
+ this.ipnsKeyPair = keyPair;
4437
+ this.ipnsName = ipnsName;
4438
+ this.log(`IPNS name derived: ${ipnsName}`);
4439
+ const persisted = await this.statePersistence.load(ipnsName);
4440
+ if (persisted) {
4441
+ this.ipnsSequenceNumber = BigInt(persisted.sequenceNumber);
4442
+ this.lastCid = persisted.lastCid;
4443
+ this.remoteCid = persisted.lastCid;
4444
+ this.dataVersion = persisted.version;
4445
+ this.log(`Loaded persisted state: seq=${this.ipnsSequenceNumber}, cid=${this.lastCid}`);
4446
+ }
4447
+ if (this.createWebSocket) {
4448
+ try {
4449
+ const wsUrlFinal = this.wsUrl ?? this.deriveWsUrl();
4450
+ if (wsUrlFinal) {
4451
+ this.subscriptionClient = new IpnsSubscriptionClient({
4452
+ wsUrl: wsUrlFinal,
4453
+ createWebSocket: this.createWebSocket,
4454
+ debug: this.debug
4455
+ });
4456
+ this.subscriptionUnsubscribe = this.subscriptionClient.subscribe(
4457
+ ipnsName,
4458
+ (update) => {
4459
+ this.log(`Push update: seq=${update.sequence}, cid=${update.cid}`);
4460
+ this.emitEvent({
4461
+ type: "storage:remote-updated",
4462
+ timestamp: Date.now(),
4463
+ data: { name: update.name, sequence: update.sequence, cid: update.cid }
4464
+ });
4465
+ }
4466
+ );
4467
+ this.subscriptionClient.setFallbackPoll(
4468
+ () => this.pollForRemoteChanges(),
4469
+ this.fallbackPollIntervalMs
4470
+ );
4471
+ this.subscriptionClient.connect();
4472
+ }
4473
+ } catch (wsError) {
4474
+ this.log(`Failed to set up IPNS subscription: ${wsError}`);
4475
+ }
4476
+ }
4477
+ this.httpClient.findHealthyGateways().then((healthy) => {
4478
+ if (healthy.length > 0) {
4479
+ this.log(`${healthy.length} healthy gateway(s) found`);
4480
+ } else {
4481
+ this.log("Warning: no healthy gateways found");
4482
+ }
4483
+ }).catch(() => {
4484
+ });
4485
+ this.isShuttingDown = false;
4486
+ this.status = "connected";
4487
+ this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
4488
+ return true;
4489
+ } catch (error) {
4490
+ this.status = "error";
4491
+ this.emitEvent({
4492
+ type: "storage:error",
4493
+ timestamp: Date.now(),
4494
+ error: error instanceof Error ? error.message : String(error)
4495
+ });
4496
+ return false;
4497
+ }
4498
+ }
4499
+ async shutdown() {
4500
+ this.isShuttingDown = true;
4501
+ if (this.flushTimer) {
4502
+ clearTimeout(this.flushTimer);
4503
+ this.flushTimer = null;
4504
+ }
4505
+ await this.flushQueue.enqueue(async () => {
4506
+ if (!this.pendingBuffer.isEmpty) {
4507
+ try {
4508
+ await this.executeFlush();
4509
+ } catch {
4510
+ this.log("Final flush on shutdown failed (data may be lost)");
4511
+ }
4512
+ }
4513
+ });
4514
+ if (this.subscriptionUnsubscribe) {
4515
+ this.subscriptionUnsubscribe();
4516
+ this.subscriptionUnsubscribe = null;
4517
+ }
4518
+ if (this.subscriptionClient) {
4519
+ this.subscriptionClient.disconnect();
4520
+ this.subscriptionClient = null;
4521
+ }
4522
+ this.cache.clear();
4523
+ this.status = "disconnected";
4524
+ }
4525
+ // ---------------------------------------------------------------------------
4526
+ // Save (non-blocking — buffers data for async flush)
4527
+ // ---------------------------------------------------------------------------
4528
+ async save(data) {
4529
+ if (!this.ipnsKeyPair || !this.ipnsName) {
4530
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
4531
+ }
4532
+ this.pendingBuffer.txfData = data;
4533
+ this.scheduleFlush();
4534
+ return { success: true, timestamp: Date.now() };
4535
+ }
4536
+ // ---------------------------------------------------------------------------
4537
+ // Internal: Blocking save (used by sync and executeFlush)
4538
+ // ---------------------------------------------------------------------------
4539
+ /**
4540
+ * Perform the actual upload + IPNS publish synchronously.
4541
+ * Called by executeFlush() and sync() — never by public save().
4542
+ */
4543
+ async _doSave(data) {
4544
+ if (!this.ipnsKeyPair || !this.ipnsName) {
4545
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
4546
+ }
4547
+ this.emitEvent({ type: "storage:saving", timestamp: Date.now() });
4548
+ try {
4549
+ this.dataVersion++;
4550
+ const metaUpdate = {
4551
+ ...data._meta,
4552
+ version: this.dataVersion,
4553
+ ipnsName: this.ipnsName,
4554
+ updatedAt: Date.now()
4555
+ };
4556
+ if (this.remoteCid) {
4557
+ metaUpdate.lastCid = this.remoteCid;
4558
+ }
4559
+ const updatedData = { ...data, _meta: metaUpdate };
4560
+ for (const [tokenId, tokenData] of this.tokenBuffer) {
4561
+ if (!this.deletedTokenIds.has(tokenId)) {
4562
+ updatedData[tokenId] = tokenData;
4563
+ }
4564
+ }
4565
+ for (const tokenId of this.deletedTokenIds) {
4566
+ delete updatedData[tokenId];
4567
+ }
4568
+ const { cid } = await this.httpClient.upload(updatedData);
4569
+ this.log(`Content uploaded: CID=${cid}`);
4570
+ const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence ? this.ipnsSequenceNumber : this.lastKnownRemoteSequence;
4571
+ const newSeq = baseSeq + 1n;
4572
+ const marshalledRecord = await createSignedRecord(
4573
+ this.ipnsKeyPair,
4574
+ cid,
4575
+ newSeq,
4576
+ this.ipnsLifetimeMs
4577
+ );
4578
+ const publishResult = await this.httpClient.publishIpns(
4579
+ this.ipnsName,
4580
+ marshalledRecord
4581
+ );
4582
+ if (!publishResult.success) {
4583
+ this.dataVersion--;
4584
+ this.log(`IPNS publish failed: ${publishResult.error}`);
4585
+ return {
4586
+ success: false,
4587
+ error: publishResult.error ?? "IPNS publish failed",
4588
+ timestamp: Date.now()
4589
+ };
4590
+ }
4591
+ this.ipnsSequenceNumber = newSeq;
4592
+ this.lastCid = cid;
4593
+ this.remoteCid = cid;
4594
+ this.cache.setIpnsRecord(this.ipnsName, {
4595
+ cid,
4596
+ sequence: newSeq,
4597
+ gateway: "local"
4598
+ });
4599
+ this.cache.setContent(cid, updatedData);
4600
+ this.cache.markIpnsFresh(this.ipnsName);
4601
+ await this.statePersistence.save(this.ipnsName, {
4602
+ sequenceNumber: newSeq.toString(),
4603
+ lastCid: cid,
4604
+ version: this.dataVersion
4605
+ });
4606
+ this.deletedTokenIds.clear();
4607
+ this.emitEvent({
4608
+ type: "storage:saved",
4609
+ timestamp: Date.now(),
4610
+ data: { cid, sequence: newSeq.toString() }
4611
+ });
4612
+ this.log(`Saved: CID=${cid}, seq=${newSeq}`);
4613
+ return { success: true, cid, timestamp: Date.now() };
4614
+ } catch (error) {
4615
+ this.dataVersion--;
4616
+ const errorMessage = error instanceof Error ? error.message : String(error);
4617
+ this.emitEvent({
4618
+ type: "storage:error",
4619
+ timestamp: Date.now(),
4620
+ error: errorMessage
4621
+ });
4622
+ return { success: false, error: errorMessage, timestamp: Date.now() };
4623
+ }
4624
+ }
4625
+ // ---------------------------------------------------------------------------
4626
+ // Write-behind buffer: scheduling and flushing
4627
+ // ---------------------------------------------------------------------------
4628
+ /**
4629
+ * Schedule a debounced background flush.
4630
+ * Resets the timer on each call so rapid mutations coalesce.
4631
+ */
4632
+ scheduleFlush() {
4633
+ if (this.isShuttingDown) return;
4634
+ if (this.flushTimer) clearTimeout(this.flushTimer);
4635
+ this.flushTimer = setTimeout(() => {
4636
+ this.flushTimer = null;
4637
+ this.flushQueue.enqueue(() => this.executeFlush()).catch((err) => {
4638
+ this.log(`Background flush failed: ${err}`);
4639
+ });
4640
+ }, this.flushDebounceMs);
4641
+ }
4642
+ /**
4643
+ * Execute a flush of the pending buffer to IPFS.
4644
+ * Runs inside AsyncSerialQueue for concurrency safety.
4645
+ */
4646
+ async executeFlush() {
4647
+ if (this.pendingBuffer.isEmpty) return;
4648
+ const active = this.pendingBuffer;
4649
+ this.pendingBuffer = new WriteBuffer();
4650
+ try {
4651
+ const baseData = active.txfData ?? {
4652
+ _meta: { version: 0, address: this.identity?.directAddress ?? "", formatVersion: "2.0", updatedAt: 0 }
4653
+ };
4654
+ const result = await this._doSave(baseData);
4655
+ if (!result.success) {
4656
+ throw new Error(result.error ?? "Save failed");
4657
+ }
4658
+ this.log(`Flushed successfully: CID=${result.cid}`);
4659
+ } catch (error) {
4660
+ this.pendingBuffer.mergeFrom(active);
4661
+ const msg = error instanceof Error ? error.message : String(error);
4662
+ this.log(`Flush failed (will retry): ${msg}`);
4663
+ this.scheduleFlush();
4664
+ throw error;
4665
+ }
4666
+ }
4667
+ // ---------------------------------------------------------------------------
4668
+ // Load
4669
+ // ---------------------------------------------------------------------------
4670
+ async load(identifier) {
4671
+ if (!this.ipnsName && !identifier) {
4672
+ return { success: false, error: "Not initialized", source: "local", timestamp: Date.now() };
4673
+ }
4674
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
4675
+ try {
4676
+ if (identifier) {
4677
+ const data2 = await this.httpClient.fetchContent(identifier);
4678
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
4679
+ }
4680
+ const ipnsName = this.ipnsName;
4681
+ if (this.cache.isIpnsKnownFresh(ipnsName)) {
4682
+ const cached = this.cache.getIpnsRecordIgnoreTtl(ipnsName);
4683
+ if (cached) {
4684
+ const content = this.cache.getContent(cached.cid);
4685
+ if (content) {
4686
+ this.log("Using known-fresh cached data");
4687
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4688
+ }
4689
+ }
4690
+ }
4691
+ const cachedRecord = this.cache.getIpnsRecord(ipnsName);
4692
+ if (cachedRecord) {
4693
+ const content = this.cache.getContent(cachedRecord.cid);
4694
+ if (content) {
4695
+ this.log("IPNS cache hit");
4696
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4697
+ }
4698
+ try {
4699
+ const data2 = await this.httpClient.fetchContent(cachedRecord.cid);
4700
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
4701
+ } catch {
4702
+ }
4703
+ }
4704
+ const { best } = await this.httpClient.resolveIpns(ipnsName);
4705
+ if (!best) {
4706
+ this.log("IPNS record not found (new wallet?)");
4707
+ return { success: false, error: "IPNS record not found", source: "remote", timestamp: Date.now() };
4708
+ }
4709
+ if (best.sequence > this.lastKnownRemoteSequence) {
4710
+ this.lastKnownRemoteSequence = best.sequence;
4711
+ }
4712
+ this.remoteCid = best.cid;
4713
+ const data = await this.httpClient.fetchContent(best.cid);
4714
+ const remoteVersion = data?._meta?.version;
4715
+ if (typeof remoteVersion === "number" && remoteVersion > this.dataVersion) {
4716
+ this.dataVersion = remoteVersion;
4717
+ }
4718
+ this.populateTokenBuffer(data);
4719
+ this.emitEvent({
4720
+ type: "storage:loaded",
4721
+ timestamp: Date.now(),
4722
+ data: { cid: best.cid, sequence: best.sequence.toString() }
4723
+ });
4724
+ return { success: true, data, source: "remote", timestamp: Date.now() };
4725
+ } catch (error) {
4726
+ if (this.ipnsName) {
4727
+ const cached = this.cache.getIpnsRecordIgnoreTtl(this.ipnsName);
4728
+ if (cached) {
4729
+ const content = this.cache.getContent(cached.cid);
4730
+ if (content) {
4731
+ this.log("Network error, returning stale cache");
4732
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4733
+ }
4734
+ }
4735
+ }
4736
+ const errorMessage = error instanceof Error ? error.message : String(error);
4737
+ this.emitEvent({
4738
+ type: "storage:error",
4739
+ timestamp: Date.now(),
4740
+ error: errorMessage
4741
+ });
4742
+ return { success: false, error: errorMessage, source: "remote", timestamp: Date.now() };
4743
+ }
4744
+ }
4745
+ // ---------------------------------------------------------------------------
4746
+ // Sync (enters serial queue to avoid concurrent IPNS conflicts)
4747
+ // ---------------------------------------------------------------------------
4748
+ async sync(localData) {
4749
+ return this.flushQueue.enqueue(async () => {
4750
+ if (this.flushTimer) {
4751
+ clearTimeout(this.flushTimer);
4752
+ this.flushTimer = null;
4753
+ }
4754
+ this.emitEvent({ type: "sync:started", timestamp: Date.now() });
4755
+ try {
4756
+ this.pendingBuffer.clear();
4757
+ const remoteResult = await this.load();
4758
+ if (!remoteResult.success || !remoteResult.data) {
4759
+ this.log("No remote data found, uploading local data");
4760
+ const saveResult2 = await this._doSave(localData);
4761
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
4762
+ return {
4763
+ success: saveResult2.success,
4764
+ merged: this.enrichWithTokenBuffer(localData),
4765
+ added: 0,
4766
+ removed: 0,
4767
+ conflicts: 0,
4768
+ error: saveResult2.error
4769
+ };
4770
+ }
4771
+ const remoteData = remoteResult.data;
4772
+ const localVersion = localData._meta?.version ?? 0;
4773
+ const remoteVersion = remoteData._meta?.version ?? 0;
4774
+ if (localVersion === remoteVersion && this.lastCid) {
4775
+ this.log("Data is in sync (same version)");
4776
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
4777
+ return {
4778
+ success: true,
4779
+ merged: this.enrichWithTokenBuffer(localData),
4780
+ added: 0,
4781
+ removed: 0,
4782
+ conflicts: 0
4783
+ };
4784
+ }
4785
+ this.log(`Merging: local v${localVersion} <-> remote v${remoteVersion}`);
4786
+ const { merged, added, removed, conflicts } = mergeTxfData(localData, remoteData);
4787
+ if (conflicts > 0) {
4788
+ this.emitEvent({
4789
+ type: "sync:conflict",
4790
+ timestamp: Date.now(),
4791
+ data: { conflicts }
4792
+ });
4793
+ }
4794
+ const saveResult = await this._doSave(merged);
4795
+ this.emitEvent({
4796
+ type: "sync:completed",
4797
+ timestamp: Date.now(),
4798
+ data: { added, removed, conflicts }
4799
+ });
4800
+ return {
4801
+ success: saveResult.success,
4802
+ merged: this.enrichWithTokenBuffer(merged),
4803
+ added,
4804
+ removed,
4805
+ conflicts,
4806
+ error: saveResult.error
4807
+ };
4808
+ } catch (error) {
4809
+ const errorMessage = error instanceof Error ? error.message : String(error);
4810
+ this.emitEvent({
4811
+ type: "sync:error",
4812
+ timestamp: Date.now(),
4813
+ error: errorMessage
4814
+ });
4815
+ return {
4816
+ success: false,
4817
+ added: 0,
4818
+ removed: 0,
4819
+ conflicts: 0,
4820
+ error: errorMessage
4821
+ };
4822
+ }
4823
+ });
4824
+ }
4825
+ // ---------------------------------------------------------------------------
4826
+ // Private Helpers
4827
+ // ---------------------------------------------------------------------------
4828
+ /**
4829
+ * Enrich TXF data with individually-buffered tokens before returning to caller.
4830
+ * PaymentsModule.createStorageData() passes empty tokens (they're stored via
4831
+ * saveToken()), but loadFromStorageData() needs them in the merged result.
4832
+ */
4833
+ enrichWithTokenBuffer(data) {
4834
+ if (this.tokenBuffer.size === 0) return data;
4835
+ const enriched = { ...data };
4836
+ for (const [tokenId, tokenData] of this.tokenBuffer) {
4837
+ if (!this.deletedTokenIds.has(tokenId)) {
4838
+ enriched[tokenId] = tokenData;
4839
+ }
4840
+ }
4841
+ return enriched;
4842
+ }
4843
+ // ---------------------------------------------------------------------------
4844
+ // Optional Methods
4845
+ // ---------------------------------------------------------------------------
4846
+ async exists() {
4847
+ if (!this.ipnsName) return false;
4848
+ const cached = this.cache.getIpnsRecord(this.ipnsName);
4849
+ if (cached) return true;
4850
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
4851
+ return best !== null;
4852
+ }
4853
+ async clear() {
4854
+ if (!this.ipnsKeyPair || !this.ipnsName) return false;
4855
+ this.pendingBuffer.clear();
4856
+ if (this.flushTimer) {
4857
+ clearTimeout(this.flushTimer);
4858
+ this.flushTimer = null;
4859
+ }
4860
+ const emptyData = {
4861
+ _meta: {
4862
+ version: 0,
4863
+ address: this.identity?.directAddress ?? "",
4864
+ ipnsName: this.ipnsName,
4865
+ formatVersion: "2.0",
4866
+ updatedAt: Date.now()
4867
+ }
4868
+ };
4869
+ const result = await this._doSave(emptyData);
4870
+ if (result.success) {
4871
+ this.cache.clear();
4872
+ this.tokenBuffer.clear();
4873
+ this.deletedTokenIds.clear();
4874
+ await this.statePersistence.clear(this.ipnsName);
4875
+ }
4876
+ return result.success;
4877
+ }
4878
+ onEvent(callback) {
4879
+ this.eventCallbacks.add(callback);
4880
+ return () => {
4881
+ this.eventCallbacks.delete(callback);
4882
+ };
4883
+ }
4884
+ async saveToken(tokenId, tokenData) {
4885
+ this.pendingBuffer.tokenMutations.set(tokenId, { op: "save", data: tokenData });
4886
+ this.tokenBuffer.set(tokenId, tokenData);
4887
+ this.deletedTokenIds.delete(tokenId);
4888
+ this.scheduleFlush();
4889
+ }
4890
+ async getToken(tokenId) {
4891
+ if (this.deletedTokenIds.has(tokenId)) return null;
4892
+ return this.tokenBuffer.get(tokenId) ?? null;
4893
+ }
4894
+ async listTokenIds() {
4895
+ return Array.from(this.tokenBuffer.keys()).filter(
4896
+ (id) => !this.deletedTokenIds.has(id)
4897
+ );
4898
+ }
4899
+ async deleteToken(tokenId) {
4900
+ this.pendingBuffer.tokenMutations.set(tokenId, { op: "delete" });
4901
+ this.tokenBuffer.delete(tokenId);
4902
+ this.deletedTokenIds.add(tokenId);
4903
+ this.scheduleFlush();
4904
+ }
4905
+ // ---------------------------------------------------------------------------
4906
+ // Public Accessors
4907
+ // ---------------------------------------------------------------------------
4908
+ getIpnsName() {
4909
+ return this.ipnsName;
4910
+ }
4911
+ getLastCid() {
4912
+ return this.lastCid;
4913
+ }
4914
+ getSequenceNumber() {
4915
+ return this.ipnsSequenceNumber;
4916
+ }
4917
+ getDataVersion() {
4918
+ return this.dataVersion;
4919
+ }
4920
+ getRemoteCid() {
4921
+ return this.remoteCid;
4922
+ }
4923
+ // ---------------------------------------------------------------------------
4924
+ // Testing helper: wait for pending flush to complete
4925
+ // ---------------------------------------------------------------------------
4926
+ /**
4927
+ * Wait for the pending flush timer to fire and the flush operation to
4928
+ * complete. Useful in tests to await background writes.
4929
+ * Returns immediately if no flush is pending.
4930
+ */
4931
+ async waitForFlush() {
4932
+ if (this.flushTimer) {
4933
+ clearTimeout(this.flushTimer);
4934
+ this.flushTimer = null;
4935
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
4936
+ });
4937
+ } else if (!this.pendingBuffer.isEmpty) {
4938
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
4939
+ });
4940
+ } else {
4941
+ await this.flushQueue.enqueue(async () => {
4942
+ });
4943
+ }
4944
+ }
4945
+ // ---------------------------------------------------------------------------
4946
+ // Internal: Push Subscription Helpers
4947
+ // ---------------------------------------------------------------------------
4948
+ /**
4949
+ * Derive WebSocket URL from the first configured gateway.
4950
+ * Converts https://host → wss://host/ws/ipns
4951
+ */
4952
+ deriveWsUrl() {
4953
+ const gateways = this.httpClient.getGateways();
4954
+ if (gateways.length === 0) return null;
4955
+ const gateway = gateways[0];
4956
+ const wsProtocol = gateway.startsWith("https://") ? "wss://" : "ws://";
4957
+ const host = gateway.replace(/^https?:\/\//, "");
4958
+ return `${wsProtocol}${host}/ws/ipns`;
4959
+ }
4960
+ /**
4961
+ * Poll for remote IPNS changes (fallback when WS is unavailable).
4962
+ * Compares remote sequence number with last known and emits event if changed.
4963
+ */
4964
+ async pollForRemoteChanges() {
4965
+ if (!this.ipnsName) return;
4966
+ try {
4967
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
4968
+ if (best && best.sequence > this.lastKnownRemoteSequence) {
4969
+ this.log(`Poll detected remote change: seq=${best.sequence} (was ${this.lastKnownRemoteSequence})`);
4970
+ this.lastKnownRemoteSequence = best.sequence;
4971
+ this.emitEvent({
4972
+ type: "storage:remote-updated",
4973
+ timestamp: Date.now(),
4974
+ data: { name: this.ipnsName, sequence: Number(best.sequence), cid: best.cid }
4975
+ });
4976
+ }
4977
+ } catch {
4978
+ }
4979
+ }
4980
+ // ---------------------------------------------------------------------------
4981
+ // Internal
4982
+ // ---------------------------------------------------------------------------
4983
+ emitEvent(event) {
4984
+ for (const callback of this.eventCallbacks) {
4985
+ try {
4986
+ callback(event);
4987
+ } catch {
4988
+ }
4989
+ }
4990
+ }
4991
+ log(message) {
4992
+ if (this.debug) {
4993
+ console.log(`[IPFS-Storage] ${message}`);
4994
+ }
4995
+ }
4996
+ META_KEYS = /* @__PURE__ */ new Set([
4997
+ "_meta",
4998
+ "_tombstones",
4999
+ "_outbox",
5000
+ "_sent",
5001
+ "_invalid",
5002
+ "_nametag",
5003
+ "_mintOutbox",
5004
+ "_invalidatedNametags",
5005
+ "_integrity"
5006
+ ]);
5007
+ populateTokenBuffer(data) {
5008
+ this.tokenBuffer.clear();
5009
+ this.deletedTokenIds.clear();
5010
+ for (const key of Object.keys(data)) {
5011
+ if (!this.META_KEYS.has(key)) {
5012
+ this.tokenBuffer.set(key, data[key]);
5013
+ }
5014
+ }
5015
+ }
5016
+ };
5017
+
5018
+ // impl/browser/ipfs/browser-ipfs-state-persistence.ts
5019
+ var KEY_PREFIX = "sphere_ipfs_";
5020
+ function seqKey(ipnsName) {
5021
+ return `${KEY_PREFIX}seq_${ipnsName}`;
5022
+ }
5023
+ function cidKey(ipnsName) {
5024
+ return `${KEY_PREFIX}cid_${ipnsName}`;
5025
+ }
5026
+ function verKey(ipnsName) {
5027
+ return `${KEY_PREFIX}ver_${ipnsName}`;
5028
+ }
5029
+ var BrowserIpfsStatePersistence = class {
5030
+ async load(ipnsName) {
5031
+ try {
5032
+ const seq = localStorage.getItem(seqKey(ipnsName));
5033
+ if (!seq) return null;
5034
+ return {
5035
+ sequenceNumber: seq,
5036
+ lastCid: localStorage.getItem(cidKey(ipnsName)),
5037
+ version: parseInt(localStorage.getItem(verKey(ipnsName)) ?? "0", 10)
5038
+ };
5039
+ } catch {
5040
+ return null;
5041
+ }
5042
+ }
5043
+ async save(ipnsName, state) {
5044
+ try {
5045
+ localStorage.setItem(seqKey(ipnsName), state.sequenceNumber);
5046
+ if (state.lastCid) {
5047
+ localStorage.setItem(cidKey(ipnsName), state.lastCid);
5048
+ } else {
5049
+ localStorage.removeItem(cidKey(ipnsName));
5050
+ }
5051
+ localStorage.setItem(verKey(ipnsName), String(state.version));
5052
+ } catch {
5053
+ }
5054
+ }
5055
+ async clear(ipnsName) {
5056
+ try {
5057
+ localStorage.removeItem(seqKey(ipnsName));
5058
+ localStorage.removeItem(cidKey(ipnsName));
5059
+ localStorage.removeItem(verKey(ipnsName));
5060
+ } catch {
5061
+ }
5062
+ }
5063
+ };
5064
+
5065
+ // impl/browser/ipfs/index.ts
5066
+ function createBrowserWebSocket2(url) {
5067
+ return new WebSocket(url);
5068
+ }
5069
+ function createBrowserIpfsStorageProvider(config) {
5070
+ return new IpfsStorageProvider(
5071
+ { ...config, createWebSocket: config?.createWebSocket ?? createBrowserWebSocket2 },
5072
+ new BrowserIpfsStatePersistence()
5073
+ );
5074
+ }
5075
+
3022
5076
  // price/CoinGeckoPriceProvider.ts
3023
5077
  var CoinGeckoPriceProvider = class {
3024
5078
  platform = "coingecko";
@@ -3255,15 +5309,23 @@ function createBrowserProviders(config) {
3255
5309
  const l1Config = resolveL1Config(network, config?.l1);
3256
5310
  const tokenSyncConfig = resolveTokenSyncConfig(network, config?.tokenSync);
3257
5311
  const priceConfig = resolvePriceConfig(config?.price);
5312
+ const storage = createLocalStorageProvider(config?.storage);
5313
+ const ipfsConfig = tokenSyncConfig?.ipfs;
5314
+ const ipfsTokenStorage = ipfsConfig?.enabled ? createBrowserIpfsStorageProvider({
5315
+ gateways: ipfsConfig.gateways,
5316
+ debug: config?.tokenSync?.ipfs?.useDht
5317
+ // reuse debug-like flag
5318
+ }) : void 0;
3258
5319
  return {
3259
- storage: createLocalStorageProvider(config?.storage),
5320
+ storage,
3260
5321
  transport: createNostrTransportProvider({
3261
5322
  relays: transportConfig.relays,
3262
5323
  timeout: transportConfig.timeout,
3263
5324
  autoReconnect: transportConfig.autoReconnect,
3264
5325
  reconnectDelay: transportConfig.reconnectDelay,
3265
5326
  maxReconnectAttempts: transportConfig.maxReconnectAttempts,
3266
- debug: transportConfig.debug
5327
+ debug: transportConfig.debug,
5328
+ storage
3267
5329
  }),
3268
5330
  oracle: createUnicityAggregatorProvider({
3269
5331
  url: oracleConfig.url,
@@ -3276,6 +5338,7 @@ function createBrowserProviders(config) {
3276
5338
  tokenStorage: createIndexedDBTokenStorageProvider(),
3277
5339
  l1: l1Config,
3278
5340
  price: priceConfig ? createPriceProvider(priceConfig) : void 0,
5341
+ ipfsTokenStorage,
3279
5342
  tokenSyncConfig
3280
5343
  };
3281
5344
  }