@toon-protocol/client 0.4.2

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/index.js ADDED
@@ -0,0 +1,1728 @@
1
+ // src/ToonClient.ts
2
+ import { generateSecretKey as generateSecretKey2, getPublicKey } from "nostr-tools/pure";
3
+
4
+ // src/config.ts
5
+ import { generateSecretKey } from "nostr-tools/pure";
6
+
7
+ // src/errors.ts
8
+ var ToonClientError = class extends Error {
9
+ constructor(message, code, cause) {
10
+ super(message, { cause });
11
+ this.code = code;
12
+ this.name = "ToonClientError";
13
+ }
14
+ };
15
+ var NetworkError = class extends ToonClientError {
16
+ constructor(message, cause) {
17
+ super(message, "NETWORK_ERROR", cause);
18
+ this.name = "NetworkError";
19
+ }
20
+ };
21
+ var ConnectorError = class extends ToonClientError {
22
+ constructor(message, cause) {
23
+ super(message, "CONNECTOR_ERROR", cause);
24
+ this.name = "ConnectorError";
25
+ }
26
+ };
27
+ var ValidationError = class extends ToonClientError {
28
+ constructor(message, cause) {
29
+ super(message, "VALIDATION_ERROR", cause);
30
+ this.name = "ValidationError";
31
+ }
32
+ };
33
+ var UnauthorizedError = class extends ToonClientError {
34
+ constructor(message, cause) {
35
+ super(message, "UNAUTHORIZED", cause);
36
+ this.name = "UnauthorizedError";
37
+ }
38
+ };
39
+ var PeerNotFoundError = class extends ToonClientError {
40
+ constructor(message, cause) {
41
+ super(message, "PEER_NOT_FOUND", cause);
42
+ this.name = "PeerNotFoundError";
43
+ }
44
+ };
45
+ var PeerAlreadyExistsError = class extends ToonClientError {
46
+ constructor(message, cause) {
47
+ super(message, "PEER_ALREADY_EXISTS", cause);
48
+ this.name = "PeerAlreadyExistsError";
49
+ }
50
+ };
51
+
52
+ // src/config.ts
53
+ function validateConfig(config) {
54
+ if (config.connector !== void 0) {
55
+ throw new ValidationError(
56
+ "Embedded mode not yet implemented in ToonClient. Use connectorUrl for HTTP mode."
57
+ );
58
+ }
59
+ if (!config.connectorUrl) {
60
+ throw new ValidationError(
61
+ 'connectorUrl is required for HTTP mode. Example: "http://localhost:8080"'
62
+ );
63
+ }
64
+ try {
65
+ const url = new URL(config.connectorUrl);
66
+ if (!url.protocol.startsWith("http")) {
67
+ throw new Error("Must be HTTP or HTTPS");
68
+ }
69
+ } catch (error) {
70
+ throw new ValidationError(
71
+ `Invalid connectorUrl: must be a valid HTTP/HTTPS URL (e.g., "http://localhost:8080"). Error: ${error instanceof Error ? error.message : String(error)}`
72
+ );
73
+ }
74
+ if (config.secretKey !== void 0) {
75
+ if (!config.secretKey || config.secretKey.length !== 32) {
76
+ throw new ValidationError(
77
+ "secretKey must be 32 bytes (Nostr private key)"
78
+ );
79
+ }
80
+ }
81
+ if (!config.ilpInfo?.ilpAddress) {
82
+ throw new ValidationError("ilpInfo.ilpAddress is required");
83
+ }
84
+ if (!config.toonEncoder || typeof config.toonEncoder !== "function") {
85
+ throw new ValidationError("toonEncoder function is required");
86
+ }
87
+ if (!config.toonDecoder || typeof config.toonDecoder !== "function") {
88
+ throw new ValidationError("toonDecoder function is required");
89
+ }
90
+ if (config.evmPrivateKey !== void 0) {
91
+ if (config.evmPrivateKey instanceof Uint8Array) {
92
+ if (config.evmPrivateKey.length !== 32) {
93
+ throw new ValidationError("evmPrivateKey must be 32 bytes");
94
+ }
95
+ } else if (typeof config.evmPrivateKey === "string") {
96
+ const hex = config.evmPrivateKey.startsWith("0x") ? config.evmPrivateKey.slice(2) : config.evmPrivateKey;
97
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
98
+ throw new ValidationError("evmPrivateKey must be a 32-byte hex string");
99
+ }
100
+ } else {
101
+ throw new ValidationError(
102
+ "evmPrivateKey must be a hex string or Uint8Array"
103
+ );
104
+ }
105
+ }
106
+ if (config.btpUrl !== void 0) {
107
+ try {
108
+ const url = new URL(config.btpUrl);
109
+ if (!url.protocol.startsWith("ws")) {
110
+ throw new Error("Must be WS or WSS");
111
+ }
112
+ } catch (error) {
113
+ throw new ValidationError(
114
+ `Invalid btpUrl: must be a valid WebSocket URL (e.g., "ws://localhost:3000"). Error: ${error instanceof Error ? error.message : String(error)}`
115
+ );
116
+ }
117
+ }
118
+ if (config.chainRpcUrls && config.supportedChains) {
119
+ for (const chain of Object.keys(config.chainRpcUrls)) {
120
+ if (!config.supportedChains.includes(chain)) {
121
+ throw new ValidationError(
122
+ `chainRpcUrls key "${chain}" is not in supportedChains`
123
+ );
124
+ }
125
+ }
126
+ }
127
+ }
128
+ function applyDefaults(config) {
129
+ const secretKey = config.secretKey ?? generateSecretKey();
130
+ let btpUrl = config.btpUrl;
131
+ if (!btpUrl && config.connectorUrl) {
132
+ try {
133
+ const url = new URL(config.connectorUrl);
134
+ const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
135
+ btpUrl = `${wsProtocol}//${url.hostname}:3000`;
136
+ } catch {
137
+ }
138
+ }
139
+ let destinationAddress = config.destinationAddress;
140
+ if (!destinationAddress && config.connectorUrl) {
141
+ try {
142
+ const url = new URL(config.connectorUrl);
143
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
144
+ if (url.port === "8080") {
145
+ destinationAddress = "g.toon.genesis";
146
+ } else if (url.port === "8090") {
147
+ destinationAddress = "g.toon.peer1";
148
+ } else if (url.port === "8100") {
149
+ destinationAddress = "g.toon.peer2";
150
+ } else {
151
+ destinationAddress = config.ilpInfo?.ilpAddress || "g.toon.relay";
152
+ }
153
+ } else {
154
+ destinationAddress = config.ilpInfo?.ilpAddress || "g.toon.relay";
155
+ }
156
+ } catch {
157
+ destinationAddress = config.ilpInfo?.ilpAddress || "g.toon.relay";
158
+ }
159
+ }
160
+ const evmPrivateKey = config.evmPrivateKey ?? secretKey;
161
+ return {
162
+ ...config,
163
+ secretKey,
164
+ evmPrivateKey,
165
+ connectorUrl: config.connectorUrl,
166
+ // Already validated as required
167
+ relayUrl: config.relayUrl ?? "ws://localhost:7100",
168
+ queryTimeout: config.queryTimeout ?? 3e4,
169
+ maxRetries: config.maxRetries ?? 3,
170
+ retryDelay: config.retryDelay ?? 1e3,
171
+ btpUrl,
172
+ destinationAddress
173
+ // Always set by logic above
174
+ };
175
+ }
176
+ function buildSettlementInfo(config) {
177
+ if (!config.supportedChains?.length && !config.settlementAddresses && !config.preferredTokens && !config.tokenNetworks) {
178
+ return void 0;
179
+ }
180
+ return {
181
+ ilpAddress: config.ilpInfo?.ilpAddress,
182
+ supportedChains: config.supportedChains,
183
+ settlementAddresses: config.settlementAddresses,
184
+ preferredTokens: config.preferredTokens,
185
+ tokenNetworks: config.tokenNetworks
186
+ };
187
+ }
188
+
189
+ // src/modes/http.ts
190
+ import { BootstrapService, createDiscoveryTracker } from "@toon-protocol/core";
191
+
192
+ // src/utils/retry.ts
193
+ async function withRetry(operation, options) {
194
+ const {
195
+ maxRetries,
196
+ retryDelay,
197
+ exponentialBackoff = true,
198
+ maxDelay = 3e4,
199
+ shouldRetry
200
+ } = options;
201
+ let lastError;
202
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
203
+ try {
204
+ return await operation();
205
+ } catch (error) {
206
+ lastError = error instanceof Error ? error : new Error(String(error));
207
+ if (shouldRetry && !shouldRetry(lastError)) {
208
+ throw lastError;
209
+ }
210
+ if (attempt === maxRetries) {
211
+ throw lastError;
212
+ }
213
+ const currentDelay = exponentialBackoff ? Math.min(retryDelay * Math.pow(2, attempt), maxDelay) : retryDelay;
214
+ await new Promise((resolve) => setTimeout(resolve, currentDelay));
215
+ }
216
+ }
217
+ throw lastError ?? new Error("Unknown error during retry");
218
+ }
219
+
220
+ // src/adapters/HttpRuntimeClient.ts
221
+ var HttpRuntimeClient = class {
222
+ connectorUrl;
223
+ timeout;
224
+ retryConfig;
225
+ httpClient;
226
+ constructor(config) {
227
+ this.connectorUrl = config.connectorUrl.replace(/\/$/, "");
228
+ this.timeout = config.timeout ?? 3e4;
229
+ this.retryConfig = {
230
+ maxRetries: config.maxRetries ?? 3,
231
+ retryDelay: config.retryDelay ?? 1e3
232
+ };
233
+ this.httpClient = config.httpClient ?? fetch;
234
+ }
235
+ /**
236
+ * Send an ILP packet to the connector runtime API.
237
+ *
238
+ * @param params - ILP packet parameters
239
+ * @returns ILP packet response with acceptance status
240
+ * @throws {ValidationError} If request parameters are invalid
241
+ * @throws {NetworkError} If network connection fails after retries
242
+ * @throws {ConnectorError} If connector returns 5xx server error
243
+ */
244
+ async sendIlpPacket(params) {
245
+ this.validateRequest(params);
246
+ return withRetry(async () => this.sendHttpRequest(params), {
247
+ maxRetries: this.retryConfig.maxRetries,
248
+ retryDelay: this.retryConfig.retryDelay,
249
+ exponentialBackoff: true,
250
+ shouldRetry: (error) => {
251
+ return error instanceof NetworkError;
252
+ }
253
+ });
254
+ }
255
+ /**
256
+ * Validate ILP packet request parameters.
257
+ *
258
+ * @throws {ValidationError} If any parameter is invalid
259
+ */
260
+ validateRequest(params) {
261
+ if (!params.destination || params.destination.trim() === "") {
262
+ throw new ValidationError("Destination cannot be empty");
263
+ }
264
+ if (!params.destination.startsWith("g.")) {
265
+ throw new ValidationError(
266
+ `Invalid ILP address format: "${params.destination}" (must start with "g.")`
267
+ );
268
+ }
269
+ if (!params.amount || params.amount.trim() === "") {
270
+ throw new ValidationError("Amount cannot be empty");
271
+ }
272
+ try {
273
+ const amountBigInt = BigInt(params.amount);
274
+ if (amountBigInt <= 0n) {
275
+ throw new ValidationError(
276
+ `Amount must be positive: "${params.amount}"`
277
+ );
278
+ }
279
+ } catch (error) {
280
+ if (error instanceof ValidationError) throw error;
281
+ throw new ValidationError(
282
+ `Amount must be a valid integer: "${params.amount}"`,
283
+ error instanceof Error ? error : void 0
284
+ );
285
+ }
286
+ if (!params.data || params.data.trim() === "") {
287
+ throw new ValidationError("Data cannot be empty");
288
+ }
289
+ try {
290
+ Buffer.from(params.data, "base64");
291
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(params.data)) {
292
+ throw new ValidationError(
293
+ `Data must be valid Base64 encoding: "${params.data}"`
294
+ );
295
+ }
296
+ } catch (error) {
297
+ if (error instanceof ValidationError) throw error;
298
+ throw new ValidationError(
299
+ `Data must be valid Base64 encoding: "${params.data}"`,
300
+ error instanceof Error ? error : void 0
301
+ );
302
+ }
303
+ }
304
+ /**
305
+ * Send HTTP POST request to connector runtime API.
306
+ *
307
+ * @throws {NetworkError} On connection failures (ECONNREFUSED, ETIMEDOUT)
308
+ * @throws {ConnectorError} On 5xx server errors
309
+ * @returns IlpSendResult with acceptance status
310
+ */
311
+ async sendHttpRequest(params) {
312
+ const requestTimeout = params.timeout ?? this.timeout;
313
+ const controller = new AbortController();
314
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
315
+ try {
316
+ const response = await this.httpClient(
317
+ `${this.connectorUrl}/admin/ilp/send`,
318
+ {
319
+ method: "POST",
320
+ headers: {
321
+ "Content-Type": "application/json"
322
+ },
323
+ body: JSON.stringify({
324
+ destination: params.destination,
325
+ amount: params.amount,
326
+ data: params.data
327
+ }),
328
+ signal: controller.signal
329
+ }
330
+ );
331
+ clearTimeout(timeoutId);
332
+ if (response.ok) {
333
+ const result = await response.json();
334
+ return {
335
+ accepted: result["accepted"] ?? false,
336
+ fulfillment: result["fulfillment"],
337
+ data: result["data"],
338
+ code: result["code"],
339
+ message: result["message"]
340
+ };
341
+ } else if (response.status >= 400 && response.status < 500) {
342
+ const errorBody = await response.json().catch(() => ({}));
343
+ return {
344
+ accepted: false,
345
+ code: `HTTP_${response.status}`,
346
+ message: errorBody["message"] ?? errorBody["error"] ?? response.statusText
347
+ };
348
+ } else if (response.status >= 500 && response.status < 600) {
349
+ const errorBody = await response.json().catch(() => ({}));
350
+ throw new ConnectorError(
351
+ `Connector server error (${response.status}): ${errorBody["message"] ?? errorBody["error"] ?? response.statusText}`
352
+ );
353
+ }
354
+ throw new ConnectorError(
355
+ `Unexpected HTTP status: ${response.status} ${response.statusText}`
356
+ );
357
+ } catch (error) {
358
+ clearTimeout(timeoutId);
359
+ if (error instanceof Error && error.name === "AbortError") {
360
+ throw new NetworkError(
361
+ `Request timeout after ${requestTimeout}ms`,
362
+ error
363
+ );
364
+ }
365
+ if (error instanceof TypeError && (error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED") || error.message.includes("ETIMEDOUT") || error.message.includes("network"))) {
366
+ throw new NetworkError(
367
+ `Network connection failed: ${error.message}`,
368
+ error
369
+ );
370
+ }
371
+ if (error instanceof NetworkError || error instanceof ConnectorError || error instanceof ValidationError) {
372
+ throw error;
373
+ }
374
+ throw new ConnectorError(
375
+ `Unexpected error during HTTP request: ${error instanceof Error ? error.message : String(error)}`,
376
+ error instanceof Error ? error : void 0
377
+ );
378
+ }
379
+ }
380
+ };
381
+
382
+ // src/adapters/BtpRuntimeClient.ts
383
+ import { BTPClient } from "@toon-protocol/connector";
384
+ var BTP_CLAIM_PROTOCOL = {
385
+ NAME: "payment-channel-claim",
386
+ CONTENT_TYPE: 1
387
+ };
388
+ function createConsoleLogger() {
389
+ const noop = (..._args) => {
390
+ };
391
+ const logger = {
392
+ level: "info",
393
+ silent: noop,
394
+ info: console.info.bind(console),
395
+ warn: console.warn.bind(console),
396
+ error: console.error.bind(console),
397
+ debug: console.debug.bind(console),
398
+ trace: console.debug.bind(console),
399
+ fatal: console.error.bind(console),
400
+ child: () => createConsoleLogger()
401
+ };
402
+ return logger;
403
+ }
404
+ var ILP_PACKET_TYPE = {
405
+ PREPARE: 12,
406
+ FULFILL: 13,
407
+ REJECT: 14
408
+ };
409
+ function isConnectionError(error) {
410
+ const msg = error.message.toLowerCase();
411
+ return msg.includes("not connected") || msg.includes("connection") || msg.includes("websocket") || msg.includes("econnrefused") || msg.includes("econnreset") || msg.includes("socket hang up") || msg.includes("timeout");
412
+ }
413
+ var BtpRuntimeClient = class {
414
+ btpClient = null;
415
+ config;
416
+ _isConnected = false;
417
+ logger;
418
+ constructor(config) {
419
+ this.config = config;
420
+ this.logger = config.logger ?? createConsoleLogger();
421
+ }
422
+ /**
423
+ * Connects to the BTP peer via WebSocket.
424
+ */
425
+ async connect() {
426
+ const peer = {
427
+ id: this.config.peerId,
428
+ url: this.config.btpUrl,
429
+ authToken: this.config.authToken,
430
+ connected: false,
431
+ lastSeen: /* @__PURE__ */ new Date()
432
+ };
433
+ this.btpClient = new BTPClient(
434
+ peer,
435
+ this.config.peerId,
436
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
437
+ this.logger
438
+ );
439
+ await this.btpClient.connect();
440
+ this._isConnected = true;
441
+ }
442
+ /**
443
+ * Attempts to reconnect by creating a fresh BTPClient and connecting.
444
+ */
445
+ async reconnect() {
446
+ if (this.btpClient) {
447
+ try {
448
+ await this.btpClient.disconnect();
449
+ } catch {
450
+ }
451
+ this.btpClient = null;
452
+ this._isConnected = false;
453
+ }
454
+ this.logger.info("[BtpRuntimeClient] Reconnecting...");
455
+ await this.connect();
456
+ }
457
+ /**
458
+ * Disconnects from the BTP peer.
459
+ */
460
+ async disconnect() {
461
+ if (this.btpClient) {
462
+ await this.btpClient.disconnect();
463
+ this._isConnected = false;
464
+ this.btpClient = null;
465
+ }
466
+ }
467
+ get isConnected() {
468
+ return this._isConnected;
469
+ }
470
+ /**
471
+ * Sends an ILP packet via BTP with auto-reconnect on connection errors.
472
+ * Satisfies IlpClient interface.
473
+ */
474
+ async sendIlpPacket(params) {
475
+ return withRetry(() => this._sendIlpPacketOnce(params), {
476
+ maxRetries: this.config.maxRetries ?? 3,
477
+ retryDelay: this.config.retryDelay ?? 1e3,
478
+ shouldRetry: (error) => {
479
+ if (!isConnectionError(error)) return false;
480
+ this._isConnected = false;
481
+ return true;
482
+ }
483
+ });
484
+ }
485
+ /**
486
+ * Sends a balance proof claim via BTP protocol data, then sends an ILP packet.
487
+ * Auto-reconnects on connection errors.
488
+ */
489
+ async sendIlpPacketWithClaim(params, claim) {
490
+ return withRetry(() => this._sendIlpPacketWithClaimOnce(params, claim), {
491
+ maxRetries: this.config.maxRetries ?? 3,
492
+ retryDelay: this.config.retryDelay ?? 1e3,
493
+ shouldRetry: (error) => {
494
+ if (!isConnectionError(error)) return false;
495
+ this._isConnected = false;
496
+ return true;
497
+ }
498
+ });
499
+ }
500
+ /**
501
+ * Single-attempt ILP packet send. Reconnects if not connected.
502
+ */
503
+ async _sendIlpPacketOnce(params) {
504
+ if (!this._isConnected) {
505
+ await this.reconnect();
506
+ }
507
+ const packet = {
508
+ type: ILP_PACKET_TYPE.PREPARE,
509
+ amount: BigInt(params.amount),
510
+ destination: params.destination,
511
+ executionCondition: Buffer.alloc(32),
512
+ expiresAt: new Date(Date.now() + (params.timeout ?? 3e4)),
513
+ data: Buffer.from(params.data, "base64")
514
+ };
515
+ const response = await this.btpClient?.sendPacket(packet);
516
+ if (!response) {
517
+ throw new Error("BTP client not connected");
518
+ }
519
+ if (response.type === ILP_PACKET_TYPE.FULFILL) {
520
+ const fulfill = response;
521
+ return {
522
+ accepted: true,
523
+ fulfillment: fulfill.fulfillment.toString("base64"),
524
+ data: fulfill.data.length > 0 ? fulfill.data.toString("base64") : void 0
525
+ };
526
+ }
527
+ const reject = response;
528
+ return {
529
+ accepted: false,
530
+ code: reject.code,
531
+ message: reject.message,
532
+ data: reject.data.length > 0 ? reject.data.toString("base64") : void 0
533
+ };
534
+ }
535
+ /**
536
+ * Single-attempt claim + ILP packet send. Reconnects if not connected.
537
+ */
538
+ async _sendIlpPacketWithClaimOnce(params, claim) {
539
+ if (!this._isConnected) {
540
+ await this.reconnect();
541
+ }
542
+ if (!this.btpClient) {
543
+ throw new Error("BTP client not connected");
544
+ }
545
+ await this.btpClient.sendProtocolData(
546
+ BTP_CLAIM_PROTOCOL.NAME,
547
+ BTP_CLAIM_PROTOCOL.CONTENT_TYPE,
548
+ Buffer.from(JSON.stringify(claim))
549
+ );
550
+ return this._sendIlpPacketOnce(params);
551
+ }
552
+ };
553
+
554
+ // src/channel/OnChainChannelClient.ts
555
+ import {
556
+ createPublicClient,
557
+ createWalletClient,
558
+ http,
559
+ maxUint256,
560
+ decodeEventLog,
561
+ defineChain
562
+ } from "viem";
563
+ var TOKEN_NETWORK_ABI = [
564
+ {
565
+ name: "openChannel",
566
+ type: "function",
567
+ stateMutability: "nonpayable",
568
+ inputs: [
569
+ { name: "participant2", type: "address" },
570
+ { name: "settlementTimeout", type: "uint256" }
571
+ ],
572
+ outputs: [{ type: "bytes32" }]
573
+ },
574
+ {
575
+ name: "setTotalDeposit",
576
+ type: "function",
577
+ stateMutability: "nonpayable",
578
+ inputs: [
579
+ { name: "channelId", type: "bytes32" },
580
+ { name: "participant", type: "address" },
581
+ { name: "totalDeposit", type: "uint256" }
582
+ ],
583
+ outputs: []
584
+ },
585
+ {
586
+ name: "channels",
587
+ type: "function",
588
+ stateMutability: "view",
589
+ inputs: [{ type: "bytes32" }],
590
+ outputs: [
591
+ { name: "settlementTimeout", type: "uint256" },
592
+ { name: "state", type: "uint8" },
593
+ { name: "closedAt", type: "uint256" },
594
+ { name: "openedAt", type: "uint256" },
595
+ { name: "participant1", type: "address" },
596
+ { name: "participant2", type: "address" }
597
+ ]
598
+ },
599
+ {
600
+ name: "ChannelOpened",
601
+ type: "event",
602
+ inputs: [
603
+ { name: "channelId", type: "bytes32", indexed: true },
604
+ { name: "participant1", type: "address", indexed: true },
605
+ { name: "participant2", type: "address", indexed: true },
606
+ { name: "settlementTimeout", type: "uint256", indexed: false }
607
+ ]
608
+ }
609
+ ];
610
+ var ERC20_ABI = [
611
+ {
612
+ name: "approve",
613
+ type: "function",
614
+ stateMutability: "nonpayable",
615
+ inputs: [
616
+ { name: "spender", type: "address" },
617
+ { name: "amount", type: "uint256" }
618
+ ],
619
+ outputs: [{ type: "bool" }]
620
+ },
621
+ {
622
+ name: "allowance",
623
+ type: "function",
624
+ stateMutability: "view",
625
+ inputs: [
626
+ { name: "owner", type: "address" },
627
+ { name: "spender", type: "address" }
628
+ ],
629
+ outputs: [{ type: "uint256" }]
630
+ }
631
+ ];
632
+ var STATE_MAP = {
633
+ 0: "settled",
634
+ 1: "open",
635
+ 2: "closed",
636
+ 3: "settled"
637
+ };
638
+ var OnChainChannelClient = class {
639
+ evmSigner;
640
+ chainRpcUrls;
641
+ channelContext = /* @__PURE__ */ new Map();
642
+ constructor(config) {
643
+ this.evmSigner = config.evmSigner;
644
+ this.chainRpcUrls = config.chainRpcUrls;
645
+ }
646
+ /**
647
+ * Parse chain identifier to extract chainId.
648
+ * Format: "evm:{network}:{chainId}" e.g., "evm:anvil:31337"
649
+ */
650
+ parseChainId(chain) {
651
+ const parts = chain.split(":");
652
+ if (parts.length < 3) {
653
+ throw new Error(
654
+ `Invalid chain format: "${chain}". Expected "evm:{network}:{chainId}".`
655
+ );
656
+ }
657
+ const chainIdStr = parts[2];
658
+ if (!chainIdStr) {
659
+ throw new Error(
660
+ `Invalid chain format: "${chain}". Expected "evm:{network}:{chainId}".`
661
+ );
662
+ }
663
+ const chainId = parseInt(chainIdStr, 10);
664
+ if (isNaN(chainId)) {
665
+ throw new Error(`Invalid chainId in chain "${chain}".`);
666
+ }
667
+ return chainId;
668
+ }
669
+ /**
670
+ * Create viem clients for a given chain.
671
+ */
672
+ createClients(chain) {
673
+ const rpcUrl = this.chainRpcUrls[chain];
674
+ if (!rpcUrl) {
675
+ throw new Error(
676
+ `No RPC URL configured for chain "${chain}". Available: ${Object.keys(this.chainRpcUrls).join(", ")}`
677
+ );
678
+ }
679
+ const chainId = this.parseChainId(chain);
680
+ const viemChain = defineChain({
681
+ id: chainId,
682
+ name: chain,
683
+ nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
684
+ rpcUrls: { default: { http: [rpcUrl] } }
685
+ });
686
+ const publicClient = createPublicClient({
687
+ transport: http(rpcUrl),
688
+ chain: viemChain
689
+ });
690
+ const walletClient = createWalletClient({
691
+ account: this.evmSigner.account,
692
+ transport: http(rpcUrl),
693
+ chain: viemChain
694
+ });
695
+ return { publicClient, walletClient };
696
+ }
697
+ /**
698
+ * Opens a new payment channel on-chain.
699
+ *
700
+ * 1. Approve token spend if needed
701
+ * 2. Call TokenNetwork.openChannel()
702
+ * 3. Extract channelId from ChannelOpened event
703
+ * 4. Deposit initial funds if specified
704
+ */
705
+ async openChannel(params) {
706
+ const {
707
+ chain,
708
+ tokenNetwork,
709
+ peerAddress,
710
+ initialDeposit,
711
+ settlementTimeout
712
+ } = params;
713
+ if (!tokenNetwork) {
714
+ throw new Error(
715
+ "tokenNetwork address is required for on-chain channel opening"
716
+ );
717
+ }
718
+ const { publicClient, walletClient } = this.createClients(chain);
719
+ const tokenNetworkAddr = tokenNetwork;
720
+ const deposit = initialDeposit ? BigInt(initialDeposit) : 0n;
721
+ if (deposit > 0n && params.token) {
722
+ const tokenAddr = params.token;
723
+ const myAddress = this.evmSigner.address;
724
+ const currentAllowance = await publicClient.readContract({
725
+ address: tokenAddr,
726
+ abi: ERC20_ABI,
727
+ functionName: "allowance",
728
+ args: [myAddress, tokenNetworkAddr]
729
+ });
730
+ if (currentAllowance < deposit) {
731
+ const approveHash = await walletClient.writeContract({
732
+ address: tokenAddr,
733
+ abi: ERC20_ABI,
734
+ functionName: "approve",
735
+ args: [tokenNetworkAddr, maxUint256]
736
+ });
737
+ await publicClient.waitForTransactionReceipt({ hash: approveHash });
738
+ }
739
+ }
740
+ const timeout = BigInt(settlementTimeout ?? 86400);
741
+ const openHash = await walletClient.writeContract({
742
+ address: tokenNetworkAddr,
743
+ abi: TOKEN_NETWORK_ABI,
744
+ functionName: "openChannel",
745
+ args: [peerAddress, timeout]
746
+ });
747
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: openHash });
748
+ let channelId;
749
+ for (const log of receipt.logs) {
750
+ try {
751
+ const decoded = decodeEventLog({
752
+ abi: TOKEN_NETWORK_ABI,
753
+ data: log.data,
754
+ topics: log.topics
755
+ });
756
+ if (decoded.eventName === "ChannelOpened") {
757
+ channelId = decoded.args["channelId"];
758
+ break;
759
+ }
760
+ } catch {
761
+ }
762
+ }
763
+ if (!channelId) {
764
+ throw new Error("Failed to extract channelId from ChannelOpened event");
765
+ }
766
+ this.channelContext.set(channelId, {
767
+ chain,
768
+ tokenNetworkAddress: tokenNetwork
769
+ });
770
+ if (deposit > 0n) {
771
+ const depositHash = await walletClient.writeContract({
772
+ address: tokenNetworkAddr,
773
+ abi: TOKEN_NETWORK_ABI,
774
+ functionName: "setTotalDeposit",
775
+ args: [channelId, this.evmSigner.address, deposit]
776
+ });
777
+ await publicClient.waitForTransactionReceipt({ hash: depositHash });
778
+ }
779
+ return { channelId, status: "opening" };
780
+ }
781
+ /**
782
+ * Gets the current state of a payment channel from on-chain data.
783
+ */
784
+ async getChannelState(channelId) {
785
+ const context = this.channelContext.get(channelId);
786
+ if (!context) {
787
+ throw new Error(
788
+ `No context for channel "${channelId}". Channel must be opened via this client first.`
789
+ );
790
+ }
791
+ const { publicClient } = this.createClients(context.chain);
792
+ const result = await publicClient.readContract({
793
+ address: context.tokenNetworkAddress,
794
+ abi: TOKEN_NETWORK_ABI,
795
+ functionName: "channels",
796
+ args: [channelId]
797
+ });
798
+ const [, state] = result;
799
+ const status = STATE_MAP[state] ?? "settled";
800
+ return {
801
+ channelId,
802
+ status,
803
+ chain: context.chain
804
+ };
805
+ }
806
+ };
807
+
808
+ // src/signing/evm-signer.ts
809
+ import { privateKeyToAccount } from "viem/accounts";
810
+ import { toHex } from "viem";
811
+ function getBalanceProofDomain(chainId, tokenNetworkAddress) {
812
+ return {
813
+ name: "TokenNetwork",
814
+ version: "1",
815
+ chainId,
816
+ verifyingContract: tokenNetworkAddress
817
+ };
818
+ }
819
+ var BALANCE_PROOF_TYPES = {
820
+ BalanceProof: [
821
+ { name: "channelId", type: "bytes32" },
822
+ { name: "nonce", type: "uint256" },
823
+ { name: "transferredAmount", type: "uint256" },
824
+ { name: "lockedAmount", type: "uint256" },
825
+ { name: "locksRoot", type: "bytes32" }
826
+ ]
827
+ };
828
+ var EvmSigner = class {
829
+ _account;
830
+ /**
831
+ * @param privateKey - EVM private key as hex string (with or without 0x prefix) or Uint8Array
832
+ */
833
+ constructor(privateKey) {
834
+ let hexKey;
835
+ if (privateKey instanceof Uint8Array) {
836
+ hexKey = toHex(privateKey);
837
+ } else {
838
+ hexKey = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
839
+ }
840
+ this._account = privateKeyToAccount(hexKey);
841
+ }
842
+ /** Derived 0x EVM address */
843
+ get address() {
844
+ return this._account.address;
845
+ }
846
+ /** Viem PrivateKeyAccount — usable with walletClient for on-chain transactions */
847
+ get account() {
848
+ return this._account;
849
+ }
850
+ /**
851
+ * Signs a balance proof using EIP-712 typed data.
852
+ *
853
+ * @param params - Balance proof parameters plus chain context
854
+ * @returns Signed balance proof with signature
855
+ */
856
+ async signBalanceProof(params) {
857
+ const domain = getBalanceProofDomain(
858
+ params.chainId,
859
+ params.tokenNetworkAddress
860
+ );
861
+ const signature = await this._account.signTypedData({
862
+ domain,
863
+ types: BALANCE_PROOF_TYPES,
864
+ primaryType: "BalanceProof",
865
+ message: {
866
+ channelId: params.channelId,
867
+ nonce: BigInt(params.nonce),
868
+ transferredAmount: params.transferredAmount,
869
+ lockedAmount: params.lockedAmount,
870
+ locksRoot: params.locksRoot
871
+ }
872
+ });
873
+ return {
874
+ channelId: params.channelId,
875
+ nonce: params.nonce,
876
+ transferredAmount: params.transferredAmount,
877
+ lockedAmount: params.lockedAmount,
878
+ locksRoot: params.locksRoot,
879
+ signature,
880
+ signerAddress: this._account.address
881
+ };
882
+ }
883
+ /**
884
+ * Builds an EVMClaimMessage from a signed balance proof.
885
+ * Static so it can be called without an EvmSigner instance.
886
+ *
887
+ * @param proof - Signed balance proof
888
+ * @param senderId - Nostr pubkey or identifier of the sender
889
+ * @returns EVMClaimMessage compatible with BTP_CLAIM_PROTOCOL
890
+ */
891
+ static buildClaimMessage(proof, senderId) {
892
+ return {
893
+ blockchain: "evm",
894
+ senderId,
895
+ channelId: proof.channelId,
896
+ nonce: proof.nonce,
897
+ transferredAmount: proof.transferredAmount.toString(),
898
+ lockedAmount: proof.lockedAmount.toString(),
899
+ locksRoot: proof.locksRoot,
900
+ signature: proof.signature,
901
+ signerAddress: proof.signerAddress
902
+ };
903
+ }
904
+ };
905
+
906
+ // src/modes/http.ts
907
+ async function initializeHttpMode(config) {
908
+ const connectorUrl = config.connectorUrl;
909
+ const settlementInfo = buildSettlementInfo(config);
910
+ let btpClient = null;
911
+ if (config.btpUrl) {
912
+ btpClient = new BtpRuntimeClient({
913
+ btpUrl: config.btpUrl,
914
+ peerId: config.btpPeerId ?? `client`,
915
+ authToken: config.btpAuthToken ?? ""
916
+ });
917
+ await btpClient.connect();
918
+ }
919
+ const runtimeClient = btpClient ?? new HttpRuntimeClient({
920
+ connectorUrl,
921
+ timeout: config.queryTimeout,
922
+ maxRetries: config.maxRetries,
923
+ retryDelay: config.retryDelay
924
+ });
925
+ let onChainChannelClient = null;
926
+ if (config.chainRpcUrls) {
927
+ const evmSigner = new EvmSigner(config.evmPrivateKey);
928
+ onChainChannelClient = new OnChainChannelClient({
929
+ evmSigner,
930
+ chainRpcUrls: config.chainRpcUrls
931
+ });
932
+ }
933
+ const bootstrapConfig = {
934
+ knownPeers: (config.knownPeers || []).map((p) => ({
935
+ pubkey: p.pubkey,
936
+ relayUrl: p.relayUrl,
937
+ btpEndpoint: p.btpEndpoint ?? ""
938
+ })),
939
+ queryTimeout: config.queryTimeout,
940
+ ardriveEnabled: true,
941
+ defaultRelayUrl: config.relayUrl,
942
+ settlementInfo,
943
+ ownIlpAddress: config.ilpInfo.ilpAddress,
944
+ toonEncoder: config.toonEncoder,
945
+ toonDecoder: config.toonDecoder,
946
+ basePricePerByte: 10n
947
+ // Default pricing
948
+ };
949
+ const bootstrapService = new BootstrapService(
950
+ bootstrapConfig,
951
+ config.secretKey,
952
+ config.ilpInfo
953
+ );
954
+ bootstrapService.setIlpClient(runtimeClient);
955
+ if (onChainChannelClient) {
956
+ bootstrapService.setChannelClient(onChainChannelClient);
957
+ }
958
+ const discoveryTracker = createDiscoveryTracker({
959
+ secretKey: config.secretKey,
960
+ settlementInfo
961
+ });
962
+ return {
963
+ bootstrapService,
964
+ discoveryTracker,
965
+ runtimeClient,
966
+ adminClient: null,
967
+ btpClient,
968
+ onChainChannelClient
969
+ };
970
+ }
971
+
972
+ // src/channel/ChannelManager.ts
973
+ var ChannelManager = class {
974
+ evmSigner;
975
+ channels = /* @__PURE__ */ new Map();
976
+ store;
977
+ constructor(evmSigner, store) {
978
+ this.evmSigner = evmSigner;
979
+ this.store = store;
980
+ }
981
+ /**
982
+ * Start tracking a channel.
983
+ * Called after bootstrap returns a channelId.
984
+ *
985
+ * @param channelId - Payment channel identifier
986
+ * @param initialNonce - Starting nonce (default: 0)
987
+ * @param initialAmount - Starting cumulative amount (default: 0n)
988
+ */
989
+ trackChannel(channelId, initialNonce = 0, initialAmount = 0n) {
990
+ if (this.store) {
991
+ const persisted = this.store.load(channelId);
992
+ if (persisted) {
993
+ this.channels.set(channelId, {
994
+ nonce: persisted.nonce,
995
+ cumulativeAmount: persisted.cumulativeAmount
996
+ });
997
+ return;
998
+ }
999
+ }
1000
+ this.channels.set(channelId, {
1001
+ nonce: initialNonce,
1002
+ cumulativeAmount: initialAmount
1003
+ });
1004
+ }
1005
+ /**
1006
+ * Signs a balance proof for the given channel.
1007
+ * Auto-increments nonce and adds to cumulative amount.
1008
+ *
1009
+ * @param channelId - Payment channel identifier
1010
+ * @param additionalAmount - Amount to add to cumulative transferred amount
1011
+ * @returns Signed balance proof
1012
+ * @throws Error if channel is not being tracked
1013
+ */
1014
+ async signBalanceProof(channelId, additionalAmount) {
1015
+ const tracking = this.channels.get(channelId);
1016
+ if (!tracking) {
1017
+ throw new Error(
1018
+ `Channel "${channelId}" is not being tracked. Call trackChannel() first.`
1019
+ );
1020
+ }
1021
+ tracking.nonce += 1;
1022
+ tracking.cumulativeAmount += additionalAmount;
1023
+ if (this.store) {
1024
+ this.store.save(channelId, {
1025
+ nonce: tracking.nonce,
1026
+ cumulativeAmount: tracking.cumulativeAmount
1027
+ });
1028
+ }
1029
+ return this.evmSigner.signBalanceProof({
1030
+ channelId,
1031
+ nonce: tracking.nonce,
1032
+ transferredAmount: tracking.cumulativeAmount,
1033
+ lockedAmount: 0n,
1034
+ locksRoot: "0x0000000000000000000000000000000000000000000000000000000000000000",
1035
+ chainId: 31337,
1036
+ // Default — will be configurable via channel context
1037
+ tokenNetworkAddress: "0x0000000000000000000000000000000000000000"
1038
+ });
1039
+ }
1040
+ /**
1041
+ * Gets the current nonce for a tracked channel.
1042
+ */
1043
+ getNonce(channelId) {
1044
+ const tracking = this.channels.get(channelId);
1045
+ if (!tracking) {
1046
+ throw new Error(`Channel "${channelId}" is not being tracked.`);
1047
+ }
1048
+ return tracking.nonce;
1049
+ }
1050
+ /**
1051
+ * Gets the cumulative transferred amount for a tracked channel.
1052
+ */
1053
+ getCumulativeAmount(channelId) {
1054
+ const tracking = this.channels.get(channelId);
1055
+ if (!tracking) {
1056
+ throw new Error(`Channel "${channelId}" is not being tracked.`);
1057
+ }
1058
+ return tracking.cumulativeAmount;
1059
+ }
1060
+ /**
1061
+ * Gets all tracked channel IDs.
1062
+ */
1063
+ getTrackedChannels() {
1064
+ return Array.from(this.channels.keys());
1065
+ }
1066
+ /**
1067
+ * Returns true if the channel is being tracked.
1068
+ */
1069
+ isTracking(channelId) {
1070
+ return this.channels.has(channelId);
1071
+ }
1072
+ };
1073
+
1074
+ // src/channel/ChannelStore.ts
1075
+ import { readFileSync, writeFileSync, existsSync } from "fs";
1076
+ var JsonFileChannelStore = class {
1077
+ filePath;
1078
+ constructor(filePath) {
1079
+ this.filePath = filePath;
1080
+ }
1081
+ save(channelId, tracking) {
1082
+ const data = this.readFile();
1083
+ data[channelId] = {
1084
+ nonce: tracking.nonce,
1085
+ cumulativeAmount: tracking.cumulativeAmount.toString()
1086
+ };
1087
+ this.writeFile(data);
1088
+ }
1089
+ load(channelId) {
1090
+ const data = this.readFile();
1091
+ const entry = data[channelId];
1092
+ if (!entry) return void 0;
1093
+ return {
1094
+ nonce: entry.nonce,
1095
+ cumulativeAmount: BigInt(entry.cumulativeAmount)
1096
+ };
1097
+ }
1098
+ list() {
1099
+ return Object.keys(this.readFile());
1100
+ }
1101
+ delete(channelId) {
1102
+ const data = this.readFile();
1103
+ const { [channelId]: _, ...rest } = data;
1104
+ this.writeFile(rest);
1105
+ }
1106
+ readFile() {
1107
+ if (!existsSync(this.filePath)) {
1108
+ return {};
1109
+ }
1110
+ const raw = readFileSync(this.filePath, "utf-8");
1111
+ return JSON.parse(raw);
1112
+ }
1113
+ writeFile(data) {
1114
+ writeFileSync(this.filePath, JSON.stringify(data, null, 2), "utf-8");
1115
+ }
1116
+ };
1117
+
1118
+ // src/ToonClient.ts
1119
+ var ToonClient = class {
1120
+ config;
1121
+ state = null;
1122
+ evmSigner;
1123
+ channelManager;
1124
+ /**
1125
+ * Creates a new ToonClient instance.
1126
+ *
1127
+ * @param config - Client configuration
1128
+ * @throws {ValidationError} If configuration is invalid
1129
+ */
1130
+ constructor(config) {
1131
+ validateConfig(config);
1132
+ this.config = applyDefaults(config);
1133
+ if (this.config.evmPrivateKey) {
1134
+ this.evmSigner = new EvmSigner(this.config.evmPrivateKey);
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Generates a new Nostr keypair.
1139
+ *
1140
+ * @returns Object with secretKey (Uint8Array) and pubkey (hex string)
1141
+ */
1142
+ static generateKeypair() {
1143
+ const secretKey = generateSecretKey2();
1144
+ const pubkey = getPublicKey(secretKey);
1145
+ return { secretKey, pubkey };
1146
+ }
1147
+ /**
1148
+ * Gets the Nostr public key derived from the secret key.
1149
+ * Works before start() is called.
1150
+ */
1151
+ getPublicKey() {
1152
+ return getPublicKey(this.config.secretKey);
1153
+ }
1154
+ /**
1155
+ * Gets the EVM address derived from the Nostr secret key (or explicit evmPrivateKey override).
1156
+ */
1157
+ getEvmAddress() {
1158
+ return this.evmSigner?.address;
1159
+ }
1160
+ /**
1161
+ * Starts the ToonClient.
1162
+ *
1163
+ * This will:
1164
+ * 1. Initialize HTTP mode components (runtime client, admin client, bootstrap, monitor)
1165
+ * 2. Bootstrap the network (discover peers, register, and open channels)
1166
+ * 3. Start monitoring relay for new peers (kind:10032 events)
1167
+ *
1168
+ * @returns Result with number of peers discovered and mode
1169
+ * @throws {ToonClientError} If client is already started
1170
+ * @throws {ToonClientError} If initialization fails
1171
+ */
1172
+ async start() {
1173
+ if (this.state !== null) {
1174
+ throw new ToonClientError("Client already started", "INVALID_STATE");
1175
+ }
1176
+ try {
1177
+ if (this.evmSigner) {
1178
+ const store = this.config.channelStorePath ? new JsonFileChannelStore(this.config.channelStorePath) : void 0;
1179
+ this.channelManager = new ChannelManager(this.evmSigner, store);
1180
+ }
1181
+ const initialization = await initializeHttpMode(this.config);
1182
+ const { bootstrapService, discoveryTracker, runtimeClient, btpClient } = initialization;
1183
+ if (this.channelManager) {
1184
+ const cm = this.channelManager;
1185
+ bootstrapService.setClaimSigner(
1186
+ async (channelId, amount) => {
1187
+ if (!cm.isTracking(channelId)) {
1188
+ cm.trackChannel(channelId);
1189
+ }
1190
+ return cm.signBalanceProof(channelId, amount);
1191
+ }
1192
+ );
1193
+ }
1194
+ const bootstrapResults = await bootstrapService.bootstrap();
1195
+ if (this.channelManager) {
1196
+ for (const result of bootstrapResults) {
1197
+ if (result.channelId && !this.channelManager.isTracking(result.channelId)) {
1198
+ this.channelManager.trackChannel(result.channelId);
1199
+ }
1200
+ }
1201
+ }
1202
+ this.state = {
1203
+ bootstrapService,
1204
+ discoveryTracker,
1205
+ runtimeClient,
1206
+ peersDiscovered: bootstrapResults.length,
1207
+ btpClient: btpClient ?? void 0
1208
+ };
1209
+ return {
1210
+ peersDiscovered: bootstrapResults.length,
1211
+ mode: "http"
1212
+ };
1213
+ } catch (error) {
1214
+ throw new ToonClientError(
1215
+ "Failed to start client",
1216
+ "INITIALIZATION_ERROR",
1217
+ error instanceof Error ? error : void 0
1218
+ );
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Publishes a Nostr event to the relay via ILP payment.
1223
+ *
1224
+ * The event must already be finalized (signed with id, pubkey, sig).
1225
+ *
1226
+ * @param event - Signed Nostr event to publish
1227
+ * @param options - Optional options including destination and signed balance proof claim
1228
+ * @returns Result with success status, event ID, and fulfillment
1229
+ * @throws {ToonClientError} If client is not started
1230
+ * @throws {ToonClientError} If event publishing fails
1231
+ */
1232
+ async publishEvent(event, options) {
1233
+ if (!this.state) {
1234
+ throw new ToonClientError(
1235
+ "Client not started. Call start() first.",
1236
+ "INVALID_STATE"
1237
+ );
1238
+ }
1239
+ try {
1240
+ const toonData = this.config.toonEncoder(event);
1241
+ const basePricePerByte = 10n;
1242
+ const amount = String(BigInt(toonData.length) * basePricePerByte);
1243
+ const destination = options?.destination ?? this.config.destinationAddress;
1244
+ let response;
1245
+ if (options?.claim && this.state.btpClient) {
1246
+ const claimMessage = EvmSigner.buildClaimMessage(
1247
+ options.claim,
1248
+ this.getPublicKey()
1249
+ );
1250
+ response = await this.state.btpClient.sendIlpPacketWithClaim(
1251
+ {
1252
+ destination,
1253
+ amount,
1254
+ data: Buffer.from(toonData).toString("base64")
1255
+ },
1256
+ claimMessage
1257
+ );
1258
+ } else {
1259
+ response = await this.state.runtimeClient.sendIlpPacket({
1260
+ destination,
1261
+ amount,
1262
+ data: Buffer.from(toonData).toString("base64")
1263
+ });
1264
+ }
1265
+ if (!response.accepted) {
1266
+ return {
1267
+ success: false,
1268
+ error: `Event rejected: ${response.code} - ${response.message}`
1269
+ };
1270
+ }
1271
+ return {
1272
+ success: true,
1273
+ eventId: event.id,
1274
+ fulfillment: response.fulfillment
1275
+ };
1276
+ } catch (error) {
1277
+ throw new ToonClientError(
1278
+ "Failed to publish event",
1279
+ "PUBLISH_ERROR",
1280
+ error instanceof Error ? error : void 0
1281
+ );
1282
+ }
1283
+ }
1284
+ /**
1285
+ * Signs a balance proof for the given channel with the specified amount.
1286
+ * Delegates to ChannelManager which auto-increments nonce and tracks cumulative amount.
1287
+ *
1288
+ * @param channelId - Payment channel identifier
1289
+ * @param amount - Additional amount to add to cumulative transferred amount
1290
+ * @returns Signed balance proof
1291
+ * @throws {ToonClientError} If no EVM signer configured or channel not tracked
1292
+ */
1293
+ async signBalanceProof(channelId, amount) {
1294
+ if (!this.channelManager) {
1295
+ throw new ToonClientError(
1296
+ "No EVM signer configured. Provide evmPrivateKey in config.",
1297
+ "NO_EVM_SIGNER"
1298
+ );
1299
+ }
1300
+ return this.channelManager.signBalanceProof(channelId, amount);
1301
+ }
1302
+ /**
1303
+ * Gets list of tracked payment channel IDs.
1304
+ */
1305
+ getTrackedChannels() {
1306
+ return this.channelManager?.getTrackedChannels() ?? [];
1307
+ }
1308
+ /**
1309
+ * Sends an ILP payment, optionally with a balance proof claim via BTP.
1310
+ *
1311
+ * @param params - Payment parameters
1312
+ * @returns ILP send result
1313
+ * @throws {ToonClientError} If client is not started
1314
+ */
1315
+ async sendPayment(params) {
1316
+ if (!this.state) {
1317
+ throw new ToonClientError(
1318
+ "Client not started. Call start() first.",
1319
+ "INVALID_STATE"
1320
+ );
1321
+ }
1322
+ const ilpParams = {
1323
+ destination: params.destination,
1324
+ amount: params.amount,
1325
+ data: params.data ?? ""
1326
+ };
1327
+ if (params.claim && this.state.btpClient) {
1328
+ const claimMessage = EvmSigner.buildClaimMessage(
1329
+ params.claim,
1330
+ this.getPublicKey()
1331
+ );
1332
+ return this.state.btpClient.sendIlpPacketWithClaim(
1333
+ ilpParams,
1334
+ claimMessage
1335
+ );
1336
+ }
1337
+ return this.state.runtimeClient.sendIlpPacket(ilpParams);
1338
+ }
1339
+ /**
1340
+ * Stops the ToonClient and cleans up resources.
1341
+ *
1342
+ * This will:
1343
+ * 1. Disconnect BTP client if connected
1344
+ * 2. Clear internal state
1345
+ *
1346
+ * @throws {ToonClientError} If client is not started
1347
+ */
1348
+ async stop() {
1349
+ if (!this.state) {
1350
+ throw new ToonClientError("Client not started", "INVALID_STATE");
1351
+ }
1352
+ try {
1353
+ if (this.state.btpClient) {
1354
+ await this.state.btpClient.disconnect();
1355
+ }
1356
+ this.state = null;
1357
+ } catch (error) {
1358
+ throw new ToonClientError(
1359
+ "Failed to stop client",
1360
+ "STOP_ERROR",
1361
+ error instanceof Error ? error : void 0
1362
+ );
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Returns true if the client is currently started.
1367
+ */
1368
+ isStarted() {
1369
+ return this.state !== null;
1370
+ }
1371
+ /**
1372
+ * Gets the number of peers discovered during bootstrap.
1373
+ *
1374
+ * @returns Number of peers discovered
1375
+ * @throws {ToonClientError} If client is not started
1376
+ */
1377
+ getPeersCount() {
1378
+ if (!this.state) {
1379
+ throw new ToonClientError(
1380
+ "Client not started. Call start() first.",
1381
+ "INVALID_STATE"
1382
+ );
1383
+ }
1384
+ return this.state.peersDiscovered;
1385
+ }
1386
+ /**
1387
+ * Gets the list of peers discovered by the relay monitor.
1388
+ *
1389
+ * @returns Array of discovered peer objects
1390
+ * @throws {ToonClientError} If client is not started
1391
+ */
1392
+ getDiscoveredPeers() {
1393
+ if (!this.state) {
1394
+ throw new ToonClientError(
1395
+ "Client not started. Call start() first.",
1396
+ "INVALID_STATE"
1397
+ );
1398
+ }
1399
+ return this.state.discoveryTracker.getDiscoveredPeers();
1400
+ }
1401
+ };
1402
+
1403
+ // src/adapters/HttpConnectorAdmin.ts
1404
+ var HttpConnectorAdmin = class {
1405
+ adminUrl;
1406
+ timeout;
1407
+ retryConfig;
1408
+ httpClient;
1409
+ constructor(config) {
1410
+ this.adminUrl = config.adminUrl.replace(/\/$/, "");
1411
+ this.timeout = config.timeout ?? 3e4;
1412
+ this.retryConfig = {
1413
+ maxRetries: config.maxRetries ?? 3,
1414
+ retryDelay: config.retryDelay ?? 1e3
1415
+ };
1416
+ this.httpClient = config.httpClient ?? fetch;
1417
+ }
1418
+ /**
1419
+ * Add a peer to the connector via the admin API.
1420
+ *
1421
+ * Validates peer config parameters and sends HTTP POST to /admin/peers.
1422
+ *
1423
+ * @param config - Peer configuration
1424
+ * @param config.id - Unique peer identifier (non-empty string)
1425
+ * @param config.url - BTP WebSocket URL (must start with 'btp+ws://' or 'btp+wss://')
1426
+ * @param config.authToken - Authentication token (non-empty string)
1427
+ * @param config.routes - Optional routing table entries
1428
+ * @param config.settlement - Optional settlement configuration
1429
+ *
1430
+ * @throws {ValidationError} Invalid peer config (missing id, invalid url, etc.)
1431
+ * @throws {PeerAlreadyExistsError} Peer with same ID already exists (409 Conflict)
1432
+ * @throws {UnauthorizedError} Admin API authentication failed (401)
1433
+ * @throws {NetworkError} Connection to admin API failed
1434
+ * @throws {ConnectorError} Admin API server error (5xx)
1435
+ */
1436
+ async addPeer(config) {
1437
+ if (!config.id || typeof config.id !== "string" || config.id.trim() === "") {
1438
+ throw new ValidationError("Peer id must be a non-empty string");
1439
+ }
1440
+ if (!config.url || typeof config.url !== "string" || config.url.trim() === "") {
1441
+ throw new ValidationError("Peer url must be a non-empty string");
1442
+ }
1443
+ const hasWsPrefix = config.url.startsWith("ws://") || config.url.startsWith("wss://");
1444
+ const hasBtpPrefix = config.url.startsWith("btp+ws://") || config.url.startsWith("btp+wss://");
1445
+ if (!hasWsPrefix && !hasBtpPrefix) {
1446
+ throw new ValidationError(
1447
+ `Invalid BTP URL format: "${config.url}". Must start with 'ws://', 'wss://', 'btp+ws://', or 'btp+wss://'`
1448
+ );
1449
+ }
1450
+ if (config.authToken === void 0 || config.authToken === null || typeof config.authToken !== "string") {
1451
+ throw new ValidationError(
1452
+ "Peer authToken must be a string (can be empty for no auth)"
1453
+ );
1454
+ }
1455
+ if (config.routes !== void 0) {
1456
+ if (!Array.isArray(config.routes)) {
1457
+ throw new ValidationError("Peer routes must be an array");
1458
+ }
1459
+ for (const route of config.routes) {
1460
+ if (!route.prefix || typeof route.prefix !== "string" || route.prefix.trim() === "") {
1461
+ throw new ValidationError("Route prefix must be a non-empty string");
1462
+ }
1463
+ if (route.priority !== void 0 && typeof route.priority !== "number") {
1464
+ throw new ValidationError("Route priority must be a number");
1465
+ }
1466
+ }
1467
+ }
1468
+ if (config.settlement !== void 0) {
1469
+ if (typeof config.settlement !== "object" || config.settlement === null) {
1470
+ throw new ValidationError("Peer settlement must be an object");
1471
+ }
1472
+ if (!config.settlement.preference || typeof config.settlement.preference !== "string") {
1473
+ throw new ValidationError(
1474
+ "Settlement preference must be a non-empty string"
1475
+ );
1476
+ }
1477
+ }
1478
+ const url = `${this.adminUrl}/admin/peers`;
1479
+ await withRetry(async () => this.sendAddPeerRequest(url, config), {
1480
+ maxRetries: this.retryConfig.maxRetries,
1481
+ retryDelay: this.retryConfig.retryDelay,
1482
+ exponentialBackoff: true,
1483
+ shouldRetry: (error) => {
1484
+ return error instanceof NetworkError;
1485
+ }
1486
+ });
1487
+ }
1488
+ /**
1489
+ * Remove a peer from the connector via the admin API.
1490
+ *
1491
+ * Sends HTTP DELETE to /admin/peers/:id.
1492
+ *
1493
+ * @param peerId - Unique peer identifier to remove (non-empty string)
1494
+ *
1495
+ * @throws {ValidationError} Invalid peerId (empty string)
1496
+ * @throws {PeerNotFoundError} Peer does not exist (404 Not Found)
1497
+ * @throws {UnauthorizedError} Admin API authentication failed (401)
1498
+ * @throws {NetworkError} Connection to admin API failed
1499
+ * @throws {ConnectorError} Admin API server error (5xx)
1500
+ */
1501
+ async removePeer(peerId) {
1502
+ if (!peerId || typeof peerId !== "string" || peerId.trim() === "") {
1503
+ throw new ValidationError("peerId must be a non-empty string");
1504
+ }
1505
+ const url = `${this.adminUrl}/admin/peers/${encodeURIComponent(peerId)}`;
1506
+ await withRetry(async () => this.sendRemovePeerRequest(url, peerId), {
1507
+ maxRetries: this.retryConfig.maxRetries,
1508
+ retryDelay: this.retryConfig.retryDelay,
1509
+ exponentialBackoff: true,
1510
+ shouldRetry: (error) => {
1511
+ return error instanceof NetworkError;
1512
+ }
1513
+ });
1514
+ }
1515
+ /**
1516
+ * Add multiple peers in parallel for efficient bootstrapping.
1517
+ *
1518
+ * Uses Promise.allSettled() to execute peer additions concurrently,
1519
+ * returning results for each operation regardless of individual failures.
1520
+ *
1521
+ * @param configs - Array of peer configurations to add
1522
+ * @returns Array of results indicating success/failure for each peer
1523
+ *
1524
+ * @example
1525
+ * ```typescript
1526
+ * const results = await admin.addPeers([
1527
+ * { id: 'peer1', url: 'btp+ws://...', authToken: 'token1' },
1528
+ * { id: 'peer2', url: 'btp+ws://...', authToken: 'token2' },
1529
+ * ]);
1530
+ *
1531
+ * results.forEach(result => {
1532
+ * if (result.success) {
1533
+ * console.log(`Added peer: ${result.peerId}`);
1534
+ * } else {
1535
+ * console.error(`Failed to add ${result.peerId}:`, result.error);
1536
+ * }
1537
+ * });
1538
+ * ```
1539
+ */
1540
+ async addPeers(configs) {
1541
+ const results = await Promise.allSettled(
1542
+ configs.map((config) => this.addPeer(config))
1543
+ );
1544
+ return results.map((result, index) => {
1545
+ const config = configs[index];
1546
+ return {
1547
+ peerId: config ? config.id : "unknown",
1548
+ success: result.status === "fulfilled",
1549
+ error: result.status === "rejected" ? result.reason : void 0
1550
+ };
1551
+ });
1552
+ }
1553
+ /**
1554
+ * Remove multiple peers in parallel.
1555
+ *
1556
+ * Uses Promise.allSettled() to execute peer removals concurrently,
1557
+ * returning results for each operation regardless of individual failures.
1558
+ *
1559
+ * @param peerIds - Array of peer IDs to remove
1560
+ * @returns Array of results indicating success/failure for each peer
1561
+ *
1562
+ * @example
1563
+ * ```typescript
1564
+ * const results = await admin.removePeers(['peer1', 'peer2', 'peer3']);
1565
+ *
1566
+ * const succeeded = results.filter(r => r.success).length;
1567
+ * console.log(`Removed ${succeeded}/${results.length} peers`);
1568
+ * ```
1569
+ */
1570
+ async removePeers(peerIds) {
1571
+ const results = await Promise.allSettled(
1572
+ peerIds.map((peerId) => this.removePeer(peerId))
1573
+ );
1574
+ return results.map((result, index) => {
1575
+ const peerId = peerIds[index];
1576
+ return {
1577
+ peerId: peerId ?? "unknown",
1578
+ success: result.status === "fulfilled",
1579
+ error: result.status === "rejected" ? result.reason : void 0
1580
+ };
1581
+ });
1582
+ }
1583
+ /**
1584
+ * Send HTTP POST request to add a peer.
1585
+ * Separated for retry logic wrapping.
1586
+ */
1587
+ async sendAddPeerRequest(url, config) {
1588
+ let connectorUrl = config.url;
1589
+ if (connectorUrl.startsWith("btp+")) {
1590
+ connectorUrl = connectorUrl.replace(/^btp\+/, "");
1591
+ }
1592
+ try {
1593
+ const response = await this.httpClient(url, {
1594
+ method: "POST",
1595
+ headers: {
1596
+ "Content-Type": "application/json"
1597
+ },
1598
+ body: JSON.stringify({
1599
+ ...config,
1600
+ url: connectorUrl
1601
+ }),
1602
+ signal: AbortSignal.timeout(this.timeout)
1603
+ });
1604
+ if (response.ok) {
1605
+ return;
1606
+ }
1607
+ await this.handleErrorResponse(response, `POST ${url}`, config.id);
1608
+ } catch (error) {
1609
+ this.handleNetworkError(error, url, "addPeer");
1610
+ }
1611
+ }
1612
+ /**
1613
+ * Send HTTP DELETE request to remove a peer.
1614
+ * Separated for retry logic wrapping.
1615
+ */
1616
+ async sendRemovePeerRequest(url, peerId) {
1617
+ try {
1618
+ const response = await this.httpClient(url, {
1619
+ method: "DELETE",
1620
+ signal: AbortSignal.timeout(this.timeout)
1621
+ });
1622
+ if (response.ok) {
1623
+ return;
1624
+ }
1625
+ await this.handleErrorResponse(response, `DELETE ${url}`, peerId);
1626
+ } catch (error) {
1627
+ this.handleNetworkError(error, url, "removePeer");
1628
+ }
1629
+ }
1630
+ /**
1631
+ * Handle network errors from HTTP requests.
1632
+ *
1633
+ * Converts connection failures, timeouts, and unknown errors to NetworkError.
1634
+ * Re-throws existing ToonClientError instances.
1635
+ *
1636
+ * @param error - Error thrown by HTTP client
1637
+ * @param url - Request URL (for error messages)
1638
+ * @param operation - Operation name (for error messages)
1639
+ * @throws {NetworkError} Network connection or timeout error
1640
+ */
1641
+ handleNetworkError(error, url, operation) {
1642
+ if (error instanceof Error && error.name === "AbortError") {
1643
+ throw new NetworkError(
1644
+ `Request to ${url} timed out after ${this.timeout}ms`,
1645
+ error
1646
+ );
1647
+ }
1648
+ if (error instanceof Error && (error.message.includes("ECONNREFUSED") || error.message.includes("ETIMEDOUT") || error.message.includes("ENOTFOUND"))) {
1649
+ throw new NetworkError(
1650
+ `Failed to connect to connector admin API at ${url}: ${error.message}`,
1651
+ error
1652
+ );
1653
+ }
1654
+ if (error instanceof ValidationError || error instanceof PeerAlreadyExistsError || error instanceof PeerNotFoundError || error instanceof UnauthorizedError || error instanceof ConnectorError) {
1655
+ throw error;
1656
+ }
1657
+ throw new NetworkError(
1658
+ `Unexpected error during ${operation}: ${error instanceof Error ? error.message : String(error)}`,
1659
+ error instanceof Error ? error : void 0
1660
+ );
1661
+ }
1662
+ /**
1663
+ * Handle HTTP error responses from the admin API.
1664
+ *
1665
+ * Converts HTTP status codes to appropriate error types.
1666
+ *
1667
+ * @param response - HTTP response from admin API
1668
+ * @param endpoint - Endpoint being called (for error messages)
1669
+ * @param peerId - Peer ID (for error messages)
1670
+ * @throws {UnauthorizedError} 401 Unauthorized
1671
+ * @throws {PeerNotFoundError} 404 Not Found
1672
+ * @throws {PeerAlreadyExistsError} 409 Conflict
1673
+ * @throws {ConnectorError} 5xx Server Error
1674
+ */
1675
+ async handleErrorResponse(response, endpoint, peerId) {
1676
+ const status = response.status;
1677
+ const statusText = response.statusText;
1678
+ let errorMessage = "";
1679
+ try {
1680
+ const body = await response.text();
1681
+ if (body) {
1682
+ errorMessage = ` - ${body}`;
1683
+ }
1684
+ } catch {
1685
+ }
1686
+ switch (status) {
1687
+ case 401:
1688
+ throw new UnauthorizedError(
1689
+ `Admin API authentication failed for ${endpoint}: ${statusText}${errorMessage}`
1690
+ );
1691
+ case 404:
1692
+ throw new PeerNotFoundError(
1693
+ `Peer not found: "${peerId}" (${endpoint}): ${statusText}${errorMessage}`
1694
+ );
1695
+ case 409:
1696
+ throw new PeerAlreadyExistsError(
1697
+ `Peer already exists: "${peerId}" (${endpoint}): ${statusText}${errorMessage}`
1698
+ );
1699
+ default:
1700
+ if (status >= 500) {
1701
+ throw new ConnectorError(
1702
+ `Connector admin API error (${endpoint}): ${status} ${statusText}${errorMessage}`
1703
+ );
1704
+ }
1705
+ throw new ConnectorError(
1706
+ `Admin API error (${endpoint}): ${status} ${statusText}${errorMessage}`
1707
+ );
1708
+ }
1709
+ }
1710
+ };
1711
+ export {
1712
+ BtpRuntimeClient,
1713
+ ChannelManager,
1714
+ ConnectorError,
1715
+ EvmSigner,
1716
+ HttpConnectorAdmin,
1717
+ HttpRuntimeClient,
1718
+ NetworkError,
1719
+ OnChainChannelClient,
1720
+ ToonClient,
1721
+ ToonClientError,
1722
+ ValidationError,
1723
+ applyDefaults,
1724
+ buildSettlementInfo,
1725
+ validateConfig,
1726
+ withRetry
1727
+ };
1728
+ //# sourceMappingURL=index.js.map