@toon-protocol/client-mcp 0.29.6 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{anon-proxy-W3KMM7GU-L5IBCUAX.js → anon-proxy-W3KMM7GU-FN7ZJY7P.js} +2 -1
- package/dist/chunk-4K6S3VPM.js +1567 -0
- package/dist/chunk-4K6S3VPM.js.map +1 -0
- package/dist/chunk-F22GNSF6.js +12 -0
- package/dist/{chunk-OMEKDTJ4.js → chunk-QHNSETFB.js} +19 -8
- package/dist/{chunk-OMEKDTJ4.js.map → chunk-QHNSETFB.js.map} +1 -1
- package/dist/{chunk-WYWUAKYM.js → chunk-U227QSQG.js} +33 -9
- package/dist/chunk-U227QSQG.js.map +1 -0
- package/dist/daemon.js +3 -2
- package/dist/daemon.js.map +1 -1
- package/dist/{ed25519-OFFWPWRE.js → ed25519-UB2JDJJK.js} +2 -1
- package/dist/{gateway-QOK47RKS-HB65KIKC.js → gateway-QOK47RKS-SEGTXBR3.js} +2 -1
- package/dist/{gateway-QOK47RKS-HB65KIKC.js.map → gateway-QOK47RKS-SEGTXBR3.js.map} +1 -1
- package/dist/{hmac-7WSXTWW4.js → hmac-26UC6YKX.js} +2 -1
- package/dist/hmac-26UC6YKX.js.map +1 -0
- package/dist/index.d.ts +95 -9
- package/dist/index.js +4 -3
- package/dist/mcp.js +3 -2
- package/dist/mcp.js.map +1 -1
- package/dist/{sha512-LMOIUNFJ.js → sha512-WYC446ZM.js} +2 -1
- package/dist/{sha512-LMOIUNFJ.js.map → sha512-WYC446ZM.js.map} +1 -1
- package/dist/{socks5-WTJBYGME-IXWLQDE7.js → socks5-WTJBYGME-6COK4LXW.js} +2 -1
- package/dist/{socks5-WTJBYGME-IXWLQDE7.js.map → socks5-WTJBYGME-6COK4LXW.js.map} +1 -1
- package/package.json +3 -2
- package/dist/chunk-CY5GWIH5.js +0 -625
- package/dist/chunk-CY5GWIH5.js.map +0 -1
- package/dist/chunk-WYWUAKYM.js.map +0 -1
- /package/dist/{anon-proxy-W3KMM7GU-L5IBCUAX.js.map → anon-proxy-W3KMM7GU-FN7ZJY7P.js.map} +0 -0
- /package/dist/{ed25519-OFFWPWRE.js.map → chunk-F22GNSF6.js.map} +0 -0
- /package/dist/{hmac-7WSXTWW4.js.map → ed25519-UB2JDJJK.js.map} +0 -0
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
ToonError,
|
|
4
|
+
buildIlpPrepare,
|
|
5
|
+
decodeEventFromToon,
|
|
6
|
+
encodeEventToToon
|
|
7
|
+
} from "./chunk-QHNSETFB.js";
|
|
8
|
+
import {
|
|
9
|
+
__require
|
|
10
|
+
} from "./chunk-F22GNSF6.js";
|
|
11
|
+
|
|
12
|
+
// src/relay-subscription.ts
|
|
13
|
+
import { createRequire } from "module";
|
|
14
|
+
var nodeRequire = createRequire(import.meta.url);
|
|
15
|
+
var DEFAULT_BUFFER = 5e3;
|
|
16
|
+
var DEFAULT_BASE_MS = 1e3;
|
|
17
|
+
var DEFAULT_MAX_MS = 3e4;
|
|
18
|
+
var noop = () => void 0;
|
|
19
|
+
var RelaySubscription = class {
|
|
20
|
+
relayUrl;
|
|
21
|
+
socksProxy;
|
|
22
|
+
bufferSize;
|
|
23
|
+
reconnectBaseMs;
|
|
24
|
+
reconnectMaxMs;
|
|
25
|
+
log;
|
|
26
|
+
wsFactory;
|
|
27
|
+
decodeEvent;
|
|
28
|
+
/** Active subscriptions: subId -> filters (re-sent on every (re)connect). */
|
|
29
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
30
|
+
/** Ring buffer of received events, ordered by ascending `seq`. */
|
|
31
|
+
buffer = [];
|
|
32
|
+
/** De-dup index: event.id -> seq (kept in lockstep with the buffer). */
|
|
33
|
+
seen = /* @__PURE__ */ new Set();
|
|
34
|
+
seqCounter = 0;
|
|
35
|
+
subIdCounter = 0;
|
|
36
|
+
ws = null;
|
|
37
|
+
connected = false;
|
|
38
|
+
closing = false;
|
|
39
|
+
reconnectAttempts = 0;
|
|
40
|
+
reconnectTimer = null;
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
this.relayUrl = opts.relayUrl;
|
|
43
|
+
this.socksProxy = opts.socksProxy;
|
|
44
|
+
this.bufferSize = opts.bufferSize ?? DEFAULT_BUFFER;
|
|
45
|
+
this.reconnectBaseMs = opts.reconnectBaseMs ?? DEFAULT_BASE_MS;
|
|
46
|
+
this.reconnectMaxMs = opts.reconnectMaxMs ?? DEFAULT_MAX_MS;
|
|
47
|
+
this.log = opts.logger ?? noop;
|
|
48
|
+
this.wsFactory = opts.wsFactory ?? defaultWebSocketFactory(this.socksProxy);
|
|
49
|
+
this.decodeEvent = opts.decodeEvent;
|
|
50
|
+
}
|
|
51
|
+
/** Whether the underlying socket is currently open. */
|
|
52
|
+
isConnected() {
|
|
53
|
+
return this.connected;
|
|
54
|
+
}
|
|
55
|
+
/** Number of events currently held in the buffer. */
|
|
56
|
+
bufferedCount() {
|
|
57
|
+
return this.buffer.length;
|
|
58
|
+
}
|
|
59
|
+
/** Active subscription ids. */
|
|
60
|
+
activeSubscriptions() {
|
|
61
|
+
return [...this.subscriptions.keys()];
|
|
62
|
+
}
|
|
63
|
+
/** Open the connection (idempotent). */
|
|
64
|
+
start() {
|
|
65
|
+
this.closing = false;
|
|
66
|
+
if (this.ws) return;
|
|
67
|
+
this.open();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Register a persistent subscription and (if connected) send the REQ.
|
|
71
|
+
* Returns the subscription id (caller-supplied or generated).
|
|
72
|
+
*/
|
|
73
|
+
subscribe(filters, subId) {
|
|
74
|
+
const id = subId ?? `sub-${++this.subIdCounter}`;
|
|
75
|
+
const list = Array.isArray(filters) ? filters : [filters];
|
|
76
|
+
this.subscriptions.set(id, list);
|
|
77
|
+
if (this.connected) this.sendReq(id, list);
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
/** Cancel a subscription and send CLOSE if connected. */
|
|
81
|
+
unsubscribe(subId) {
|
|
82
|
+
if (!this.subscriptions.delete(subId)) return;
|
|
83
|
+
if (this.connected) this.sendRaw(["CLOSE", subId]);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Drain events newer than `cursor`. The cursor is the highest `seq` returned;
|
|
87
|
+
* pass it back to fetch only events received since. Filtering by `subId`
|
|
88
|
+
* restricts to one subscription.
|
|
89
|
+
*/
|
|
90
|
+
getEvents(opts = {}) {
|
|
91
|
+
const after = opts.cursor ?? 0;
|
|
92
|
+
const limit = opts.limit ?? 200;
|
|
93
|
+
const matches = this.buffer.filter(
|
|
94
|
+
(b) => b.seq > after && (opts.subId === void 0 || b.subId === opts.subId)
|
|
95
|
+
);
|
|
96
|
+
const page = matches.slice(0, limit);
|
|
97
|
+
const hasMore = matches.length > page.length;
|
|
98
|
+
const last = page.at(-1);
|
|
99
|
+
const cursor = last ? last.seq : after;
|
|
100
|
+
return { events: page.map((b) => b.event), cursor, hasMore };
|
|
101
|
+
}
|
|
102
|
+
/** Close the connection permanently and stop reconnecting. */
|
|
103
|
+
close() {
|
|
104
|
+
this.closing = true;
|
|
105
|
+
if (this.reconnectTimer) {
|
|
106
|
+
clearTimeout(this.reconnectTimer);
|
|
107
|
+
this.reconnectTimer = null;
|
|
108
|
+
}
|
|
109
|
+
if (this.ws) {
|
|
110
|
+
try {
|
|
111
|
+
this.ws.close();
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
this.ws = null;
|
|
115
|
+
}
|
|
116
|
+
this.connected = false;
|
|
117
|
+
}
|
|
118
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
119
|
+
open() {
|
|
120
|
+
let ws;
|
|
121
|
+
try {
|
|
122
|
+
ws = this.wsFactory(this.relayUrl);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.log(`[relay] connect failed: ${errMsg(err)}`);
|
|
125
|
+
this.scheduleReconnect();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.ws = ws;
|
|
129
|
+
ws.on("open", () => {
|
|
130
|
+
this.connected = true;
|
|
131
|
+
this.reconnectAttempts = 0;
|
|
132
|
+
this.log(`[relay] connected to ${this.relayUrl}`);
|
|
133
|
+
for (const [id, filters] of this.subscriptions) this.sendReq(id, filters);
|
|
134
|
+
});
|
|
135
|
+
ws.on("message", (data) => this.handleMessage(data));
|
|
136
|
+
ws.on("error", (err) => {
|
|
137
|
+
this.log(`[relay] socket error: ${errMsg(err)}`);
|
|
138
|
+
});
|
|
139
|
+
ws.on("close", () => {
|
|
140
|
+
this.connected = false;
|
|
141
|
+
this.ws = null;
|
|
142
|
+
if (!this.closing) {
|
|
143
|
+
this.log("[relay] disconnected; scheduling reconnect");
|
|
144
|
+
this.scheduleReconnect();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
scheduleReconnect() {
|
|
149
|
+
if (this.closing || this.reconnectTimer) return;
|
|
150
|
+
const delay = Math.min(
|
|
151
|
+
this.reconnectMaxMs,
|
|
152
|
+
this.reconnectBaseMs * 2 ** this.reconnectAttempts
|
|
153
|
+
);
|
|
154
|
+
this.reconnectAttempts += 1;
|
|
155
|
+
this.reconnectTimer = setTimeout(() => {
|
|
156
|
+
this.reconnectTimer = null;
|
|
157
|
+
if (!this.closing) this.open();
|
|
158
|
+
}, delay);
|
|
159
|
+
this.reconnectTimer.unref?.();
|
|
160
|
+
}
|
|
161
|
+
sendReq(subId, filters) {
|
|
162
|
+
this.sendRaw(["REQ", subId, ...filters]);
|
|
163
|
+
}
|
|
164
|
+
sendRaw(message) {
|
|
165
|
+
if (!this.ws || !this.connected) return;
|
|
166
|
+
try {
|
|
167
|
+
this.ws.send(JSON.stringify(message));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
this.log(`[relay] send failed: ${errMsg(err)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
handleMessage(data) {
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(toText(data));
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return;
|
|
180
|
+
const type = parsed[0];
|
|
181
|
+
switch (type) {
|
|
182
|
+
case "EVENT": {
|
|
183
|
+
const subId = typeof parsed[1] === "string" ? parsed[1] : "";
|
|
184
|
+
const event = this.parseEventPayload(parsed[2]);
|
|
185
|
+
if (event && typeof event.id === "string")
|
|
186
|
+
this.bufferEvent(subId, event);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "EOSE":
|
|
190
|
+
break;
|
|
191
|
+
case "CLOSED":
|
|
192
|
+
this.log(
|
|
193
|
+
`[relay] subscription closed by relay: ${String(parsed[2] ?? "")}`
|
|
194
|
+
);
|
|
195
|
+
break;
|
|
196
|
+
case "NOTICE":
|
|
197
|
+
this.log(`[relay] NOTICE: ${String(parsed[1] ?? "")}`);
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Normalise an `EVENT` payload to a NostrEvent. Standard relays send a JSON
|
|
205
|
+
* object; the TOON relay sends a TOON-encoded string, decoded via the injected
|
|
206
|
+
* `decodeEvent`. Returns undefined when it can't be parsed.
|
|
207
|
+
*/
|
|
208
|
+
parseEventPayload(raw) {
|
|
209
|
+
if (raw && typeof raw === "object" && typeof raw.id === "string") {
|
|
210
|
+
return raw;
|
|
211
|
+
}
|
|
212
|
+
if (typeof raw === "string" && this.decodeEvent) {
|
|
213
|
+
try {
|
|
214
|
+
return this.decodeEvent(raw);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
this.log(`[relay] event decode failed: ${errMsg(err)}`);
|
|
217
|
+
return void 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return void 0;
|
|
221
|
+
}
|
|
222
|
+
bufferEvent(subId, event) {
|
|
223
|
+
if (this.seen.has(event.id)) return;
|
|
224
|
+
this.seen.add(event.id);
|
|
225
|
+
this.buffer.push({ seq: ++this.seqCounter, subId, event });
|
|
226
|
+
if (this.buffer.length > this.bufferSize) {
|
|
227
|
+
const evicted = this.buffer.shift();
|
|
228
|
+
if (evicted) this.seen.delete(evicted.event.id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
function toText(data) {
|
|
233
|
+
if (typeof data === "string") return data;
|
|
234
|
+
if (data instanceof Uint8Array) return Buffer.from(data).toString("utf8");
|
|
235
|
+
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
236
|
+
return String(data);
|
|
237
|
+
}
|
|
238
|
+
function errMsg(err) {
|
|
239
|
+
return err instanceof Error ? err.message : String(err);
|
|
240
|
+
}
|
|
241
|
+
function defaultWebSocketFactory(socksProxy) {
|
|
242
|
+
return (url) => {
|
|
243
|
+
const WebSocketImpl = nodeRequire("ws");
|
|
244
|
+
let agent;
|
|
245
|
+
if (socksProxy) {
|
|
246
|
+
const { SocksProxyAgent } = nodeRequire("socks-proxy-agent");
|
|
247
|
+
agent = new SocksProxyAgent(socksProxy);
|
|
248
|
+
}
|
|
249
|
+
return new WebSocketImpl(url, agent ? { agent } : void 0);
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/daemon/client-runner.ts
|
|
254
|
+
import { generateSecretKey as generateSecretKey2 } from "nostr-tools/pure";
|
|
255
|
+
|
|
256
|
+
// ../sdk/dist/chunk-UP2VWCW5.js
|
|
257
|
+
var __require2 = /* @__PURE__ */ ((x) => typeof __require !== "undefined" ? __require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
258
|
+
get: (a, b) => (typeof __require !== "undefined" ? __require : a)[b]
|
|
259
|
+
}) : x)(function(x) {
|
|
260
|
+
if (typeof __require !== "undefined") return __require.apply(this, arguments);
|
|
261
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ../sdk/dist/chunk-XTHUXP63.js
|
|
265
|
+
import { getPublicKey } from "nostr-tools/pure";
|
|
266
|
+
import { generateSecretKey, getPublicKey as getPublicKey2 } from "nostr-tools/pure";
|
|
267
|
+
import { createRumor, createSeal, createWrap } from "nostr-tools/nip59";
|
|
268
|
+
import {
|
|
269
|
+
encrypt as nip44Encrypt,
|
|
270
|
+
decrypt as nip44Decrypt,
|
|
271
|
+
getConversationKey
|
|
272
|
+
} from "nostr-tools/nip44";
|
|
273
|
+
import { getPublicKey as getPublicKey3 } from "nostr-tools/pure";
|
|
274
|
+
var IdentityError = class extends ToonError {
|
|
275
|
+
constructor(message, cause) {
|
|
276
|
+
super(message, "IDENTITY_ERROR", cause);
|
|
277
|
+
this.name = "IdentityError";
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
var GiftWrapError = class extends ToonError {
|
|
281
|
+
constructor(message, cause) {
|
|
282
|
+
super(message, "GIFT_WRAP_ERROR", cause);
|
|
283
|
+
this.name = "GiftWrapError";
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
var SwapHandlerError = class extends ToonError {
|
|
287
|
+
constructor(message, cause) {
|
|
288
|
+
super(message, "SWAP_HANDLER_ERROR", cause);
|
|
289
|
+
this.name = "SwapHandlerError";
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
var StreamSwapError = class extends Error {
|
|
293
|
+
code;
|
|
294
|
+
constructor(code, message, options) {
|
|
295
|
+
super(message);
|
|
296
|
+
this.name = "StreamSwapError";
|
|
297
|
+
this.code = code;
|
|
298
|
+
if (options && "cause" in options) {
|
|
299
|
+
Object.defineProperty(this, "cause", {
|
|
300
|
+
value: options.cause,
|
|
301
|
+
enumerable: false,
|
|
302
|
+
writable: true,
|
|
303
|
+
configurable: true
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
309
|
+
function base58Decode(str) {
|
|
310
|
+
let zeros = 0;
|
|
311
|
+
for (let i = 0; i < str.length && str[i] === "1"; i++) zeros++;
|
|
312
|
+
let value = 0n;
|
|
313
|
+
for (const ch of str) {
|
|
314
|
+
const idx = BASE58_ALPHABET.indexOf(ch);
|
|
315
|
+
if (idx === -1) throw new IdentityError(`Invalid base58 character: ${ch}`);
|
|
316
|
+
value = value * 58n + BigInt(idx);
|
|
317
|
+
}
|
|
318
|
+
const hex = value === 0n ? "" : value.toString(16);
|
|
319
|
+
const hexPadded = hex.length % 2 ? "0" + hex : hex;
|
|
320
|
+
const rawBytes = [];
|
|
321
|
+
for (let i = 0; i < hexPadded.length; i += 2) {
|
|
322
|
+
rawBytes.push(parseInt(hexPadded.slice(i, i + 2), 16));
|
|
323
|
+
}
|
|
324
|
+
const result = new Uint8Array(zeros + rawBytes.length);
|
|
325
|
+
result.set(rawBytes, zeros);
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
function validateSecretKey(key2, paramName) {
|
|
329
|
+
if (!(key2 instanceof Uint8Array) || key2.length !== 32) {
|
|
330
|
+
throw new GiftWrapError(
|
|
331
|
+
`${paramName} must be a 32-byte Uint8Array, got ${key2 instanceof Uint8Array ? `${key2.length} bytes` : typeof key2}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function validatePubkey(pubkey, paramName) {
|
|
336
|
+
if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/.test(pubkey)) {
|
|
337
|
+
throw new GiftWrapError(
|
|
338
|
+
`${paramName} must be a 64-character lowercase hex string`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function wrapSwapPacket(params) {
|
|
343
|
+
const { rumor, senderSecretKey, recipientPubkey } = params;
|
|
344
|
+
validateSecretKey(senderSecretKey, "senderSecretKey");
|
|
345
|
+
validatePubkey(recipientPubkey, "recipientPubkey");
|
|
346
|
+
try {
|
|
347
|
+
const rumorEvent = createRumor(rumor, senderSecretKey);
|
|
348
|
+
const seal = createSeal(rumorEvent, senderSecretKey, recipientPubkey);
|
|
349
|
+
const giftWrap = createWrap(seal, recipientPubkey);
|
|
350
|
+
const ephemeralPubkey = giftWrap.pubkey;
|
|
351
|
+
return { giftWrap, ephemeralPubkey };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw new GiftWrapError(
|
|
354
|
+
`Failed to wrap swap packet: ${error instanceof Error ? error.message : String(error)}`,
|
|
355
|
+
error instanceof Error ? error : void 0
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function wrapSwapPacketToToon(params) {
|
|
360
|
+
const {
|
|
361
|
+
rumor,
|
|
362
|
+
senderSecretKey,
|
|
363
|
+
recipientPubkey,
|
|
364
|
+
destination,
|
|
365
|
+
amount,
|
|
366
|
+
expiresAt
|
|
367
|
+
} = params;
|
|
368
|
+
const { giftWrap, ephemeralPubkey } = wrapSwapPacket({
|
|
369
|
+
rumor,
|
|
370
|
+
senderSecretKey,
|
|
371
|
+
recipientPubkey
|
|
372
|
+
});
|
|
373
|
+
const toonBinary = encodeEventToToon(giftWrap);
|
|
374
|
+
const ilpPrepare = buildIlpPrepare({
|
|
375
|
+
destination,
|
|
376
|
+
amount,
|
|
377
|
+
data: toonBinary,
|
|
378
|
+
expiresAt
|
|
379
|
+
});
|
|
380
|
+
return { ilpPrepare, ephemeralPubkey };
|
|
381
|
+
}
|
|
382
|
+
function decryptFulfillClaim(params) {
|
|
383
|
+
const { ciphertext, ephemeralPubkey, recipientSecretKey } = params;
|
|
384
|
+
validateSecretKey(recipientSecretKey, "recipientSecretKey");
|
|
385
|
+
validatePubkey(ephemeralPubkey, "ephemeralPubkey");
|
|
386
|
+
if (!(ciphertext instanceof Uint8Array) || ciphertext.length === 0) {
|
|
387
|
+
throw new GiftWrapError("ciphertext must be a non-empty Uint8Array");
|
|
388
|
+
}
|
|
389
|
+
let conversationKey = null;
|
|
390
|
+
try {
|
|
391
|
+
conversationKey = getConversationKey(recipientSecretKey, ephemeralPubkey);
|
|
392
|
+
const ciphertextString = new TextDecoder().decode(ciphertext);
|
|
393
|
+
const claimString = nip44Decrypt(ciphertextString, conversationKey);
|
|
394
|
+
return new Uint8Array(Buffer.from(claimString, "base64"));
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof GiftWrapError) {
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
throw new GiftWrapError(
|
|
400
|
+
`Failed to decrypt FULFILL claim: ${error instanceof Error ? error.message : String(error)}`,
|
|
401
|
+
error instanceof Error ? error : void 0
|
|
402
|
+
);
|
|
403
|
+
} finally {
|
|
404
|
+
if (conversationKey) {
|
|
405
|
+
conversationKey.fill(0);
|
|
406
|
+
conversationKey = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
var RATE_REGEX = /^(0|[1-9]\d*)(\.\d+)?$/;
|
|
411
|
+
function applyRate(params) {
|
|
412
|
+
const { sourceAmount, fromScale, toScale, rate } = params;
|
|
413
|
+
if (!RATE_REGEX.test(rate)) {
|
|
414
|
+
throw new SwapHandlerError(`Invalid rate format: ${rate}`);
|
|
415
|
+
}
|
|
416
|
+
if (/^0(\.0+)?$/.test(rate)) {
|
|
417
|
+
throw new SwapHandlerError("Rate is zero (pair not quoting)");
|
|
418
|
+
}
|
|
419
|
+
if (sourceAmount <= 0n) {
|
|
420
|
+
throw new SwapHandlerError(
|
|
421
|
+
`sourceAmount must be positive, got ${sourceAmount}`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const dotIdx = rate.indexOf(".");
|
|
425
|
+
const integerPart = dotIdx === -1 ? rate : rate.slice(0, dotIdx);
|
|
426
|
+
const fractionalPart = dotIdx === -1 ? "" : rate.slice(dotIdx + 1);
|
|
427
|
+
const rateNumerator = BigInt(integerPart + fractionalPart);
|
|
428
|
+
const rateDenominator = 10n ** BigInt(fractionalPart.length);
|
|
429
|
+
const scaleUp = 10n ** BigInt(toScale);
|
|
430
|
+
const scaleDown = 10n ** BigInt(fromScale);
|
|
431
|
+
return sourceAmount * rateNumerator * scaleUp / (rateDenominator * scaleDown);
|
|
432
|
+
}
|
|
433
|
+
var RATE_REGEX2 = /^(0|[1-9]\d*)(\.\d+)?$/;
|
|
434
|
+
var HEX64_REGEX = /^[0-9a-f]{64}$/;
|
|
435
|
+
var BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
436
|
+
function isBase64(s) {
|
|
437
|
+
if (s.length === 0 || s.length % 4 !== 0) return false;
|
|
438
|
+
return BASE64_REGEX.test(s);
|
|
439
|
+
}
|
|
440
|
+
function chunkAmount(total, count) {
|
|
441
|
+
if (!Number.isInteger(count) || count <= 0 || count > Number.MAX_SAFE_INTEGER) {
|
|
442
|
+
throw new StreamSwapError(
|
|
443
|
+
"INVALID_CHUNKING",
|
|
444
|
+
`packetCount must be a positive integer, got ${count}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (total < BigInt(count)) {
|
|
448
|
+
throw new StreamSwapError(
|
|
449
|
+
"INVALID_CHUNKING",
|
|
450
|
+
`totalAmount (${total}) must be >= packetCount (${count}) so per-packet amount >= 1`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const base = total / BigInt(count);
|
|
454
|
+
const remainder = total - base * BigInt(count);
|
|
455
|
+
const out = new Array(count);
|
|
456
|
+
for (let i = 0; i < count; i++) out[i] = base;
|
|
457
|
+
const last = out[count - 1] ?? 0n;
|
|
458
|
+
out[count - 1] = last + remainder;
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
function buildSwapRumor(input) {
|
|
462
|
+
const {
|
|
463
|
+
senderPubkey,
|
|
464
|
+
pair,
|
|
465
|
+
sourceAmount,
|
|
466
|
+
packetIndex,
|
|
467
|
+
totalPackets,
|
|
468
|
+
nonce,
|
|
469
|
+
createdAt,
|
|
470
|
+
chainRecipient
|
|
471
|
+
} = input;
|
|
472
|
+
return {
|
|
473
|
+
kind: 20032,
|
|
474
|
+
pubkey: senderPubkey,
|
|
475
|
+
content: "",
|
|
476
|
+
created_at: createdAt,
|
|
477
|
+
tags: [
|
|
478
|
+
["swap-from", `${pair.from.assetCode}:${pair.from.chain}`],
|
|
479
|
+
["swap-to", `${pair.to.assetCode}:${pair.to.chain}`],
|
|
480
|
+
["amount", sourceAmount.toString()],
|
|
481
|
+
["seq", String(packetIndex), String(totalPackets)],
|
|
482
|
+
["nonce", Buffer.from(nonce).toString("hex")],
|
|
483
|
+
["chain-recipient", chainRecipient]
|
|
484
|
+
]
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
var EVM_CHANNEL_ID_REGEX = /^0x[0-9a-f]{64}$/;
|
|
488
|
+
var EVM_ADDRESS_REGEX = /^0x[0-9a-f]{40}$/;
|
|
489
|
+
var DECIMAL_UINT_REGEX = /^(0|[1-9]\d*)$/;
|
|
490
|
+
var BASE58_REGEX = /^[1-9A-HJ-NP-Za-km-z]+$/;
|
|
491
|
+
function validateChainAddress(value, chain, kind) {
|
|
492
|
+
if (chain.startsWith("evm:")) {
|
|
493
|
+
const normalized = value.toLowerCase();
|
|
494
|
+
if (kind === "channelId") return EVM_CHANNEL_ID_REGEX.test(normalized);
|
|
495
|
+
return EVM_ADDRESS_REGEX.test(normalized);
|
|
496
|
+
}
|
|
497
|
+
if (chain.startsWith("solana:")) {
|
|
498
|
+
if (!BASE58_REGEX.test(value)) return false;
|
|
499
|
+
if (value.length < 32 || value.length > 44) return false;
|
|
500
|
+
try {
|
|
501
|
+
return base58Decode(value).length === 32;
|
|
502
|
+
} catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (chain.startsWith("mina:")) {
|
|
507
|
+
return BASE58_REGEX.test(value) && value.length >= 32;
|
|
508
|
+
}
|
|
509
|
+
return value.length > 0;
|
|
510
|
+
}
|
|
511
|
+
function decodeFulfillMetadata(data, chain) {
|
|
512
|
+
if (data === void 0 || data === null || data === "") {
|
|
513
|
+
throw new StreamSwapError("FULFILL_DECODE_FAILED", "FULFILL data missing");
|
|
514
|
+
}
|
|
515
|
+
if (!isBase64(data)) {
|
|
516
|
+
throw new StreamSwapError(
|
|
517
|
+
"FULFILL_DECODE_FAILED",
|
|
518
|
+
"FULFILL data is not valid base64"
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
let jsonBytes;
|
|
522
|
+
try {
|
|
523
|
+
jsonBytes = Buffer.from(data, "base64");
|
|
524
|
+
} catch (err) {
|
|
525
|
+
throw new StreamSwapError(
|
|
526
|
+
"FULFILL_DECODE_FAILED",
|
|
527
|
+
`FULFILL data base64 decode failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
528
|
+
{ cause: err }
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
let parsed;
|
|
532
|
+
try {
|
|
533
|
+
parsed = JSON.parse(jsonBytes.toString("utf8"));
|
|
534
|
+
} catch (err) {
|
|
535
|
+
throw new StreamSwapError(
|
|
536
|
+
"FULFILL_DECODE_FAILED",
|
|
537
|
+
`FULFILL data JSON parse failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
538
|
+
{ cause: err }
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (!parsed || typeof parsed !== "object") {
|
|
542
|
+
throw new StreamSwapError(
|
|
543
|
+
"FULFILL_DECODE_FAILED",
|
|
544
|
+
"FULFILL metadata is not an object"
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const obj = parsed;
|
|
548
|
+
const claim = obj["claim"];
|
|
549
|
+
const ephemeralPubkey = obj["ephemeralPubkey"];
|
|
550
|
+
if (typeof claim !== "string" || !isBase64(claim)) {
|
|
551
|
+
throw new StreamSwapError(
|
|
552
|
+
"FULFILL_DECODE_FAILED",
|
|
553
|
+
"FULFILL metadata.claim is missing or not base64 string"
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
if (typeof ephemeralPubkey !== "string" || !HEX64_REGEX.test(ephemeralPubkey)) {
|
|
557
|
+
throw new StreamSwapError(
|
|
558
|
+
"FULFILL_DECODE_FAILED",
|
|
559
|
+
"FULFILL metadata.ephemeralPubkey is missing or not 64-char hex"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const result = {
|
|
563
|
+
claim,
|
|
564
|
+
ephemeralPubkey
|
|
565
|
+
};
|
|
566
|
+
if (typeof obj["claimId"] === "string") {
|
|
567
|
+
result.claimId = obj["claimId"];
|
|
568
|
+
}
|
|
569
|
+
if (obj["targetAmount"] !== void 0) {
|
|
570
|
+
const ta = obj["targetAmount"];
|
|
571
|
+
if (typeof ta !== "string" || !/^(0|[1-9]\d*)$/.test(ta)) {
|
|
572
|
+
throw new StreamSwapError(
|
|
573
|
+
"FULFILL_DECODE_FAILED",
|
|
574
|
+
"FULFILL metadata.targetAmount must be a non-negative integer decimal string"
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
result.targetAmount = ta;
|
|
578
|
+
}
|
|
579
|
+
const channelId = obj["channelId"];
|
|
580
|
+
if (typeof channelId === "string" && (!chain || validateChainAddress(channelId, chain, "channelId"))) {
|
|
581
|
+
result.channelId = channelId;
|
|
582
|
+
}
|
|
583
|
+
const nonce = obj["nonce"];
|
|
584
|
+
if (typeof nonce === "string" && DECIMAL_UINT_REGEX.test(nonce)) {
|
|
585
|
+
result.nonce = nonce;
|
|
586
|
+
}
|
|
587
|
+
const cumulativeAmount = obj["cumulativeAmount"];
|
|
588
|
+
if (typeof cumulativeAmount === "string" && DECIMAL_UINT_REGEX.test(cumulativeAmount)) {
|
|
589
|
+
result.cumulativeAmount = cumulativeAmount;
|
|
590
|
+
}
|
|
591
|
+
const recipient = obj["recipient"];
|
|
592
|
+
if (typeof recipient === "string" && recipient.length > 0) {
|
|
593
|
+
result.recipient = recipient;
|
|
594
|
+
}
|
|
595
|
+
const millSignerAddress = obj["millSignerAddress"];
|
|
596
|
+
if (typeof millSignerAddress === "string" && (!chain || validateChainAddress(millSignerAddress, chain, "address"))) {
|
|
597
|
+
result.millSignerAddress = millSignerAddress;
|
|
598
|
+
}
|
|
599
|
+
return result;
|
|
600
|
+
}
|
|
601
|
+
var Deferred = class {
|
|
602
|
+
promise;
|
|
603
|
+
resolve;
|
|
604
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror Promise constructor
|
|
605
|
+
reject;
|
|
606
|
+
constructor() {
|
|
607
|
+
this.promise = new Promise((res, rej) => {
|
|
608
|
+
this.resolve = res;
|
|
609
|
+
this.reject = rej;
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
var noop2 = () => void 0;
|
|
614
|
+
var NOOP_LOGGER2 = {
|
|
615
|
+
debug: noop2,
|
|
616
|
+
info: noop2,
|
|
617
|
+
warn: noop2,
|
|
618
|
+
error: noop2
|
|
619
|
+
};
|
|
620
|
+
function validateParams(params) {
|
|
621
|
+
if (typeof params.totalAmount !== "bigint" || params.totalAmount <= 0n) {
|
|
622
|
+
throw new StreamSwapError(
|
|
623
|
+
"INVALID_AMOUNT",
|
|
624
|
+
`totalAmount must be a positive bigint, got ${String(params.totalAmount)}`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const hasCount = params.packetCount !== void 0;
|
|
628
|
+
const hasAmounts = params.packetAmounts !== void 0;
|
|
629
|
+
if (hasCount === hasAmounts) {
|
|
630
|
+
throw new StreamSwapError(
|
|
631
|
+
"INVALID_CHUNKING",
|
|
632
|
+
"Exactly one of packetCount or packetAmounts must be provided"
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
if (hasCount) {
|
|
636
|
+
const c = params.packetCount;
|
|
637
|
+
if (!Number.isInteger(c) || c <= 0) {
|
|
638
|
+
throw new StreamSwapError(
|
|
639
|
+
"INVALID_CHUNKING",
|
|
640
|
+
`packetCount must be a positive integer, got ${c}`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (BigInt(c) > params.totalAmount) {
|
|
644
|
+
throw new StreamSwapError(
|
|
645
|
+
"INVALID_CHUNKING",
|
|
646
|
+
`packetCount (${c}) exceeds totalAmount (${params.totalAmount}); per-packet amount would be < 1 micro-unit`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
const arr = params.packetAmounts;
|
|
651
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
652
|
+
throw new StreamSwapError(
|
|
653
|
+
"INVALID_CHUNKING",
|
|
654
|
+
"packetAmounts must be a non-empty array"
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
let sum = 0n;
|
|
658
|
+
for (const a of arr) {
|
|
659
|
+
if (typeof a !== "bigint" || a <= 0n) {
|
|
660
|
+
throw new StreamSwapError(
|
|
661
|
+
"INVALID_CHUNKING",
|
|
662
|
+
`packetAmounts entries must be positive bigint, got ${String(a)}`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
sum += a;
|
|
666
|
+
}
|
|
667
|
+
if (sum !== params.totalAmount) {
|
|
668
|
+
throw new StreamSwapError(
|
|
669
|
+
"INVALID_CHUNKING",
|
|
670
|
+
`sum(packetAmounts) (${sum}) !== totalAmount (${params.totalAmount})`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!(params.senderSecretKey instanceof Uint8Array) || params.senderSecretKey.length !== 32) {
|
|
675
|
+
throw new StreamSwapError(
|
|
676
|
+
"INVALID_STATE",
|
|
677
|
+
"senderSecretKey must be a 32-byte Uint8Array"
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
if (typeof params.millPubkey !== "string" || !HEX64_REGEX.test(params.millPubkey)) {
|
|
681
|
+
throw new StreamSwapError(
|
|
682
|
+
"INVALID_STATE",
|
|
683
|
+
"millPubkey must be a 64-char lowercase hex string"
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (!params.pair || typeof params.pair !== "object") {
|
|
687
|
+
throw new StreamSwapError("INVALID_PAIR", "pair is required");
|
|
688
|
+
}
|
|
689
|
+
if (!params.pair.from || typeof params.pair.from !== "object" || typeof params.pair.from.assetCode !== "string" || typeof params.pair.from.assetScale !== "number" || typeof params.pair.from.chain !== "string") {
|
|
690
|
+
throw new StreamSwapError(
|
|
691
|
+
"INVALID_PAIR",
|
|
692
|
+
"pair.from must have { assetCode: string, assetScale: number, chain: string }"
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (!params.pair.to || typeof params.pair.to !== "object" || typeof params.pair.to.assetCode !== "string" || typeof params.pair.to.assetScale !== "number" || typeof params.pair.to.chain !== "string") {
|
|
696
|
+
throw new StreamSwapError(
|
|
697
|
+
"INVALID_PAIR",
|
|
698
|
+
"pair.to must have { assetCode: string, assetScale: number, chain: string }"
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
if (typeof params.pair.rate !== "string" || !RATE_REGEX2.test(params.pair.rate)) {
|
|
702
|
+
throw new StreamSwapError(
|
|
703
|
+
"INVALID_PAIR",
|
|
704
|
+
`pair.rate must match ${RATE_REGEX2}, got ${params.pair.rate}`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
applyRate({
|
|
709
|
+
sourceAmount: 1n,
|
|
710
|
+
fromScale: params.pair.from.assetScale,
|
|
711
|
+
toScale: params.pair.to.assetScale,
|
|
712
|
+
rate: params.pair.rate
|
|
713
|
+
});
|
|
714
|
+
} catch (err) {
|
|
715
|
+
throw new StreamSwapError(
|
|
716
|
+
"INVALID_PAIR",
|
|
717
|
+
`pair failed applyRate sanity check: ${err instanceof Error ? err.message : String(err)}`,
|
|
718
|
+
{ cause: err }
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (params.rateDeviationThreshold !== void 0 && (typeof params.rateDeviationThreshold !== "number" || !Number.isFinite(params.rateDeviationThreshold) || params.rateDeviationThreshold < 0)) {
|
|
722
|
+
throw new StreamSwapError(
|
|
723
|
+
"INVALID_STATE",
|
|
724
|
+
`rateDeviationThreshold must be a non-negative finite number, got ${params.rateDeviationThreshold}`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (typeof params.chainRecipient !== "string" || params.chainRecipient.length === 0) {
|
|
728
|
+
throw new StreamSwapError(
|
|
729
|
+
"INVALID_STATE",
|
|
730
|
+
"chainRecipient must be a non-empty string (sender payout address for pair.to.chain)"
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (!validateChainAddress(
|
|
734
|
+
params.chainRecipient,
|
|
735
|
+
params.pair.to.chain,
|
|
736
|
+
"address"
|
|
737
|
+
)) {
|
|
738
|
+
throw new StreamSwapError(
|
|
739
|
+
"INVALID_CHAIN_RECIPIENT",
|
|
740
|
+
`chainRecipient ${params.chainRecipient} is malformed for chain ${params.pair.to.chain}`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async function streamSwap(params) {
|
|
745
|
+
return streamSwapControlled(params).result;
|
|
746
|
+
}
|
|
747
|
+
function streamSwapControlled(params) {
|
|
748
|
+
validateParams(params);
|
|
749
|
+
const logger = params.logger ?? NOOP_LOGGER2;
|
|
750
|
+
const schedule = params.packetAmounts ? [...params.packetAmounts] : chunkAmount(params.totalAmount, params.packetCount);
|
|
751
|
+
const frozenPair = Object.freeze({
|
|
752
|
+
from: Object.freeze({ ...params.pair.from }),
|
|
753
|
+
to: Object.freeze({ ...params.pair.to }),
|
|
754
|
+
rate: params.pair.rate
|
|
755
|
+
});
|
|
756
|
+
const senderPubkey = getPublicKey3(params.senderSecretKey);
|
|
757
|
+
let streamState = "running";
|
|
758
|
+
let resumeDeferred = null;
|
|
759
|
+
const controller = {
|
|
760
|
+
pause() {
|
|
761
|
+
if (streamState === "running") {
|
|
762
|
+
streamState = "paused";
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
resume() {
|
|
766
|
+
if (streamState === "paused") {
|
|
767
|
+
streamState = "running";
|
|
768
|
+
if (resumeDeferred) {
|
|
769
|
+
resumeDeferred.resolve("resume");
|
|
770
|
+
resumeDeferred = null;
|
|
771
|
+
}
|
|
772
|
+
} else if (streamState === "running") {
|
|
773
|
+
} else {
|
|
774
|
+
throw new StreamSwapError(
|
|
775
|
+
"INVALID_STATE",
|
|
776
|
+
`Cannot resume from state "${streamState}"`
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
stop() {
|
|
781
|
+
if (streamState === "completed" || streamState === "failed") return;
|
|
782
|
+
const prev = streamState;
|
|
783
|
+
streamState = "stopped";
|
|
784
|
+
if (prev === "paused" && resumeDeferred) {
|
|
785
|
+
resumeDeferred.resolve("stop");
|
|
786
|
+
resumeDeferred = null;
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
get state() {
|
|
790
|
+
return streamState;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const result = runLoop(
|
|
794
|
+
params,
|
|
795
|
+
frozenPair,
|
|
796
|
+
schedule,
|
|
797
|
+
senderPubkey,
|
|
798
|
+
logger,
|
|
799
|
+
() => streamState,
|
|
800
|
+
(v) => {
|
|
801
|
+
streamState = v;
|
|
802
|
+
},
|
|
803
|
+
() => {
|
|
804
|
+
if (streamState !== "paused") return Promise.resolve("resume");
|
|
805
|
+
if (!resumeDeferred) resumeDeferred = new Deferred();
|
|
806
|
+
return resumeDeferred.promise;
|
|
807
|
+
}
|
|
808
|
+
);
|
|
809
|
+
return { result, controller };
|
|
810
|
+
}
|
|
811
|
+
async function runLoop(params, pair, schedule, senderPubkey, logger, getState, setState, waitForResumeOrStop) {
|
|
812
|
+
const claims = [];
|
|
813
|
+
const rejections = [];
|
|
814
|
+
const errors = [];
|
|
815
|
+
let cumulativeSource = 0n;
|
|
816
|
+
let cumulativeTarget = 0n;
|
|
817
|
+
let packetsSent = 0;
|
|
818
|
+
let abortReason = "complete";
|
|
819
|
+
const totalPackets = schedule.length;
|
|
820
|
+
const isAborted = () => params.signal?.aborted === true;
|
|
821
|
+
packetLoop: for (let packetIndex = 0; packetIndex < totalPackets; packetIndex++) {
|
|
822
|
+
if (isAborted()) {
|
|
823
|
+
abortReason = "aborted";
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
if (getState() === "stopped") {
|
|
827
|
+
abortReason = "stopped";
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
if (getState() === "paused") {
|
|
831
|
+
const resumedBy = await waitForResumeOrStop();
|
|
832
|
+
if (resumedBy === "stop" || getState() === "stopped") {
|
|
833
|
+
abortReason = "stopped";
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
if (isAborted()) {
|
|
837
|
+
abortReason = "aborted";
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const sourceAmount = schedule[packetIndex];
|
|
842
|
+
if (sourceAmount === void 0) {
|
|
843
|
+
throw new StreamSwapError(
|
|
844
|
+
"INVALID_STATE",
|
|
845
|
+
`schedule[${packetIndex}] is undefined; schedule was mutated mid-stream`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
const nonce = new Uint8Array(16);
|
|
849
|
+
getRandomValues(nonce);
|
|
850
|
+
const rumor = buildSwapRumor({
|
|
851
|
+
senderPubkey,
|
|
852
|
+
pair,
|
|
853
|
+
sourceAmount,
|
|
854
|
+
packetIndex: packetIndex + 1,
|
|
855
|
+
totalPackets,
|
|
856
|
+
nonce,
|
|
857
|
+
createdAt: Math.floor(Date.now() / 1e3),
|
|
858
|
+
chainRecipient: params.chainRecipient
|
|
859
|
+
});
|
|
860
|
+
let toonData;
|
|
861
|
+
try {
|
|
862
|
+
const wrapped = wrapSwapPacketToToon({
|
|
863
|
+
rumor,
|
|
864
|
+
senderSecretKey: params.senderSecretKey,
|
|
865
|
+
recipientPubkey: params.millPubkey,
|
|
866
|
+
destination: params.millIlpAddress,
|
|
867
|
+
amount: sourceAmount
|
|
868
|
+
});
|
|
869
|
+
toonData = new Uint8Array(Buffer.from(wrapped.ilpPrepare.data, "base64"));
|
|
870
|
+
} catch (err) {
|
|
871
|
+
logger.error({
|
|
872
|
+
event: "stream_swap.wrap_failed",
|
|
873
|
+
packetIndex,
|
|
874
|
+
error: err instanceof Error ? err.message : String(err)
|
|
875
|
+
});
|
|
876
|
+
errors.push({
|
|
877
|
+
packetIndex,
|
|
878
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
879
|
+
});
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
let sendResult;
|
|
883
|
+
try {
|
|
884
|
+
sendResult = await params.client.sendSwapPacket({
|
|
885
|
+
destination: params.millIlpAddress,
|
|
886
|
+
amount: sourceAmount,
|
|
887
|
+
toonData,
|
|
888
|
+
timeout: params.packetTimeoutMs ?? 3e4,
|
|
889
|
+
claim: params.claim
|
|
890
|
+
});
|
|
891
|
+
packetsSent += 1;
|
|
892
|
+
} catch (err) {
|
|
893
|
+
logger.error({
|
|
894
|
+
event: "stream_swap.send_failed",
|
|
895
|
+
packetIndex,
|
|
896
|
+
error: err instanceof Error ? err.message : String(err)
|
|
897
|
+
});
|
|
898
|
+
errors.push({
|
|
899
|
+
packetIndex,
|
|
900
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
901
|
+
});
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (!sendResult.accepted) {
|
|
905
|
+
const code = sendResult.code ?? "F00";
|
|
906
|
+
const message = sendResult.message ?? "rejected";
|
|
907
|
+
logger.warn({
|
|
908
|
+
event: "stream_swap.packet_rejected",
|
|
909
|
+
packetIndex,
|
|
910
|
+
code,
|
|
911
|
+
message
|
|
912
|
+
});
|
|
913
|
+
rejections.push({ packetIndex, sourceAmount, code, message });
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
let metadata;
|
|
917
|
+
try {
|
|
918
|
+
metadata = decodeFulfillMetadata(sendResult.data, pair.to.chain);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
logger.error({
|
|
921
|
+
event: "stream_swap.fulfill_decode_failed",
|
|
922
|
+
packetIndex,
|
|
923
|
+
error: err instanceof Error ? err.message : String(err)
|
|
924
|
+
});
|
|
925
|
+
errors.push({
|
|
926
|
+
packetIndex,
|
|
927
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
928
|
+
});
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const isEvmTarget = pair.to.chain.startsWith("evm:");
|
|
932
|
+
const recipientMatches = metadata.recipient === void 0 || (isEvmTarget ? metadata.recipient.toLowerCase() === params.chainRecipient.toLowerCase() : metadata.recipient === params.chainRecipient);
|
|
933
|
+
if (!recipientMatches) {
|
|
934
|
+
logger.warn({
|
|
935
|
+
event: "stream_swap.recipient_mismatch",
|
|
936
|
+
packetIndex,
|
|
937
|
+
expected: params.chainRecipient,
|
|
938
|
+
actual: metadata.recipient
|
|
939
|
+
});
|
|
940
|
+
rejections.push({
|
|
941
|
+
packetIndex,
|
|
942
|
+
sourceAmount,
|
|
943
|
+
code: "MILL_RECIPIENT_MISMATCH",
|
|
944
|
+
message: `Mill echoed recipient ${metadata.recipient} but sender expected ${params.chainRecipient}`
|
|
945
|
+
});
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
let claimBytes;
|
|
949
|
+
try {
|
|
950
|
+
const ciphertext = new Uint8Array(Buffer.from(metadata.claim, "base64"));
|
|
951
|
+
claimBytes = decryptFulfillClaim({
|
|
952
|
+
ciphertext,
|
|
953
|
+
ephemeralPubkey: metadata.ephemeralPubkey,
|
|
954
|
+
recipientSecretKey: params.senderSecretKey
|
|
955
|
+
});
|
|
956
|
+
} catch (err) {
|
|
957
|
+
logger.error({
|
|
958
|
+
event: "stream_swap.decrypt_failed",
|
|
959
|
+
packetIndex,
|
|
960
|
+
error: err instanceof Error ? err.message : String(err)
|
|
961
|
+
});
|
|
962
|
+
errors.push({
|
|
963
|
+
packetIndex,
|
|
964
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
965
|
+
});
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (claimBytes.length === 0) {
|
|
969
|
+
logger.warn({
|
|
970
|
+
event: "stream_swap.empty_claim_bytes",
|
|
971
|
+
packetIndex
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
const expectedTargetAmount = applyRate({
|
|
975
|
+
sourceAmount,
|
|
976
|
+
fromScale: pair.from.assetScale,
|
|
977
|
+
toScale: pair.to.assetScale,
|
|
978
|
+
rate: pair.rate
|
|
979
|
+
});
|
|
980
|
+
const targetAmount = metadata.targetAmount !== void 0 ? BigInt(metadata.targetAmount) : expectedTargetAmount;
|
|
981
|
+
let rateDeviation = 0;
|
|
982
|
+
if (expectedTargetAmount > 0n) {
|
|
983
|
+
const diff = targetAmount >= expectedTargetAmount ? targetAmount - expectedTargetAmount : expectedTargetAmount - targetAmount;
|
|
984
|
+
const scaled = diff * 1000000n / expectedTargetAmount;
|
|
985
|
+
rateDeviation = Number(scaled) / 1e6;
|
|
986
|
+
}
|
|
987
|
+
const advertisedRate = parseFloat(pair.rate);
|
|
988
|
+
let effectiveRate;
|
|
989
|
+
if (targetAmount === expectedTargetAmount) {
|
|
990
|
+
effectiveRate = advertisedRate;
|
|
991
|
+
} else {
|
|
992
|
+
const signedDeviation = targetAmount >= expectedTargetAmount ? rateDeviation : -rateDeviation;
|
|
993
|
+
effectiveRate = advertisedRate * (1 + signedDeviation);
|
|
994
|
+
}
|
|
995
|
+
if (!Number.isFinite(effectiveRate)) {
|
|
996
|
+
effectiveRate = advertisedRate;
|
|
997
|
+
}
|
|
998
|
+
cumulativeSource += sourceAmount;
|
|
999
|
+
cumulativeTarget += targetAmount;
|
|
1000
|
+
const accumulated = {
|
|
1001
|
+
packetIndex,
|
|
1002
|
+
sourceAmount,
|
|
1003
|
+
targetAmount,
|
|
1004
|
+
claimBytes,
|
|
1005
|
+
millEphemeralPubkey: metadata.ephemeralPubkey,
|
|
1006
|
+
pair,
|
|
1007
|
+
receivedAt: Date.now()
|
|
1008
|
+
};
|
|
1009
|
+
if (metadata.claimId !== void 0) accumulated.claimId = metadata.claimId;
|
|
1010
|
+
if (metadata.channelId !== void 0)
|
|
1011
|
+
accumulated.channelId = metadata.channelId;
|
|
1012
|
+
if (metadata.nonce !== void 0) accumulated.nonce = metadata.nonce;
|
|
1013
|
+
if (metadata.cumulativeAmount !== void 0)
|
|
1014
|
+
accumulated.cumulativeAmount = metadata.cumulativeAmount;
|
|
1015
|
+
if (metadata.recipient !== void 0)
|
|
1016
|
+
accumulated.recipient = metadata.recipient;
|
|
1017
|
+
if (metadata.millSignerAddress !== void 0)
|
|
1018
|
+
accumulated.millSignerAddress = metadata.millSignerAddress;
|
|
1019
|
+
claims.push(accumulated);
|
|
1020
|
+
logger.debug({
|
|
1021
|
+
event: "stream_swap.packet_accepted",
|
|
1022
|
+
packetIndex,
|
|
1023
|
+
sourceAmount: sourceAmount.toString(),
|
|
1024
|
+
targetAmount: targetAmount.toString()
|
|
1025
|
+
});
|
|
1026
|
+
if (params.onPacket) {
|
|
1027
|
+
const progress = Object.freeze({
|
|
1028
|
+
index: packetIndex,
|
|
1029
|
+
total: totalPackets,
|
|
1030
|
+
sourceAmount,
|
|
1031
|
+
targetAmount,
|
|
1032
|
+
advertisedRate: pair.rate,
|
|
1033
|
+
effectiveRate,
|
|
1034
|
+
rateDeviation,
|
|
1035
|
+
cumulativeSource,
|
|
1036
|
+
cumulativeTarget,
|
|
1037
|
+
state: getState() === "paused" ? "paused" : getState() === "stopped" ? "stopped" : "running"
|
|
1038
|
+
});
|
|
1039
|
+
try {
|
|
1040
|
+
const maybePromise = params.onPacket(progress);
|
|
1041
|
+
if (maybePromise && typeof maybePromise.then === "function") {
|
|
1042
|
+
await maybePromise;
|
|
1043
|
+
}
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
logger.warn({
|
|
1046
|
+
event: "stream_swap.callback_threw",
|
|
1047
|
+
packetIndex,
|
|
1048
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1049
|
+
});
|
|
1050
|
+
errors.push({
|
|
1051
|
+
packetIndex,
|
|
1052
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
1053
|
+
});
|
|
1054
|
+
abortReason = "callback-throw";
|
|
1055
|
+
break packetLoop;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (isAborted()) {
|
|
1059
|
+
abortReason = "aborted";
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
if (getState() === "stopped") {
|
|
1063
|
+
abortReason = "stopped";
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
if (params.rateDeviationThreshold !== void 0 && rateDeviation > params.rateDeviationThreshold) {
|
|
1067
|
+
abortReason = "rate-deviation";
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
let finalState;
|
|
1072
|
+
if (abortReason === "aborted" || abortReason === "stopped") {
|
|
1073
|
+
finalState = "stopped";
|
|
1074
|
+
} else if (claims.length === 0 && (rejections.length > 0 || errors.length > 0)) {
|
|
1075
|
+
finalState = "failed";
|
|
1076
|
+
if (abortReason === "complete" && rejections.length > 0 && errors.length === 0) {
|
|
1077
|
+
abortReason = "all-rejected";
|
|
1078
|
+
}
|
|
1079
|
+
} else {
|
|
1080
|
+
finalState = "completed";
|
|
1081
|
+
}
|
|
1082
|
+
setState(finalState);
|
|
1083
|
+
return {
|
|
1084
|
+
state: finalState,
|
|
1085
|
+
claims,
|
|
1086
|
+
rejections,
|
|
1087
|
+
errors,
|
|
1088
|
+
abortReason,
|
|
1089
|
+
cumulativeSource,
|
|
1090
|
+
cumulativeTarget,
|
|
1091
|
+
packetsSent,
|
|
1092
|
+
packetsScheduled: totalPackets
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function getRandomValues(buf) {
|
|
1096
|
+
const g = globalThis;
|
|
1097
|
+
if (g.crypto && typeof g.crypto.getRandomValues === "function") {
|
|
1098
|
+
g.crypto.getRandomValues(buf);
|
|
1099
|
+
return buf;
|
|
1100
|
+
}
|
|
1101
|
+
const nodeCrypto = __require2("crypto");
|
|
1102
|
+
if (nodeCrypto.webcrypto?.getRandomValues) {
|
|
1103
|
+
nodeCrypto.webcrypto.getRandomValues(buf);
|
|
1104
|
+
return buf;
|
|
1105
|
+
}
|
|
1106
|
+
if (nodeCrypto.randomFillSync) {
|
|
1107
|
+
nodeCrypto.randomFillSync(buf);
|
|
1108
|
+
return buf;
|
|
1109
|
+
}
|
|
1110
|
+
throw new StreamSwapError(
|
|
1111
|
+
"INVALID_STATE",
|
|
1112
|
+
"No crypto.getRandomValues available in this environment"
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/daemon/apex-channel-store.ts
|
|
1117
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
1118
|
+
import { dirname } from "path";
|
|
1119
|
+
function key(destination, chain) {
|
|
1120
|
+
return `${destination}|${chain}`;
|
|
1121
|
+
}
|
|
1122
|
+
function readStore(path) {
|
|
1123
|
+
try {
|
|
1124
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1125
|
+
} catch {
|
|
1126
|
+
return {};
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function loadApexChannel(path, destination, chain) {
|
|
1130
|
+
return readStore(path)[key(destination, chain)] ?? null;
|
|
1131
|
+
}
|
|
1132
|
+
function saveApexChannel(path, destination, chain, record) {
|
|
1133
|
+
const store = readStore(path);
|
|
1134
|
+
store[key(destination, chain)] = record;
|
|
1135
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1136
|
+
writeFileSync(path, JSON.stringify(store, null, 2), { mode: 384 });
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/daemon/client-runner.ts
|
|
1140
|
+
var ClientRunner = class {
|
|
1141
|
+
config;
|
|
1142
|
+
client;
|
|
1143
|
+
relay;
|
|
1144
|
+
log;
|
|
1145
|
+
startedAt = Date.now();
|
|
1146
|
+
bootstrapping = false;
|
|
1147
|
+
ready = false;
|
|
1148
|
+
lastError;
|
|
1149
|
+
/** Channel opened against the default apex destination. */
|
|
1150
|
+
apexChannelId;
|
|
1151
|
+
stopped = false;
|
|
1152
|
+
constructor(deps) {
|
|
1153
|
+
this.config = deps.config;
|
|
1154
|
+
this.client = deps.createClient();
|
|
1155
|
+
this.relay = deps.createRelay?.() ?? new RelaySubscription({
|
|
1156
|
+
relayUrl: deps.config.relayUrl,
|
|
1157
|
+
socksProxy: deps.config.socksProxy,
|
|
1158
|
+
logger: deps.logger,
|
|
1159
|
+
// The TOON relay sends events TOON-encoded (text) on reads, not as JSON.
|
|
1160
|
+
decodeEvent: (raw) => decodeEventFromToon(new TextEncoder().encode(raw))
|
|
1161
|
+
});
|
|
1162
|
+
this.log = deps.logger ?? (() => void 0);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Begin bootstrapping in the background. Resolves once kicked off; the
|
|
1166
|
+
* connection becomes ready asynchronously. Awaitable for tests via the
|
|
1167
|
+
* returned promise of the underlying work.
|
|
1168
|
+
*/
|
|
1169
|
+
start() {
|
|
1170
|
+
if (this.bootstrapping || this.ready) return;
|
|
1171
|
+
this.bootstrapping = true;
|
|
1172
|
+
this.relay.start();
|
|
1173
|
+
void this.bootstrap();
|
|
1174
|
+
}
|
|
1175
|
+
/** The background bootstrap routine (exposed for awaiting in tests). */
|
|
1176
|
+
async bootstrap() {
|
|
1177
|
+
try {
|
|
1178
|
+
await this.client.start();
|
|
1179
|
+
this.injectApexNegotiation(this.config.apex);
|
|
1180
|
+
this.apexChannelId = await this.openOrResumeApexChannel();
|
|
1181
|
+
this.routeChildPeersThroughApexChannel();
|
|
1182
|
+
this.ready = true;
|
|
1183
|
+
this.lastError = void 0;
|
|
1184
|
+
this.log(`[runner] ready; apex channel ${this.apexChannelId}`);
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
1187
|
+
this.log(`[runner] bootstrap failed: ${this.lastError}`);
|
|
1188
|
+
} finally {
|
|
1189
|
+
this.bootstrapping = false;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Open the apex channel — or, on a restart, RESUME the existing one.
|
|
1194
|
+
*
|
|
1195
|
+
* `ChannelManager` persists the nonce watermark (by channelId) but not the
|
|
1196
|
+
* peer→channelId mapping, so a naive `openChannel()` after restart re-deposits
|
|
1197
|
+
* into a fresh channel and reverts on-chain. We persist the channelId here and,
|
|
1198
|
+
* when present, `trackChannel()` the live channel (which rehydrates the nonce
|
|
1199
|
+
* from the channel store) — no on-chain write, watermark continues.
|
|
1200
|
+
*/
|
|
1201
|
+
async openOrResumeApexChannel() {
|
|
1202
|
+
const { destination, chain, apexChannelStorePath } = this.config;
|
|
1203
|
+
const saved = loadApexChannel(apexChannelStorePath, destination, chain);
|
|
1204
|
+
const cm = this.client.channelManager;
|
|
1205
|
+
if (saved && cm && typeof cm.trackChannel === "function") {
|
|
1206
|
+
cm.trackChannel(saved.channelId, saved.context);
|
|
1207
|
+
this.log(
|
|
1208
|
+
`[runner] resumed apex channel ${saved.channelId} (tracked, no re-deposit)`
|
|
1209
|
+
);
|
|
1210
|
+
return saved.channelId;
|
|
1211
|
+
}
|
|
1212
|
+
const channelId = await this.client.openChannel(destination);
|
|
1213
|
+
if (this.config.apex) {
|
|
1214
|
+
const a = this.config.apex;
|
|
1215
|
+
saveApexChannel(apexChannelStorePath, destination, chain, {
|
|
1216
|
+
channelId,
|
|
1217
|
+
context: {
|
|
1218
|
+
chainType: a.chain,
|
|
1219
|
+
chainId: a.chainId,
|
|
1220
|
+
tokenNetworkAddress: a.tokenNetwork ?? "",
|
|
1221
|
+
tokenAddress: a.tokenAddress,
|
|
1222
|
+
recipient: a.settlementAddress
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
return channelId;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Inject the apex settlement negotiation directly into the ToonClient when
|
|
1230
|
+
* configured. Required for HS / direct-apex mode where bootstrap discovers 0
|
|
1231
|
+
* peers (no relay-based discovery). Mirrors the docker entrypoint's approach.
|
|
1232
|
+
*/
|
|
1233
|
+
injectApexNegotiation(apex) {
|
|
1234
|
+
if (!apex) return;
|
|
1235
|
+
const negotiations = this.client.peerNegotiations;
|
|
1236
|
+
if (!(negotiations instanceof Map)) {
|
|
1237
|
+
throw new Error(
|
|
1238
|
+
"ToonClient.peerNegotiations layout changed \u2014 cannot inject apex negotiation"
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
negotiations.set(apex.peerId, {
|
|
1242
|
+
chain: apex.chainKey,
|
|
1243
|
+
chainType: apex.chain,
|
|
1244
|
+
chainId: apex.chainId,
|
|
1245
|
+
settlementAddress: apex.settlementAddress,
|
|
1246
|
+
tokenAddress: apex.tokenAddress,
|
|
1247
|
+
tokenNetwork: apex.tokenNetwork
|
|
1248
|
+
});
|
|
1249
|
+
this.log(`[runner] injected apex negotiation for peer "${apex.peerId}"`);
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Route additional apex CHILD peers (e.g. `dvm`, `mill`) through the SAME
|
|
1253
|
+
* apex payment channel. In the parent→child apex model the client holds ONE
|
|
1254
|
+
* channel with the apex (g.townhouse) and pays via it regardless of which
|
|
1255
|
+
* child the ILP destination addresses; but `ToonClient.resolvePeerId` keys off
|
|
1256
|
+
* the destination's last segment (`town`/`dvm`/`mill`), so without this each
|
|
1257
|
+
* child would (a) fail the "no negotiation for peer" guard and (b) try to open
|
|
1258
|
+
* a SECOND on-chain channel to the same apex receive (which reverts —
|
|
1259
|
+
* channel-exists). So: inject the same apex negotiation under each child peer
|
|
1260
|
+
* AND pre-map its peer→channel to the already-open apex channel so
|
|
1261
|
+
* `ensureChannel` reuses it (no second open; one shared nonce sequence).
|
|
1262
|
+
*/
|
|
1263
|
+
routeChildPeersThroughApexChannel() {
|
|
1264
|
+
const apex = this.config.apex;
|
|
1265
|
+
const children = this.config.apexChildPeers ?? [];
|
|
1266
|
+
if (!apex || !this.apexChannelId || children.length === 0) return;
|
|
1267
|
+
const client = this.client;
|
|
1268
|
+
const negotiations = client.peerNegotiations;
|
|
1269
|
+
const peerChannels = client.channelManager?.peerChannels;
|
|
1270
|
+
if (!(negotiations instanceof Map) || !(peerChannels instanceof Map)) {
|
|
1271
|
+
this.log(
|
|
1272
|
+
"[runner] cannot route child peers \u2014 ToonClient internals layout changed"
|
|
1273
|
+
);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
for (const peer of children) {
|
|
1277
|
+
if (peer === apex.peerId) continue;
|
|
1278
|
+
negotiations.set(peer, {
|
|
1279
|
+
chain: apex.chainKey,
|
|
1280
|
+
chainType: apex.chain,
|
|
1281
|
+
chainId: apex.chainId,
|
|
1282
|
+
settlementAddress: apex.settlementAddress,
|
|
1283
|
+
tokenAddress: apex.tokenAddress,
|
|
1284
|
+
tokenNetwork: apex.tokenNetwork
|
|
1285
|
+
});
|
|
1286
|
+
peerChannels.set(peer, this.apexChannelId);
|
|
1287
|
+
this.log(
|
|
1288
|
+
`[runner] routed child peer "${peer}" through apex channel ${this.apexChannelId}`
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
isReady() {
|
|
1293
|
+
return this.ready;
|
|
1294
|
+
}
|
|
1295
|
+
isBootstrapping() {
|
|
1296
|
+
return this.bootstrapping;
|
|
1297
|
+
}
|
|
1298
|
+
getStatus() {
|
|
1299
|
+
const net = this.client.getNetworkStatus();
|
|
1300
|
+
const network = net ? ["evm", "solana", "mina"].map((c) => ({
|
|
1301
|
+
chain: c,
|
|
1302
|
+
ready: net[c] === "configured",
|
|
1303
|
+
detail: net[c]
|
|
1304
|
+
})) : void 0;
|
|
1305
|
+
return {
|
|
1306
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
1307
|
+
bootstrapping: this.bootstrapping,
|
|
1308
|
+
ready: this.ready,
|
|
1309
|
+
settlementChain: this.config.chain,
|
|
1310
|
+
identity: {
|
|
1311
|
+
nostrPubkey: safe(() => this.client.getPublicKey()) ?? "",
|
|
1312
|
+
evmAddress: safe(() => this.client.getEvmAddress()),
|
|
1313
|
+
solanaAddress: safe(() => this.client.getSolanaAddress()),
|
|
1314
|
+
minaAddress: safe(() => this.client.getMinaAddress())
|
|
1315
|
+
},
|
|
1316
|
+
transport: {
|
|
1317
|
+
type: this.config.socksProxy ? "socks5" : "direct",
|
|
1318
|
+
socksProxy: this.config.socksProxy,
|
|
1319
|
+
btpUrl: this.config.toonClientConfig.btpUrl
|
|
1320
|
+
},
|
|
1321
|
+
relay: {
|
|
1322
|
+
url: this.config.relayUrl,
|
|
1323
|
+
connected: this.relay.isConnected(),
|
|
1324
|
+
buffered: this.relay.bufferedCount(),
|
|
1325
|
+
subscriptions: this.relay.activeSubscriptions()
|
|
1326
|
+
},
|
|
1327
|
+
...network ? { network } : {},
|
|
1328
|
+
...this.lastError ? { lastError: this.lastError } : {}
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
/** Pay-to-write a single event. Throws NOT_READY while bootstrapping. */
|
|
1332
|
+
async publish(req) {
|
|
1333
|
+
this.assertReady();
|
|
1334
|
+
const channelId = this.apexChannelId ?? await this.client.openChannel(req.destination);
|
|
1335
|
+
const fee = req.fee !== void 0 ? BigInt(req.fee) : this.config.feePerEvent;
|
|
1336
|
+
const claim = await this.client.signBalanceProof(channelId, fee);
|
|
1337
|
+
const result = await this.client.publishEvent(req.event, {
|
|
1338
|
+
destination: req.destination,
|
|
1339
|
+
claim,
|
|
1340
|
+
ilpAmount: fee
|
|
1341
|
+
});
|
|
1342
|
+
if (!result.success) {
|
|
1343
|
+
throw new PublishRejectedError(result.error ?? "relay rejected event");
|
|
1344
|
+
}
|
|
1345
|
+
return {
|
|
1346
|
+
eventId: result.eventId ?? req.event.id,
|
|
1347
|
+
data: result.data,
|
|
1348
|
+
channelId,
|
|
1349
|
+
nonce: this.client.getChannelNonce(channelId)
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
/** Register a free-read subscription (does not require the paid path). */
|
|
1353
|
+
subscribe(req) {
|
|
1354
|
+
const subId = this.relay.subscribe(req.filters, req.subId);
|
|
1355
|
+
return { subId };
|
|
1356
|
+
}
|
|
1357
|
+
/** Drain buffered events newer than the cursor (free read). */
|
|
1358
|
+
getEvents(query) {
|
|
1359
|
+
const opts = {};
|
|
1360
|
+
if (query.subId !== void 0) opts.subId = query.subId;
|
|
1361
|
+
if (query.cursor !== void 0) opts.cursor = query.cursor;
|
|
1362
|
+
if (query.limit !== void 0) opts.limit = query.limit;
|
|
1363
|
+
const { events, cursor, hasMore } = this.relay.getEvents(opts);
|
|
1364
|
+
return { events, cursor, hasMore };
|
|
1365
|
+
}
|
|
1366
|
+
/** Open (or return) a payment channel for a destination. */
|
|
1367
|
+
async openChannel(destination) {
|
|
1368
|
+
this.assertReady();
|
|
1369
|
+
const channelId = await this.client.openChannel(
|
|
1370
|
+
destination ?? this.config.destination
|
|
1371
|
+
);
|
|
1372
|
+
if (!destination || destination === this.config.destination) {
|
|
1373
|
+
this.apexChannelId = channelId;
|
|
1374
|
+
}
|
|
1375
|
+
return { channelId };
|
|
1376
|
+
}
|
|
1377
|
+
/** List tracked channels with their nonce watermark + cumulative amount. */
|
|
1378
|
+
getChannels() {
|
|
1379
|
+
const channels = this.client.getTrackedChannels().map((channelId) => ({
|
|
1380
|
+
channelId,
|
|
1381
|
+
nonce: this.client.getChannelNonce(channelId),
|
|
1382
|
+
cumulativeAmount: this.client.getChannelCumulativeAmount(channelId).toString()
|
|
1383
|
+
}));
|
|
1384
|
+
return { channels };
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Swap source asset → target asset against a mill peer.
|
|
1388
|
+
*
|
|
1389
|
+
* Uses SDK `streamSwap`, which builds a NIP-59 gift-wrapped kind:20032 swap
|
|
1390
|
+
* rumor per packet and sends it over the open BTP session. The source-asset
|
|
1391
|
+
* balance proof is signed by the ToonClient's ChannelManager against the apex
|
|
1392
|
+
* channel — so the mill peer MUST be routed via `apexChildPeers` (otherwise
|
|
1393
|
+
* there is no channel to sign against and the mill rejects with F99).
|
|
1394
|
+
*
|
|
1395
|
+
* A fresh ephemeral gift-wrap key is generated per swap (used for sealing the
|
|
1396
|
+
* rumor AND decrypting the FULFILL claims) — independent of the daemon's
|
|
1397
|
+
* settlement identity, so callers never need to expose a key.
|
|
1398
|
+
*/
|
|
1399
|
+
async swap(req) {
|
|
1400
|
+
this.assertReady();
|
|
1401
|
+
const senderSecretKey = generateSecretKey2();
|
|
1402
|
+
const result = await streamSwap({
|
|
1403
|
+
client: this.client,
|
|
1404
|
+
millPubkey: req.millPubkey,
|
|
1405
|
+
millIlpAddress: req.destination,
|
|
1406
|
+
pair: req.pair,
|
|
1407
|
+
senderSecretKey,
|
|
1408
|
+
chainRecipient: req.chainRecipient,
|
|
1409
|
+
totalAmount: BigInt(req.amount),
|
|
1410
|
+
packetCount: req.packetCount ?? 1
|
|
1411
|
+
});
|
|
1412
|
+
const firstReject = result.rejections[0];
|
|
1413
|
+
return {
|
|
1414
|
+
accepted: result.claims.length > 0,
|
|
1415
|
+
packetsAccepted: result.claims.length,
|
|
1416
|
+
claims: result.claims.map((c) => ({
|
|
1417
|
+
sourceAmount: c.sourceAmount.toString(),
|
|
1418
|
+
targetAmount: c.targetAmount.toString(),
|
|
1419
|
+
claim: Buffer.from(c.claimBytes).toString("base64"),
|
|
1420
|
+
...c.channelId ? { channelId: c.channelId } : {},
|
|
1421
|
+
...c.recipient ? { recipient: c.recipient } : {},
|
|
1422
|
+
...c.millSignerAddress ? { millSignerAddress: c.millSignerAddress } : {},
|
|
1423
|
+
...c.claimId ? { claimId: c.claimId } : {},
|
|
1424
|
+
...c.nonce ? { nonce: c.nonce } : {},
|
|
1425
|
+
...c.cumulativeAmount ? { cumulativeAmount: c.cumulativeAmount } : {}
|
|
1426
|
+
})),
|
|
1427
|
+
cumulativeSource: result.cumulativeSource.toString(),
|
|
1428
|
+
cumulativeTarget: result.cumulativeTarget.toString(),
|
|
1429
|
+
state: result.state,
|
|
1430
|
+
...firstReject ? { code: firstReject.code, message: firstReject.message } : {}
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
/** Graceful teardown of the relay subscription + ToonClient. */
|
|
1434
|
+
async stop() {
|
|
1435
|
+
if (this.stopped) return;
|
|
1436
|
+
this.stopped = true;
|
|
1437
|
+
this.relay.close();
|
|
1438
|
+
try {
|
|
1439
|
+
await this.client.stop();
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
this.log(
|
|
1442
|
+
`[runner] client stop error: ${err instanceof Error ? err.message : String(err)}`
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
assertReady() {
|
|
1447
|
+
if (!this.ready) {
|
|
1448
|
+
throw new NotReadyError(
|
|
1449
|
+
this.bootstrapping ? "Daemon is still bootstrapping (BTP/anon coming up) \u2014 retry shortly." : this.lastError ?? "Daemon is not ready."
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
var NotReadyError = class extends Error {
|
|
1455
|
+
retryable = true;
|
|
1456
|
+
constructor(message) {
|
|
1457
|
+
super(message);
|
|
1458
|
+
this.name = "NotReadyError";
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
var PublishRejectedError = class extends Error {
|
|
1462
|
+
constructor(message) {
|
|
1463
|
+
super(message);
|
|
1464
|
+
this.name = "PublishRejectedError";
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
function safe(fn) {
|
|
1468
|
+
try {
|
|
1469
|
+
return fn();
|
|
1470
|
+
} catch {
|
|
1471
|
+
return void 0;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// src/daemon/routes.ts
|
|
1476
|
+
function registerRoutes(app, runner) {
|
|
1477
|
+
app.get("/status", async () => runner.getStatus());
|
|
1478
|
+
app.post("/publish", async (req, reply) => {
|
|
1479
|
+
const body = req.body;
|
|
1480
|
+
if (!body || !isSignedEvent(body.event)) {
|
|
1481
|
+
return sendError(reply, 400, "invalid_event", {
|
|
1482
|
+
detail: "body.event must be a fully-signed Nostr event (id + sig)."
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
try {
|
|
1486
|
+
return await runner.publish(body);
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
return mapError(reply, err);
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
app.post("/subscribe", async (req, reply) => {
|
|
1492
|
+
const body = req.body;
|
|
1493
|
+
if (!body || body.filters === void 0) {
|
|
1494
|
+
return sendError(reply, 400, "invalid_filters", {
|
|
1495
|
+
detail: "body.filters is required (a NIP-01 filter or array of filters)."
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
return runner.subscribe(body);
|
|
1499
|
+
});
|
|
1500
|
+
app.get(
|
|
1501
|
+
"/events",
|
|
1502
|
+
async (req) => {
|
|
1503
|
+
const q = req.query;
|
|
1504
|
+
const query = {};
|
|
1505
|
+
if (q.subId) query.subId = q.subId;
|
|
1506
|
+
if (q.cursor !== void 0) query.cursor = Number(q.cursor);
|
|
1507
|
+
if (q.limit !== void 0) query.limit = Number(q.limit);
|
|
1508
|
+
return runner.getEvents(query);
|
|
1509
|
+
}
|
|
1510
|
+
);
|
|
1511
|
+
app.post("/channels", async (req, reply) => {
|
|
1512
|
+
try {
|
|
1513
|
+
return await runner.openChannel(req.body?.destination);
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
return mapError(reply, err);
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
app.get("/channels", async () => runner.getChannels());
|
|
1519
|
+
app.post("/swap", async (req, reply) => {
|
|
1520
|
+
const body = req.body;
|
|
1521
|
+
if (!body || !body.destination || body.amount === void 0 || !body.millPubkey || !body.pair || !body.chainRecipient) {
|
|
1522
|
+
return sendError(reply, 400, "invalid_swap", {
|
|
1523
|
+
detail: "body.destination, amount, millPubkey, pair, and chainRecipient are required."
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
return await runner.swap(body);
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
return mapError(reply, err);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
function isSignedEvent(event) {
|
|
1534
|
+
if (typeof event !== "object" || event === null) return false;
|
|
1535
|
+
const e = event;
|
|
1536
|
+
return typeof e["id"] === "string" && typeof e["sig"] === "string" && typeof e["pubkey"] === "string" && typeof e["kind"] === "number";
|
|
1537
|
+
}
|
|
1538
|
+
function mapError(reply, err) {
|
|
1539
|
+
if (err instanceof NotReadyError) {
|
|
1540
|
+
return sendError(reply, 503, "bootstrapping", {
|
|
1541
|
+
detail: err.message,
|
|
1542
|
+
retryable: true
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
if (err instanceof PublishRejectedError) {
|
|
1546
|
+
return sendError(reply, 502, "rejected", { detail: err.message });
|
|
1547
|
+
}
|
|
1548
|
+
return sendError(reply, 500, "internal_error", {
|
|
1549
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
function sendError(reply, status, error, extra = {}) {
|
|
1553
|
+
return reply.status(status).send({
|
|
1554
|
+
error,
|
|
1555
|
+
...extra.detail ? { detail: extra.detail } : {},
|
|
1556
|
+
...extra.retryable ? { retryable: true } : {}
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
export {
|
|
1561
|
+
RelaySubscription,
|
|
1562
|
+
ClientRunner,
|
|
1563
|
+
NotReadyError,
|
|
1564
|
+
PublishRejectedError,
|
|
1565
|
+
registerRoutes
|
|
1566
|
+
};
|
|
1567
|
+
//# sourceMappingURL=chunk-4K6S3VPM.js.map
|