@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.
@@ -74,7 +74,9 @@ var STORAGE_KEYS_GLOBAL = {
74
74
  /** Nametag cache per address (separate from tracked addresses registry) */
75
75
  ADDRESS_NAMETAGS: "address_nametags",
76
76
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
77
- TRACKED_ADDRESSES: "tracked_addresses"
77
+ TRACKED_ADDRESSES: "tracked_addresses",
78
+ /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
79
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
78
80
  };
79
81
  var STORAGE_KEYS_ADDRESS = {
80
82
  /** Pending transfers for this address */
@@ -86,7 +88,9 @@ var STORAGE_KEYS_ADDRESS = {
86
88
  /** Messages for this address */
87
89
  MESSAGES: "messages",
88
90
  /** Transaction history for this address */
89
- TRANSACTION_HISTORY: "transaction_history"
91
+ TRANSACTION_HISTORY: "transaction_history",
92
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
93
+ PENDING_V5_TOKENS: "pending_v5_tokens"
90
94
  };
91
95
  var STORAGE_KEYS = {
92
96
  ...STORAGE_KEYS_GLOBAL,
@@ -133,6 +137,19 @@ var DEFAULT_IPFS_GATEWAYS = [
133
137
  "https://dweb.link",
134
138
  "https://ipfs.io"
135
139
  ];
140
+ var UNICITY_IPFS_NODES = [
141
+ {
142
+ host: "unicity-ipfs1.dyndns.org",
143
+ peerId: "12D3KooWDKJqEMAhH4nsSSiKtK1VLcas5coUqSPZAfbWbZpxtL4u",
144
+ httpPort: 9080,
145
+ httpsPort: 443
146
+ }
147
+ ];
148
+ function getIpfsGatewayUrls(isSecure) {
149
+ return UNICITY_IPFS_NODES.map(
150
+ (node) => isSecure !== false ? `https://${node.host}` : `http://${node.host}:${node.httpPort}`
151
+ );
152
+ }
136
153
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
137
154
  var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
138
155
  var DEFAULT_ELECTRUM_URL = "wss://fulcrum.alpha.unicity.network:50004";
@@ -1063,8 +1080,21 @@ function publicKeyToAddress(publicKey, prefix = "alpha", witnessVersion = 0) {
1063
1080
  const programBytes = hash160ToBytes(pubKeyHash);
1064
1081
  return encodeBech32(prefix, witnessVersion, programBytes);
1065
1082
  }
1083
+ function hexToBytes(hex) {
1084
+ const matches = hex.match(/../g);
1085
+ if (!matches) {
1086
+ return new Uint8Array(0);
1087
+ }
1088
+ return Uint8Array.from(matches.map((x) => parseInt(x, 16)));
1089
+ }
1066
1090
 
1067
1091
  // transport/websocket.ts
1092
+ var WebSocketReadyState = {
1093
+ CONNECTING: 0,
1094
+ OPEN: 1,
1095
+ CLOSING: 2,
1096
+ CLOSED: 3
1097
+ };
1068
1098
  function defaultUUIDGenerator() {
1069
1099
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
1070
1100
  return crypto.randomUUID();
@@ -1141,6 +1171,9 @@ var NostrTransportProvider = class {
1141
1171
  type = "p2p";
1142
1172
  description = "P2P messaging via Nostr protocol";
1143
1173
  config;
1174
+ storage = null;
1175
+ /** In-memory max event timestamp to avoid read-before-write races in updateLastEventTimestamp. */
1176
+ lastEventTs = 0;
1144
1177
  identity = null;
1145
1178
  keyManager = null;
1146
1179
  status = "disconnected";
@@ -1149,6 +1182,7 @@ var NostrTransportProvider = class {
1149
1182
  nostrClient = null;
1150
1183
  mainSubscriptionId = null;
1151
1184
  // Event handlers
1185
+ processedEventIds = /* @__PURE__ */ new Set();
1152
1186
  messageHandlers = /* @__PURE__ */ new Set();
1153
1187
  transferHandlers = /* @__PURE__ */ new Set();
1154
1188
  paymentRequestHandlers = /* @__PURE__ */ new Set();
@@ -1166,6 +1200,7 @@ var NostrTransportProvider = class {
1166
1200
  createWebSocket: config.createWebSocket,
1167
1201
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
1168
1202
  };
1203
+ this.storage = config.storage ?? null;
1169
1204
  }
1170
1205
  // ===========================================================================
1171
1206
  // BaseProvider Implementation
@@ -1204,7 +1239,14 @@ var NostrTransportProvider = class {
1204
1239
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
1205
1240
  }
1206
1241
  });
1207
- await this.nostrClient.connect(...this.config.relays);
1242
+ await Promise.race([
1243
+ this.nostrClient.connect(...this.config.relays),
1244
+ new Promise(
1245
+ (_, reject) => setTimeout(() => reject(new Error(
1246
+ `Transport connection timed out after ${this.config.timeout}ms`
1247
+ )), this.config.timeout)
1248
+ )
1249
+ ]);
1208
1250
  if (!this.nostrClient.isConnected()) {
1209
1251
  throw new Error("Failed to connect to any relay");
1210
1252
  }
@@ -1212,7 +1254,7 @@ var NostrTransportProvider = class {
1212
1254
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
1213
1255
  this.log("Connected to", this.nostrClient.getConnectedRelays().size, "relays");
1214
1256
  if (this.identity) {
1215
- this.subscribeToEvents();
1257
+ await this.subscribeToEvents();
1216
1258
  }
1217
1259
  } catch (error) {
1218
1260
  this.status = "error";
@@ -1365,11 +1407,18 @@ var NostrTransportProvider = class {
1365
1407
  this.log("NostrClient reconnected to relay:", url);
1366
1408
  }
1367
1409
  });
1368
- await this.nostrClient.connect(...this.config.relays);
1369
- this.subscribeToEvents();
1410
+ await Promise.race([
1411
+ this.nostrClient.connect(...this.config.relays),
1412
+ new Promise(
1413
+ (_, reject) => setTimeout(() => reject(new Error(
1414
+ `Transport reconnection timed out after ${this.config.timeout}ms`
1415
+ )), this.config.timeout)
1416
+ )
1417
+ ]);
1418
+ await this.subscribeToEvents();
1370
1419
  oldClient.disconnect();
1371
1420
  } else if (this.isConnected()) {
1372
- this.subscribeToEvents();
1421
+ await this.subscribeToEvents();
1373
1422
  }
1374
1423
  }
1375
1424
  /**
@@ -1874,6 +1923,12 @@ var NostrTransportProvider = class {
1874
1923
  // Private: Message Handling
1875
1924
  // ===========================================================================
1876
1925
  async handleEvent(event) {
1926
+ if (event.id && this.processedEventIds.has(event.id)) {
1927
+ return;
1928
+ }
1929
+ if (event.id) {
1930
+ this.processedEventIds.add(event.id);
1931
+ }
1877
1932
  this.log("Processing event kind:", event.kind, "id:", event.id?.slice(0, 12));
1878
1933
  try {
1879
1934
  switch (event.kind) {
@@ -1897,10 +1952,31 @@ var NostrTransportProvider = class {
1897
1952
  this.handleBroadcast(event);
1898
1953
  break;
1899
1954
  }
1955
+ if (event.created_at && this.storage && this.keyManager) {
1956
+ const kind = event.kind;
1957
+ if (kind === EVENT_KINDS.DIRECT_MESSAGE || kind === EVENT_KINDS.TOKEN_TRANSFER || kind === EVENT_KINDS.PAYMENT_REQUEST || kind === EVENT_KINDS.PAYMENT_REQUEST_RESPONSE) {
1958
+ this.updateLastEventTimestamp(event.created_at);
1959
+ }
1960
+ }
1900
1961
  } catch (error) {
1901
1962
  this.log("Failed to handle event:", error);
1902
1963
  }
1903
1964
  }
1965
+ /**
1966
+ * Save the max event timestamp to storage (fire-and-forget, no await needed by caller).
1967
+ * Uses in-memory `lastEventTs` to avoid read-before-write race conditions
1968
+ * when multiple events arrive in quick succession.
1969
+ */
1970
+ updateLastEventTimestamp(createdAt) {
1971
+ if (!this.storage || !this.keyManager) return;
1972
+ if (createdAt <= this.lastEventTs) return;
1973
+ this.lastEventTs = createdAt;
1974
+ const pubkey = this.keyManager.getPublicKeyHex();
1975
+ const storageKey = `${STORAGE_KEYS_GLOBAL.LAST_WALLET_EVENT_TS}_${pubkey.slice(0, 16)}`;
1976
+ this.storage.set(storageKey, createdAt.toString()).catch((err) => {
1977
+ this.log("Failed to save last event timestamp:", err);
1978
+ });
1979
+ }
1904
1980
  async handleDirectMessage(event) {
1905
1981
  this.log("Ignoring NIP-04 kind 4 event (DMs use NIP-17):", event.id?.slice(0, 12));
1906
1982
  }
@@ -1965,7 +2041,7 @@ var NostrTransportProvider = class {
1965
2041
  this.emitEvent({ type: "transfer:received", timestamp: Date.now() });
1966
2042
  for (const handler of this.transferHandlers) {
1967
2043
  try {
1968
- handler(transfer);
2044
+ await handler(transfer);
1969
2045
  } catch (error) {
1970
2046
  this.log("Transfer handler error:", error);
1971
2047
  }
@@ -1979,6 +2055,7 @@ var NostrTransportProvider = class {
1979
2055
  const request = {
1980
2056
  id: event.id,
1981
2057
  senderTransportPubkey: event.pubkey,
2058
+ senderNametag: requestData.recipientNametag,
1982
2059
  request: {
1983
2060
  requestId: requestData.requestId,
1984
2061
  amount: requestData.amount,
@@ -2094,6 +2171,49 @@ var NostrTransportProvider = class {
2094
2171
  const sdkEvent = import_nostr_js_sdk.Event.fromJSON(event);
2095
2172
  await this.nostrClient.publishEvent(sdkEvent);
2096
2173
  }
2174
+ async fetchPendingEvents() {
2175
+ if (!this.nostrClient?.isConnected() || !this.keyManager) {
2176
+ throw new Error("Transport not connected");
2177
+ }
2178
+ const nostrPubkey = this.keyManager.getPublicKeyHex();
2179
+ const walletFilter = new import_nostr_js_sdk.Filter();
2180
+ walletFilter.kinds = [
2181
+ EVENT_KINDS.DIRECT_MESSAGE,
2182
+ EVENT_KINDS.TOKEN_TRANSFER,
2183
+ EVENT_KINDS.PAYMENT_REQUEST,
2184
+ EVENT_KINDS.PAYMENT_REQUEST_RESPONSE
2185
+ ];
2186
+ walletFilter["#p"] = [nostrPubkey];
2187
+ walletFilter.since = Math.floor(Date.now() / 1e3) - 86400;
2188
+ const events = [];
2189
+ await new Promise((resolve) => {
2190
+ const timeout = setTimeout(() => {
2191
+ if (subId) this.nostrClient?.unsubscribe(subId);
2192
+ resolve();
2193
+ }, 5e3);
2194
+ const subId = this.nostrClient.subscribe(walletFilter, {
2195
+ onEvent: (event) => {
2196
+ events.push({
2197
+ id: event.id,
2198
+ kind: event.kind,
2199
+ content: event.content,
2200
+ tags: event.tags,
2201
+ pubkey: event.pubkey,
2202
+ created_at: event.created_at,
2203
+ sig: event.sig
2204
+ });
2205
+ },
2206
+ onEndOfStoredEvents: () => {
2207
+ clearTimeout(timeout);
2208
+ this.nostrClient?.unsubscribe(subId);
2209
+ resolve();
2210
+ }
2211
+ });
2212
+ });
2213
+ for (const event of events) {
2214
+ await this.handleEvent(event);
2215
+ }
2216
+ }
2097
2217
  async queryEvents(filterObj) {
2098
2218
  if (!this.nostrClient || !this.nostrClient.isConnected()) {
2099
2219
  throw new Error("No connected relays");
@@ -2133,7 +2253,7 @@ var NostrTransportProvider = class {
2133
2253
  // Track subscription IDs for cleanup
2134
2254
  walletSubscriptionId = null;
2135
2255
  chatSubscriptionId = null;
2136
- subscribeToEvents() {
2256
+ async subscribeToEvents() {
2137
2257
  this.log("subscribeToEvents called, identity:", !!this.identity, "keyManager:", !!this.keyManager, "nostrClient:", !!this.nostrClient);
2138
2258
  if (!this.identity || !this.keyManager || !this.nostrClient) {
2139
2259
  this.log("subscribeToEvents: skipped - no identity, keyManager, or nostrClient");
@@ -2153,6 +2273,27 @@ var NostrTransportProvider = class {
2153
2273
  }
2154
2274
  const nostrPubkey = this.keyManager.getPublicKeyHex();
2155
2275
  this.log("Subscribing with Nostr pubkey:", nostrPubkey);
2276
+ let since;
2277
+ if (this.storage) {
2278
+ const storageKey = `${STORAGE_KEYS_GLOBAL.LAST_WALLET_EVENT_TS}_${nostrPubkey.slice(0, 16)}`;
2279
+ try {
2280
+ const stored = await this.storage.get(storageKey);
2281
+ if (stored) {
2282
+ since = parseInt(stored, 10);
2283
+ this.lastEventTs = since;
2284
+ this.log("Resuming from stored event timestamp:", since);
2285
+ } else {
2286
+ since = Math.floor(Date.now() / 1e3);
2287
+ this.log("No stored timestamp, starting from now:", since);
2288
+ }
2289
+ } catch (err) {
2290
+ this.log("Failed to read last event timestamp, falling back to now:", err);
2291
+ since = Math.floor(Date.now() / 1e3);
2292
+ }
2293
+ } else {
2294
+ since = Math.floor(Date.now() / 1e3) - 86400;
2295
+ this.log("No storage adapter, using 24h fallback");
2296
+ }
2156
2297
  const walletFilter = new import_nostr_js_sdk.Filter();
2157
2298
  walletFilter.kinds = [
2158
2299
  EVENT_KINDS.DIRECT_MESSAGE,
@@ -2161,7 +2302,7 @@ var NostrTransportProvider = class {
2161
2302
  EVENT_KINDS.PAYMENT_REQUEST_RESPONSE
2162
2303
  ];
2163
2304
  walletFilter["#p"] = [nostrPubkey];
2164
- walletFilter.since = Math.floor(Date.now() / 1e3) - 86400;
2305
+ walletFilter.since = since;
2165
2306
  this.walletSubscriptionId = this.nostrClient.subscribe(walletFilter, {
2166
2307
  onEvent: (event) => {
2167
2308
  this.log("Received wallet event kind:", event.kind, "id:", event.id?.slice(0, 12));
@@ -2782,186 +2923,2093 @@ function createUnicityAggregatorProvider(config) {
2782
2923
  }
2783
2924
  var createUnicityOracleProvider = createUnicityAggregatorProvider;
2784
2925
 
2785
- // price/CoinGeckoPriceProvider.ts
2786
- var CoinGeckoPriceProvider = class {
2787
- platform = "coingecko";
2788
- cache = /* @__PURE__ */ new Map();
2789
- apiKey;
2790
- cacheTtlMs;
2791
- timeout;
2792
- debug;
2793
- baseUrl;
2794
- constructor(config) {
2795
- this.apiKey = config?.apiKey;
2796
- this.cacheTtlMs = config?.cacheTtlMs ?? 6e4;
2797
- this.timeout = config?.timeout ?? 1e4;
2798
- this.debug = config?.debug ?? false;
2799
- this.baseUrl = config?.baseUrl ?? (this.apiKey ? "https://pro-api.coingecko.com/api/v3" : "https://api.coingecko.com/api/v3");
2926
+ // impl/shared/ipfs/ipfs-error-types.ts
2927
+ var IpfsError = class extends Error {
2928
+ category;
2929
+ gateway;
2930
+ cause;
2931
+ constructor(message, category, gateway, cause) {
2932
+ super(message);
2933
+ this.name = "IpfsError";
2934
+ this.category = category;
2935
+ this.gateway = gateway;
2936
+ this.cause = cause;
2937
+ }
2938
+ /** Whether this error should trigger the circuit breaker */
2939
+ get shouldTriggerCircuitBreaker() {
2940
+ return this.category !== "NOT_FOUND" && this.category !== "SEQUENCE_DOWNGRADE";
2800
2941
  }
2801
- async getPrices(tokenNames) {
2802
- if (tokenNames.length === 0) {
2803
- return /* @__PURE__ */ new Map();
2804
- }
2805
- const now = Date.now();
2806
- const result = /* @__PURE__ */ new Map();
2807
- const uncachedNames = [];
2808
- for (const name of tokenNames) {
2809
- const cached = this.cache.get(name);
2810
- if (cached && cached.expiresAt > now) {
2811
- if (cached.price !== null) {
2812
- result.set(name, cached.price);
2813
- }
2814
- } else {
2815
- uncachedNames.push(name);
2816
- }
2817
- }
2818
- if (uncachedNames.length === 0) {
2819
- return result;
2820
- }
2821
- try {
2822
- const ids = uncachedNames.join(",");
2823
- const url = `${this.baseUrl}/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=usd,eur&include_24hr_change=true`;
2824
- const headers = { Accept: "application/json" };
2825
- if (this.apiKey) {
2826
- headers["x-cg-pro-api-key"] = this.apiKey;
2827
- }
2828
- if (this.debug) {
2829
- console.log(`[CoinGecko] Fetching prices for: ${uncachedNames.join(", ")}`);
2830
- }
2831
- const response = await fetch(url, {
2832
- headers,
2833
- signal: AbortSignal.timeout(this.timeout)
2834
- });
2835
- if (!response.ok) {
2836
- throw new Error(`CoinGecko API error: ${response.status} ${response.statusText}`);
2837
- }
2838
- const data = await response.json();
2839
- for (const [name, values] of Object.entries(data)) {
2840
- if (values && typeof values === "object") {
2841
- const price = {
2842
- tokenName: name,
2843
- priceUsd: values.usd ?? 0,
2844
- priceEur: values.eur,
2845
- change24h: values.usd_24h_change,
2846
- timestamp: now
2847
- };
2848
- this.cache.set(name, { price, expiresAt: now + this.cacheTtlMs });
2849
- result.set(name, price);
2850
- }
2851
- }
2852
- for (const name of uncachedNames) {
2853
- if (!result.has(name)) {
2854
- this.cache.set(name, { price: null, expiresAt: now + this.cacheTtlMs });
2855
- }
2856
- }
2857
- if (this.debug) {
2858
- console.log(`[CoinGecko] Fetched ${result.size} prices`);
2859
- }
2860
- } catch (error) {
2861
- if (this.debug) {
2862
- console.warn("[CoinGecko] Fetch failed, using stale cache:", error);
2863
- }
2864
- for (const name of uncachedNames) {
2865
- const stale = this.cache.get(name);
2866
- if (stale?.price) {
2867
- result.set(name, stale.price);
2868
- }
2869
- }
2942
+ };
2943
+ function classifyFetchError(error) {
2944
+ if (error instanceof DOMException && error.name === "AbortError") {
2945
+ return "TIMEOUT";
2946
+ }
2947
+ if (error instanceof TypeError) {
2948
+ return "NETWORK_ERROR";
2949
+ }
2950
+ if (error instanceof Error && error.name === "TimeoutError") {
2951
+ return "TIMEOUT";
2952
+ }
2953
+ return "NETWORK_ERROR";
2954
+ }
2955
+ function classifyHttpStatus(status, responseBody) {
2956
+ if (status === 404) {
2957
+ return "NOT_FOUND";
2958
+ }
2959
+ if (status === 500 && responseBody) {
2960
+ if (/routing:\s*not\s*found/i.test(responseBody)) {
2961
+ return "NOT_FOUND";
2870
2962
  }
2871
- return result;
2872
2963
  }
2873
- async getPrice(tokenName) {
2874
- const prices = await this.getPrices([tokenName]);
2875
- return prices.get(tokenName) ?? null;
2964
+ if (status >= 500) {
2965
+ return "GATEWAY_ERROR";
2876
2966
  }
2877
- clearCache() {
2878
- this.cache.clear();
2967
+ if (status >= 400) {
2968
+ return "GATEWAY_ERROR";
2879
2969
  }
2880
- };
2970
+ return "GATEWAY_ERROR";
2971
+ }
2881
2972
 
2882
- // price/index.ts
2883
- function createPriceProvider(config) {
2884
- switch (config.platform) {
2885
- case "coingecko":
2886
- return new CoinGeckoPriceProvider(config);
2887
- default:
2888
- throw new Error(`Unsupported price platform: ${String(config.platform)}`);
2973
+ // impl/shared/ipfs/ipfs-state-persistence.ts
2974
+ var InMemoryIpfsStatePersistence = class {
2975
+ states = /* @__PURE__ */ new Map();
2976
+ async load(ipnsName) {
2977
+ return this.states.get(ipnsName) ?? null;
2889
2978
  }
2890
- }
2979
+ async save(ipnsName, state) {
2980
+ this.states.set(ipnsName, { ...state });
2981
+ }
2982
+ async clear(ipnsName) {
2983
+ this.states.delete(ipnsName);
2984
+ }
2985
+ };
2891
2986
 
2892
- // impl/shared/resolvers.ts
2893
- function getNetworkConfig(network = "mainnet") {
2894
- return NETWORKS[network];
2895
- }
2896
- function resolveTransportConfig(network, config) {
2897
- const networkConfig = getNetworkConfig(network);
2898
- let relays;
2899
- if (config?.relays) {
2900
- relays = config.relays;
2901
- } else {
2902
- relays = [...networkConfig.nostrRelays];
2903
- if (config?.additionalRelays) {
2904
- relays = [...relays, ...config.additionalRelays];
2905
- }
2987
+ // impl/shared/ipfs/ipns-key-derivation.ts
2988
+ var IPNS_HKDF_INFO = "ipfs-storage-ed25519-v1";
2989
+ var libp2pCryptoModule = null;
2990
+ var libp2pPeerIdModule = null;
2991
+ async function loadLibp2pModules() {
2992
+ if (!libp2pCryptoModule) {
2993
+ [libp2pCryptoModule, libp2pPeerIdModule] = await Promise.all([
2994
+ import("@libp2p/crypto/keys"),
2995
+ import("@libp2p/peer-id")
2996
+ ]);
2906
2997
  }
2907
2998
  return {
2908
- relays,
2909
- timeout: config?.timeout,
2910
- autoReconnect: config?.autoReconnect,
2911
- debug: config?.debug,
2912
- // Browser-specific
2913
- reconnectDelay: config?.reconnectDelay,
2914
- maxReconnectAttempts: config?.maxReconnectAttempts
2999
+ generateKeyPairFromSeed: libp2pCryptoModule.generateKeyPairFromSeed,
3000
+ peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey
2915
3001
  };
2916
3002
  }
2917
- function resolveOracleConfig(network, config) {
2918
- const networkConfig = getNetworkConfig(network);
3003
+ function deriveEd25519KeyMaterial(privateKeyHex, info = IPNS_HKDF_INFO) {
3004
+ const walletSecret = hexToBytes(privateKeyHex);
3005
+ const infoBytes = new TextEncoder().encode(info);
3006
+ return hkdf(sha256, walletSecret, void 0, infoBytes, 32);
3007
+ }
3008
+ async function deriveIpnsIdentity(privateKeyHex) {
3009
+ const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadLibp2pModules();
3010
+ const derivedKey = deriveEd25519KeyMaterial(privateKeyHex);
3011
+ const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
3012
+ const peerId = peerIdFromPrivateKey(keyPair);
2919
3013
  return {
2920
- url: config?.url ?? networkConfig.aggregatorUrl,
2921
- apiKey: config?.apiKey ?? DEFAULT_AGGREGATOR_API_KEY,
2922
- timeout: config?.timeout,
2923
- skipVerification: config?.skipVerification,
2924
- debug: config?.debug,
2925
- // Node.js-specific
2926
- trustBasePath: config?.trustBasePath
3014
+ keyPair,
3015
+ ipnsName: peerId.toString()
2927
3016
  };
2928
3017
  }
2929
- function resolveL1Config(network, config) {
2930
- if (config === void 0) {
2931
- return void 0;
3018
+
3019
+ // impl/shared/ipfs/ipns-record-manager.ts
3020
+ var DEFAULT_LIFETIME_MS = 99 * 365 * 24 * 60 * 60 * 1e3;
3021
+ var ipnsModule = null;
3022
+ async function loadIpnsModule() {
3023
+ if (!ipnsModule) {
3024
+ const mod = await import("ipns");
3025
+ ipnsModule = {
3026
+ createIPNSRecord: mod.createIPNSRecord,
3027
+ marshalIPNSRecord: mod.marshalIPNSRecord,
3028
+ unmarshalIPNSRecord: mod.unmarshalIPNSRecord
3029
+ };
2932
3030
  }
2933
- const networkConfig = getNetworkConfig(network);
2934
- return {
2935
- electrumUrl: config.electrumUrl ?? networkConfig.electrumUrl,
2936
- defaultFeeRate: config.defaultFeeRate,
2937
- enableVesting: config.enableVesting
2938
- };
3031
+ return ipnsModule;
2939
3032
  }
2940
- function resolvePriceConfig(config) {
2941
- if (config === void 0) {
2942
- return void 0;
3033
+ async function createSignedRecord(keyPair, cid, sequenceNumber, lifetimeMs = DEFAULT_LIFETIME_MS) {
3034
+ const { createIPNSRecord, marshalIPNSRecord } = await loadIpnsModule();
3035
+ const record = await createIPNSRecord(
3036
+ keyPair,
3037
+ `/ipfs/${cid}`,
3038
+ sequenceNumber,
3039
+ lifetimeMs
3040
+ );
3041
+ return marshalIPNSRecord(record);
3042
+ }
3043
+ async function parseRoutingApiResponse(responseText) {
3044
+ const { unmarshalIPNSRecord } = await loadIpnsModule();
3045
+ const lines = responseText.trim().split("\n");
3046
+ for (const line of lines) {
3047
+ if (!line.trim()) continue;
3048
+ try {
3049
+ const obj = JSON.parse(line);
3050
+ if (obj.Extra) {
3051
+ const recordData = base64ToUint8Array(obj.Extra);
3052
+ const record = unmarshalIPNSRecord(recordData);
3053
+ const valueBytes = typeof record.value === "string" ? new TextEncoder().encode(record.value) : record.value;
3054
+ const valueStr = new TextDecoder().decode(valueBytes);
3055
+ const cidMatch = valueStr.match(/\/ipfs\/([a-zA-Z0-9]+)/);
3056
+ if (cidMatch) {
3057
+ return {
3058
+ cid: cidMatch[1],
3059
+ sequence: record.sequence,
3060
+ recordData
3061
+ };
3062
+ }
3063
+ }
3064
+ } catch {
3065
+ continue;
3066
+ }
2943
3067
  }
2944
- return {
2945
- platform: config.platform ?? "coingecko",
2946
- apiKey: config.apiKey,
2947
- baseUrl: config.baseUrl,
2948
- cacheTtlMs: config.cacheTtlMs,
2949
- timeout: config.timeout,
2950
- debug: config.debug
2951
- };
3068
+ return null;
3069
+ }
3070
+ function base64ToUint8Array(base64) {
3071
+ const binary = atob(base64);
3072
+ const bytes = new Uint8Array(binary.length);
3073
+ for (let i = 0; i < binary.length; i++) {
3074
+ bytes[i] = binary.charCodeAt(i);
3075
+ }
3076
+ return bytes;
2952
3077
  }
2953
3078
 
2954
- // impl/nodejs/index.ts
2955
- function createNodeProviders(config) {
2956
- const network = config?.network ?? "mainnet";
2957
- const transportConfig = resolveTransportConfig(network, config?.transport);
2958
- const oracleConfig = resolveOracleConfig(network, config?.oracle);
2959
- const l1Config = resolveL1Config(network, config?.l1);
2960
- const priceConfig = resolvePriceConfig(config?.price);
2961
- return {
2962
- storage: createFileStorageProvider({
2963
- dataDir: config?.dataDir ?? "./sphere-data"
2964
- }),
3079
+ // impl/shared/ipfs/ipfs-cache.ts
3080
+ var DEFAULT_IPNS_TTL_MS = 6e4;
3081
+ var DEFAULT_FAILURE_COOLDOWN_MS = 6e4;
3082
+ var DEFAULT_FAILURE_THRESHOLD = 3;
3083
+ var DEFAULT_KNOWN_FRESH_WINDOW_MS = 3e4;
3084
+ var IpfsCache = class {
3085
+ ipnsRecords = /* @__PURE__ */ new Map();
3086
+ content = /* @__PURE__ */ new Map();
3087
+ gatewayFailures = /* @__PURE__ */ new Map();
3088
+ knownFreshTimestamps = /* @__PURE__ */ new Map();
3089
+ ipnsTtlMs;
3090
+ failureCooldownMs;
3091
+ failureThreshold;
3092
+ knownFreshWindowMs;
3093
+ constructor(config) {
3094
+ this.ipnsTtlMs = config?.ipnsTtlMs ?? DEFAULT_IPNS_TTL_MS;
3095
+ this.failureCooldownMs = config?.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;
3096
+ this.failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
3097
+ this.knownFreshWindowMs = config?.knownFreshWindowMs ?? DEFAULT_KNOWN_FRESH_WINDOW_MS;
3098
+ }
3099
+ // ---------------------------------------------------------------------------
3100
+ // IPNS Record Cache (60s TTL)
3101
+ // ---------------------------------------------------------------------------
3102
+ getIpnsRecord(ipnsName) {
3103
+ const entry = this.ipnsRecords.get(ipnsName);
3104
+ if (!entry) return null;
3105
+ if (Date.now() - entry.timestamp > this.ipnsTtlMs) {
3106
+ this.ipnsRecords.delete(ipnsName);
3107
+ return null;
3108
+ }
3109
+ return entry.data;
3110
+ }
3111
+ /**
3112
+ * Get cached IPNS record ignoring TTL (for known-fresh optimization).
3113
+ */
3114
+ getIpnsRecordIgnoreTtl(ipnsName) {
3115
+ const entry = this.ipnsRecords.get(ipnsName);
3116
+ return entry?.data ?? null;
3117
+ }
3118
+ setIpnsRecord(ipnsName, result) {
3119
+ this.ipnsRecords.set(ipnsName, {
3120
+ data: result,
3121
+ timestamp: Date.now()
3122
+ });
3123
+ }
3124
+ invalidateIpns(ipnsName) {
3125
+ this.ipnsRecords.delete(ipnsName);
3126
+ }
3127
+ // ---------------------------------------------------------------------------
3128
+ // Content Cache (infinite TTL - content is immutable by CID)
3129
+ // ---------------------------------------------------------------------------
3130
+ getContent(cid) {
3131
+ const entry = this.content.get(cid);
3132
+ return entry?.data ?? null;
3133
+ }
3134
+ setContent(cid, data) {
3135
+ this.content.set(cid, {
3136
+ data,
3137
+ timestamp: Date.now()
3138
+ });
3139
+ }
3140
+ // ---------------------------------------------------------------------------
3141
+ // Gateway Failure Tracking (Circuit Breaker)
3142
+ // ---------------------------------------------------------------------------
3143
+ /**
3144
+ * Record a gateway failure. After threshold consecutive failures,
3145
+ * the gateway enters cooldown and is considered unhealthy.
3146
+ */
3147
+ recordGatewayFailure(gateway) {
3148
+ const existing = this.gatewayFailures.get(gateway);
3149
+ this.gatewayFailures.set(gateway, {
3150
+ count: (existing?.count ?? 0) + 1,
3151
+ lastFailure: Date.now()
3152
+ });
3153
+ }
3154
+ /** Reset failure count for a gateway (on successful request) */
3155
+ recordGatewaySuccess(gateway) {
3156
+ this.gatewayFailures.delete(gateway);
3157
+ }
3158
+ /**
3159
+ * Check if a gateway is currently in circuit breaker cooldown.
3160
+ * A gateway is considered unhealthy if it has had >= threshold
3161
+ * consecutive failures and the cooldown period hasn't elapsed.
3162
+ */
3163
+ isGatewayInCooldown(gateway) {
3164
+ const failure = this.gatewayFailures.get(gateway);
3165
+ if (!failure) return false;
3166
+ if (failure.count < this.failureThreshold) return false;
3167
+ const elapsed = Date.now() - failure.lastFailure;
3168
+ if (elapsed >= this.failureCooldownMs) {
3169
+ this.gatewayFailures.delete(gateway);
3170
+ return false;
3171
+ }
3172
+ return true;
3173
+ }
3174
+ // ---------------------------------------------------------------------------
3175
+ // Known-Fresh Flag (FAST mode optimization)
3176
+ // ---------------------------------------------------------------------------
3177
+ /**
3178
+ * Mark IPNS cache as "known-fresh" (after local publish or push notification).
3179
+ * Within the fresh window, we can skip network resolution.
3180
+ */
3181
+ markIpnsFresh(ipnsName) {
3182
+ this.knownFreshTimestamps.set(ipnsName, Date.now());
3183
+ }
3184
+ /**
3185
+ * Check if the cache is known-fresh (within the fresh window).
3186
+ */
3187
+ isIpnsKnownFresh(ipnsName) {
3188
+ const timestamp = this.knownFreshTimestamps.get(ipnsName);
3189
+ if (!timestamp) return false;
3190
+ if (Date.now() - timestamp > this.knownFreshWindowMs) {
3191
+ this.knownFreshTimestamps.delete(ipnsName);
3192
+ return false;
3193
+ }
3194
+ return true;
3195
+ }
3196
+ // ---------------------------------------------------------------------------
3197
+ // Cache Management
3198
+ // ---------------------------------------------------------------------------
3199
+ clear() {
3200
+ this.ipnsRecords.clear();
3201
+ this.content.clear();
3202
+ this.gatewayFailures.clear();
3203
+ this.knownFreshTimestamps.clear();
3204
+ }
3205
+ };
3206
+
3207
+ // impl/shared/ipfs/ipfs-http-client.ts
3208
+ var DEFAULT_CONNECTIVITY_TIMEOUT_MS = 5e3;
3209
+ var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
3210
+ var DEFAULT_RESOLVE_TIMEOUT_MS = 1e4;
3211
+ var DEFAULT_PUBLISH_TIMEOUT_MS = 3e4;
3212
+ var DEFAULT_GATEWAY_PATH_TIMEOUT_MS = 3e3;
3213
+ var DEFAULT_ROUTING_API_TIMEOUT_MS = 2e3;
3214
+ var IpfsHttpClient = class {
3215
+ gateways;
3216
+ fetchTimeoutMs;
3217
+ resolveTimeoutMs;
3218
+ publishTimeoutMs;
3219
+ connectivityTimeoutMs;
3220
+ debug;
3221
+ cache;
3222
+ constructor(config, cache) {
3223
+ this.gateways = config.gateways;
3224
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3225
+ this.resolveTimeoutMs = config.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;
3226
+ this.publishTimeoutMs = config.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS;
3227
+ this.connectivityTimeoutMs = config.connectivityTimeoutMs ?? DEFAULT_CONNECTIVITY_TIMEOUT_MS;
3228
+ this.debug = config.debug ?? false;
3229
+ this.cache = cache;
3230
+ }
3231
+ // ---------------------------------------------------------------------------
3232
+ // Public Accessors
3233
+ // ---------------------------------------------------------------------------
3234
+ /**
3235
+ * Get configured gateway URLs.
3236
+ */
3237
+ getGateways() {
3238
+ return [...this.gateways];
3239
+ }
3240
+ // ---------------------------------------------------------------------------
3241
+ // Gateway Health
3242
+ // ---------------------------------------------------------------------------
3243
+ /**
3244
+ * Test connectivity to a single gateway.
3245
+ */
3246
+ async testConnectivity(gateway) {
3247
+ const start = Date.now();
3248
+ try {
3249
+ const response = await this.fetchWithTimeout(
3250
+ `${gateway}/api/v0/version`,
3251
+ this.connectivityTimeoutMs,
3252
+ { method: "POST" }
3253
+ );
3254
+ if (!response.ok) {
3255
+ return { gateway, healthy: false, error: `HTTP ${response.status}` };
3256
+ }
3257
+ return {
3258
+ gateway,
3259
+ healthy: true,
3260
+ responseTimeMs: Date.now() - start
3261
+ };
3262
+ } catch (error) {
3263
+ return {
3264
+ gateway,
3265
+ healthy: false,
3266
+ error: error instanceof Error ? error.message : String(error)
3267
+ };
3268
+ }
3269
+ }
3270
+ /**
3271
+ * Find healthy gateways from the configured list.
3272
+ */
3273
+ async findHealthyGateways() {
3274
+ const results = await Promise.allSettled(
3275
+ this.gateways.map((gw) => this.testConnectivity(gw))
3276
+ );
3277
+ return results.filter((r) => r.status === "fulfilled" && r.value.healthy).map((r) => r.value.gateway);
3278
+ }
3279
+ /**
3280
+ * Get gateways that are not in circuit breaker cooldown.
3281
+ */
3282
+ getAvailableGateways() {
3283
+ return this.gateways.filter((gw) => !this.cache.isGatewayInCooldown(gw));
3284
+ }
3285
+ // ---------------------------------------------------------------------------
3286
+ // Content Upload
3287
+ // ---------------------------------------------------------------------------
3288
+ /**
3289
+ * Upload JSON content to IPFS.
3290
+ * Tries all gateways in parallel, returns first success.
3291
+ */
3292
+ async upload(data, gateways) {
3293
+ const targets = gateways ?? this.getAvailableGateways();
3294
+ if (targets.length === 0) {
3295
+ throw new IpfsError("No gateways available for upload", "NETWORK_ERROR");
3296
+ }
3297
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(data));
3298
+ const promises = targets.map(async (gateway) => {
3299
+ try {
3300
+ const formData = new FormData();
3301
+ formData.append("file", new Blob([jsonBytes], { type: "application/json" }), "data.json");
3302
+ const response = await this.fetchWithTimeout(
3303
+ `${gateway}/api/v0/add?pin=true&cid-version=1`,
3304
+ this.publishTimeoutMs,
3305
+ { method: "POST", body: formData }
3306
+ );
3307
+ if (!response.ok) {
3308
+ throw new IpfsError(
3309
+ `Upload failed: HTTP ${response.status}`,
3310
+ classifyHttpStatus(response.status),
3311
+ gateway
3312
+ );
3313
+ }
3314
+ const result = await response.json();
3315
+ this.cache.recordGatewaySuccess(gateway);
3316
+ this.log(`Uploaded to ${gateway}: CID=${result.Hash}`);
3317
+ return { cid: result.Hash, gateway };
3318
+ } catch (error) {
3319
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3320
+ this.cache.recordGatewayFailure(gateway);
3321
+ }
3322
+ throw error;
3323
+ }
3324
+ });
3325
+ try {
3326
+ const result = await Promise.any(promises);
3327
+ return { cid: result.cid };
3328
+ } catch (error) {
3329
+ if (error instanceof AggregateError) {
3330
+ throw new IpfsError(
3331
+ `Upload failed on all gateways: ${error.errors.map((e) => e.message).join("; ")}`,
3332
+ "NETWORK_ERROR"
3333
+ );
3334
+ }
3335
+ throw error;
3336
+ }
3337
+ }
3338
+ // ---------------------------------------------------------------------------
3339
+ // Content Fetch
3340
+ // ---------------------------------------------------------------------------
3341
+ /**
3342
+ * Fetch content by CID from IPFS gateways.
3343
+ * Checks content cache first. Races all gateways for fastest response.
3344
+ */
3345
+ async fetchContent(cid, gateways) {
3346
+ const cached = this.cache.getContent(cid);
3347
+ if (cached) {
3348
+ this.log(`Content cache hit for CID=${cid}`);
3349
+ return cached;
3350
+ }
3351
+ const targets = gateways ?? this.getAvailableGateways();
3352
+ if (targets.length === 0) {
3353
+ throw new IpfsError("No gateways available for fetch", "NETWORK_ERROR");
3354
+ }
3355
+ const promises = targets.map(async (gateway) => {
3356
+ try {
3357
+ const response = await this.fetchWithTimeout(
3358
+ `${gateway}/ipfs/${cid}`,
3359
+ this.fetchTimeoutMs,
3360
+ { headers: { Accept: "application/octet-stream" } }
3361
+ );
3362
+ if (!response.ok) {
3363
+ const body = await response.text().catch(() => "");
3364
+ throw new IpfsError(
3365
+ `Fetch failed: HTTP ${response.status}`,
3366
+ classifyHttpStatus(response.status, body),
3367
+ gateway
3368
+ );
3369
+ }
3370
+ const data = await response.json();
3371
+ this.cache.recordGatewaySuccess(gateway);
3372
+ this.cache.setContent(cid, data);
3373
+ this.log(`Fetched from ${gateway}: CID=${cid}`);
3374
+ return data;
3375
+ } catch (error) {
3376
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3377
+ this.cache.recordGatewayFailure(gateway);
3378
+ }
3379
+ throw error;
3380
+ }
3381
+ });
3382
+ try {
3383
+ return await Promise.any(promises);
3384
+ } catch (error) {
3385
+ if (error instanceof AggregateError) {
3386
+ throw new IpfsError(
3387
+ `Fetch failed on all gateways for CID=${cid}`,
3388
+ "NETWORK_ERROR"
3389
+ );
3390
+ }
3391
+ throw error;
3392
+ }
3393
+ }
3394
+ // ---------------------------------------------------------------------------
3395
+ // IPNS Resolution
3396
+ // ---------------------------------------------------------------------------
3397
+ /**
3398
+ * Resolve IPNS via Routing API (returns record with sequence number).
3399
+ * POST /api/v0/routing/get?arg=/ipns/{name}
3400
+ */
3401
+ async resolveIpnsViaRoutingApi(gateway, ipnsName, timeoutMs = DEFAULT_ROUTING_API_TIMEOUT_MS) {
3402
+ try {
3403
+ const response = await this.fetchWithTimeout(
3404
+ `${gateway}/api/v0/routing/get?arg=/ipns/${ipnsName}`,
3405
+ timeoutMs,
3406
+ { method: "POST" }
3407
+ );
3408
+ if (!response.ok) {
3409
+ const body = await response.text().catch(() => "");
3410
+ const category = classifyHttpStatus(response.status, body);
3411
+ if (category === "NOT_FOUND") return null;
3412
+ throw new IpfsError(`Routing API: HTTP ${response.status}`, category, gateway);
3413
+ }
3414
+ const text = await response.text();
3415
+ const parsed = await parseRoutingApiResponse(text);
3416
+ if (!parsed) return null;
3417
+ this.cache.recordGatewaySuccess(gateway);
3418
+ return {
3419
+ cid: parsed.cid,
3420
+ sequence: parsed.sequence,
3421
+ gateway,
3422
+ recordData: parsed.recordData
3423
+ };
3424
+ } catch (error) {
3425
+ if (error instanceof IpfsError) throw error;
3426
+ const category = classifyFetchError(error);
3427
+ if (category !== "NOT_FOUND") {
3428
+ this.cache.recordGatewayFailure(gateway);
3429
+ }
3430
+ return null;
3431
+ }
3432
+ }
3433
+ /**
3434
+ * Resolve IPNS via gateway path (simpler, no sequence number).
3435
+ * GET /ipns/{name}?format=dag-json
3436
+ */
3437
+ async resolveIpnsViaGatewayPath(gateway, ipnsName, timeoutMs = DEFAULT_GATEWAY_PATH_TIMEOUT_MS) {
3438
+ try {
3439
+ const response = await this.fetchWithTimeout(
3440
+ `${gateway}/ipns/${ipnsName}`,
3441
+ timeoutMs,
3442
+ { headers: { Accept: "application/json" } }
3443
+ );
3444
+ if (!response.ok) return null;
3445
+ const content = await response.json();
3446
+ const cidHeader = response.headers.get("X-Ipfs-Path");
3447
+ if (cidHeader) {
3448
+ const match = cidHeader.match(/\/ipfs\/([a-zA-Z0-9]+)/);
3449
+ if (match) {
3450
+ this.cache.recordGatewaySuccess(gateway);
3451
+ return { cid: match[1], content };
3452
+ }
3453
+ }
3454
+ return { cid: "", content };
3455
+ } catch {
3456
+ return null;
3457
+ }
3458
+ }
3459
+ /**
3460
+ * Progressive IPNS resolution across all gateways.
3461
+ * Queries all gateways in parallel, returns highest sequence number.
3462
+ */
3463
+ async resolveIpns(ipnsName, gateways) {
3464
+ const targets = gateways ?? this.getAvailableGateways();
3465
+ if (targets.length === 0) {
3466
+ return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 };
3467
+ }
3468
+ const results = [];
3469
+ let respondedCount = 0;
3470
+ const promises = targets.map(async (gateway) => {
3471
+ const result = await this.resolveIpnsViaRoutingApi(
3472
+ gateway,
3473
+ ipnsName,
3474
+ this.resolveTimeoutMs
3475
+ );
3476
+ if (result) results.push(result);
3477
+ respondedCount++;
3478
+ return result;
3479
+ });
3480
+ await Promise.race([
3481
+ Promise.allSettled(promises),
3482
+ new Promise((resolve) => setTimeout(resolve, this.resolveTimeoutMs + 1e3))
3483
+ ]);
3484
+ let best = null;
3485
+ for (const result of results) {
3486
+ if (!best || result.sequence > best.sequence) {
3487
+ best = result;
3488
+ }
3489
+ }
3490
+ if (best) {
3491
+ this.cache.setIpnsRecord(ipnsName, best);
3492
+ }
3493
+ return {
3494
+ best,
3495
+ allResults: results,
3496
+ respondedCount,
3497
+ totalGateways: targets.length
3498
+ };
3499
+ }
3500
+ // ---------------------------------------------------------------------------
3501
+ // IPNS Publishing
3502
+ // ---------------------------------------------------------------------------
3503
+ /**
3504
+ * Publish IPNS record to a single gateway via routing API.
3505
+ */
3506
+ async publishIpnsViaRoutingApi(gateway, ipnsName, marshalledRecord, timeoutMs = DEFAULT_PUBLISH_TIMEOUT_MS) {
3507
+ try {
3508
+ const formData = new FormData();
3509
+ formData.append(
3510
+ "file",
3511
+ new Blob([new Uint8Array(marshalledRecord)]),
3512
+ "record"
3513
+ );
3514
+ const response = await this.fetchWithTimeout(
3515
+ `${gateway}/api/v0/routing/put?arg=/ipns/${ipnsName}&allow-offline=true`,
3516
+ timeoutMs,
3517
+ { method: "POST", body: formData }
3518
+ );
3519
+ if (!response.ok) {
3520
+ const errorText = await response.text().catch(() => "");
3521
+ throw new IpfsError(
3522
+ `IPNS publish: HTTP ${response.status}: ${errorText.slice(0, 100)}`,
3523
+ classifyHttpStatus(response.status, errorText),
3524
+ gateway
3525
+ );
3526
+ }
3527
+ this.cache.recordGatewaySuccess(gateway);
3528
+ this.log(`IPNS published to ${gateway}: ${ipnsName}`);
3529
+ return true;
3530
+ } catch (error) {
3531
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3532
+ this.cache.recordGatewayFailure(gateway);
3533
+ }
3534
+ this.log(`IPNS publish to ${gateway} failed: ${error}`);
3535
+ return false;
3536
+ }
3537
+ }
3538
+ /**
3539
+ * Publish IPNS record to all gateways in parallel.
3540
+ */
3541
+ async publishIpns(ipnsName, marshalledRecord, gateways) {
3542
+ const targets = gateways ?? this.getAvailableGateways();
3543
+ if (targets.length === 0) {
3544
+ return { success: false, error: "No gateways available" };
3545
+ }
3546
+ const results = await Promise.allSettled(
3547
+ targets.map((gw) => this.publishIpnsViaRoutingApi(gw, ipnsName, marshalledRecord, this.publishTimeoutMs))
3548
+ );
3549
+ const successfulGateways = [];
3550
+ results.forEach((result, index) => {
3551
+ if (result.status === "fulfilled" && result.value) {
3552
+ successfulGateways.push(targets[index]);
3553
+ }
3554
+ });
3555
+ return {
3556
+ success: successfulGateways.length > 0,
3557
+ ipnsName,
3558
+ successfulGateways,
3559
+ error: successfulGateways.length === 0 ? "All gateways failed" : void 0
3560
+ };
3561
+ }
3562
+ // ---------------------------------------------------------------------------
3563
+ // IPNS Verification
3564
+ // ---------------------------------------------------------------------------
3565
+ /**
3566
+ * Verify IPNS record persistence after publishing.
3567
+ * Retries resolution to confirm the record was accepted.
3568
+ */
3569
+ async verifyIpnsRecord(ipnsName, expectedSeq, expectedCid, retries = 3, delayMs = 1e3) {
3570
+ for (let i = 0; i < retries; i++) {
3571
+ if (i > 0) {
3572
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
3573
+ }
3574
+ const { best } = await this.resolveIpns(ipnsName);
3575
+ if (best && best.sequence >= expectedSeq && best.cid === expectedCid) {
3576
+ return true;
3577
+ }
3578
+ }
3579
+ return false;
3580
+ }
3581
+ // ---------------------------------------------------------------------------
3582
+ // Helpers
3583
+ // ---------------------------------------------------------------------------
3584
+ async fetchWithTimeout(url, timeoutMs, options) {
3585
+ const controller = new AbortController();
3586
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3587
+ try {
3588
+ return await fetch(url, {
3589
+ ...options,
3590
+ signal: controller.signal
3591
+ });
3592
+ } finally {
3593
+ clearTimeout(timer);
3594
+ }
3595
+ }
3596
+ log(message) {
3597
+ if (this.debug) {
3598
+ console.log(`[IPFS-HTTP] ${message}`);
3599
+ }
3600
+ }
3601
+ };
3602
+
3603
+ // impl/shared/ipfs/txf-merge.ts
3604
+ function mergeTxfData(local, remote) {
3605
+ let added = 0;
3606
+ let removed = 0;
3607
+ let conflicts = 0;
3608
+ const localVersion = local._meta?.version ?? 0;
3609
+ const remoteVersion = remote._meta?.version ?? 0;
3610
+ const baseMeta = localVersion >= remoteVersion ? local._meta : remote._meta;
3611
+ const mergedMeta = {
3612
+ ...baseMeta,
3613
+ version: Math.max(localVersion, remoteVersion) + 1,
3614
+ updatedAt: Date.now()
3615
+ };
3616
+ const mergedTombstones = mergeTombstones(
3617
+ local._tombstones ?? [],
3618
+ remote._tombstones ?? []
3619
+ );
3620
+ const tombstoneKeys = new Set(
3621
+ mergedTombstones.map((t) => `${t.tokenId}:${t.stateHash}`)
3622
+ );
3623
+ const localTokenKeys = getTokenKeys(local);
3624
+ const remoteTokenKeys = getTokenKeys(remote);
3625
+ const allTokenKeys = /* @__PURE__ */ new Set([...localTokenKeys, ...remoteTokenKeys]);
3626
+ const mergedTokens = {};
3627
+ for (const key of allTokenKeys) {
3628
+ const tokenId = key.startsWith("_") ? key.slice(1) : key;
3629
+ const localToken = local[key];
3630
+ const remoteToken = remote[key];
3631
+ if (isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys)) {
3632
+ if (localTokenKeys.has(key)) removed++;
3633
+ continue;
3634
+ }
3635
+ if (localToken && !remoteToken) {
3636
+ mergedTokens[key] = localToken;
3637
+ } else if (!localToken && remoteToken) {
3638
+ mergedTokens[key] = remoteToken;
3639
+ added++;
3640
+ } else if (localToken && remoteToken) {
3641
+ mergedTokens[key] = localToken;
3642
+ conflicts++;
3643
+ }
3644
+ }
3645
+ const mergedOutbox = mergeArrayById(
3646
+ local._outbox ?? [],
3647
+ remote._outbox ?? [],
3648
+ "id"
3649
+ );
3650
+ const mergedSent = mergeArrayById(
3651
+ local._sent ?? [],
3652
+ remote._sent ?? [],
3653
+ "tokenId"
3654
+ );
3655
+ const mergedInvalid = mergeArrayById(
3656
+ local._invalid ?? [],
3657
+ remote._invalid ?? [],
3658
+ "tokenId"
3659
+ );
3660
+ const merged = {
3661
+ _meta: mergedMeta,
3662
+ _tombstones: mergedTombstones.length > 0 ? mergedTombstones : void 0,
3663
+ _outbox: mergedOutbox.length > 0 ? mergedOutbox : void 0,
3664
+ _sent: mergedSent.length > 0 ? mergedSent : void 0,
3665
+ _invalid: mergedInvalid.length > 0 ? mergedInvalid : void 0,
3666
+ ...mergedTokens
3667
+ };
3668
+ return { merged, added, removed, conflicts };
3669
+ }
3670
+ function mergeTombstones(local, remote) {
3671
+ const merged = /* @__PURE__ */ new Map();
3672
+ for (const tombstone of [...local, ...remote]) {
3673
+ const key = `${tombstone.tokenId}:${tombstone.stateHash}`;
3674
+ const existing = merged.get(key);
3675
+ if (!existing || tombstone.timestamp > existing.timestamp) {
3676
+ merged.set(key, tombstone);
3677
+ }
3678
+ }
3679
+ return Array.from(merged.values());
3680
+ }
3681
+ function getTokenKeys(data) {
3682
+ const reservedKeys = /* @__PURE__ */ new Set([
3683
+ "_meta",
3684
+ "_tombstones",
3685
+ "_outbox",
3686
+ "_sent",
3687
+ "_invalid",
3688
+ "_nametag",
3689
+ "_mintOutbox",
3690
+ "_invalidatedNametags",
3691
+ "_integrity"
3692
+ ]);
3693
+ const keys = /* @__PURE__ */ new Set();
3694
+ for (const key of Object.keys(data)) {
3695
+ if (reservedKeys.has(key)) continue;
3696
+ if (key.startsWith("archived-") || key.startsWith("_forked_") || key.startsWith("nametag-")) continue;
3697
+ keys.add(key);
3698
+ }
3699
+ return keys;
3700
+ }
3701
+ function isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys) {
3702
+ for (const key of tombstoneKeys) {
3703
+ if (key.startsWith(`${tokenId}:`)) {
3704
+ return true;
3705
+ }
3706
+ }
3707
+ void localToken;
3708
+ void remoteToken;
3709
+ return false;
3710
+ }
3711
+ function mergeArrayById(local, remote, idField) {
3712
+ const seen = /* @__PURE__ */ new Map();
3713
+ for (const item of local) {
3714
+ const id = item[idField];
3715
+ if (id !== void 0) {
3716
+ seen.set(id, item);
3717
+ }
3718
+ }
3719
+ for (const item of remote) {
3720
+ const id = item[idField];
3721
+ if (id !== void 0 && !seen.has(id)) {
3722
+ seen.set(id, item);
3723
+ }
3724
+ }
3725
+ return Array.from(seen.values());
3726
+ }
3727
+
3728
+ // impl/shared/ipfs/ipns-subscription-client.ts
3729
+ var IpnsSubscriptionClient = class {
3730
+ ws = null;
3731
+ subscriptions = /* @__PURE__ */ new Map();
3732
+ reconnectTimeout = null;
3733
+ pingInterval = null;
3734
+ fallbackPollInterval = null;
3735
+ wsUrl;
3736
+ createWebSocket;
3737
+ pingIntervalMs;
3738
+ initialReconnectDelayMs;
3739
+ maxReconnectDelayMs;
3740
+ debugEnabled;
3741
+ reconnectAttempts = 0;
3742
+ isConnecting = false;
3743
+ connectionOpenedAt = 0;
3744
+ destroyed = false;
3745
+ /** Minimum stable connection time before resetting backoff (30 seconds) */
3746
+ minStableConnectionMs = 3e4;
3747
+ fallbackPollFn = null;
3748
+ fallbackPollIntervalMs = 0;
3749
+ constructor(config) {
3750
+ this.wsUrl = config.wsUrl;
3751
+ this.createWebSocket = config.createWebSocket;
3752
+ this.pingIntervalMs = config.pingIntervalMs ?? 3e4;
3753
+ this.initialReconnectDelayMs = config.reconnectDelayMs ?? 5e3;
3754
+ this.maxReconnectDelayMs = config.maxReconnectDelayMs ?? 6e4;
3755
+ this.debugEnabled = config.debug ?? false;
3756
+ }
3757
+ // ---------------------------------------------------------------------------
3758
+ // Public API
3759
+ // ---------------------------------------------------------------------------
3760
+ /**
3761
+ * Subscribe to IPNS updates for a specific name.
3762
+ * Automatically connects the WebSocket if not already connected.
3763
+ * If WebSocket is connecting, the name will be subscribed once connected.
3764
+ */
3765
+ subscribe(ipnsName, callback) {
3766
+ if (!ipnsName || typeof ipnsName !== "string") {
3767
+ this.log("Invalid IPNS name for subscription");
3768
+ return () => {
3769
+ };
3770
+ }
3771
+ const isNewSubscription = !this.subscriptions.has(ipnsName);
3772
+ if (isNewSubscription) {
3773
+ this.subscriptions.set(ipnsName, /* @__PURE__ */ new Set());
3774
+ }
3775
+ this.subscriptions.get(ipnsName).add(callback);
3776
+ if (isNewSubscription && this.ws?.readyState === WebSocketReadyState.OPEN) {
3777
+ this.sendSubscribe([ipnsName]);
3778
+ }
3779
+ if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) {
3780
+ this.connect();
3781
+ }
3782
+ return () => {
3783
+ const callbacks = this.subscriptions.get(ipnsName);
3784
+ if (callbacks) {
3785
+ callbacks.delete(callback);
3786
+ if (callbacks.size === 0) {
3787
+ this.subscriptions.delete(ipnsName);
3788
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
3789
+ this.sendUnsubscribe([ipnsName]);
3790
+ }
3791
+ if (this.subscriptions.size === 0) {
3792
+ this.disconnect();
3793
+ }
3794
+ }
3795
+ }
3796
+ };
3797
+ }
3798
+ /**
3799
+ * Register a convenience update callback for all subscriptions.
3800
+ * Returns an unsubscribe function.
3801
+ */
3802
+ onUpdate(callback) {
3803
+ if (!this.subscriptions.has("*")) {
3804
+ this.subscriptions.set("*", /* @__PURE__ */ new Set());
3805
+ }
3806
+ this.subscriptions.get("*").add(callback);
3807
+ return () => {
3808
+ const callbacks = this.subscriptions.get("*");
3809
+ if (callbacks) {
3810
+ callbacks.delete(callback);
3811
+ if (callbacks.size === 0) {
3812
+ this.subscriptions.delete("*");
3813
+ }
3814
+ }
3815
+ };
3816
+ }
3817
+ /**
3818
+ * Set a fallback poll function to use when WebSocket is disconnected.
3819
+ * The poll function will be called at the specified interval while WS is down.
3820
+ */
3821
+ setFallbackPoll(fn, intervalMs) {
3822
+ this.fallbackPollFn = fn;
3823
+ this.fallbackPollIntervalMs = intervalMs;
3824
+ if (!this.isConnected()) {
3825
+ this.startFallbackPolling();
3826
+ }
3827
+ }
3828
+ /**
3829
+ * Connect to the WebSocket server.
3830
+ */
3831
+ connect() {
3832
+ if (this.destroyed) return;
3833
+ if (this.ws?.readyState === WebSocketReadyState.OPEN || this.isConnecting) {
3834
+ return;
3835
+ }
3836
+ this.isConnecting = true;
3837
+ try {
3838
+ this.log(`Connecting to ${this.wsUrl}...`);
3839
+ this.ws = this.createWebSocket(this.wsUrl);
3840
+ this.ws.onopen = () => {
3841
+ this.log("WebSocket connected");
3842
+ this.isConnecting = false;
3843
+ this.connectionOpenedAt = Date.now();
3844
+ const names = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
3845
+ if (names.length > 0) {
3846
+ this.sendSubscribe(names);
3847
+ }
3848
+ this.startPingInterval();
3849
+ this.stopFallbackPolling();
3850
+ };
3851
+ this.ws.onmessage = (event) => {
3852
+ this.handleMessage(event.data);
3853
+ };
3854
+ this.ws.onclose = () => {
3855
+ const connectionDuration = this.connectionOpenedAt > 0 ? Date.now() - this.connectionOpenedAt : 0;
3856
+ const wasStable = connectionDuration >= this.minStableConnectionMs;
3857
+ this.log(`WebSocket closed (duration: ${Math.round(connectionDuration / 1e3)}s)`);
3858
+ this.isConnecting = false;
3859
+ this.connectionOpenedAt = 0;
3860
+ this.stopPingInterval();
3861
+ if (wasStable) {
3862
+ this.reconnectAttempts = 0;
3863
+ }
3864
+ this.startFallbackPolling();
3865
+ this.scheduleReconnect();
3866
+ };
3867
+ this.ws.onerror = () => {
3868
+ this.log("WebSocket error");
3869
+ this.isConnecting = false;
3870
+ };
3871
+ } catch (e) {
3872
+ this.log(`Failed to connect: ${e}`);
3873
+ this.isConnecting = false;
3874
+ this.startFallbackPolling();
3875
+ this.scheduleReconnect();
3876
+ }
3877
+ }
3878
+ /**
3879
+ * Disconnect from the WebSocket server and clean up.
3880
+ */
3881
+ disconnect() {
3882
+ this.destroyed = true;
3883
+ if (this.reconnectTimeout) {
3884
+ clearTimeout(this.reconnectTimeout);
3885
+ this.reconnectTimeout = null;
3886
+ }
3887
+ this.stopPingInterval();
3888
+ this.stopFallbackPolling();
3889
+ if (this.ws) {
3890
+ this.ws.onopen = null;
3891
+ this.ws.onclose = null;
3892
+ this.ws.onerror = null;
3893
+ this.ws.onmessage = null;
3894
+ this.ws.close();
3895
+ this.ws = null;
3896
+ }
3897
+ this.isConnecting = false;
3898
+ this.reconnectAttempts = 0;
3899
+ }
3900
+ /**
3901
+ * Check if connected to the WebSocket server.
3902
+ */
3903
+ isConnected() {
3904
+ return this.ws?.readyState === WebSocketReadyState.OPEN;
3905
+ }
3906
+ // ---------------------------------------------------------------------------
3907
+ // Internal: Message Handling
3908
+ // ---------------------------------------------------------------------------
3909
+ handleMessage(data) {
3910
+ try {
3911
+ const message = JSON.parse(data);
3912
+ switch (message.type) {
3913
+ case "update":
3914
+ if (message.name && message.sequence !== void 0) {
3915
+ this.notifySubscribers({
3916
+ type: "update",
3917
+ name: message.name,
3918
+ sequence: message.sequence,
3919
+ cid: message.cid ?? "",
3920
+ timestamp: message.timestamp || (/* @__PURE__ */ new Date()).toISOString()
3921
+ });
3922
+ }
3923
+ break;
3924
+ case "subscribed":
3925
+ this.log(`Subscribed to ${message.names?.length || 0} names`);
3926
+ break;
3927
+ case "unsubscribed":
3928
+ this.log(`Unsubscribed from ${message.names?.length || 0} names`);
3929
+ break;
3930
+ case "pong":
3931
+ break;
3932
+ case "error":
3933
+ this.log(`Server error: ${message.message}`);
3934
+ break;
3935
+ default:
3936
+ break;
3937
+ }
3938
+ } catch {
3939
+ this.log("Failed to parse message");
3940
+ }
3941
+ }
3942
+ notifySubscribers(update) {
3943
+ const callbacks = this.subscriptions.get(update.name);
3944
+ if (callbacks) {
3945
+ this.log(`Update: ${update.name.slice(0, 16)}... seq=${update.sequence}`);
3946
+ for (const callback of callbacks) {
3947
+ try {
3948
+ callback(update);
3949
+ } catch {
3950
+ }
3951
+ }
3952
+ }
3953
+ const globalCallbacks = this.subscriptions.get("*");
3954
+ if (globalCallbacks) {
3955
+ for (const callback of globalCallbacks) {
3956
+ try {
3957
+ callback(update);
3958
+ } catch {
3959
+ }
3960
+ }
3961
+ }
3962
+ }
3963
+ // ---------------------------------------------------------------------------
3964
+ // Internal: WebSocket Send
3965
+ // ---------------------------------------------------------------------------
3966
+ sendSubscribe(names) {
3967
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
3968
+ this.ws.send(JSON.stringify({ action: "subscribe", names }));
3969
+ }
3970
+ }
3971
+ sendUnsubscribe(names) {
3972
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
3973
+ this.ws.send(JSON.stringify({ action: "unsubscribe", names }));
3974
+ }
3975
+ }
3976
+ // ---------------------------------------------------------------------------
3977
+ // Internal: Reconnection
3978
+ // ---------------------------------------------------------------------------
3979
+ /**
3980
+ * Schedule reconnection with exponential backoff.
3981
+ * Sequence: 5s, 10s, 20s, 40s, 60s (capped)
3982
+ */
3983
+ scheduleReconnect() {
3984
+ if (this.destroyed || this.reconnectTimeout) return;
3985
+ const realSubscriptions = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
3986
+ if (realSubscriptions.length === 0) return;
3987
+ this.reconnectAttempts++;
3988
+ const delay = Math.min(
3989
+ this.initialReconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
3990
+ this.maxReconnectDelayMs
3991
+ );
3992
+ this.log(`Reconnecting in ${(delay / 1e3).toFixed(1)}s (attempt ${this.reconnectAttempts})...`);
3993
+ this.reconnectTimeout = setTimeout(() => {
3994
+ this.reconnectTimeout = null;
3995
+ this.connect();
3996
+ }, delay);
3997
+ }
3998
+ // ---------------------------------------------------------------------------
3999
+ // Internal: Keepalive
4000
+ // ---------------------------------------------------------------------------
4001
+ startPingInterval() {
4002
+ this.stopPingInterval();
4003
+ this.pingInterval = setInterval(() => {
4004
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4005
+ this.ws.send(JSON.stringify({ action: "ping" }));
4006
+ }
4007
+ }, this.pingIntervalMs);
4008
+ }
4009
+ stopPingInterval() {
4010
+ if (this.pingInterval) {
4011
+ clearInterval(this.pingInterval);
4012
+ this.pingInterval = null;
4013
+ }
4014
+ }
4015
+ // ---------------------------------------------------------------------------
4016
+ // Internal: Fallback Polling
4017
+ // ---------------------------------------------------------------------------
4018
+ startFallbackPolling() {
4019
+ if (this.fallbackPollInterval || !this.fallbackPollFn || this.destroyed) return;
4020
+ this.log(`Starting fallback polling (${this.fallbackPollIntervalMs / 1e3}s interval)`);
4021
+ this.fallbackPollFn().catch(() => {
4022
+ });
4023
+ this.fallbackPollInterval = setInterval(() => {
4024
+ this.fallbackPollFn?.().catch(() => {
4025
+ });
4026
+ }, this.fallbackPollIntervalMs);
4027
+ }
4028
+ stopFallbackPolling() {
4029
+ if (this.fallbackPollInterval) {
4030
+ clearInterval(this.fallbackPollInterval);
4031
+ this.fallbackPollInterval = null;
4032
+ }
4033
+ }
4034
+ // ---------------------------------------------------------------------------
4035
+ // Internal: Logging
4036
+ // ---------------------------------------------------------------------------
4037
+ log(message) {
4038
+ if (this.debugEnabled) {
4039
+ console.log(`[IPNS-WS] ${message}`);
4040
+ }
4041
+ }
4042
+ };
4043
+
4044
+ // impl/shared/ipfs/write-behind-buffer.ts
4045
+ var AsyncSerialQueue = class {
4046
+ tail = Promise.resolve();
4047
+ /** Enqueue an async operation. Returns when it completes. */
4048
+ enqueue(fn) {
4049
+ let resolve;
4050
+ let reject;
4051
+ const promise = new Promise((res, rej) => {
4052
+ resolve = res;
4053
+ reject = rej;
4054
+ });
4055
+ this.tail = this.tail.then(
4056
+ () => fn().then(resolve, reject),
4057
+ () => fn().then(resolve, reject)
4058
+ );
4059
+ return promise;
4060
+ }
4061
+ };
4062
+ var WriteBuffer = class {
4063
+ /** Full TXF data from save() calls — latest wins */
4064
+ txfData = null;
4065
+ /** Individual token mutations: key -> { op: 'save'|'delete', data? } */
4066
+ tokenMutations = /* @__PURE__ */ new Map();
4067
+ get isEmpty() {
4068
+ return this.txfData === null && this.tokenMutations.size === 0;
4069
+ }
4070
+ clear() {
4071
+ this.txfData = null;
4072
+ this.tokenMutations.clear();
4073
+ }
4074
+ /**
4075
+ * Merge another buffer's contents into this one (for rollback).
4076
+ * Existing (newer) mutations in `this` take precedence over `other`.
4077
+ */
4078
+ mergeFrom(other) {
4079
+ if (other.txfData && !this.txfData) {
4080
+ this.txfData = other.txfData;
4081
+ }
4082
+ for (const [id, mutation] of other.tokenMutations) {
4083
+ if (!this.tokenMutations.has(id)) {
4084
+ this.tokenMutations.set(id, mutation);
4085
+ }
4086
+ }
4087
+ }
4088
+ };
4089
+
4090
+ // impl/shared/ipfs/ipfs-storage-provider.ts
4091
+ var IpfsStorageProvider = class {
4092
+ id = "ipfs";
4093
+ name = "IPFS Storage";
4094
+ type = "p2p";
4095
+ status = "disconnected";
4096
+ identity = null;
4097
+ ipnsKeyPair = null;
4098
+ ipnsName = null;
4099
+ ipnsSequenceNumber = 0n;
4100
+ lastCid = null;
4101
+ lastKnownRemoteSequence = 0n;
4102
+ dataVersion = 0;
4103
+ /**
4104
+ * The CID currently stored on the sidecar for this IPNS name.
4105
+ * Used as `_meta.lastCid` in the next save to satisfy chain validation.
4106
+ * - null for bootstrap (first-ever save)
4107
+ * - set after every successful save() or load()
4108
+ */
4109
+ remoteCid = null;
4110
+ cache;
4111
+ httpClient;
4112
+ statePersistence;
4113
+ eventCallbacks = /* @__PURE__ */ new Set();
4114
+ debug;
4115
+ ipnsLifetimeMs;
4116
+ /** WebSocket factory for push subscriptions */
4117
+ createWebSocket;
4118
+ /** Override WS URL */
4119
+ wsUrl;
4120
+ /** Fallback poll interval (default: 90000) */
4121
+ fallbackPollIntervalMs;
4122
+ /** IPNS subscription client for push notifications */
4123
+ subscriptionClient = null;
4124
+ /** Unsubscribe function from subscription client */
4125
+ subscriptionUnsubscribe = null;
4126
+ /** In-memory buffer for individual token save/delete calls */
4127
+ tokenBuffer = /* @__PURE__ */ new Map();
4128
+ deletedTokenIds = /* @__PURE__ */ new Set();
4129
+ /** Write-behind buffer: serializes flush / sync / shutdown */
4130
+ flushQueue = new AsyncSerialQueue();
4131
+ /** Pending mutations not yet flushed to IPFS */
4132
+ pendingBuffer = new WriteBuffer();
4133
+ /** Debounce timer for background flush */
4134
+ flushTimer = null;
4135
+ /** Debounce interval in ms */
4136
+ flushDebounceMs;
4137
+ /** Set to true during shutdown to prevent new flushes */
4138
+ isShuttingDown = false;
4139
+ constructor(config, statePersistence) {
4140
+ const gateways = config?.gateways ?? getIpfsGatewayUrls();
4141
+ this.debug = config?.debug ?? false;
4142
+ this.ipnsLifetimeMs = config?.ipnsLifetimeMs ?? 99 * 365 * 24 * 60 * 60 * 1e3;
4143
+ this.flushDebounceMs = config?.flushDebounceMs ?? 2e3;
4144
+ this.cache = new IpfsCache({
4145
+ ipnsTtlMs: config?.ipnsCacheTtlMs,
4146
+ failureCooldownMs: config?.circuitBreakerCooldownMs,
4147
+ failureThreshold: config?.circuitBreakerThreshold,
4148
+ knownFreshWindowMs: config?.knownFreshWindowMs
4149
+ });
4150
+ this.httpClient = new IpfsHttpClient({
4151
+ gateways,
4152
+ fetchTimeoutMs: config?.fetchTimeoutMs,
4153
+ resolveTimeoutMs: config?.resolveTimeoutMs,
4154
+ publishTimeoutMs: config?.publishTimeoutMs,
4155
+ connectivityTimeoutMs: config?.connectivityTimeoutMs,
4156
+ debug: this.debug
4157
+ }, this.cache);
4158
+ this.statePersistence = statePersistence ?? new InMemoryIpfsStatePersistence();
4159
+ this.createWebSocket = config?.createWebSocket;
4160
+ this.wsUrl = config?.wsUrl;
4161
+ this.fallbackPollIntervalMs = config?.fallbackPollIntervalMs ?? 9e4;
4162
+ }
4163
+ // ---------------------------------------------------------------------------
4164
+ // BaseProvider interface
4165
+ // ---------------------------------------------------------------------------
4166
+ async connect() {
4167
+ await this.initialize();
4168
+ }
4169
+ async disconnect() {
4170
+ await this.shutdown();
4171
+ }
4172
+ isConnected() {
4173
+ return this.status === "connected";
4174
+ }
4175
+ getStatus() {
4176
+ return this.status;
4177
+ }
4178
+ // ---------------------------------------------------------------------------
4179
+ // Identity & Initialization
4180
+ // ---------------------------------------------------------------------------
4181
+ setIdentity(identity) {
4182
+ this.identity = identity;
4183
+ }
4184
+ async initialize() {
4185
+ if (!this.identity) {
4186
+ this.log("Cannot initialize: no identity set");
4187
+ return false;
4188
+ }
4189
+ this.status = "connecting";
4190
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
4191
+ try {
4192
+ const { keyPair, ipnsName } = await deriveIpnsIdentity(this.identity.privateKey);
4193
+ this.ipnsKeyPair = keyPair;
4194
+ this.ipnsName = ipnsName;
4195
+ this.log(`IPNS name derived: ${ipnsName}`);
4196
+ const persisted = await this.statePersistence.load(ipnsName);
4197
+ if (persisted) {
4198
+ this.ipnsSequenceNumber = BigInt(persisted.sequenceNumber);
4199
+ this.lastCid = persisted.lastCid;
4200
+ this.remoteCid = persisted.lastCid;
4201
+ this.dataVersion = persisted.version;
4202
+ this.log(`Loaded persisted state: seq=${this.ipnsSequenceNumber}, cid=${this.lastCid}`);
4203
+ }
4204
+ if (this.createWebSocket) {
4205
+ try {
4206
+ const wsUrlFinal = this.wsUrl ?? this.deriveWsUrl();
4207
+ if (wsUrlFinal) {
4208
+ this.subscriptionClient = new IpnsSubscriptionClient({
4209
+ wsUrl: wsUrlFinal,
4210
+ createWebSocket: this.createWebSocket,
4211
+ debug: this.debug
4212
+ });
4213
+ this.subscriptionUnsubscribe = this.subscriptionClient.subscribe(
4214
+ ipnsName,
4215
+ (update) => {
4216
+ this.log(`Push update: seq=${update.sequence}, cid=${update.cid}`);
4217
+ this.emitEvent({
4218
+ type: "storage:remote-updated",
4219
+ timestamp: Date.now(),
4220
+ data: { name: update.name, sequence: update.sequence, cid: update.cid }
4221
+ });
4222
+ }
4223
+ );
4224
+ this.subscriptionClient.setFallbackPoll(
4225
+ () => this.pollForRemoteChanges(),
4226
+ this.fallbackPollIntervalMs
4227
+ );
4228
+ this.subscriptionClient.connect();
4229
+ }
4230
+ } catch (wsError) {
4231
+ this.log(`Failed to set up IPNS subscription: ${wsError}`);
4232
+ }
4233
+ }
4234
+ this.httpClient.findHealthyGateways().then((healthy) => {
4235
+ if (healthy.length > 0) {
4236
+ this.log(`${healthy.length} healthy gateway(s) found`);
4237
+ } else {
4238
+ this.log("Warning: no healthy gateways found");
4239
+ }
4240
+ }).catch(() => {
4241
+ });
4242
+ this.isShuttingDown = false;
4243
+ this.status = "connected";
4244
+ this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
4245
+ return true;
4246
+ } catch (error) {
4247
+ this.status = "error";
4248
+ this.emitEvent({
4249
+ type: "storage:error",
4250
+ timestamp: Date.now(),
4251
+ error: error instanceof Error ? error.message : String(error)
4252
+ });
4253
+ return false;
4254
+ }
4255
+ }
4256
+ async shutdown() {
4257
+ this.isShuttingDown = true;
4258
+ if (this.flushTimer) {
4259
+ clearTimeout(this.flushTimer);
4260
+ this.flushTimer = null;
4261
+ }
4262
+ await this.flushQueue.enqueue(async () => {
4263
+ if (!this.pendingBuffer.isEmpty) {
4264
+ try {
4265
+ await this.executeFlush();
4266
+ } catch {
4267
+ this.log("Final flush on shutdown failed (data may be lost)");
4268
+ }
4269
+ }
4270
+ });
4271
+ if (this.subscriptionUnsubscribe) {
4272
+ this.subscriptionUnsubscribe();
4273
+ this.subscriptionUnsubscribe = null;
4274
+ }
4275
+ if (this.subscriptionClient) {
4276
+ this.subscriptionClient.disconnect();
4277
+ this.subscriptionClient = null;
4278
+ }
4279
+ this.cache.clear();
4280
+ this.status = "disconnected";
4281
+ }
4282
+ // ---------------------------------------------------------------------------
4283
+ // Save (non-blocking — buffers data for async flush)
4284
+ // ---------------------------------------------------------------------------
4285
+ async save(data) {
4286
+ if (!this.ipnsKeyPair || !this.ipnsName) {
4287
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
4288
+ }
4289
+ this.pendingBuffer.txfData = data;
4290
+ this.scheduleFlush();
4291
+ return { success: true, timestamp: Date.now() };
4292
+ }
4293
+ // ---------------------------------------------------------------------------
4294
+ // Internal: Blocking save (used by sync and executeFlush)
4295
+ // ---------------------------------------------------------------------------
4296
+ /**
4297
+ * Perform the actual upload + IPNS publish synchronously.
4298
+ * Called by executeFlush() and sync() — never by public save().
4299
+ */
4300
+ async _doSave(data) {
4301
+ if (!this.ipnsKeyPair || !this.ipnsName) {
4302
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
4303
+ }
4304
+ this.emitEvent({ type: "storage:saving", timestamp: Date.now() });
4305
+ try {
4306
+ this.dataVersion++;
4307
+ const metaUpdate = {
4308
+ ...data._meta,
4309
+ version: this.dataVersion,
4310
+ ipnsName: this.ipnsName,
4311
+ updatedAt: Date.now()
4312
+ };
4313
+ if (this.remoteCid) {
4314
+ metaUpdate.lastCid = this.remoteCid;
4315
+ }
4316
+ const updatedData = { ...data, _meta: metaUpdate };
4317
+ for (const [tokenId, tokenData] of this.tokenBuffer) {
4318
+ if (!this.deletedTokenIds.has(tokenId)) {
4319
+ updatedData[tokenId] = tokenData;
4320
+ }
4321
+ }
4322
+ for (const tokenId of this.deletedTokenIds) {
4323
+ delete updatedData[tokenId];
4324
+ }
4325
+ const { cid } = await this.httpClient.upload(updatedData);
4326
+ this.log(`Content uploaded: CID=${cid}`);
4327
+ const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence ? this.ipnsSequenceNumber : this.lastKnownRemoteSequence;
4328
+ const newSeq = baseSeq + 1n;
4329
+ const marshalledRecord = await createSignedRecord(
4330
+ this.ipnsKeyPair,
4331
+ cid,
4332
+ newSeq,
4333
+ this.ipnsLifetimeMs
4334
+ );
4335
+ const publishResult = await this.httpClient.publishIpns(
4336
+ this.ipnsName,
4337
+ marshalledRecord
4338
+ );
4339
+ if (!publishResult.success) {
4340
+ this.dataVersion--;
4341
+ this.log(`IPNS publish failed: ${publishResult.error}`);
4342
+ return {
4343
+ success: false,
4344
+ error: publishResult.error ?? "IPNS publish failed",
4345
+ timestamp: Date.now()
4346
+ };
4347
+ }
4348
+ this.ipnsSequenceNumber = newSeq;
4349
+ this.lastCid = cid;
4350
+ this.remoteCid = cid;
4351
+ this.cache.setIpnsRecord(this.ipnsName, {
4352
+ cid,
4353
+ sequence: newSeq,
4354
+ gateway: "local"
4355
+ });
4356
+ this.cache.setContent(cid, updatedData);
4357
+ this.cache.markIpnsFresh(this.ipnsName);
4358
+ await this.statePersistence.save(this.ipnsName, {
4359
+ sequenceNumber: newSeq.toString(),
4360
+ lastCid: cid,
4361
+ version: this.dataVersion
4362
+ });
4363
+ this.deletedTokenIds.clear();
4364
+ this.emitEvent({
4365
+ type: "storage:saved",
4366
+ timestamp: Date.now(),
4367
+ data: { cid, sequence: newSeq.toString() }
4368
+ });
4369
+ this.log(`Saved: CID=${cid}, seq=${newSeq}`);
4370
+ return { success: true, cid, timestamp: Date.now() };
4371
+ } catch (error) {
4372
+ this.dataVersion--;
4373
+ const errorMessage = error instanceof Error ? error.message : String(error);
4374
+ this.emitEvent({
4375
+ type: "storage:error",
4376
+ timestamp: Date.now(),
4377
+ error: errorMessage
4378
+ });
4379
+ return { success: false, error: errorMessage, timestamp: Date.now() };
4380
+ }
4381
+ }
4382
+ // ---------------------------------------------------------------------------
4383
+ // Write-behind buffer: scheduling and flushing
4384
+ // ---------------------------------------------------------------------------
4385
+ /**
4386
+ * Schedule a debounced background flush.
4387
+ * Resets the timer on each call so rapid mutations coalesce.
4388
+ */
4389
+ scheduleFlush() {
4390
+ if (this.isShuttingDown) return;
4391
+ if (this.flushTimer) clearTimeout(this.flushTimer);
4392
+ this.flushTimer = setTimeout(() => {
4393
+ this.flushTimer = null;
4394
+ this.flushQueue.enqueue(() => this.executeFlush()).catch((err) => {
4395
+ this.log(`Background flush failed: ${err}`);
4396
+ });
4397
+ }, this.flushDebounceMs);
4398
+ }
4399
+ /**
4400
+ * Execute a flush of the pending buffer to IPFS.
4401
+ * Runs inside AsyncSerialQueue for concurrency safety.
4402
+ */
4403
+ async executeFlush() {
4404
+ if (this.pendingBuffer.isEmpty) return;
4405
+ const active = this.pendingBuffer;
4406
+ this.pendingBuffer = new WriteBuffer();
4407
+ try {
4408
+ const baseData = active.txfData ?? {
4409
+ _meta: { version: 0, address: this.identity?.directAddress ?? "", formatVersion: "2.0", updatedAt: 0 }
4410
+ };
4411
+ const result = await this._doSave(baseData);
4412
+ if (!result.success) {
4413
+ throw new Error(result.error ?? "Save failed");
4414
+ }
4415
+ this.log(`Flushed successfully: CID=${result.cid}`);
4416
+ } catch (error) {
4417
+ this.pendingBuffer.mergeFrom(active);
4418
+ const msg = error instanceof Error ? error.message : String(error);
4419
+ this.log(`Flush failed (will retry): ${msg}`);
4420
+ this.scheduleFlush();
4421
+ throw error;
4422
+ }
4423
+ }
4424
+ // ---------------------------------------------------------------------------
4425
+ // Load
4426
+ // ---------------------------------------------------------------------------
4427
+ async load(identifier) {
4428
+ if (!this.ipnsName && !identifier) {
4429
+ return { success: false, error: "Not initialized", source: "local", timestamp: Date.now() };
4430
+ }
4431
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
4432
+ try {
4433
+ if (identifier) {
4434
+ const data2 = await this.httpClient.fetchContent(identifier);
4435
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
4436
+ }
4437
+ const ipnsName = this.ipnsName;
4438
+ if (this.cache.isIpnsKnownFresh(ipnsName)) {
4439
+ const cached = this.cache.getIpnsRecordIgnoreTtl(ipnsName);
4440
+ if (cached) {
4441
+ const content = this.cache.getContent(cached.cid);
4442
+ if (content) {
4443
+ this.log("Using known-fresh cached data");
4444
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4445
+ }
4446
+ }
4447
+ }
4448
+ const cachedRecord = this.cache.getIpnsRecord(ipnsName);
4449
+ if (cachedRecord) {
4450
+ const content = this.cache.getContent(cachedRecord.cid);
4451
+ if (content) {
4452
+ this.log("IPNS cache hit");
4453
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4454
+ }
4455
+ try {
4456
+ const data2 = await this.httpClient.fetchContent(cachedRecord.cid);
4457
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
4458
+ } catch {
4459
+ }
4460
+ }
4461
+ const { best } = await this.httpClient.resolveIpns(ipnsName);
4462
+ if (!best) {
4463
+ this.log("IPNS record not found (new wallet?)");
4464
+ return { success: false, error: "IPNS record not found", source: "remote", timestamp: Date.now() };
4465
+ }
4466
+ if (best.sequence > this.lastKnownRemoteSequence) {
4467
+ this.lastKnownRemoteSequence = best.sequence;
4468
+ }
4469
+ this.remoteCid = best.cid;
4470
+ const data = await this.httpClient.fetchContent(best.cid);
4471
+ const remoteVersion = data?._meta?.version;
4472
+ if (typeof remoteVersion === "number" && remoteVersion > this.dataVersion) {
4473
+ this.dataVersion = remoteVersion;
4474
+ }
4475
+ this.populateTokenBuffer(data);
4476
+ this.emitEvent({
4477
+ type: "storage:loaded",
4478
+ timestamp: Date.now(),
4479
+ data: { cid: best.cid, sequence: best.sequence.toString() }
4480
+ });
4481
+ return { success: true, data, source: "remote", timestamp: Date.now() };
4482
+ } catch (error) {
4483
+ if (this.ipnsName) {
4484
+ const cached = this.cache.getIpnsRecordIgnoreTtl(this.ipnsName);
4485
+ if (cached) {
4486
+ const content = this.cache.getContent(cached.cid);
4487
+ if (content) {
4488
+ this.log("Network error, returning stale cache");
4489
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4490
+ }
4491
+ }
4492
+ }
4493
+ const errorMessage = error instanceof Error ? error.message : String(error);
4494
+ this.emitEvent({
4495
+ type: "storage:error",
4496
+ timestamp: Date.now(),
4497
+ error: errorMessage
4498
+ });
4499
+ return { success: false, error: errorMessage, source: "remote", timestamp: Date.now() };
4500
+ }
4501
+ }
4502
+ // ---------------------------------------------------------------------------
4503
+ // Sync (enters serial queue to avoid concurrent IPNS conflicts)
4504
+ // ---------------------------------------------------------------------------
4505
+ async sync(localData) {
4506
+ return this.flushQueue.enqueue(async () => {
4507
+ if (this.flushTimer) {
4508
+ clearTimeout(this.flushTimer);
4509
+ this.flushTimer = null;
4510
+ }
4511
+ this.emitEvent({ type: "sync:started", timestamp: Date.now() });
4512
+ try {
4513
+ this.pendingBuffer.clear();
4514
+ const remoteResult = await this.load();
4515
+ if (!remoteResult.success || !remoteResult.data) {
4516
+ this.log("No remote data found, uploading local data");
4517
+ const saveResult2 = await this._doSave(localData);
4518
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
4519
+ return {
4520
+ success: saveResult2.success,
4521
+ merged: this.enrichWithTokenBuffer(localData),
4522
+ added: 0,
4523
+ removed: 0,
4524
+ conflicts: 0,
4525
+ error: saveResult2.error
4526
+ };
4527
+ }
4528
+ const remoteData = remoteResult.data;
4529
+ const localVersion = localData._meta?.version ?? 0;
4530
+ const remoteVersion = remoteData._meta?.version ?? 0;
4531
+ if (localVersion === remoteVersion && this.lastCid) {
4532
+ this.log("Data is in sync (same version)");
4533
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
4534
+ return {
4535
+ success: true,
4536
+ merged: this.enrichWithTokenBuffer(localData),
4537
+ added: 0,
4538
+ removed: 0,
4539
+ conflicts: 0
4540
+ };
4541
+ }
4542
+ this.log(`Merging: local v${localVersion} <-> remote v${remoteVersion}`);
4543
+ const { merged, added, removed, conflicts } = mergeTxfData(localData, remoteData);
4544
+ if (conflicts > 0) {
4545
+ this.emitEvent({
4546
+ type: "sync:conflict",
4547
+ timestamp: Date.now(),
4548
+ data: { conflicts }
4549
+ });
4550
+ }
4551
+ const saveResult = await this._doSave(merged);
4552
+ this.emitEvent({
4553
+ type: "sync:completed",
4554
+ timestamp: Date.now(),
4555
+ data: { added, removed, conflicts }
4556
+ });
4557
+ return {
4558
+ success: saveResult.success,
4559
+ merged: this.enrichWithTokenBuffer(merged),
4560
+ added,
4561
+ removed,
4562
+ conflicts,
4563
+ error: saveResult.error
4564
+ };
4565
+ } catch (error) {
4566
+ const errorMessage = error instanceof Error ? error.message : String(error);
4567
+ this.emitEvent({
4568
+ type: "sync:error",
4569
+ timestamp: Date.now(),
4570
+ error: errorMessage
4571
+ });
4572
+ return {
4573
+ success: false,
4574
+ added: 0,
4575
+ removed: 0,
4576
+ conflicts: 0,
4577
+ error: errorMessage
4578
+ };
4579
+ }
4580
+ });
4581
+ }
4582
+ // ---------------------------------------------------------------------------
4583
+ // Private Helpers
4584
+ // ---------------------------------------------------------------------------
4585
+ /**
4586
+ * Enrich TXF data with individually-buffered tokens before returning to caller.
4587
+ * PaymentsModule.createStorageData() passes empty tokens (they're stored via
4588
+ * saveToken()), but loadFromStorageData() needs them in the merged result.
4589
+ */
4590
+ enrichWithTokenBuffer(data) {
4591
+ if (this.tokenBuffer.size === 0) return data;
4592
+ const enriched = { ...data };
4593
+ for (const [tokenId, tokenData] of this.tokenBuffer) {
4594
+ if (!this.deletedTokenIds.has(tokenId)) {
4595
+ enriched[tokenId] = tokenData;
4596
+ }
4597
+ }
4598
+ return enriched;
4599
+ }
4600
+ // ---------------------------------------------------------------------------
4601
+ // Optional Methods
4602
+ // ---------------------------------------------------------------------------
4603
+ async exists() {
4604
+ if (!this.ipnsName) return false;
4605
+ const cached = this.cache.getIpnsRecord(this.ipnsName);
4606
+ if (cached) return true;
4607
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
4608
+ return best !== null;
4609
+ }
4610
+ async clear() {
4611
+ if (!this.ipnsKeyPair || !this.ipnsName) return false;
4612
+ this.pendingBuffer.clear();
4613
+ if (this.flushTimer) {
4614
+ clearTimeout(this.flushTimer);
4615
+ this.flushTimer = null;
4616
+ }
4617
+ const emptyData = {
4618
+ _meta: {
4619
+ version: 0,
4620
+ address: this.identity?.directAddress ?? "",
4621
+ ipnsName: this.ipnsName,
4622
+ formatVersion: "2.0",
4623
+ updatedAt: Date.now()
4624
+ }
4625
+ };
4626
+ const result = await this._doSave(emptyData);
4627
+ if (result.success) {
4628
+ this.cache.clear();
4629
+ this.tokenBuffer.clear();
4630
+ this.deletedTokenIds.clear();
4631
+ await this.statePersistence.clear(this.ipnsName);
4632
+ }
4633
+ return result.success;
4634
+ }
4635
+ onEvent(callback) {
4636
+ this.eventCallbacks.add(callback);
4637
+ return () => {
4638
+ this.eventCallbacks.delete(callback);
4639
+ };
4640
+ }
4641
+ async saveToken(tokenId, tokenData) {
4642
+ this.pendingBuffer.tokenMutations.set(tokenId, { op: "save", data: tokenData });
4643
+ this.tokenBuffer.set(tokenId, tokenData);
4644
+ this.deletedTokenIds.delete(tokenId);
4645
+ this.scheduleFlush();
4646
+ }
4647
+ async getToken(tokenId) {
4648
+ if (this.deletedTokenIds.has(tokenId)) return null;
4649
+ return this.tokenBuffer.get(tokenId) ?? null;
4650
+ }
4651
+ async listTokenIds() {
4652
+ return Array.from(this.tokenBuffer.keys()).filter(
4653
+ (id) => !this.deletedTokenIds.has(id)
4654
+ );
4655
+ }
4656
+ async deleteToken(tokenId) {
4657
+ this.pendingBuffer.tokenMutations.set(tokenId, { op: "delete" });
4658
+ this.tokenBuffer.delete(tokenId);
4659
+ this.deletedTokenIds.add(tokenId);
4660
+ this.scheduleFlush();
4661
+ }
4662
+ // ---------------------------------------------------------------------------
4663
+ // Public Accessors
4664
+ // ---------------------------------------------------------------------------
4665
+ getIpnsName() {
4666
+ return this.ipnsName;
4667
+ }
4668
+ getLastCid() {
4669
+ return this.lastCid;
4670
+ }
4671
+ getSequenceNumber() {
4672
+ return this.ipnsSequenceNumber;
4673
+ }
4674
+ getDataVersion() {
4675
+ return this.dataVersion;
4676
+ }
4677
+ getRemoteCid() {
4678
+ return this.remoteCid;
4679
+ }
4680
+ // ---------------------------------------------------------------------------
4681
+ // Testing helper: wait for pending flush to complete
4682
+ // ---------------------------------------------------------------------------
4683
+ /**
4684
+ * Wait for the pending flush timer to fire and the flush operation to
4685
+ * complete. Useful in tests to await background writes.
4686
+ * Returns immediately if no flush is pending.
4687
+ */
4688
+ async waitForFlush() {
4689
+ if (this.flushTimer) {
4690
+ clearTimeout(this.flushTimer);
4691
+ this.flushTimer = null;
4692
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
4693
+ });
4694
+ } else if (!this.pendingBuffer.isEmpty) {
4695
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
4696
+ });
4697
+ } else {
4698
+ await this.flushQueue.enqueue(async () => {
4699
+ });
4700
+ }
4701
+ }
4702
+ // ---------------------------------------------------------------------------
4703
+ // Internal: Push Subscription Helpers
4704
+ // ---------------------------------------------------------------------------
4705
+ /**
4706
+ * Derive WebSocket URL from the first configured gateway.
4707
+ * Converts https://host → wss://host/ws/ipns
4708
+ */
4709
+ deriveWsUrl() {
4710
+ const gateways = this.httpClient.getGateways();
4711
+ if (gateways.length === 0) return null;
4712
+ const gateway = gateways[0];
4713
+ const wsProtocol = gateway.startsWith("https://") ? "wss://" : "ws://";
4714
+ const host = gateway.replace(/^https?:\/\//, "");
4715
+ return `${wsProtocol}${host}/ws/ipns`;
4716
+ }
4717
+ /**
4718
+ * Poll for remote IPNS changes (fallback when WS is unavailable).
4719
+ * Compares remote sequence number with last known and emits event if changed.
4720
+ */
4721
+ async pollForRemoteChanges() {
4722
+ if (!this.ipnsName) return;
4723
+ try {
4724
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
4725
+ if (best && best.sequence > this.lastKnownRemoteSequence) {
4726
+ this.log(`Poll detected remote change: seq=${best.sequence} (was ${this.lastKnownRemoteSequence})`);
4727
+ this.lastKnownRemoteSequence = best.sequence;
4728
+ this.emitEvent({
4729
+ type: "storage:remote-updated",
4730
+ timestamp: Date.now(),
4731
+ data: { name: this.ipnsName, sequence: Number(best.sequence), cid: best.cid }
4732
+ });
4733
+ }
4734
+ } catch {
4735
+ }
4736
+ }
4737
+ // ---------------------------------------------------------------------------
4738
+ // Internal
4739
+ // ---------------------------------------------------------------------------
4740
+ emitEvent(event) {
4741
+ for (const callback of this.eventCallbacks) {
4742
+ try {
4743
+ callback(event);
4744
+ } catch {
4745
+ }
4746
+ }
4747
+ }
4748
+ log(message) {
4749
+ if (this.debug) {
4750
+ console.log(`[IPFS-Storage] ${message}`);
4751
+ }
4752
+ }
4753
+ META_KEYS = /* @__PURE__ */ new Set([
4754
+ "_meta",
4755
+ "_tombstones",
4756
+ "_outbox",
4757
+ "_sent",
4758
+ "_invalid",
4759
+ "_nametag",
4760
+ "_mintOutbox",
4761
+ "_invalidatedNametags",
4762
+ "_integrity"
4763
+ ]);
4764
+ populateTokenBuffer(data) {
4765
+ this.tokenBuffer.clear();
4766
+ this.deletedTokenIds.clear();
4767
+ for (const key of Object.keys(data)) {
4768
+ if (!this.META_KEYS.has(key)) {
4769
+ this.tokenBuffer.set(key, data[key]);
4770
+ }
4771
+ }
4772
+ }
4773
+ };
4774
+
4775
+ // impl/nodejs/ipfs/nodejs-ipfs-state-persistence.ts
4776
+ var KEY_PREFIX = "sphere_ipfs_";
4777
+ function seqKey(ipnsName) {
4778
+ return `${KEY_PREFIX}seq_${ipnsName}`;
4779
+ }
4780
+ function cidKey(ipnsName) {
4781
+ return `${KEY_PREFIX}cid_${ipnsName}`;
4782
+ }
4783
+ function verKey(ipnsName) {
4784
+ return `${KEY_PREFIX}ver_${ipnsName}`;
4785
+ }
4786
+ var NodejsIpfsStatePersistence = class {
4787
+ constructor(storage) {
4788
+ this.storage = storage;
4789
+ }
4790
+ async load(ipnsName) {
4791
+ try {
4792
+ const seq = await this.storage.get(seqKey(ipnsName));
4793
+ if (!seq) return null;
4794
+ const cid = await this.storage.get(cidKey(ipnsName));
4795
+ const ver = await this.storage.get(verKey(ipnsName));
4796
+ return {
4797
+ sequenceNumber: seq,
4798
+ lastCid: cid,
4799
+ version: parseInt(ver ?? "0", 10)
4800
+ };
4801
+ } catch {
4802
+ return null;
4803
+ }
4804
+ }
4805
+ async save(ipnsName, state) {
4806
+ await this.storage.set(seqKey(ipnsName), state.sequenceNumber);
4807
+ if (state.lastCid) {
4808
+ await this.storage.set(cidKey(ipnsName), state.lastCid);
4809
+ } else {
4810
+ await this.storage.remove(cidKey(ipnsName));
4811
+ }
4812
+ await this.storage.set(verKey(ipnsName), String(state.version));
4813
+ }
4814
+ async clear(ipnsName) {
4815
+ await this.storage.remove(seqKey(ipnsName));
4816
+ await this.storage.remove(cidKey(ipnsName));
4817
+ await this.storage.remove(verKey(ipnsName));
4818
+ }
4819
+ };
4820
+
4821
+ // impl/nodejs/ipfs/index.ts
4822
+ function createNodeIpfsStorageProvider(config, storageProvider) {
4823
+ const persistence = storageProvider ? new NodejsIpfsStatePersistence(storageProvider) : void 0;
4824
+ return new IpfsStorageProvider(
4825
+ { ...config, createWebSocket: config?.createWebSocket ?? createNodeWebSocketFactory() },
4826
+ persistence
4827
+ );
4828
+ }
4829
+
4830
+ // price/CoinGeckoPriceProvider.ts
4831
+ var CoinGeckoPriceProvider = class {
4832
+ platform = "coingecko";
4833
+ cache = /* @__PURE__ */ new Map();
4834
+ apiKey;
4835
+ cacheTtlMs;
4836
+ timeout;
4837
+ debug;
4838
+ baseUrl;
4839
+ constructor(config) {
4840
+ this.apiKey = config?.apiKey;
4841
+ this.cacheTtlMs = config?.cacheTtlMs ?? 6e4;
4842
+ this.timeout = config?.timeout ?? 1e4;
4843
+ this.debug = config?.debug ?? false;
4844
+ this.baseUrl = config?.baseUrl ?? (this.apiKey ? "https://pro-api.coingecko.com/api/v3" : "https://api.coingecko.com/api/v3");
4845
+ }
4846
+ async getPrices(tokenNames) {
4847
+ if (tokenNames.length === 0) {
4848
+ return /* @__PURE__ */ new Map();
4849
+ }
4850
+ const now = Date.now();
4851
+ const result = /* @__PURE__ */ new Map();
4852
+ const uncachedNames = [];
4853
+ for (const name of tokenNames) {
4854
+ const cached = this.cache.get(name);
4855
+ if (cached && cached.expiresAt > now) {
4856
+ if (cached.price !== null) {
4857
+ result.set(name, cached.price);
4858
+ }
4859
+ } else {
4860
+ uncachedNames.push(name);
4861
+ }
4862
+ }
4863
+ if (uncachedNames.length === 0) {
4864
+ return result;
4865
+ }
4866
+ try {
4867
+ const ids = uncachedNames.join(",");
4868
+ const url = `${this.baseUrl}/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=usd,eur&include_24hr_change=true`;
4869
+ const headers = { Accept: "application/json" };
4870
+ if (this.apiKey) {
4871
+ headers["x-cg-pro-api-key"] = this.apiKey;
4872
+ }
4873
+ if (this.debug) {
4874
+ console.log(`[CoinGecko] Fetching prices for: ${uncachedNames.join(", ")}`);
4875
+ }
4876
+ const response = await fetch(url, {
4877
+ headers,
4878
+ signal: AbortSignal.timeout(this.timeout)
4879
+ });
4880
+ if (!response.ok) {
4881
+ throw new Error(`CoinGecko API error: ${response.status} ${response.statusText}`);
4882
+ }
4883
+ const data = await response.json();
4884
+ for (const [name, values] of Object.entries(data)) {
4885
+ if (values && typeof values === "object") {
4886
+ const price = {
4887
+ tokenName: name,
4888
+ priceUsd: values.usd ?? 0,
4889
+ priceEur: values.eur,
4890
+ change24h: values.usd_24h_change,
4891
+ timestamp: now
4892
+ };
4893
+ this.cache.set(name, { price, expiresAt: now + this.cacheTtlMs });
4894
+ result.set(name, price);
4895
+ }
4896
+ }
4897
+ for (const name of uncachedNames) {
4898
+ if (!result.has(name)) {
4899
+ this.cache.set(name, { price: null, expiresAt: now + this.cacheTtlMs });
4900
+ }
4901
+ }
4902
+ if (this.debug) {
4903
+ console.log(`[CoinGecko] Fetched ${result.size} prices`);
4904
+ }
4905
+ } catch (error) {
4906
+ if (this.debug) {
4907
+ console.warn("[CoinGecko] Fetch failed, using stale cache:", error);
4908
+ }
4909
+ for (const name of uncachedNames) {
4910
+ const stale = this.cache.get(name);
4911
+ if (stale?.price) {
4912
+ result.set(name, stale.price);
4913
+ }
4914
+ }
4915
+ }
4916
+ return result;
4917
+ }
4918
+ async getPrice(tokenName) {
4919
+ const prices = await this.getPrices([tokenName]);
4920
+ return prices.get(tokenName) ?? null;
4921
+ }
4922
+ clearCache() {
4923
+ this.cache.clear();
4924
+ }
4925
+ };
4926
+
4927
+ // price/index.ts
4928
+ function createPriceProvider(config) {
4929
+ switch (config.platform) {
4930
+ case "coingecko":
4931
+ return new CoinGeckoPriceProvider(config);
4932
+ default:
4933
+ throw new Error(`Unsupported price platform: ${String(config.platform)}`);
4934
+ }
4935
+ }
4936
+
4937
+ // impl/shared/resolvers.ts
4938
+ function getNetworkConfig(network = "mainnet") {
4939
+ return NETWORKS[network];
4940
+ }
4941
+ function resolveTransportConfig(network, config) {
4942
+ const networkConfig = getNetworkConfig(network);
4943
+ let relays;
4944
+ if (config?.relays) {
4945
+ relays = config.relays;
4946
+ } else {
4947
+ relays = [...networkConfig.nostrRelays];
4948
+ if (config?.additionalRelays) {
4949
+ relays = [...relays, ...config.additionalRelays];
4950
+ }
4951
+ }
4952
+ return {
4953
+ relays,
4954
+ timeout: config?.timeout,
4955
+ autoReconnect: config?.autoReconnect,
4956
+ debug: config?.debug,
4957
+ // Browser-specific
4958
+ reconnectDelay: config?.reconnectDelay,
4959
+ maxReconnectAttempts: config?.maxReconnectAttempts
4960
+ };
4961
+ }
4962
+ function resolveOracleConfig(network, config) {
4963
+ const networkConfig = getNetworkConfig(network);
4964
+ return {
4965
+ url: config?.url ?? networkConfig.aggregatorUrl,
4966
+ apiKey: config?.apiKey ?? DEFAULT_AGGREGATOR_API_KEY,
4967
+ timeout: config?.timeout,
4968
+ skipVerification: config?.skipVerification,
4969
+ debug: config?.debug,
4970
+ // Node.js-specific
4971
+ trustBasePath: config?.trustBasePath
4972
+ };
4973
+ }
4974
+ function resolveL1Config(network, config) {
4975
+ if (config === void 0) {
4976
+ return void 0;
4977
+ }
4978
+ const networkConfig = getNetworkConfig(network);
4979
+ return {
4980
+ electrumUrl: config.electrumUrl ?? networkConfig.electrumUrl,
4981
+ defaultFeeRate: config.defaultFeeRate,
4982
+ enableVesting: config.enableVesting
4983
+ };
4984
+ }
4985
+ function resolvePriceConfig(config) {
4986
+ if (config === void 0) {
4987
+ return void 0;
4988
+ }
4989
+ return {
4990
+ platform: config.platform ?? "coingecko",
4991
+ apiKey: config.apiKey,
4992
+ baseUrl: config.baseUrl,
4993
+ cacheTtlMs: config.cacheTtlMs,
4994
+ timeout: config.timeout,
4995
+ debug: config.debug
4996
+ };
4997
+ }
4998
+
4999
+ // impl/nodejs/index.ts
5000
+ function createNodeProviders(config) {
5001
+ const network = config?.network ?? "mainnet";
5002
+ const transportConfig = resolveTransportConfig(network, config?.transport);
5003
+ const oracleConfig = resolveOracleConfig(network, config?.oracle);
5004
+ const l1Config = resolveL1Config(network, config?.l1);
5005
+ const priceConfig = resolvePriceConfig(config?.price);
5006
+ const storage = createFileStorageProvider({
5007
+ dataDir: config?.dataDir ?? "./sphere-data"
5008
+ });
5009
+ const ipfsSync = config?.tokenSync?.ipfs;
5010
+ const ipfsTokenStorage = ipfsSync?.enabled ? createNodeIpfsStorageProvider(ipfsSync.config, storage) : void 0;
5011
+ return {
5012
+ storage,
2965
5013
  tokenStorage: createFileTokenStorageProvider({
2966
5014
  tokensDir: config?.tokensDir ?? "./sphere-tokens"
2967
5015
  }),
@@ -2969,7 +5017,8 @@ function createNodeProviders(config) {
2969
5017
  relays: transportConfig.relays,
2970
5018
  timeout: transportConfig.timeout,
2971
5019
  autoReconnect: transportConfig.autoReconnect,
2972
- debug: transportConfig.debug
5020
+ debug: transportConfig.debug,
5021
+ storage
2973
5022
  }),
2974
5023
  oracle: createUnicityAggregatorProvider({
2975
5024
  url: oracleConfig.url,
@@ -2981,7 +5030,8 @@ function createNodeProviders(config) {
2981
5030
  network
2982
5031
  }),
2983
5032
  l1: l1Config,
2984
- price: priceConfig ? createPriceProvider(priceConfig) : void 0
5033
+ price: priceConfig ? createPriceProvider(priceConfig) : void 0,
5034
+ ipfsTokenStorage
2985
5035
  };
2986
5036
  }
2987
5037
  // Annotate the CommonJS export names for ESM import in node: