@unicitylabs/sphere-sdk 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,17 @@ var STORAGE_KEYS_GLOBAL = {
26
26
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
27
27
  TRACKED_ADDRESSES: "tracked_addresses",
28
28
  /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
29
- LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
29
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts",
30
+ /** Group chat: joined groups */
31
+ GROUP_CHAT_GROUPS: "group_chat_groups",
32
+ /** Group chat: messages */
33
+ GROUP_CHAT_MESSAGES: "group_chat_messages",
34
+ /** Group chat: members */
35
+ GROUP_CHAT_MEMBERS: "group_chat_members",
36
+ /** Group chat: processed event IDs for deduplication */
37
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
38
+ /** Group chat: last used relay URL (stale data detection) */
39
+ GROUP_CHAT_RELAY_URL: "group_chat_relay_url"
30
40
  };
31
41
  var STORAGE_KEYS_ADDRESS = {
32
42
  /** Pending transfers for this address */
@@ -38,7 +48,9 @@ var STORAGE_KEYS_ADDRESS = {
38
48
  /** Messages for this address */
39
49
  MESSAGES: "messages",
40
50
  /** Transaction history for this address */
41
- TRANSACTION_HISTORY: "transaction_history"
51
+ TRANSACTION_HISTORY: "transaction_history",
52
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
53
+ PENDING_V5_TOKENS: "pending_v5_tokens"
42
54
  };
43
55
  var STORAGE_KEYS = {
44
56
  ...STORAGE_KEYS_GLOBAL,
@@ -85,6 +97,19 @@ var DEFAULT_IPFS_GATEWAYS = [
85
97
  "https://dweb.link",
86
98
  "https://ipfs.io"
87
99
  ];
100
+ var UNICITY_IPFS_NODES = [
101
+ {
102
+ host: "unicity-ipfs1.dyndns.org",
103
+ peerId: "12D3KooWDKJqEMAhH4nsSSiKtK1VLcas5coUqSPZAfbWbZpxtL4u",
104
+ httpPort: 9080,
105
+ httpsPort: 443
106
+ }
107
+ ];
108
+ function getIpfsGatewayUrls(isSecure) {
109
+ return UNICITY_IPFS_NODES.map(
110
+ (node) => isSecure !== false ? `https://${node.host}` : `http://${node.host}:${node.httpPort}`
111
+ );
112
+ }
88
113
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
89
114
  var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
90
115
  var DEFAULT_ELECTRUM_URL = "wss://fulcrum.alpha.unicity.network:50004";
@@ -92,27 +117,33 @@ var TEST_ELECTRUM_URL = "wss://fulcrum.alpha.testnet.unicity.network:50004";
92
117
  var TEST_NOSTR_RELAYS = [
93
118
  "wss://nostr-relay.testnet.unicity.network"
94
119
  ];
120
+ var DEFAULT_GROUP_RELAYS = [
121
+ "wss://sphere-relay.unicity.network"
122
+ ];
95
123
  var NETWORKS = {
96
124
  mainnet: {
97
125
  name: "Mainnet",
98
126
  aggregatorUrl: DEFAULT_AGGREGATOR_URL,
99
127
  nostrRelays: DEFAULT_NOSTR_RELAYS,
100
128
  ipfsGateways: DEFAULT_IPFS_GATEWAYS,
101
- electrumUrl: DEFAULT_ELECTRUM_URL
129
+ electrumUrl: DEFAULT_ELECTRUM_URL,
130
+ groupRelays: DEFAULT_GROUP_RELAYS
102
131
  },
103
132
  testnet: {
104
133
  name: "Testnet",
105
134
  aggregatorUrl: TEST_AGGREGATOR_URL,
106
135
  nostrRelays: TEST_NOSTR_RELAYS,
107
136
  ipfsGateways: DEFAULT_IPFS_GATEWAYS,
108
- electrumUrl: TEST_ELECTRUM_URL
137
+ electrumUrl: TEST_ELECTRUM_URL,
138
+ groupRelays: DEFAULT_GROUP_RELAYS
109
139
  },
110
140
  dev: {
111
141
  name: "Development",
112
142
  aggregatorUrl: DEV_AGGREGATOR_URL,
113
143
  nostrRelays: TEST_NOSTR_RELAYS,
114
144
  ipfsGateways: DEFAULT_IPFS_GATEWAYS,
115
- electrumUrl: TEST_ELECTRUM_URL
145
+ electrumUrl: TEST_ELECTRUM_URL,
146
+ groupRelays: DEFAULT_GROUP_RELAYS
116
147
  }
117
148
  };
118
149
  var TIMEOUTS = {
@@ -532,29 +563,6 @@ var IndexedDBTokenStorageProvider = class {
532
563
  }
533
564
  }
534
565
  // =========================================================================
535
- // Helper methods for individual token operations
536
- // =========================================================================
537
- async deleteToken(tokenId) {
538
- if (this.db) {
539
- await this.deleteFromStore(STORE_TOKENS, tokenId);
540
- }
541
- }
542
- async saveToken(tokenId, tokenData) {
543
- if (this.db) {
544
- await this.putToStore(STORE_TOKENS, tokenId, { id: tokenId, data: tokenData });
545
- }
546
- }
547
- async getToken(tokenId) {
548
- if (!this.db) return null;
549
- const result = await this.getFromStore(STORE_TOKENS, tokenId);
550
- return result?.data ?? null;
551
- }
552
- async listTokenIds() {
553
- if (!this.db) return [];
554
- const tokens = await this.getAllFromStore(STORE_TOKENS);
555
- return tokens.map((t) => t.id);
556
- }
557
- // =========================================================================
558
566
  // Private IndexedDB helpers
559
567
  // =========================================================================
560
568
  openDatabase() {
@@ -1206,6 +1214,13 @@ function publicKeyToAddress(publicKey, prefix = "alpha", witnessVersion = 0) {
1206
1214
  const programBytes = hash160ToBytes(pubKeyHash);
1207
1215
  return encodeBech32(prefix, witnessVersion, programBytes);
1208
1216
  }
1217
+ function hexToBytes(hex) {
1218
+ const matches = hex.match(/../g);
1219
+ if (!matches) {
1220
+ return new Uint8Array(0);
1221
+ }
1222
+ return Uint8Array.from(matches.map((x) => parseInt(x, 16)));
1223
+ }
1209
1224
 
1210
1225
  // transport/websocket.ts
1211
1226
  var WebSocketReadyState = {
@@ -1301,6 +1316,7 @@ var NostrTransportProvider = class {
1301
1316
  nostrClient = null;
1302
1317
  mainSubscriptionId = null;
1303
1318
  // Event handlers
1319
+ processedEventIds = /* @__PURE__ */ new Set();
1304
1320
  messageHandlers = /* @__PURE__ */ new Set();
1305
1321
  transferHandlers = /* @__PURE__ */ new Set();
1306
1322
  paymentRequestHandlers = /* @__PURE__ */ new Set();
@@ -2041,6 +2057,12 @@ var NostrTransportProvider = class {
2041
2057
  // Private: Message Handling
2042
2058
  // ===========================================================================
2043
2059
  async handleEvent(event) {
2060
+ if (event.id && this.processedEventIds.has(event.id)) {
2061
+ return;
2062
+ }
2063
+ if (event.id) {
2064
+ this.processedEventIds.add(event.id);
2065
+ }
2044
2066
  this.log("Processing event kind:", event.kind, "id:", event.id?.slice(0, 12));
2045
2067
  try {
2046
2068
  switch (event.kind) {
@@ -2153,7 +2175,7 @@ var NostrTransportProvider = class {
2153
2175
  this.emitEvent({ type: "transfer:received", timestamp: Date.now() });
2154
2176
  for (const handler of this.transferHandlers) {
2155
2177
  try {
2156
- handler(transfer);
2178
+ await handler(transfer);
2157
2179
  } catch (error) {
2158
2180
  this.log("Transfer handler error:", error);
2159
2181
  }
@@ -2283,6 +2305,49 @@ var NostrTransportProvider = class {
2283
2305
  const sdkEvent = NostrEventClass.fromJSON(event);
2284
2306
  await this.nostrClient.publishEvent(sdkEvent);
2285
2307
  }
2308
+ async fetchPendingEvents() {
2309
+ if (!this.nostrClient?.isConnected() || !this.keyManager) {
2310
+ throw new Error("Transport not connected");
2311
+ }
2312
+ const nostrPubkey = this.keyManager.getPublicKeyHex();
2313
+ const walletFilter = new Filter();
2314
+ walletFilter.kinds = [
2315
+ EVENT_KINDS.DIRECT_MESSAGE,
2316
+ EVENT_KINDS.TOKEN_TRANSFER,
2317
+ EVENT_KINDS.PAYMENT_REQUEST,
2318
+ EVENT_KINDS.PAYMENT_REQUEST_RESPONSE
2319
+ ];
2320
+ walletFilter["#p"] = [nostrPubkey];
2321
+ walletFilter.since = Math.floor(Date.now() / 1e3) - 86400;
2322
+ const events = [];
2323
+ await new Promise((resolve) => {
2324
+ const timeout = setTimeout(() => {
2325
+ if (subId) this.nostrClient?.unsubscribe(subId);
2326
+ resolve();
2327
+ }, 5e3);
2328
+ const subId = this.nostrClient.subscribe(walletFilter, {
2329
+ onEvent: (event) => {
2330
+ events.push({
2331
+ id: event.id,
2332
+ kind: event.kind,
2333
+ content: event.content,
2334
+ tags: event.tags,
2335
+ pubkey: event.pubkey,
2336
+ created_at: event.created_at,
2337
+ sig: event.sig
2338
+ });
2339
+ },
2340
+ onEndOfStoredEvents: () => {
2341
+ clearTimeout(timeout);
2342
+ this.nostrClient?.unsubscribe(subId);
2343
+ resolve();
2344
+ }
2345
+ });
2346
+ });
2347
+ for (const event of events) {
2348
+ await this.handleEvent(event);
2349
+ }
2350
+ }
2286
2351
  async queryEvents(filterObj) {
2287
2352
  if (!this.nostrClient || !this.nostrClient.isConnected()) {
2288
2353
  throw new Error("No connected relays");
@@ -3045,183 +3110,2042 @@ async function readFileAsUint8Array(file) {
3045
3110
  return new Uint8Array(buffer);
3046
3111
  }
3047
3112
 
3048
- // price/CoinGeckoPriceProvider.ts
3049
- var CoinGeckoPriceProvider = class {
3050
- platform = "coingecko";
3051
- cache = /* @__PURE__ */ new Map();
3052
- apiKey;
3053
- cacheTtlMs;
3054
- timeout;
3055
- debug;
3056
- baseUrl;
3057
- constructor(config) {
3058
- this.apiKey = config?.apiKey;
3059
- this.cacheTtlMs = config?.cacheTtlMs ?? 6e4;
3060
- this.timeout = config?.timeout ?? 1e4;
3061
- this.debug = config?.debug ?? false;
3062
- this.baseUrl = config?.baseUrl ?? (this.apiKey ? "https://pro-api.coingecko.com/api/v3" : "https://api.coingecko.com/api/v3");
3113
+ // impl/shared/ipfs/ipfs-error-types.ts
3114
+ var IpfsError = class extends Error {
3115
+ category;
3116
+ gateway;
3117
+ cause;
3118
+ constructor(message, category, gateway, cause) {
3119
+ super(message);
3120
+ this.name = "IpfsError";
3121
+ this.category = category;
3122
+ this.gateway = gateway;
3123
+ this.cause = cause;
3124
+ }
3125
+ /** Whether this error should trigger the circuit breaker */
3126
+ get shouldTriggerCircuitBreaker() {
3127
+ return this.category !== "NOT_FOUND" && this.category !== "SEQUENCE_DOWNGRADE";
3063
3128
  }
3064
- async getPrices(tokenNames) {
3065
- if (tokenNames.length === 0) {
3066
- return /* @__PURE__ */ new Map();
3067
- }
3068
- const now = Date.now();
3069
- const result = /* @__PURE__ */ new Map();
3070
- const uncachedNames = [];
3071
- for (const name of tokenNames) {
3072
- const cached = this.cache.get(name);
3073
- if (cached && cached.expiresAt > now) {
3074
- if (cached.price !== null) {
3075
- result.set(name, cached.price);
3076
- }
3077
- } else {
3078
- uncachedNames.push(name);
3079
- }
3080
- }
3081
- if (uncachedNames.length === 0) {
3082
- return result;
3083
- }
3084
- try {
3085
- const ids = uncachedNames.join(",");
3086
- const url = `${this.baseUrl}/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=usd,eur&include_24hr_change=true`;
3087
- const headers = { Accept: "application/json" };
3088
- if (this.apiKey) {
3089
- headers["x-cg-pro-api-key"] = this.apiKey;
3090
- }
3091
- if (this.debug) {
3092
- console.log(`[CoinGecko] Fetching prices for: ${uncachedNames.join(", ")}`);
3093
- }
3094
- const response = await fetch(url, {
3095
- headers,
3096
- signal: AbortSignal.timeout(this.timeout)
3097
- });
3098
- if (!response.ok) {
3099
- throw new Error(`CoinGecko API error: ${response.status} ${response.statusText}`);
3100
- }
3101
- const data = await response.json();
3102
- for (const [name, values] of Object.entries(data)) {
3103
- if (values && typeof values === "object") {
3104
- const price = {
3105
- tokenName: name,
3106
- priceUsd: values.usd ?? 0,
3107
- priceEur: values.eur,
3108
- change24h: values.usd_24h_change,
3109
- timestamp: now
3110
- };
3111
- this.cache.set(name, { price, expiresAt: now + this.cacheTtlMs });
3112
- result.set(name, price);
3113
- }
3114
- }
3115
- for (const name of uncachedNames) {
3116
- if (!result.has(name)) {
3117
- this.cache.set(name, { price: null, expiresAt: now + this.cacheTtlMs });
3118
- }
3119
- }
3120
- if (this.debug) {
3121
- console.log(`[CoinGecko] Fetched ${result.size} prices`);
3122
- }
3123
- } catch (error) {
3124
- if (this.debug) {
3125
- console.warn("[CoinGecko] Fetch failed, using stale cache:", error);
3126
- }
3127
- for (const name of uncachedNames) {
3128
- const stale = this.cache.get(name);
3129
- if (stale?.price) {
3130
- result.set(name, stale.price);
3131
- }
3132
- }
3129
+ };
3130
+ function classifyFetchError(error) {
3131
+ if (error instanceof DOMException && error.name === "AbortError") {
3132
+ return "TIMEOUT";
3133
+ }
3134
+ if (error instanceof TypeError) {
3135
+ return "NETWORK_ERROR";
3136
+ }
3137
+ if (error instanceof Error && error.name === "TimeoutError") {
3138
+ return "TIMEOUT";
3139
+ }
3140
+ return "NETWORK_ERROR";
3141
+ }
3142
+ function classifyHttpStatus(status, responseBody) {
3143
+ if (status === 404) {
3144
+ return "NOT_FOUND";
3145
+ }
3146
+ if (status === 500 && responseBody) {
3147
+ if (/routing:\s*not\s*found/i.test(responseBody)) {
3148
+ return "NOT_FOUND";
3133
3149
  }
3134
- return result;
3135
3150
  }
3136
- async getPrice(tokenName) {
3137
- const prices = await this.getPrices([tokenName]);
3138
- return prices.get(tokenName) ?? null;
3151
+ if (status >= 500) {
3152
+ return "GATEWAY_ERROR";
3139
3153
  }
3140
- clearCache() {
3141
- this.cache.clear();
3154
+ if (status >= 400) {
3155
+ return "GATEWAY_ERROR";
3142
3156
  }
3143
- };
3157
+ return "GATEWAY_ERROR";
3158
+ }
3144
3159
 
3145
- // price/index.ts
3146
- function createPriceProvider(config) {
3147
- switch (config.platform) {
3148
- case "coingecko":
3149
- return new CoinGeckoPriceProvider(config);
3150
- default:
3151
- throw new Error(`Unsupported price platform: ${String(config.platform)}`);
3160
+ // impl/shared/ipfs/ipfs-state-persistence.ts
3161
+ var InMemoryIpfsStatePersistence = class {
3162
+ states = /* @__PURE__ */ new Map();
3163
+ async load(ipnsName) {
3164
+ return this.states.get(ipnsName) ?? null;
3152
3165
  }
3153
- }
3166
+ async save(ipnsName, state) {
3167
+ this.states.set(ipnsName, { ...state });
3168
+ }
3169
+ async clear(ipnsName) {
3170
+ this.states.delete(ipnsName);
3171
+ }
3172
+ };
3154
3173
 
3155
- // impl/shared/resolvers.ts
3156
- function getNetworkConfig(network = "mainnet") {
3157
- return NETWORKS[network];
3158
- }
3159
- function resolveTransportConfig(network, config) {
3160
- const networkConfig = getNetworkConfig(network);
3161
- let relays;
3162
- if (config?.relays) {
3163
- relays = config.relays;
3164
- } else {
3165
- relays = [...networkConfig.nostrRelays];
3166
- if (config?.additionalRelays) {
3167
- relays = [...relays, ...config.additionalRelays];
3168
- }
3174
+ // impl/shared/ipfs/ipns-key-derivation.ts
3175
+ var IPNS_HKDF_INFO = "ipfs-storage-ed25519-v1";
3176
+ var libp2pCryptoModule = null;
3177
+ var libp2pPeerIdModule = null;
3178
+ async function loadLibp2pModules() {
3179
+ if (!libp2pCryptoModule) {
3180
+ [libp2pCryptoModule, libp2pPeerIdModule] = await Promise.all([
3181
+ import("@libp2p/crypto/keys"),
3182
+ import("@libp2p/peer-id")
3183
+ ]);
3169
3184
  }
3170
3185
  return {
3171
- relays,
3172
- timeout: config?.timeout,
3173
- autoReconnect: config?.autoReconnect,
3174
- debug: config?.debug,
3175
- // Browser-specific
3176
- reconnectDelay: config?.reconnectDelay,
3177
- maxReconnectAttempts: config?.maxReconnectAttempts
3186
+ generateKeyPairFromSeed: libp2pCryptoModule.generateKeyPairFromSeed,
3187
+ peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey
3178
3188
  };
3179
3189
  }
3180
- function resolveOracleConfig(network, config) {
3181
- const networkConfig = getNetworkConfig(network);
3190
+ function deriveEd25519KeyMaterial(privateKeyHex, info = IPNS_HKDF_INFO) {
3191
+ const walletSecret = hexToBytes(privateKeyHex);
3192
+ const infoBytes = new TextEncoder().encode(info);
3193
+ return hkdf(sha256, walletSecret, void 0, infoBytes, 32);
3194
+ }
3195
+ async function deriveIpnsIdentity(privateKeyHex) {
3196
+ const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadLibp2pModules();
3197
+ const derivedKey = deriveEd25519KeyMaterial(privateKeyHex);
3198
+ const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
3199
+ const peerId = peerIdFromPrivateKey(keyPair);
3182
3200
  return {
3183
- url: config?.url ?? networkConfig.aggregatorUrl,
3184
- apiKey: config?.apiKey ?? DEFAULT_AGGREGATOR_API_KEY,
3185
- timeout: config?.timeout,
3186
- skipVerification: config?.skipVerification,
3187
- debug: config?.debug,
3188
- // Node.js-specific
3189
- trustBasePath: config?.trustBasePath
3201
+ keyPair,
3202
+ ipnsName: peerId.toString()
3190
3203
  };
3191
3204
  }
3192
- function resolveL1Config(network, config) {
3193
- if (config === void 0) {
3194
- return void 0;
3205
+
3206
+ // impl/shared/ipfs/ipns-record-manager.ts
3207
+ var DEFAULT_LIFETIME_MS = 99 * 365 * 24 * 60 * 60 * 1e3;
3208
+ var ipnsModule = null;
3209
+ async function loadIpnsModule() {
3210
+ if (!ipnsModule) {
3211
+ const mod = await import("ipns");
3212
+ ipnsModule = {
3213
+ createIPNSRecord: mod.createIPNSRecord,
3214
+ marshalIPNSRecord: mod.marshalIPNSRecord,
3215
+ unmarshalIPNSRecord: mod.unmarshalIPNSRecord
3216
+ };
3195
3217
  }
3196
- const networkConfig = getNetworkConfig(network);
3197
- return {
3198
- electrumUrl: config.electrumUrl ?? networkConfig.electrumUrl,
3199
- defaultFeeRate: config.defaultFeeRate,
3200
- enableVesting: config.enableVesting
3201
- };
3218
+ return ipnsModule;
3202
3219
  }
3203
- function resolvePriceConfig(config) {
3204
- if (config === void 0) {
3205
- return void 0;
3220
+ async function createSignedRecord(keyPair, cid, sequenceNumber, lifetimeMs = DEFAULT_LIFETIME_MS) {
3221
+ const { createIPNSRecord, marshalIPNSRecord } = await loadIpnsModule();
3222
+ const record = await createIPNSRecord(
3223
+ keyPair,
3224
+ `/ipfs/${cid}`,
3225
+ sequenceNumber,
3226
+ lifetimeMs
3227
+ );
3228
+ return marshalIPNSRecord(record);
3229
+ }
3230
+ async function parseRoutingApiResponse(responseText) {
3231
+ const { unmarshalIPNSRecord } = await loadIpnsModule();
3232
+ const lines = responseText.trim().split("\n");
3233
+ for (const line of lines) {
3234
+ if (!line.trim()) continue;
3235
+ try {
3236
+ const obj = JSON.parse(line);
3237
+ if (obj.Extra) {
3238
+ const recordData = base64ToUint8Array(obj.Extra);
3239
+ const record = unmarshalIPNSRecord(recordData);
3240
+ const valueBytes = typeof record.value === "string" ? new TextEncoder().encode(record.value) : record.value;
3241
+ const valueStr = new TextDecoder().decode(valueBytes);
3242
+ const cidMatch = valueStr.match(/\/ipfs\/([a-zA-Z0-9]+)/);
3243
+ if (cidMatch) {
3244
+ return {
3245
+ cid: cidMatch[1],
3246
+ sequence: record.sequence,
3247
+ recordData
3248
+ };
3249
+ }
3250
+ }
3251
+ } catch {
3252
+ continue;
3253
+ }
3206
3254
  }
3207
- return {
3208
- platform: config.platform ?? "coingecko",
3209
- apiKey: config.apiKey,
3210
- baseUrl: config.baseUrl,
3211
- cacheTtlMs: config.cacheTtlMs,
3212
- timeout: config.timeout,
3213
- debug: config.debug
3214
- };
3255
+ return null;
3215
3256
  }
3216
- function resolveArrayConfig(defaults, replace, additional) {
3217
- if (replace) {
3218
- return replace;
3257
+ function base64ToUint8Array(base64) {
3258
+ const binary = atob(base64);
3259
+ const bytes = new Uint8Array(binary.length);
3260
+ for (let i = 0; i < binary.length; i++) {
3261
+ bytes[i] = binary.charCodeAt(i);
3219
3262
  }
3220
- const result = [...defaults];
3221
- if (additional) {
3222
- return [...result, ...additional];
3263
+ return bytes;
3264
+ }
3265
+
3266
+ // impl/shared/ipfs/ipfs-cache.ts
3267
+ var DEFAULT_IPNS_TTL_MS = 6e4;
3268
+ var DEFAULT_FAILURE_COOLDOWN_MS = 6e4;
3269
+ var DEFAULT_FAILURE_THRESHOLD = 3;
3270
+ var DEFAULT_KNOWN_FRESH_WINDOW_MS = 3e4;
3271
+ var IpfsCache = class {
3272
+ ipnsRecords = /* @__PURE__ */ new Map();
3273
+ content = /* @__PURE__ */ new Map();
3274
+ gatewayFailures = /* @__PURE__ */ new Map();
3275
+ knownFreshTimestamps = /* @__PURE__ */ new Map();
3276
+ ipnsTtlMs;
3277
+ failureCooldownMs;
3278
+ failureThreshold;
3279
+ knownFreshWindowMs;
3280
+ constructor(config) {
3281
+ this.ipnsTtlMs = config?.ipnsTtlMs ?? DEFAULT_IPNS_TTL_MS;
3282
+ this.failureCooldownMs = config?.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;
3283
+ this.failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
3284
+ this.knownFreshWindowMs = config?.knownFreshWindowMs ?? DEFAULT_KNOWN_FRESH_WINDOW_MS;
3285
+ }
3286
+ // ---------------------------------------------------------------------------
3287
+ // IPNS Record Cache (60s TTL)
3288
+ // ---------------------------------------------------------------------------
3289
+ getIpnsRecord(ipnsName) {
3290
+ const entry = this.ipnsRecords.get(ipnsName);
3291
+ if (!entry) return null;
3292
+ if (Date.now() - entry.timestamp > this.ipnsTtlMs) {
3293
+ this.ipnsRecords.delete(ipnsName);
3294
+ return null;
3295
+ }
3296
+ return entry.data;
3223
3297
  }
3224
- return result;
3298
+ /**
3299
+ * Get cached IPNS record ignoring TTL (for known-fresh optimization).
3300
+ */
3301
+ getIpnsRecordIgnoreTtl(ipnsName) {
3302
+ const entry = this.ipnsRecords.get(ipnsName);
3303
+ return entry?.data ?? null;
3304
+ }
3305
+ setIpnsRecord(ipnsName, result) {
3306
+ this.ipnsRecords.set(ipnsName, {
3307
+ data: result,
3308
+ timestamp: Date.now()
3309
+ });
3310
+ }
3311
+ invalidateIpns(ipnsName) {
3312
+ this.ipnsRecords.delete(ipnsName);
3313
+ }
3314
+ // ---------------------------------------------------------------------------
3315
+ // Content Cache (infinite TTL - content is immutable by CID)
3316
+ // ---------------------------------------------------------------------------
3317
+ getContent(cid) {
3318
+ const entry = this.content.get(cid);
3319
+ return entry?.data ?? null;
3320
+ }
3321
+ setContent(cid, data) {
3322
+ this.content.set(cid, {
3323
+ data,
3324
+ timestamp: Date.now()
3325
+ });
3326
+ }
3327
+ // ---------------------------------------------------------------------------
3328
+ // Gateway Failure Tracking (Circuit Breaker)
3329
+ // ---------------------------------------------------------------------------
3330
+ /**
3331
+ * Record a gateway failure. After threshold consecutive failures,
3332
+ * the gateway enters cooldown and is considered unhealthy.
3333
+ */
3334
+ recordGatewayFailure(gateway) {
3335
+ const existing = this.gatewayFailures.get(gateway);
3336
+ this.gatewayFailures.set(gateway, {
3337
+ count: (existing?.count ?? 0) + 1,
3338
+ lastFailure: Date.now()
3339
+ });
3340
+ }
3341
+ /** Reset failure count for a gateway (on successful request) */
3342
+ recordGatewaySuccess(gateway) {
3343
+ this.gatewayFailures.delete(gateway);
3344
+ }
3345
+ /**
3346
+ * Check if a gateway is currently in circuit breaker cooldown.
3347
+ * A gateway is considered unhealthy if it has had >= threshold
3348
+ * consecutive failures and the cooldown period hasn't elapsed.
3349
+ */
3350
+ isGatewayInCooldown(gateway) {
3351
+ const failure = this.gatewayFailures.get(gateway);
3352
+ if (!failure) return false;
3353
+ if (failure.count < this.failureThreshold) return false;
3354
+ const elapsed = Date.now() - failure.lastFailure;
3355
+ if (elapsed >= this.failureCooldownMs) {
3356
+ this.gatewayFailures.delete(gateway);
3357
+ return false;
3358
+ }
3359
+ return true;
3360
+ }
3361
+ // ---------------------------------------------------------------------------
3362
+ // Known-Fresh Flag (FAST mode optimization)
3363
+ // ---------------------------------------------------------------------------
3364
+ /**
3365
+ * Mark IPNS cache as "known-fresh" (after local publish or push notification).
3366
+ * Within the fresh window, we can skip network resolution.
3367
+ */
3368
+ markIpnsFresh(ipnsName) {
3369
+ this.knownFreshTimestamps.set(ipnsName, Date.now());
3370
+ }
3371
+ /**
3372
+ * Check if the cache is known-fresh (within the fresh window).
3373
+ */
3374
+ isIpnsKnownFresh(ipnsName) {
3375
+ const timestamp = this.knownFreshTimestamps.get(ipnsName);
3376
+ if (!timestamp) return false;
3377
+ if (Date.now() - timestamp > this.knownFreshWindowMs) {
3378
+ this.knownFreshTimestamps.delete(ipnsName);
3379
+ return false;
3380
+ }
3381
+ return true;
3382
+ }
3383
+ // ---------------------------------------------------------------------------
3384
+ // Cache Management
3385
+ // ---------------------------------------------------------------------------
3386
+ clear() {
3387
+ this.ipnsRecords.clear();
3388
+ this.content.clear();
3389
+ this.gatewayFailures.clear();
3390
+ this.knownFreshTimestamps.clear();
3391
+ }
3392
+ };
3393
+
3394
+ // impl/shared/ipfs/ipfs-http-client.ts
3395
+ var DEFAULT_CONNECTIVITY_TIMEOUT_MS = 5e3;
3396
+ var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
3397
+ var DEFAULT_RESOLVE_TIMEOUT_MS = 1e4;
3398
+ var DEFAULT_PUBLISH_TIMEOUT_MS = 3e4;
3399
+ var DEFAULT_GATEWAY_PATH_TIMEOUT_MS = 3e3;
3400
+ var DEFAULT_ROUTING_API_TIMEOUT_MS = 2e3;
3401
+ var IpfsHttpClient = class {
3402
+ gateways;
3403
+ fetchTimeoutMs;
3404
+ resolveTimeoutMs;
3405
+ publishTimeoutMs;
3406
+ connectivityTimeoutMs;
3407
+ debug;
3408
+ cache;
3409
+ constructor(config, cache) {
3410
+ this.gateways = config.gateways;
3411
+ this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3412
+ this.resolveTimeoutMs = config.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;
3413
+ this.publishTimeoutMs = config.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS;
3414
+ this.connectivityTimeoutMs = config.connectivityTimeoutMs ?? DEFAULT_CONNECTIVITY_TIMEOUT_MS;
3415
+ this.debug = config.debug ?? false;
3416
+ this.cache = cache;
3417
+ }
3418
+ // ---------------------------------------------------------------------------
3419
+ // Public Accessors
3420
+ // ---------------------------------------------------------------------------
3421
+ /**
3422
+ * Get configured gateway URLs.
3423
+ */
3424
+ getGateways() {
3425
+ return [...this.gateways];
3426
+ }
3427
+ // ---------------------------------------------------------------------------
3428
+ // Gateway Health
3429
+ // ---------------------------------------------------------------------------
3430
+ /**
3431
+ * Test connectivity to a single gateway.
3432
+ */
3433
+ async testConnectivity(gateway) {
3434
+ const start = Date.now();
3435
+ try {
3436
+ const response = await this.fetchWithTimeout(
3437
+ `${gateway}/api/v0/version`,
3438
+ this.connectivityTimeoutMs,
3439
+ { method: "POST" }
3440
+ );
3441
+ if (!response.ok) {
3442
+ return { gateway, healthy: false, error: `HTTP ${response.status}` };
3443
+ }
3444
+ return {
3445
+ gateway,
3446
+ healthy: true,
3447
+ responseTimeMs: Date.now() - start
3448
+ };
3449
+ } catch (error) {
3450
+ return {
3451
+ gateway,
3452
+ healthy: false,
3453
+ error: error instanceof Error ? error.message : String(error)
3454
+ };
3455
+ }
3456
+ }
3457
+ /**
3458
+ * Find healthy gateways from the configured list.
3459
+ */
3460
+ async findHealthyGateways() {
3461
+ const results = await Promise.allSettled(
3462
+ this.gateways.map((gw) => this.testConnectivity(gw))
3463
+ );
3464
+ return results.filter((r) => r.status === "fulfilled" && r.value.healthy).map((r) => r.value.gateway);
3465
+ }
3466
+ /**
3467
+ * Get gateways that are not in circuit breaker cooldown.
3468
+ */
3469
+ getAvailableGateways() {
3470
+ return this.gateways.filter((gw) => !this.cache.isGatewayInCooldown(gw));
3471
+ }
3472
+ // ---------------------------------------------------------------------------
3473
+ // Content Upload
3474
+ // ---------------------------------------------------------------------------
3475
+ /**
3476
+ * Upload JSON content to IPFS.
3477
+ * Tries all gateways in parallel, returns first success.
3478
+ */
3479
+ async upload(data, gateways) {
3480
+ const targets = gateways ?? this.getAvailableGateways();
3481
+ if (targets.length === 0) {
3482
+ throw new IpfsError("No gateways available for upload", "NETWORK_ERROR");
3483
+ }
3484
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(data));
3485
+ const promises = targets.map(async (gateway) => {
3486
+ try {
3487
+ const formData = new FormData();
3488
+ formData.append("file", new Blob([jsonBytes], { type: "application/json" }), "data.json");
3489
+ const response = await this.fetchWithTimeout(
3490
+ `${gateway}/api/v0/add?pin=true&cid-version=1`,
3491
+ this.publishTimeoutMs,
3492
+ { method: "POST", body: formData }
3493
+ );
3494
+ if (!response.ok) {
3495
+ throw new IpfsError(
3496
+ `Upload failed: HTTP ${response.status}`,
3497
+ classifyHttpStatus(response.status),
3498
+ gateway
3499
+ );
3500
+ }
3501
+ const result = await response.json();
3502
+ this.cache.recordGatewaySuccess(gateway);
3503
+ this.log(`Uploaded to ${gateway}: CID=${result.Hash}`);
3504
+ return { cid: result.Hash, gateway };
3505
+ } catch (error) {
3506
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3507
+ this.cache.recordGatewayFailure(gateway);
3508
+ }
3509
+ throw error;
3510
+ }
3511
+ });
3512
+ try {
3513
+ const result = await Promise.any(promises);
3514
+ return { cid: result.cid };
3515
+ } catch (error) {
3516
+ if (error instanceof AggregateError) {
3517
+ throw new IpfsError(
3518
+ `Upload failed on all gateways: ${error.errors.map((e) => e.message).join("; ")}`,
3519
+ "NETWORK_ERROR"
3520
+ );
3521
+ }
3522
+ throw error;
3523
+ }
3524
+ }
3525
+ // ---------------------------------------------------------------------------
3526
+ // Content Fetch
3527
+ // ---------------------------------------------------------------------------
3528
+ /**
3529
+ * Fetch content by CID from IPFS gateways.
3530
+ * Checks content cache first. Races all gateways for fastest response.
3531
+ */
3532
+ async fetchContent(cid, gateways) {
3533
+ const cached = this.cache.getContent(cid);
3534
+ if (cached) {
3535
+ this.log(`Content cache hit for CID=${cid}`);
3536
+ return cached;
3537
+ }
3538
+ const targets = gateways ?? this.getAvailableGateways();
3539
+ if (targets.length === 0) {
3540
+ throw new IpfsError("No gateways available for fetch", "NETWORK_ERROR");
3541
+ }
3542
+ const promises = targets.map(async (gateway) => {
3543
+ try {
3544
+ const response = await this.fetchWithTimeout(
3545
+ `${gateway}/ipfs/${cid}`,
3546
+ this.fetchTimeoutMs,
3547
+ { headers: { Accept: "application/octet-stream" } }
3548
+ );
3549
+ if (!response.ok) {
3550
+ const body = await response.text().catch(() => "");
3551
+ throw new IpfsError(
3552
+ `Fetch failed: HTTP ${response.status}`,
3553
+ classifyHttpStatus(response.status, body),
3554
+ gateway
3555
+ );
3556
+ }
3557
+ const data = await response.json();
3558
+ this.cache.recordGatewaySuccess(gateway);
3559
+ this.cache.setContent(cid, data);
3560
+ this.log(`Fetched from ${gateway}: CID=${cid}`);
3561
+ return data;
3562
+ } catch (error) {
3563
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3564
+ this.cache.recordGatewayFailure(gateway);
3565
+ }
3566
+ throw error;
3567
+ }
3568
+ });
3569
+ try {
3570
+ return await Promise.any(promises);
3571
+ } catch (error) {
3572
+ if (error instanceof AggregateError) {
3573
+ throw new IpfsError(
3574
+ `Fetch failed on all gateways for CID=${cid}`,
3575
+ "NETWORK_ERROR"
3576
+ );
3577
+ }
3578
+ throw error;
3579
+ }
3580
+ }
3581
+ // ---------------------------------------------------------------------------
3582
+ // IPNS Resolution
3583
+ // ---------------------------------------------------------------------------
3584
+ /**
3585
+ * Resolve IPNS via Routing API (returns record with sequence number).
3586
+ * POST /api/v0/routing/get?arg=/ipns/{name}
3587
+ */
3588
+ async resolveIpnsViaRoutingApi(gateway, ipnsName, timeoutMs = DEFAULT_ROUTING_API_TIMEOUT_MS) {
3589
+ try {
3590
+ const response = await this.fetchWithTimeout(
3591
+ `${gateway}/api/v0/routing/get?arg=/ipns/${ipnsName}`,
3592
+ timeoutMs,
3593
+ { method: "POST" }
3594
+ );
3595
+ if (!response.ok) {
3596
+ const body = await response.text().catch(() => "");
3597
+ const category = classifyHttpStatus(response.status, body);
3598
+ if (category === "NOT_FOUND") return null;
3599
+ throw new IpfsError(`Routing API: HTTP ${response.status}`, category, gateway);
3600
+ }
3601
+ const text = await response.text();
3602
+ const parsed = await parseRoutingApiResponse(text);
3603
+ if (!parsed) return null;
3604
+ this.cache.recordGatewaySuccess(gateway);
3605
+ return {
3606
+ cid: parsed.cid,
3607
+ sequence: parsed.sequence,
3608
+ gateway,
3609
+ recordData: parsed.recordData
3610
+ };
3611
+ } catch (error) {
3612
+ if (error instanceof IpfsError) throw error;
3613
+ const category = classifyFetchError(error);
3614
+ if (category !== "NOT_FOUND") {
3615
+ this.cache.recordGatewayFailure(gateway);
3616
+ }
3617
+ return null;
3618
+ }
3619
+ }
3620
+ /**
3621
+ * Resolve IPNS via gateway path (simpler, no sequence number).
3622
+ * GET /ipns/{name}?format=dag-json
3623
+ */
3624
+ async resolveIpnsViaGatewayPath(gateway, ipnsName, timeoutMs = DEFAULT_GATEWAY_PATH_TIMEOUT_MS) {
3625
+ try {
3626
+ const response = await this.fetchWithTimeout(
3627
+ `${gateway}/ipns/${ipnsName}`,
3628
+ timeoutMs,
3629
+ { headers: { Accept: "application/json" } }
3630
+ );
3631
+ if (!response.ok) return null;
3632
+ const content = await response.json();
3633
+ const cidHeader = response.headers.get("X-Ipfs-Path");
3634
+ if (cidHeader) {
3635
+ const match = cidHeader.match(/\/ipfs\/([a-zA-Z0-9]+)/);
3636
+ if (match) {
3637
+ this.cache.recordGatewaySuccess(gateway);
3638
+ return { cid: match[1], content };
3639
+ }
3640
+ }
3641
+ return { cid: "", content };
3642
+ } catch {
3643
+ return null;
3644
+ }
3645
+ }
3646
+ /**
3647
+ * Progressive IPNS resolution across all gateways.
3648
+ * Queries all gateways in parallel, returns highest sequence number.
3649
+ */
3650
+ async resolveIpns(ipnsName, gateways) {
3651
+ const targets = gateways ?? this.getAvailableGateways();
3652
+ if (targets.length === 0) {
3653
+ return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 };
3654
+ }
3655
+ const results = [];
3656
+ let respondedCount = 0;
3657
+ const promises = targets.map(async (gateway) => {
3658
+ const result = await this.resolveIpnsViaRoutingApi(
3659
+ gateway,
3660
+ ipnsName,
3661
+ this.resolveTimeoutMs
3662
+ );
3663
+ if (result) results.push(result);
3664
+ respondedCount++;
3665
+ return result;
3666
+ });
3667
+ await Promise.race([
3668
+ Promise.allSettled(promises),
3669
+ new Promise((resolve) => setTimeout(resolve, this.resolveTimeoutMs + 1e3))
3670
+ ]);
3671
+ let best = null;
3672
+ for (const result of results) {
3673
+ if (!best || result.sequence > best.sequence) {
3674
+ best = result;
3675
+ }
3676
+ }
3677
+ if (best) {
3678
+ this.cache.setIpnsRecord(ipnsName, best);
3679
+ }
3680
+ return {
3681
+ best,
3682
+ allResults: results,
3683
+ respondedCount,
3684
+ totalGateways: targets.length
3685
+ };
3686
+ }
3687
+ // ---------------------------------------------------------------------------
3688
+ // IPNS Publishing
3689
+ // ---------------------------------------------------------------------------
3690
+ /**
3691
+ * Publish IPNS record to a single gateway via routing API.
3692
+ */
3693
+ async publishIpnsViaRoutingApi(gateway, ipnsName, marshalledRecord, timeoutMs = DEFAULT_PUBLISH_TIMEOUT_MS) {
3694
+ try {
3695
+ const formData = new FormData();
3696
+ formData.append(
3697
+ "file",
3698
+ new Blob([new Uint8Array(marshalledRecord)]),
3699
+ "record"
3700
+ );
3701
+ const response = await this.fetchWithTimeout(
3702
+ `${gateway}/api/v0/routing/put?arg=/ipns/${ipnsName}&allow-offline=true`,
3703
+ timeoutMs,
3704
+ { method: "POST", body: formData }
3705
+ );
3706
+ if (!response.ok) {
3707
+ const errorText = await response.text().catch(() => "");
3708
+ throw new IpfsError(
3709
+ `IPNS publish: HTTP ${response.status}: ${errorText.slice(0, 100)}`,
3710
+ classifyHttpStatus(response.status, errorText),
3711
+ gateway
3712
+ );
3713
+ }
3714
+ this.cache.recordGatewaySuccess(gateway);
3715
+ this.log(`IPNS published to ${gateway}: ${ipnsName}`);
3716
+ return true;
3717
+ } catch (error) {
3718
+ if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
3719
+ this.cache.recordGatewayFailure(gateway);
3720
+ }
3721
+ this.log(`IPNS publish to ${gateway} failed: ${error}`);
3722
+ return false;
3723
+ }
3724
+ }
3725
+ /**
3726
+ * Publish IPNS record to all gateways in parallel.
3727
+ */
3728
+ async publishIpns(ipnsName, marshalledRecord, gateways) {
3729
+ const targets = gateways ?? this.getAvailableGateways();
3730
+ if (targets.length === 0) {
3731
+ return { success: false, error: "No gateways available" };
3732
+ }
3733
+ const results = await Promise.allSettled(
3734
+ targets.map((gw) => this.publishIpnsViaRoutingApi(gw, ipnsName, marshalledRecord, this.publishTimeoutMs))
3735
+ );
3736
+ const successfulGateways = [];
3737
+ results.forEach((result, index) => {
3738
+ if (result.status === "fulfilled" && result.value) {
3739
+ successfulGateways.push(targets[index]);
3740
+ }
3741
+ });
3742
+ return {
3743
+ success: successfulGateways.length > 0,
3744
+ ipnsName,
3745
+ successfulGateways,
3746
+ error: successfulGateways.length === 0 ? "All gateways failed" : void 0
3747
+ };
3748
+ }
3749
+ // ---------------------------------------------------------------------------
3750
+ // IPNS Verification
3751
+ // ---------------------------------------------------------------------------
3752
+ /**
3753
+ * Verify IPNS record persistence after publishing.
3754
+ * Retries resolution to confirm the record was accepted.
3755
+ */
3756
+ async verifyIpnsRecord(ipnsName, expectedSeq, expectedCid, retries = 3, delayMs = 1e3) {
3757
+ for (let i = 0; i < retries; i++) {
3758
+ if (i > 0) {
3759
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
3760
+ }
3761
+ const { best } = await this.resolveIpns(ipnsName);
3762
+ if (best && best.sequence >= expectedSeq && best.cid === expectedCid) {
3763
+ return true;
3764
+ }
3765
+ }
3766
+ return false;
3767
+ }
3768
+ // ---------------------------------------------------------------------------
3769
+ // Helpers
3770
+ // ---------------------------------------------------------------------------
3771
+ async fetchWithTimeout(url, timeoutMs, options) {
3772
+ const controller = new AbortController();
3773
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3774
+ try {
3775
+ return await fetch(url, {
3776
+ ...options,
3777
+ signal: controller.signal
3778
+ });
3779
+ } finally {
3780
+ clearTimeout(timer);
3781
+ }
3782
+ }
3783
+ log(message) {
3784
+ if (this.debug) {
3785
+ console.log(`[IPFS-HTTP] ${message}`);
3786
+ }
3787
+ }
3788
+ };
3789
+
3790
+ // impl/shared/ipfs/txf-merge.ts
3791
+ function mergeTxfData(local, remote) {
3792
+ let added = 0;
3793
+ let removed = 0;
3794
+ let conflicts = 0;
3795
+ const localVersion = local._meta?.version ?? 0;
3796
+ const remoteVersion = remote._meta?.version ?? 0;
3797
+ const baseMeta = localVersion >= remoteVersion ? local._meta : remote._meta;
3798
+ const mergedMeta = {
3799
+ ...baseMeta,
3800
+ version: Math.max(localVersion, remoteVersion) + 1,
3801
+ updatedAt: Date.now()
3802
+ };
3803
+ const mergedTombstones = mergeTombstones(
3804
+ local._tombstones ?? [],
3805
+ remote._tombstones ?? []
3806
+ );
3807
+ const tombstoneKeys = new Set(
3808
+ mergedTombstones.map((t) => `${t.tokenId}:${t.stateHash}`)
3809
+ );
3810
+ const localTokenKeys = getTokenKeys(local);
3811
+ const remoteTokenKeys = getTokenKeys(remote);
3812
+ const allTokenKeys = /* @__PURE__ */ new Set([...localTokenKeys, ...remoteTokenKeys]);
3813
+ const mergedTokens = {};
3814
+ for (const key of allTokenKeys) {
3815
+ const tokenId = key.startsWith("_") ? key.slice(1) : key;
3816
+ const localToken = local[key];
3817
+ const remoteToken = remote[key];
3818
+ if (isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys)) {
3819
+ if (localTokenKeys.has(key)) removed++;
3820
+ continue;
3821
+ }
3822
+ if (localToken && !remoteToken) {
3823
+ mergedTokens[key] = localToken;
3824
+ } else if (!localToken && remoteToken) {
3825
+ mergedTokens[key] = remoteToken;
3826
+ added++;
3827
+ } else if (localToken && remoteToken) {
3828
+ mergedTokens[key] = localToken;
3829
+ conflicts++;
3830
+ }
3831
+ }
3832
+ const mergedOutbox = mergeArrayById(
3833
+ local._outbox ?? [],
3834
+ remote._outbox ?? [],
3835
+ "id"
3836
+ );
3837
+ const mergedSent = mergeArrayById(
3838
+ local._sent ?? [],
3839
+ remote._sent ?? [],
3840
+ "tokenId"
3841
+ );
3842
+ const mergedInvalid = mergeArrayById(
3843
+ local._invalid ?? [],
3844
+ remote._invalid ?? [],
3845
+ "tokenId"
3846
+ );
3847
+ const localNametags = local._nametags ?? [];
3848
+ const remoteNametags = remote._nametags ?? [];
3849
+ const mergedNametags = mergeNametagsByName(localNametags, remoteNametags);
3850
+ const merged = {
3851
+ _meta: mergedMeta,
3852
+ _tombstones: mergedTombstones.length > 0 ? mergedTombstones : void 0,
3853
+ _nametags: mergedNametags.length > 0 ? mergedNametags : void 0,
3854
+ _outbox: mergedOutbox.length > 0 ? mergedOutbox : void 0,
3855
+ _sent: mergedSent.length > 0 ? mergedSent : void 0,
3856
+ _invalid: mergedInvalid.length > 0 ? mergedInvalid : void 0,
3857
+ ...mergedTokens
3858
+ };
3859
+ return { merged, added, removed, conflicts };
3860
+ }
3861
+ function mergeTombstones(local, remote) {
3862
+ const merged = /* @__PURE__ */ new Map();
3863
+ for (const tombstone of [...local, ...remote]) {
3864
+ const key = `${tombstone.tokenId}:${tombstone.stateHash}`;
3865
+ const existing = merged.get(key);
3866
+ if (!existing || tombstone.timestamp > existing.timestamp) {
3867
+ merged.set(key, tombstone);
3868
+ }
3869
+ }
3870
+ return Array.from(merged.values());
3871
+ }
3872
+ function getTokenKeys(data) {
3873
+ const reservedKeys = /* @__PURE__ */ new Set([
3874
+ "_meta",
3875
+ "_tombstones",
3876
+ "_outbox",
3877
+ "_sent",
3878
+ "_invalid",
3879
+ "_nametag",
3880
+ "_nametags",
3881
+ "_mintOutbox",
3882
+ "_invalidatedNametags",
3883
+ "_integrity"
3884
+ ]);
3885
+ const keys = /* @__PURE__ */ new Set();
3886
+ for (const key of Object.keys(data)) {
3887
+ if (reservedKeys.has(key)) continue;
3888
+ if (key.startsWith("archived-") || key.startsWith("_forked_")) continue;
3889
+ keys.add(key);
3890
+ }
3891
+ return keys;
3892
+ }
3893
+ function isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys) {
3894
+ for (const key of tombstoneKeys) {
3895
+ if (key.startsWith(`${tokenId}:`)) {
3896
+ return true;
3897
+ }
3898
+ }
3899
+ void localToken;
3900
+ void remoteToken;
3901
+ return false;
3902
+ }
3903
+ function mergeNametagsByName(local, remote) {
3904
+ const seen = /* @__PURE__ */ new Map();
3905
+ for (const item of local) {
3906
+ if (item.name) seen.set(item.name, item);
3907
+ }
3908
+ for (const item of remote) {
3909
+ if (item.name && !seen.has(item.name)) {
3910
+ seen.set(item.name, item);
3911
+ }
3912
+ }
3913
+ return Array.from(seen.values());
3914
+ }
3915
+ function mergeArrayById(local, remote, idField) {
3916
+ const seen = /* @__PURE__ */ new Map();
3917
+ for (const item of local) {
3918
+ const id = item[idField];
3919
+ if (id !== void 0) {
3920
+ seen.set(id, item);
3921
+ }
3922
+ }
3923
+ for (const item of remote) {
3924
+ const id = item[idField];
3925
+ if (id !== void 0 && !seen.has(id)) {
3926
+ seen.set(id, item);
3927
+ }
3928
+ }
3929
+ return Array.from(seen.values());
3930
+ }
3931
+
3932
+ // impl/shared/ipfs/ipns-subscription-client.ts
3933
+ var IpnsSubscriptionClient = class {
3934
+ ws = null;
3935
+ subscriptions = /* @__PURE__ */ new Map();
3936
+ reconnectTimeout = null;
3937
+ pingInterval = null;
3938
+ fallbackPollInterval = null;
3939
+ wsUrl;
3940
+ createWebSocket;
3941
+ pingIntervalMs;
3942
+ initialReconnectDelayMs;
3943
+ maxReconnectDelayMs;
3944
+ debugEnabled;
3945
+ reconnectAttempts = 0;
3946
+ isConnecting = false;
3947
+ connectionOpenedAt = 0;
3948
+ destroyed = false;
3949
+ /** Minimum stable connection time before resetting backoff (30 seconds) */
3950
+ minStableConnectionMs = 3e4;
3951
+ fallbackPollFn = null;
3952
+ fallbackPollIntervalMs = 0;
3953
+ constructor(config) {
3954
+ this.wsUrl = config.wsUrl;
3955
+ this.createWebSocket = config.createWebSocket;
3956
+ this.pingIntervalMs = config.pingIntervalMs ?? 3e4;
3957
+ this.initialReconnectDelayMs = config.reconnectDelayMs ?? 5e3;
3958
+ this.maxReconnectDelayMs = config.maxReconnectDelayMs ?? 6e4;
3959
+ this.debugEnabled = config.debug ?? false;
3960
+ }
3961
+ // ---------------------------------------------------------------------------
3962
+ // Public API
3963
+ // ---------------------------------------------------------------------------
3964
+ /**
3965
+ * Subscribe to IPNS updates for a specific name.
3966
+ * Automatically connects the WebSocket if not already connected.
3967
+ * If WebSocket is connecting, the name will be subscribed once connected.
3968
+ */
3969
+ subscribe(ipnsName, callback) {
3970
+ if (!ipnsName || typeof ipnsName !== "string") {
3971
+ this.log("Invalid IPNS name for subscription");
3972
+ return () => {
3973
+ };
3974
+ }
3975
+ const isNewSubscription = !this.subscriptions.has(ipnsName);
3976
+ if (isNewSubscription) {
3977
+ this.subscriptions.set(ipnsName, /* @__PURE__ */ new Set());
3978
+ }
3979
+ this.subscriptions.get(ipnsName).add(callback);
3980
+ if (isNewSubscription && this.ws?.readyState === WebSocketReadyState.OPEN) {
3981
+ this.sendSubscribe([ipnsName]);
3982
+ }
3983
+ if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) {
3984
+ this.connect();
3985
+ }
3986
+ return () => {
3987
+ const callbacks = this.subscriptions.get(ipnsName);
3988
+ if (callbacks) {
3989
+ callbacks.delete(callback);
3990
+ if (callbacks.size === 0) {
3991
+ this.subscriptions.delete(ipnsName);
3992
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
3993
+ this.sendUnsubscribe([ipnsName]);
3994
+ }
3995
+ if (this.subscriptions.size === 0) {
3996
+ this.disconnect();
3997
+ }
3998
+ }
3999
+ }
4000
+ };
4001
+ }
4002
+ /**
4003
+ * Register a convenience update callback for all subscriptions.
4004
+ * Returns an unsubscribe function.
4005
+ */
4006
+ onUpdate(callback) {
4007
+ if (!this.subscriptions.has("*")) {
4008
+ this.subscriptions.set("*", /* @__PURE__ */ new Set());
4009
+ }
4010
+ this.subscriptions.get("*").add(callback);
4011
+ return () => {
4012
+ const callbacks = this.subscriptions.get("*");
4013
+ if (callbacks) {
4014
+ callbacks.delete(callback);
4015
+ if (callbacks.size === 0) {
4016
+ this.subscriptions.delete("*");
4017
+ }
4018
+ }
4019
+ };
4020
+ }
4021
+ /**
4022
+ * Set a fallback poll function to use when WebSocket is disconnected.
4023
+ * The poll function will be called at the specified interval while WS is down.
4024
+ */
4025
+ setFallbackPoll(fn, intervalMs) {
4026
+ this.fallbackPollFn = fn;
4027
+ this.fallbackPollIntervalMs = intervalMs;
4028
+ if (!this.isConnected()) {
4029
+ this.startFallbackPolling();
4030
+ }
4031
+ }
4032
+ /**
4033
+ * Connect to the WebSocket server.
4034
+ */
4035
+ connect() {
4036
+ if (this.destroyed) return;
4037
+ if (this.ws?.readyState === WebSocketReadyState.OPEN || this.isConnecting) {
4038
+ return;
4039
+ }
4040
+ this.isConnecting = true;
4041
+ try {
4042
+ this.log(`Connecting to ${this.wsUrl}...`);
4043
+ this.ws = this.createWebSocket(this.wsUrl);
4044
+ this.ws.onopen = () => {
4045
+ this.log("WebSocket connected");
4046
+ this.isConnecting = false;
4047
+ this.connectionOpenedAt = Date.now();
4048
+ const names = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
4049
+ if (names.length > 0) {
4050
+ this.sendSubscribe(names);
4051
+ }
4052
+ this.startPingInterval();
4053
+ this.stopFallbackPolling();
4054
+ };
4055
+ this.ws.onmessage = (event) => {
4056
+ this.handleMessage(event.data);
4057
+ };
4058
+ this.ws.onclose = () => {
4059
+ const connectionDuration = this.connectionOpenedAt > 0 ? Date.now() - this.connectionOpenedAt : 0;
4060
+ const wasStable = connectionDuration >= this.minStableConnectionMs;
4061
+ this.log(`WebSocket closed (duration: ${Math.round(connectionDuration / 1e3)}s)`);
4062
+ this.isConnecting = false;
4063
+ this.connectionOpenedAt = 0;
4064
+ this.stopPingInterval();
4065
+ if (wasStable) {
4066
+ this.reconnectAttempts = 0;
4067
+ }
4068
+ this.startFallbackPolling();
4069
+ this.scheduleReconnect();
4070
+ };
4071
+ this.ws.onerror = () => {
4072
+ this.log("WebSocket error");
4073
+ this.isConnecting = false;
4074
+ };
4075
+ } catch (e) {
4076
+ this.log(`Failed to connect: ${e}`);
4077
+ this.isConnecting = false;
4078
+ this.startFallbackPolling();
4079
+ this.scheduleReconnect();
4080
+ }
4081
+ }
4082
+ /**
4083
+ * Disconnect from the WebSocket server and clean up.
4084
+ */
4085
+ disconnect() {
4086
+ this.destroyed = true;
4087
+ if (this.reconnectTimeout) {
4088
+ clearTimeout(this.reconnectTimeout);
4089
+ this.reconnectTimeout = null;
4090
+ }
4091
+ this.stopPingInterval();
4092
+ this.stopFallbackPolling();
4093
+ if (this.ws) {
4094
+ this.ws.onopen = null;
4095
+ this.ws.onclose = null;
4096
+ this.ws.onerror = null;
4097
+ this.ws.onmessage = null;
4098
+ this.ws.close();
4099
+ this.ws = null;
4100
+ }
4101
+ this.isConnecting = false;
4102
+ this.reconnectAttempts = 0;
4103
+ }
4104
+ /**
4105
+ * Check if connected to the WebSocket server.
4106
+ */
4107
+ isConnected() {
4108
+ return this.ws?.readyState === WebSocketReadyState.OPEN;
4109
+ }
4110
+ // ---------------------------------------------------------------------------
4111
+ // Internal: Message Handling
4112
+ // ---------------------------------------------------------------------------
4113
+ handleMessage(data) {
4114
+ try {
4115
+ const message = JSON.parse(data);
4116
+ switch (message.type) {
4117
+ case "update":
4118
+ if (message.name && message.sequence !== void 0) {
4119
+ this.notifySubscribers({
4120
+ type: "update",
4121
+ name: message.name,
4122
+ sequence: message.sequence,
4123
+ cid: message.cid ?? "",
4124
+ timestamp: message.timestamp || (/* @__PURE__ */ new Date()).toISOString()
4125
+ });
4126
+ }
4127
+ break;
4128
+ case "subscribed":
4129
+ this.log(`Subscribed to ${message.names?.length || 0} names`);
4130
+ break;
4131
+ case "unsubscribed":
4132
+ this.log(`Unsubscribed from ${message.names?.length || 0} names`);
4133
+ break;
4134
+ case "pong":
4135
+ break;
4136
+ case "error":
4137
+ this.log(`Server error: ${message.message}`);
4138
+ break;
4139
+ default:
4140
+ break;
4141
+ }
4142
+ } catch {
4143
+ this.log("Failed to parse message");
4144
+ }
4145
+ }
4146
+ notifySubscribers(update) {
4147
+ const callbacks = this.subscriptions.get(update.name);
4148
+ if (callbacks) {
4149
+ this.log(`Update: ${update.name.slice(0, 16)}... seq=${update.sequence}`);
4150
+ for (const callback of callbacks) {
4151
+ try {
4152
+ callback(update);
4153
+ } catch {
4154
+ }
4155
+ }
4156
+ }
4157
+ const globalCallbacks = this.subscriptions.get("*");
4158
+ if (globalCallbacks) {
4159
+ for (const callback of globalCallbacks) {
4160
+ try {
4161
+ callback(update);
4162
+ } catch {
4163
+ }
4164
+ }
4165
+ }
4166
+ }
4167
+ // ---------------------------------------------------------------------------
4168
+ // Internal: WebSocket Send
4169
+ // ---------------------------------------------------------------------------
4170
+ sendSubscribe(names) {
4171
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4172
+ this.ws.send(JSON.stringify({ action: "subscribe", names }));
4173
+ }
4174
+ }
4175
+ sendUnsubscribe(names) {
4176
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4177
+ this.ws.send(JSON.stringify({ action: "unsubscribe", names }));
4178
+ }
4179
+ }
4180
+ // ---------------------------------------------------------------------------
4181
+ // Internal: Reconnection
4182
+ // ---------------------------------------------------------------------------
4183
+ /**
4184
+ * Schedule reconnection with exponential backoff.
4185
+ * Sequence: 5s, 10s, 20s, 40s, 60s (capped)
4186
+ */
4187
+ scheduleReconnect() {
4188
+ if (this.destroyed || this.reconnectTimeout) return;
4189
+ const realSubscriptions = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
4190
+ if (realSubscriptions.length === 0) return;
4191
+ this.reconnectAttempts++;
4192
+ const delay = Math.min(
4193
+ this.initialReconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
4194
+ this.maxReconnectDelayMs
4195
+ );
4196
+ this.log(`Reconnecting in ${(delay / 1e3).toFixed(1)}s (attempt ${this.reconnectAttempts})...`);
4197
+ this.reconnectTimeout = setTimeout(() => {
4198
+ this.reconnectTimeout = null;
4199
+ this.connect();
4200
+ }, delay);
4201
+ }
4202
+ // ---------------------------------------------------------------------------
4203
+ // Internal: Keepalive
4204
+ // ---------------------------------------------------------------------------
4205
+ startPingInterval() {
4206
+ this.stopPingInterval();
4207
+ this.pingInterval = setInterval(() => {
4208
+ if (this.ws?.readyState === WebSocketReadyState.OPEN) {
4209
+ this.ws.send(JSON.stringify({ action: "ping" }));
4210
+ }
4211
+ }, this.pingIntervalMs);
4212
+ }
4213
+ stopPingInterval() {
4214
+ if (this.pingInterval) {
4215
+ clearInterval(this.pingInterval);
4216
+ this.pingInterval = null;
4217
+ }
4218
+ }
4219
+ // ---------------------------------------------------------------------------
4220
+ // Internal: Fallback Polling
4221
+ // ---------------------------------------------------------------------------
4222
+ startFallbackPolling() {
4223
+ if (this.fallbackPollInterval || !this.fallbackPollFn || this.destroyed) return;
4224
+ this.log(`Starting fallback polling (${this.fallbackPollIntervalMs / 1e3}s interval)`);
4225
+ this.fallbackPollFn().catch(() => {
4226
+ });
4227
+ this.fallbackPollInterval = setInterval(() => {
4228
+ this.fallbackPollFn?.().catch(() => {
4229
+ });
4230
+ }, this.fallbackPollIntervalMs);
4231
+ }
4232
+ stopFallbackPolling() {
4233
+ if (this.fallbackPollInterval) {
4234
+ clearInterval(this.fallbackPollInterval);
4235
+ this.fallbackPollInterval = null;
4236
+ }
4237
+ }
4238
+ // ---------------------------------------------------------------------------
4239
+ // Internal: Logging
4240
+ // ---------------------------------------------------------------------------
4241
+ log(message) {
4242
+ if (this.debugEnabled) {
4243
+ console.log(`[IPNS-WS] ${message}`);
4244
+ }
4245
+ }
4246
+ };
4247
+
4248
+ // impl/shared/ipfs/write-behind-buffer.ts
4249
+ var AsyncSerialQueue = class {
4250
+ tail = Promise.resolve();
4251
+ /** Enqueue an async operation. Returns when it completes. */
4252
+ enqueue(fn) {
4253
+ let resolve;
4254
+ let reject;
4255
+ const promise = new Promise((res, rej) => {
4256
+ resolve = res;
4257
+ reject = rej;
4258
+ });
4259
+ this.tail = this.tail.then(
4260
+ () => fn().then(resolve, reject),
4261
+ () => fn().then(resolve, reject)
4262
+ );
4263
+ return promise;
4264
+ }
4265
+ };
4266
+ var WriteBuffer = class {
4267
+ /** Full TXF data from save() calls — latest wins */
4268
+ txfData = null;
4269
+ get isEmpty() {
4270
+ return this.txfData === null;
4271
+ }
4272
+ clear() {
4273
+ this.txfData = null;
4274
+ }
4275
+ /**
4276
+ * Merge another buffer's contents into this one (for rollback).
4277
+ * Existing (newer) mutations in `this` take precedence over `other`.
4278
+ */
4279
+ mergeFrom(other) {
4280
+ if (other.txfData && !this.txfData) {
4281
+ this.txfData = other.txfData;
4282
+ }
4283
+ }
4284
+ };
4285
+
4286
+ // impl/shared/ipfs/ipfs-storage-provider.ts
4287
+ var IpfsStorageProvider = class {
4288
+ id = "ipfs";
4289
+ name = "IPFS Storage";
4290
+ type = "p2p";
4291
+ status = "disconnected";
4292
+ identity = null;
4293
+ ipnsKeyPair = null;
4294
+ ipnsName = null;
4295
+ ipnsSequenceNumber = 0n;
4296
+ lastCid = null;
4297
+ lastKnownRemoteSequence = 0n;
4298
+ dataVersion = 0;
4299
+ /**
4300
+ * The CID currently stored on the sidecar for this IPNS name.
4301
+ * Used as `_meta.lastCid` in the next save to satisfy chain validation.
4302
+ * - null for bootstrap (first-ever save)
4303
+ * - set after every successful save() or load()
4304
+ */
4305
+ remoteCid = null;
4306
+ cache;
4307
+ httpClient;
4308
+ statePersistence;
4309
+ eventCallbacks = /* @__PURE__ */ new Set();
4310
+ debug;
4311
+ ipnsLifetimeMs;
4312
+ /** WebSocket factory for push subscriptions */
4313
+ createWebSocket;
4314
+ /** Override WS URL */
4315
+ wsUrl;
4316
+ /** Fallback poll interval (default: 90000) */
4317
+ fallbackPollIntervalMs;
4318
+ /** IPNS subscription client for push notifications */
4319
+ subscriptionClient = null;
4320
+ /** Unsubscribe function from subscription client */
4321
+ subscriptionUnsubscribe = null;
4322
+ /** Write-behind buffer: serializes flush / sync / shutdown */
4323
+ flushQueue = new AsyncSerialQueue();
4324
+ /** Pending mutations not yet flushed to IPFS */
4325
+ pendingBuffer = new WriteBuffer();
4326
+ /** Debounce timer for background flush */
4327
+ flushTimer = null;
4328
+ /** Debounce interval in ms */
4329
+ flushDebounceMs;
4330
+ /** Set to true during shutdown to prevent new flushes */
4331
+ isShuttingDown = false;
4332
+ constructor(config, statePersistence) {
4333
+ const gateways = config?.gateways ?? getIpfsGatewayUrls();
4334
+ this.debug = config?.debug ?? false;
4335
+ this.ipnsLifetimeMs = config?.ipnsLifetimeMs ?? 99 * 365 * 24 * 60 * 60 * 1e3;
4336
+ this.flushDebounceMs = config?.flushDebounceMs ?? 2e3;
4337
+ this.cache = new IpfsCache({
4338
+ ipnsTtlMs: config?.ipnsCacheTtlMs,
4339
+ failureCooldownMs: config?.circuitBreakerCooldownMs,
4340
+ failureThreshold: config?.circuitBreakerThreshold,
4341
+ knownFreshWindowMs: config?.knownFreshWindowMs
4342
+ });
4343
+ this.httpClient = new IpfsHttpClient({
4344
+ gateways,
4345
+ fetchTimeoutMs: config?.fetchTimeoutMs,
4346
+ resolveTimeoutMs: config?.resolveTimeoutMs,
4347
+ publishTimeoutMs: config?.publishTimeoutMs,
4348
+ connectivityTimeoutMs: config?.connectivityTimeoutMs,
4349
+ debug: this.debug
4350
+ }, this.cache);
4351
+ this.statePersistence = statePersistence ?? new InMemoryIpfsStatePersistence();
4352
+ this.createWebSocket = config?.createWebSocket;
4353
+ this.wsUrl = config?.wsUrl;
4354
+ this.fallbackPollIntervalMs = config?.fallbackPollIntervalMs ?? 9e4;
4355
+ }
4356
+ // ---------------------------------------------------------------------------
4357
+ // BaseProvider interface
4358
+ // ---------------------------------------------------------------------------
4359
+ async connect() {
4360
+ await this.initialize();
4361
+ }
4362
+ async disconnect() {
4363
+ await this.shutdown();
4364
+ }
4365
+ isConnected() {
4366
+ return this.status === "connected";
4367
+ }
4368
+ getStatus() {
4369
+ return this.status;
4370
+ }
4371
+ // ---------------------------------------------------------------------------
4372
+ // Identity & Initialization
4373
+ // ---------------------------------------------------------------------------
4374
+ setIdentity(identity) {
4375
+ this.identity = identity;
4376
+ }
4377
+ async initialize() {
4378
+ if (!this.identity) {
4379
+ this.log("Cannot initialize: no identity set");
4380
+ return false;
4381
+ }
4382
+ this.status = "connecting";
4383
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
4384
+ try {
4385
+ const { keyPair, ipnsName } = await deriveIpnsIdentity(this.identity.privateKey);
4386
+ this.ipnsKeyPair = keyPair;
4387
+ this.ipnsName = ipnsName;
4388
+ this.log(`IPNS name derived: ${ipnsName}`);
4389
+ const persisted = await this.statePersistence.load(ipnsName);
4390
+ if (persisted) {
4391
+ this.ipnsSequenceNumber = BigInt(persisted.sequenceNumber);
4392
+ this.lastCid = persisted.lastCid;
4393
+ this.remoteCid = persisted.lastCid;
4394
+ this.dataVersion = persisted.version;
4395
+ this.log(`Loaded persisted state: seq=${this.ipnsSequenceNumber}, cid=${this.lastCid}`);
4396
+ }
4397
+ if (this.createWebSocket) {
4398
+ try {
4399
+ const wsUrlFinal = this.wsUrl ?? this.deriveWsUrl();
4400
+ if (wsUrlFinal) {
4401
+ this.subscriptionClient = new IpnsSubscriptionClient({
4402
+ wsUrl: wsUrlFinal,
4403
+ createWebSocket: this.createWebSocket,
4404
+ debug: this.debug
4405
+ });
4406
+ this.subscriptionUnsubscribe = this.subscriptionClient.subscribe(
4407
+ ipnsName,
4408
+ (update) => {
4409
+ this.log(`Push update: seq=${update.sequence}, cid=${update.cid}`);
4410
+ this.emitEvent({
4411
+ type: "storage:remote-updated",
4412
+ timestamp: Date.now(),
4413
+ data: { name: update.name, sequence: update.sequence, cid: update.cid }
4414
+ });
4415
+ }
4416
+ );
4417
+ this.subscriptionClient.setFallbackPoll(
4418
+ () => this.pollForRemoteChanges(),
4419
+ this.fallbackPollIntervalMs
4420
+ );
4421
+ this.subscriptionClient.connect();
4422
+ }
4423
+ } catch (wsError) {
4424
+ this.log(`Failed to set up IPNS subscription: ${wsError}`);
4425
+ }
4426
+ }
4427
+ this.httpClient.findHealthyGateways().then((healthy) => {
4428
+ if (healthy.length > 0) {
4429
+ this.log(`${healthy.length} healthy gateway(s) found`);
4430
+ } else {
4431
+ this.log("Warning: no healthy gateways found");
4432
+ }
4433
+ }).catch(() => {
4434
+ });
4435
+ this.isShuttingDown = false;
4436
+ this.status = "connected";
4437
+ this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
4438
+ return true;
4439
+ } catch (error) {
4440
+ this.status = "error";
4441
+ this.emitEvent({
4442
+ type: "storage:error",
4443
+ timestamp: Date.now(),
4444
+ error: error instanceof Error ? error.message : String(error)
4445
+ });
4446
+ return false;
4447
+ }
4448
+ }
4449
+ async shutdown() {
4450
+ this.isShuttingDown = true;
4451
+ if (this.flushTimer) {
4452
+ clearTimeout(this.flushTimer);
4453
+ this.flushTimer = null;
4454
+ }
4455
+ await this.flushQueue.enqueue(async () => {
4456
+ if (!this.pendingBuffer.isEmpty) {
4457
+ try {
4458
+ await this.executeFlush();
4459
+ } catch {
4460
+ this.log("Final flush on shutdown failed (data may be lost)");
4461
+ }
4462
+ }
4463
+ });
4464
+ if (this.subscriptionUnsubscribe) {
4465
+ this.subscriptionUnsubscribe();
4466
+ this.subscriptionUnsubscribe = null;
4467
+ }
4468
+ if (this.subscriptionClient) {
4469
+ this.subscriptionClient.disconnect();
4470
+ this.subscriptionClient = null;
4471
+ }
4472
+ this.cache.clear();
4473
+ this.status = "disconnected";
4474
+ }
4475
+ // ---------------------------------------------------------------------------
4476
+ // Save (non-blocking — buffers data for async flush)
4477
+ // ---------------------------------------------------------------------------
4478
+ async save(data) {
4479
+ if (!this.ipnsKeyPair || !this.ipnsName) {
4480
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
4481
+ }
4482
+ this.pendingBuffer.txfData = data;
4483
+ this.scheduleFlush();
4484
+ return { success: true, timestamp: Date.now() };
4485
+ }
4486
+ // ---------------------------------------------------------------------------
4487
+ // Internal: Blocking save (used by sync and executeFlush)
4488
+ // ---------------------------------------------------------------------------
4489
+ /**
4490
+ * Perform the actual upload + IPNS publish synchronously.
4491
+ * Called by executeFlush() and sync() — never by public save().
4492
+ */
4493
+ async _doSave(data) {
4494
+ if (!this.ipnsKeyPair || !this.ipnsName) {
4495
+ return { success: false, error: "Not initialized", timestamp: Date.now() };
4496
+ }
4497
+ this.emitEvent({ type: "storage:saving", timestamp: Date.now() });
4498
+ try {
4499
+ this.dataVersion++;
4500
+ const metaUpdate = {
4501
+ ...data._meta,
4502
+ version: this.dataVersion,
4503
+ ipnsName: this.ipnsName,
4504
+ updatedAt: Date.now()
4505
+ };
4506
+ if (this.remoteCid) {
4507
+ metaUpdate.lastCid = this.remoteCid;
4508
+ }
4509
+ const updatedData = { ...data, _meta: metaUpdate };
4510
+ const { cid } = await this.httpClient.upload(updatedData);
4511
+ this.log(`Content uploaded: CID=${cid}`);
4512
+ const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence ? this.ipnsSequenceNumber : this.lastKnownRemoteSequence;
4513
+ const newSeq = baseSeq + 1n;
4514
+ const marshalledRecord = await createSignedRecord(
4515
+ this.ipnsKeyPair,
4516
+ cid,
4517
+ newSeq,
4518
+ this.ipnsLifetimeMs
4519
+ );
4520
+ const publishResult = await this.httpClient.publishIpns(
4521
+ this.ipnsName,
4522
+ marshalledRecord
4523
+ );
4524
+ if (!publishResult.success) {
4525
+ this.dataVersion--;
4526
+ this.log(`IPNS publish failed: ${publishResult.error}`);
4527
+ return {
4528
+ success: false,
4529
+ error: publishResult.error ?? "IPNS publish failed",
4530
+ timestamp: Date.now()
4531
+ };
4532
+ }
4533
+ this.ipnsSequenceNumber = newSeq;
4534
+ this.lastCid = cid;
4535
+ this.remoteCid = cid;
4536
+ this.cache.setIpnsRecord(this.ipnsName, {
4537
+ cid,
4538
+ sequence: newSeq,
4539
+ gateway: "local"
4540
+ });
4541
+ this.cache.setContent(cid, updatedData);
4542
+ this.cache.markIpnsFresh(this.ipnsName);
4543
+ await this.statePersistence.save(this.ipnsName, {
4544
+ sequenceNumber: newSeq.toString(),
4545
+ lastCid: cid,
4546
+ version: this.dataVersion
4547
+ });
4548
+ this.emitEvent({
4549
+ type: "storage:saved",
4550
+ timestamp: Date.now(),
4551
+ data: { cid, sequence: newSeq.toString() }
4552
+ });
4553
+ this.log(`Saved: CID=${cid}, seq=${newSeq}`);
4554
+ return { success: true, cid, timestamp: Date.now() };
4555
+ } catch (error) {
4556
+ this.dataVersion--;
4557
+ const errorMessage = error instanceof Error ? error.message : String(error);
4558
+ this.emitEvent({
4559
+ type: "storage:error",
4560
+ timestamp: Date.now(),
4561
+ error: errorMessage
4562
+ });
4563
+ return { success: false, error: errorMessage, timestamp: Date.now() };
4564
+ }
4565
+ }
4566
+ // ---------------------------------------------------------------------------
4567
+ // Write-behind buffer: scheduling and flushing
4568
+ // ---------------------------------------------------------------------------
4569
+ /**
4570
+ * Schedule a debounced background flush.
4571
+ * Resets the timer on each call so rapid mutations coalesce.
4572
+ */
4573
+ scheduleFlush() {
4574
+ if (this.isShuttingDown) return;
4575
+ if (this.flushTimer) clearTimeout(this.flushTimer);
4576
+ this.flushTimer = setTimeout(() => {
4577
+ this.flushTimer = null;
4578
+ this.flushQueue.enqueue(() => this.executeFlush()).catch((err) => {
4579
+ this.log(`Background flush failed: ${err}`);
4580
+ });
4581
+ }, this.flushDebounceMs);
4582
+ }
4583
+ /**
4584
+ * Execute a flush of the pending buffer to IPFS.
4585
+ * Runs inside AsyncSerialQueue for concurrency safety.
4586
+ */
4587
+ async executeFlush() {
4588
+ if (this.pendingBuffer.isEmpty) return;
4589
+ const active = this.pendingBuffer;
4590
+ this.pendingBuffer = new WriteBuffer();
4591
+ try {
4592
+ const baseData = active.txfData ?? {
4593
+ _meta: { version: 0, address: this.identity?.directAddress ?? "", formatVersion: "2.0", updatedAt: 0 }
4594
+ };
4595
+ const result = await this._doSave(baseData);
4596
+ if (!result.success) {
4597
+ throw new Error(result.error ?? "Save failed");
4598
+ }
4599
+ this.log(`Flushed successfully: CID=${result.cid}`);
4600
+ } catch (error) {
4601
+ this.pendingBuffer.mergeFrom(active);
4602
+ const msg = error instanceof Error ? error.message : String(error);
4603
+ this.log(`Flush failed (will retry): ${msg}`);
4604
+ this.scheduleFlush();
4605
+ throw error;
4606
+ }
4607
+ }
4608
+ // ---------------------------------------------------------------------------
4609
+ // Load
4610
+ // ---------------------------------------------------------------------------
4611
+ async load(identifier) {
4612
+ if (!this.ipnsName && !identifier) {
4613
+ return { success: false, error: "Not initialized", source: "local", timestamp: Date.now() };
4614
+ }
4615
+ this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
4616
+ try {
4617
+ if (identifier) {
4618
+ const data2 = await this.httpClient.fetchContent(identifier);
4619
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
4620
+ }
4621
+ const ipnsName = this.ipnsName;
4622
+ if (this.cache.isIpnsKnownFresh(ipnsName)) {
4623
+ const cached = this.cache.getIpnsRecordIgnoreTtl(ipnsName);
4624
+ if (cached) {
4625
+ const content = this.cache.getContent(cached.cid);
4626
+ if (content) {
4627
+ this.log("Using known-fresh cached data");
4628
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4629
+ }
4630
+ }
4631
+ }
4632
+ const cachedRecord = this.cache.getIpnsRecord(ipnsName);
4633
+ if (cachedRecord) {
4634
+ const content = this.cache.getContent(cachedRecord.cid);
4635
+ if (content) {
4636
+ this.log("IPNS cache hit");
4637
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4638
+ }
4639
+ try {
4640
+ const data2 = await this.httpClient.fetchContent(cachedRecord.cid);
4641
+ return { success: true, data: data2, source: "remote", timestamp: Date.now() };
4642
+ } catch {
4643
+ }
4644
+ }
4645
+ const { best } = await this.httpClient.resolveIpns(ipnsName);
4646
+ if (!best) {
4647
+ this.log("IPNS record not found (new wallet?)");
4648
+ return { success: false, error: "IPNS record not found", source: "remote", timestamp: Date.now() };
4649
+ }
4650
+ if (best.sequence > this.lastKnownRemoteSequence) {
4651
+ this.lastKnownRemoteSequence = best.sequence;
4652
+ }
4653
+ this.remoteCid = best.cid;
4654
+ const data = await this.httpClient.fetchContent(best.cid);
4655
+ const remoteVersion = data?._meta?.version;
4656
+ if (typeof remoteVersion === "number" && remoteVersion > this.dataVersion) {
4657
+ this.dataVersion = remoteVersion;
4658
+ }
4659
+ this.emitEvent({
4660
+ type: "storage:loaded",
4661
+ timestamp: Date.now(),
4662
+ data: { cid: best.cid, sequence: best.sequence.toString() }
4663
+ });
4664
+ return { success: true, data, source: "remote", timestamp: Date.now() };
4665
+ } catch (error) {
4666
+ if (this.ipnsName) {
4667
+ const cached = this.cache.getIpnsRecordIgnoreTtl(this.ipnsName);
4668
+ if (cached) {
4669
+ const content = this.cache.getContent(cached.cid);
4670
+ if (content) {
4671
+ this.log("Network error, returning stale cache");
4672
+ return { success: true, data: content, source: "cache", timestamp: Date.now() };
4673
+ }
4674
+ }
4675
+ }
4676
+ const errorMessage = error instanceof Error ? error.message : String(error);
4677
+ this.emitEvent({
4678
+ type: "storage:error",
4679
+ timestamp: Date.now(),
4680
+ error: errorMessage
4681
+ });
4682
+ return { success: false, error: errorMessage, source: "remote", timestamp: Date.now() };
4683
+ }
4684
+ }
4685
+ // ---------------------------------------------------------------------------
4686
+ // Sync (enters serial queue to avoid concurrent IPNS conflicts)
4687
+ // ---------------------------------------------------------------------------
4688
+ async sync(localData) {
4689
+ return this.flushQueue.enqueue(async () => {
4690
+ if (this.flushTimer) {
4691
+ clearTimeout(this.flushTimer);
4692
+ this.flushTimer = null;
4693
+ }
4694
+ this.emitEvent({ type: "sync:started", timestamp: Date.now() });
4695
+ try {
4696
+ this.pendingBuffer.clear();
4697
+ const remoteResult = await this.load();
4698
+ if (!remoteResult.success || !remoteResult.data) {
4699
+ this.log("No remote data found, uploading local data");
4700
+ const saveResult2 = await this._doSave(localData);
4701
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
4702
+ return {
4703
+ success: saveResult2.success,
4704
+ merged: localData,
4705
+ added: 0,
4706
+ removed: 0,
4707
+ conflicts: 0,
4708
+ error: saveResult2.error
4709
+ };
4710
+ }
4711
+ const remoteData = remoteResult.data;
4712
+ const localVersion = localData._meta?.version ?? 0;
4713
+ const remoteVersion = remoteData._meta?.version ?? 0;
4714
+ if (localVersion === remoteVersion && this.lastCid) {
4715
+ this.log("Data is in sync (same version)");
4716
+ this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
4717
+ return {
4718
+ success: true,
4719
+ merged: localData,
4720
+ added: 0,
4721
+ removed: 0,
4722
+ conflicts: 0
4723
+ };
4724
+ }
4725
+ this.log(`Merging: local v${localVersion} <-> remote v${remoteVersion}`);
4726
+ const { merged, added, removed, conflicts } = mergeTxfData(localData, remoteData);
4727
+ if (conflicts > 0) {
4728
+ this.emitEvent({
4729
+ type: "sync:conflict",
4730
+ timestamp: Date.now(),
4731
+ data: { conflicts }
4732
+ });
4733
+ }
4734
+ const saveResult = await this._doSave(merged);
4735
+ this.emitEvent({
4736
+ type: "sync:completed",
4737
+ timestamp: Date.now(),
4738
+ data: { added, removed, conflicts }
4739
+ });
4740
+ return {
4741
+ success: saveResult.success,
4742
+ merged,
4743
+ added,
4744
+ removed,
4745
+ conflicts,
4746
+ error: saveResult.error
4747
+ };
4748
+ } catch (error) {
4749
+ const errorMessage = error instanceof Error ? error.message : String(error);
4750
+ this.emitEvent({
4751
+ type: "sync:error",
4752
+ timestamp: Date.now(),
4753
+ error: errorMessage
4754
+ });
4755
+ return {
4756
+ success: false,
4757
+ added: 0,
4758
+ removed: 0,
4759
+ conflicts: 0,
4760
+ error: errorMessage
4761
+ };
4762
+ }
4763
+ });
4764
+ }
4765
+ // ---------------------------------------------------------------------------
4766
+ // Private Helpers
4767
+ // ---------------------------------------------------------------------------
4768
+ // ---------------------------------------------------------------------------
4769
+ // Optional Methods
4770
+ // ---------------------------------------------------------------------------
4771
+ async exists() {
4772
+ if (!this.ipnsName) return false;
4773
+ const cached = this.cache.getIpnsRecord(this.ipnsName);
4774
+ if (cached) return true;
4775
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
4776
+ return best !== null;
4777
+ }
4778
+ async clear() {
4779
+ if (!this.ipnsKeyPair || !this.ipnsName) return false;
4780
+ this.pendingBuffer.clear();
4781
+ if (this.flushTimer) {
4782
+ clearTimeout(this.flushTimer);
4783
+ this.flushTimer = null;
4784
+ }
4785
+ const emptyData = {
4786
+ _meta: {
4787
+ version: 0,
4788
+ address: this.identity?.directAddress ?? "",
4789
+ ipnsName: this.ipnsName,
4790
+ formatVersion: "2.0",
4791
+ updatedAt: Date.now()
4792
+ }
4793
+ };
4794
+ const result = await this._doSave(emptyData);
4795
+ if (result.success) {
4796
+ this.cache.clear();
4797
+ await this.statePersistence.clear(this.ipnsName);
4798
+ }
4799
+ return result.success;
4800
+ }
4801
+ onEvent(callback) {
4802
+ this.eventCallbacks.add(callback);
4803
+ return () => {
4804
+ this.eventCallbacks.delete(callback);
4805
+ };
4806
+ }
4807
+ // ---------------------------------------------------------------------------
4808
+ // Public Accessors
4809
+ // ---------------------------------------------------------------------------
4810
+ getIpnsName() {
4811
+ return this.ipnsName;
4812
+ }
4813
+ getLastCid() {
4814
+ return this.lastCid;
4815
+ }
4816
+ getSequenceNumber() {
4817
+ return this.ipnsSequenceNumber;
4818
+ }
4819
+ getDataVersion() {
4820
+ return this.dataVersion;
4821
+ }
4822
+ getRemoteCid() {
4823
+ return this.remoteCid;
4824
+ }
4825
+ // ---------------------------------------------------------------------------
4826
+ // Testing helper: wait for pending flush to complete
4827
+ // ---------------------------------------------------------------------------
4828
+ /**
4829
+ * Wait for the pending flush timer to fire and the flush operation to
4830
+ * complete. Useful in tests to await background writes.
4831
+ * Returns immediately if no flush is pending.
4832
+ */
4833
+ async waitForFlush() {
4834
+ if (this.flushTimer) {
4835
+ clearTimeout(this.flushTimer);
4836
+ this.flushTimer = null;
4837
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
4838
+ });
4839
+ } else if (!this.pendingBuffer.isEmpty) {
4840
+ await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
4841
+ });
4842
+ } else {
4843
+ await this.flushQueue.enqueue(async () => {
4844
+ });
4845
+ }
4846
+ }
4847
+ // ---------------------------------------------------------------------------
4848
+ // Internal: Push Subscription Helpers
4849
+ // ---------------------------------------------------------------------------
4850
+ /**
4851
+ * Derive WebSocket URL from the first configured gateway.
4852
+ * Converts https://host → wss://host/ws/ipns
4853
+ */
4854
+ deriveWsUrl() {
4855
+ const gateways = this.httpClient.getGateways();
4856
+ if (gateways.length === 0) return null;
4857
+ const gateway = gateways[0];
4858
+ const wsProtocol = gateway.startsWith("https://") ? "wss://" : "ws://";
4859
+ const host = gateway.replace(/^https?:\/\//, "");
4860
+ return `${wsProtocol}${host}/ws/ipns`;
4861
+ }
4862
+ /**
4863
+ * Poll for remote IPNS changes (fallback when WS is unavailable).
4864
+ * Compares remote sequence number with last known and emits event if changed.
4865
+ */
4866
+ async pollForRemoteChanges() {
4867
+ if (!this.ipnsName) return;
4868
+ try {
4869
+ const { best } = await this.httpClient.resolveIpns(this.ipnsName);
4870
+ if (best && best.sequence > this.lastKnownRemoteSequence) {
4871
+ this.log(`Poll detected remote change: seq=${best.sequence} (was ${this.lastKnownRemoteSequence})`);
4872
+ this.lastKnownRemoteSequence = best.sequence;
4873
+ this.emitEvent({
4874
+ type: "storage:remote-updated",
4875
+ timestamp: Date.now(),
4876
+ data: { name: this.ipnsName, sequence: Number(best.sequence), cid: best.cid }
4877
+ });
4878
+ }
4879
+ } catch {
4880
+ }
4881
+ }
4882
+ // ---------------------------------------------------------------------------
4883
+ // Internal
4884
+ // ---------------------------------------------------------------------------
4885
+ emitEvent(event) {
4886
+ for (const callback of this.eventCallbacks) {
4887
+ try {
4888
+ callback(event);
4889
+ } catch {
4890
+ }
4891
+ }
4892
+ }
4893
+ log(message) {
4894
+ if (this.debug) {
4895
+ console.log(`[IPFS-Storage] ${message}`);
4896
+ }
4897
+ }
4898
+ };
4899
+
4900
+ // impl/browser/ipfs/browser-ipfs-state-persistence.ts
4901
+ var KEY_PREFIX = "sphere_ipfs_";
4902
+ function seqKey(ipnsName) {
4903
+ return `${KEY_PREFIX}seq_${ipnsName}`;
4904
+ }
4905
+ function cidKey(ipnsName) {
4906
+ return `${KEY_PREFIX}cid_${ipnsName}`;
4907
+ }
4908
+ function verKey(ipnsName) {
4909
+ return `${KEY_PREFIX}ver_${ipnsName}`;
4910
+ }
4911
+ var BrowserIpfsStatePersistence = class {
4912
+ async load(ipnsName) {
4913
+ try {
4914
+ const seq = localStorage.getItem(seqKey(ipnsName));
4915
+ if (!seq) return null;
4916
+ return {
4917
+ sequenceNumber: seq,
4918
+ lastCid: localStorage.getItem(cidKey(ipnsName)),
4919
+ version: parseInt(localStorage.getItem(verKey(ipnsName)) ?? "0", 10)
4920
+ };
4921
+ } catch {
4922
+ return null;
4923
+ }
4924
+ }
4925
+ async save(ipnsName, state) {
4926
+ try {
4927
+ localStorage.setItem(seqKey(ipnsName), state.sequenceNumber);
4928
+ if (state.lastCid) {
4929
+ localStorage.setItem(cidKey(ipnsName), state.lastCid);
4930
+ } else {
4931
+ localStorage.removeItem(cidKey(ipnsName));
4932
+ }
4933
+ localStorage.setItem(verKey(ipnsName), String(state.version));
4934
+ } catch {
4935
+ }
4936
+ }
4937
+ async clear(ipnsName) {
4938
+ try {
4939
+ localStorage.removeItem(seqKey(ipnsName));
4940
+ localStorage.removeItem(cidKey(ipnsName));
4941
+ localStorage.removeItem(verKey(ipnsName));
4942
+ } catch {
4943
+ }
4944
+ }
4945
+ };
4946
+
4947
+ // impl/browser/ipfs/index.ts
4948
+ function createBrowserWebSocket2(url) {
4949
+ return new WebSocket(url);
4950
+ }
4951
+ function createBrowserIpfsStorageProvider(config) {
4952
+ return new IpfsStorageProvider(
4953
+ { ...config, createWebSocket: config?.createWebSocket ?? createBrowserWebSocket2 },
4954
+ new BrowserIpfsStatePersistence()
4955
+ );
4956
+ }
4957
+
4958
+ // price/CoinGeckoPriceProvider.ts
4959
+ var CoinGeckoPriceProvider = class {
4960
+ platform = "coingecko";
4961
+ cache = /* @__PURE__ */ new Map();
4962
+ apiKey;
4963
+ cacheTtlMs;
4964
+ timeout;
4965
+ debug;
4966
+ baseUrl;
4967
+ constructor(config) {
4968
+ this.apiKey = config?.apiKey;
4969
+ this.cacheTtlMs = config?.cacheTtlMs ?? 6e4;
4970
+ this.timeout = config?.timeout ?? 1e4;
4971
+ this.debug = config?.debug ?? false;
4972
+ this.baseUrl = config?.baseUrl ?? (this.apiKey ? "https://pro-api.coingecko.com/api/v3" : "https://api.coingecko.com/api/v3");
4973
+ }
4974
+ async getPrices(tokenNames) {
4975
+ if (tokenNames.length === 0) {
4976
+ return /* @__PURE__ */ new Map();
4977
+ }
4978
+ const now = Date.now();
4979
+ const result = /* @__PURE__ */ new Map();
4980
+ const uncachedNames = [];
4981
+ for (const name of tokenNames) {
4982
+ const cached = this.cache.get(name);
4983
+ if (cached && cached.expiresAt > now) {
4984
+ if (cached.price !== null) {
4985
+ result.set(name, cached.price);
4986
+ }
4987
+ } else {
4988
+ uncachedNames.push(name);
4989
+ }
4990
+ }
4991
+ if (uncachedNames.length === 0) {
4992
+ return result;
4993
+ }
4994
+ try {
4995
+ const ids = uncachedNames.join(",");
4996
+ const url = `${this.baseUrl}/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=usd,eur&include_24hr_change=true`;
4997
+ const headers = { Accept: "application/json" };
4998
+ if (this.apiKey) {
4999
+ headers["x-cg-pro-api-key"] = this.apiKey;
5000
+ }
5001
+ if (this.debug) {
5002
+ console.log(`[CoinGecko] Fetching prices for: ${uncachedNames.join(", ")}`);
5003
+ }
5004
+ const response = await fetch(url, {
5005
+ headers,
5006
+ signal: AbortSignal.timeout(this.timeout)
5007
+ });
5008
+ if (!response.ok) {
5009
+ throw new Error(`CoinGecko API error: ${response.status} ${response.statusText}`);
5010
+ }
5011
+ const data = await response.json();
5012
+ for (const [name, values] of Object.entries(data)) {
5013
+ if (values && typeof values === "object") {
5014
+ const price = {
5015
+ tokenName: name,
5016
+ priceUsd: values.usd ?? 0,
5017
+ priceEur: values.eur,
5018
+ change24h: values.usd_24h_change,
5019
+ timestamp: now
5020
+ };
5021
+ this.cache.set(name, { price, expiresAt: now + this.cacheTtlMs });
5022
+ result.set(name, price);
5023
+ }
5024
+ }
5025
+ for (const name of uncachedNames) {
5026
+ if (!result.has(name)) {
5027
+ this.cache.set(name, { price: null, expiresAt: now + this.cacheTtlMs });
5028
+ }
5029
+ }
5030
+ if (this.debug) {
5031
+ console.log(`[CoinGecko] Fetched ${result.size} prices`);
5032
+ }
5033
+ } catch (error) {
5034
+ if (this.debug) {
5035
+ console.warn("[CoinGecko] Fetch failed, using stale cache:", error);
5036
+ }
5037
+ for (const name of uncachedNames) {
5038
+ const stale = this.cache.get(name);
5039
+ if (stale?.price) {
5040
+ result.set(name, stale.price);
5041
+ }
5042
+ }
5043
+ }
5044
+ return result;
5045
+ }
5046
+ async getPrice(tokenName) {
5047
+ const prices = await this.getPrices([tokenName]);
5048
+ return prices.get(tokenName) ?? null;
5049
+ }
5050
+ clearCache() {
5051
+ this.cache.clear();
5052
+ }
5053
+ };
5054
+
5055
+ // price/index.ts
5056
+ function createPriceProvider(config) {
5057
+ switch (config.platform) {
5058
+ case "coingecko":
5059
+ return new CoinGeckoPriceProvider(config);
5060
+ default:
5061
+ throw new Error(`Unsupported price platform: ${String(config.platform)}`);
5062
+ }
5063
+ }
5064
+
5065
+ // impl/shared/resolvers.ts
5066
+ function getNetworkConfig(network = "mainnet") {
5067
+ return NETWORKS[network];
5068
+ }
5069
+ function resolveTransportConfig(network, config) {
5070
+ const networkConfig = getNetworkConfig(network);
5071
+ let relays;
5072
+ if (config?.relays) {
5073
+ relays = config.relays;
5074
+ } else {
5075
+ relays = [...networkConfig.nostrRelays];
5076
+ if (config?.additionalRelays) {
5077
+ relays = [...relays, ...config.additionalRelays];
5078
+ }
5079
+ }
5080
+ return {
5081
+ relays,
5082
+ timeout: config?.timeout,
5083
+ autoReconnect: config?.autoReconnect,
5084
+ debug: config?.debug,
5085
+ // Browser-specific
5086
+ reconnectDelay: config?.reconnectDelay,
5087
+ maxReconnectAttempts: config?.maxReconnectAttempts
5088
+ };
5089
+ }
5090
+ function resolveOracleConfig(network, config) {
5091
+ const networkConfig = getNetworkConfig(network);
5092
+ return {
5093
+ url: config?.url ?? networkConfig.aggregatorUrl,
5094
+ apiKey: config?.apiKey ?? DEFAULT_AGGREGATOR_API_KEY,
5095
+ timeout: config?.timeout,
5096
+ skipVerification: config?.skipVerification,
5097
+ debug: config?.debug,
5098
+ // Node.js-specific
5099
+ trustBasePath: config?.trustBasePath
5100
+ };
5101
+ }
5102
+ function resolveL1Config(network, config) {
5103
+ if (config === void 0) {
5104
+ return void 0;
5105
+ }
5106
+ const networkConfig = getNetworkConfig(network);
5107
+ return {
5108
+ electrumUrl: config.electrumUrl ?? networkConfig.electrumUrl,
5109
+ defaultFeeRate: config.defaultFeeRate,
5110
+ enableVesting: config.enableVesting
5111
+ };
5112
+ }
5113
+ function resolvePriceConfig(config) {
5114
+ if (config === void 0) {
5115
+ return void 0;
5116
+ }
5117
+ return {
5118
+ platform: config.platform ?? "coingecko",
5119
+ apiKey: config.apiKey,
5120
+ baseUrl: config.baseUrl,
5121
+ cacheTtlMs: config.cacheTtlMs,
5122
+ timeout: config.timeout,
5123
+ debug: config.debug
5124
+ };
5125
+ }
5126
+ function resolveArrayConfig(defaults, replace, additional) {
5127
+ if (replace) {
5128
+ return replace;
5129
+ }
5130
+ const result = [...defaults];
5131
+ if (additional) {
5132
+ return [...result, ...additional];
5133
+ }
5134
+ return result;
5135
+ }
5136
+ function resolveGroupChatConfig(network, config) {
5137
+ if (!config) return void 0;
5138
+ if (config === true) {
5139
+ const netConfig2 = getNetworkConfig(network);
5140
+ return { relays: [...netConfig2.groupRelays] };
5141
+ }
5142
+ if (typeof config === "object" && config.enabled === false) {
5143
+ return void 0;
5144
+ }
5145
+ const netConfig = getNetworkConfig(network);
5146
+ return {
5147
+ relays: config.relays ?? [...netConfig.groupRelays]
5148
+ };
3225
5149
  }
3226
5150
 
3227
5151
  // impl/browser/index.ts
@@ -3282,8 +5206,16 @@ function createBrowserProviders(config) {
3282
5206
  const tokenSyncConfig = resolveTokenSyncConfig(network, config?.tokenSync);
3283
5207
  const priceConfig = resolvePriceConfig(config?.price);
3284
5208
  const storage = createLocalStorageProvider(config?.storage);
5209
+ const ipfsConfig = tokenSyncConfig?.ipfs;
5210
+ const ipfsTokenStorage = ipfsConfig?.enabled ? createBrowserIpfsStorageProvider({
5211
+ gateways: ipfsConfig.gateways,
5212
+ debug: config?.tokenSync?.ipfs?.useDht
5213
+ // reuse debug-like flag
5214
+ }) : void 0;
5215
+ const groupChat = resolveGroupChatConfig(network, config?.groupChat);
3285
5216
  return {
3286
5217
  storage,
5218
+ groupChat,
3287
5219
  transport: createNostrTransportProvider({
3288
5220
  relays: transportConfig.relays,
3289
5221
  timeout: transportConfig.timeout,
@@ -3304,6 +5236,7 @@ function createBrowserProviders(config) {
3304
5236
  tokenStorage: createIndexedDBTokenStorageProvider(),
3305
5237
  l1: l1Config,
3306
5238
  price: priceConfig ? createPriceProvider(priceConfig) : void 0,
5239
+ ipfsTokenStorage,
3307
5240
  tokenSyncConfig
3308
5241
  };
3309
5242
  }