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