@toon-protocol/client-mcp 0.31.2 → 0.32.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.
@@ -1,6 +1,7 @@
1
1
  import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
2
2
  import {
3
3
  DEFAULT_KEYSTORE_PASSWORD,
4
+ ILP_PEER_INFO_KIND,
4
5
  ToonError,
5
6
  buildIlpPrepare,
6
7
  configDir,
@@ -9,8 +10,9 @@ import {
9
10
  deriveFullIdentity,
10
11
  encodeEventToToon,
11
12
  generateKeystore,
13
+ parseIlpPeerInfo,
12
14
  readConfigFile
13
- } from "./chunk-ARFPM2PT.js";
15
+ } from "./chunk-ALMTQYYJ.js";
14
16
  import {
15
17
  startManagedAnonProxy
16
18
  } from "./chunk-SKQTKZIH.js";
@@ -128,6 +130,7 @@ var RelaySubscription = class {
128
130
  log;
129
131
  wsFactory;
130
132
  decodeEvent;
133
+ onEvent;
131
134
  /** Active subscriptions: subId -> filters (re-sent on every (re)connect). */
132
135
  subscriptions = /* @__PURE__ */ new Map();
133
136
  /** Ring buffer of received events, ordered by ascending `seq`. */
@@ -150,6 +153,7 @@ var RelaySubscription = class {
150
153
  this.log = opts.logger ?? noop;
151
154
  this.wsFactory = opts.wsFactory ?? defaultWebSocketFactory(this.socksProxy);
152
155
  this.decodeEvent = opts.decodeEvent;
156
+ this.onEvent = opts.onEvent;
153
157
  }
154
158
  /** Whether the underlying socket is currently open. */
155
159
  isConnected() {
@@ -250,7 +254,7 @@ var RelaySubscription = class {
250
254
  }
251
255
  scheduleReconnect() {
252
256
  if (this.closing || this.reconnectTimer) return;
253
- const delay = Math.min(
257
+ const delay2 = Math.min(
254
258
  this.reconnectMaxMs,
255
259
  this.reconnectBaseMs * 2 ** this.reconnectAttempts
256
260
  );
@@ -258,7 +262,7 @@ var RelaySubscription = class {
258
262
  this.reconnectTimer = setTimeout(() => {
259
263
  this.reconnectTimer = null;
260
264
  if (!this.closing) this.open();
261
- }, delay);
265
+ }, delay2);
262
266
  this.reconnectTimer.unref?.();
263
267
  }
264
268
  sendReq(subId, filters) {
@@ -330,6 +334,7 @@ var RelaySubscription = class {
330
334
  const evicted = this.buffer.shift();
331
335
  if (evicted) this.seen.delete(evicted.event.id);
332
336
  }
337
+ this.onEvent?.(subId, event);
333
338
  }
334
339
  };
335
340
  function toText(data) {
@@ -1239,54 +1244,496 @@ function saveApexChannel(path, destination, chain, record) {
1239
1244
  writeFileSync2(path, JSON.stringify(store, null, 2), { mode: 384 });
1240
1245
  }
1241
1246
 
1247
+ // src/daemon/targets-store.ts
1248
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
1249
+ import { dirname as dirname3, join as join2 } from "path";
1250
+ function defaultTargetsPath() {
1251
+ return join2(configDir(), "targets.json");
1252
+ }
1253
+ function loadTargets(path = defaultTargetsPath()) {
1254
+ let parsed;
1255
+ try {
1256
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
1257
+ } catch {
1258
+ return { relays: [], apexes: [] };
1259
+ }
1260
+ return {
1261
+ relays: Array.isArray(parsed.relays) ? parsed.relays : [],
1262
+ apexes: Array.isArray(parsed.apexes) ? parsed.apexes : []
1263
+ };
1264
+ }
1265
+ function write(path, data) {
1266
+ mkdirSync3(dirname3(path), { recursive: true });
1267
+ writeFileSync3(path, JSON.stringify(data, null, 2), { mode: 384 });
1268
+ }
1269
+ function saveRelayTarget(relayUrl, path = defaultTargetsPath()) {
1270
+ const store = loadTargets(path);
1271
+ if (!store.relays.some((r) => r.relayUrl === relayUrl)) {
1272
+ store.relays.push({ relayUrl });
1273
+ write(path, store);
1274
+ }
1275
+ }
1276
+ function removeRelayTarget(relayUrl, path = defaultTargetsPath()) {
1277
+ const store = loadTargets(path);
1278
+ const next = store.relays.filter((r) => r.relayUrl !== relayUrl);
1279
+ if (next.length === store.relays.length) return false;
1280
+ store.relays = next;
1281
+ write(path, store);
1282
+ return true;
1283
+ }
1284
+ function saveApexTarget(target, path = defaultTargetsPath()) {
1285
+ const store = loadTargets(path);
1286
+ store.apexes = store.apexes.filter((a) => a.btpUrl !== target.btpUrl);
1287
+ store.apexes.push(target);
1288
+ write(path, store);
1289
+ }
1290
+ function removeApexTarget(btpUrl, path = defaultTargetsPath()) {
1291
+ const store = loadTargets(path);
1292
+ const next = store.apexes.filter((a) => a.btpUrl !== btpUrl);
1293
+ if (next.length === store.apexes.length) return false;
1294
+ store.apexes = next;
1295
+ write(path, store);
1296
+ return true;
1297
+ }
1298
+
1299
+ // src/daemon/apex-discovery.ts
1300
+ var ApexDiscoveryError = class extends Error {
1301
+ constructor(message) {
1302
+ super(message);
1303
+ this.name = "ApexDiscoveryError";
1304
+ }
1305
+ };
1306
+ async function discoverApex(params) {
1307
+ const { relay, ilpAddress, pubkey, chain, childPeers } = params;
1308
+ const timeoutMs = params.timeoutMs ?? 15e3;
1309
+ const pollMs = params.pollMs ?? 250;
1310
+ const subId = relay.subscribe(
1311
+ [
1312
+ {
1313
+ kinds: [ILP_PEER_INFO_KIND],
1314
+ ...pubkey ? { authors: [pubkey] } : {}
1315
+ }
1316
+ ],
1317
+ `apex-discovery-${ilpAddress}`
1318
+ );
1319
+ try {
1320
+ const deadline = Date.now() + timeoutMs;
1321
+ let cursor = 0;
1322
+ while (Date.now() < deadline) {
1323
+ const { events, cursor: next } = relay.getEvents({ subId, cursor });
1324
+ cursor = next;
1325
+ const match = events.find((e) => matchesApex(e, ilpAddress));
1326
+ if (match) return mapAnnouncement(match, { chain, childPeers });
1327
+ await delay(pollMs);
1328
+ }
1329
+ throw new ApexDiscoveryError(
1330
+ `Timed out after ${timeoutMs}ms waiting for the apex kind:${ILP_PEER_INFO_KIND} announcement for "${ilpAddress}" on the relay. Is the relay reachable and the apex online?`
1331
+ );
1332
+ } finally {
1333
+ relay.unsubscribe(subId);
1334
+ }
1335
+ }
1336
+ function matchesApex(event, ilpAddress) {
1337
+ if (event.kind !== ILP_PEER_INFO_KIND) return false;
1338
+ try {
1339
+ const info = parseIlpPeerInfo(event);
1340
+ const addrs = info.ilpAddresses ?? [info.ilpAddress];
1341
+ return addrs.includes(ilpAddress) || info.ilpAddress === ilpAddress;
1342
+ } catch {
1343
+ return false;
1344
+ }
1345
+ }
1346
+ function mapAnnouncement(event, opts) {
1347
+ const info = parseIlpPeerInfo(event);
1348
+ const chains = info.supportedChains ?? [];
1349
+ if (chains.length === 0) {
1350
+ throw new ApexDiscoveryError(
1351
+ `Apex "${info.ilpAddress}" announced no supportedChains \u2014 cannot settle.`
1352
+ );
1353
+ }
1354
+ const chainKey = (opts.chain ? chains.find((c) => c.split(":")[0] === opts.chain) : void 0) ?? chains[0];
1355
+ if (!chainKey) {
1356
+ throw new ApexDiscoveryError(
1357
+ `Apex "${info.ilpAddress}" announced no usable settlement chain.`
1358
+ );
1359
+ }
1360
+ const family = chainKey.split(":")[0];
1361
+ const settlementAddress = info.settlementAddresses?.[chainKey];
1362
+ if (!settlementAddress) {
1363
+ throw new ApexDiscoveryError(
1364
+ `Apex "${info.ilpAddress}" announced no settlementAddress for chain "${chainKey}".`
1365
+ );
1366
+ }
1367
+ const btpUrl = info.btpEndpoint;
1368
+ if (!btpUrl) {
1369
+ throw new ApexDiscoveryError(
1370
+ `Apex "${info.ilpAddress}" announced an empty btpEndpoint \u2014 cannot open a BTP session.`
1371
+ );
1372
+ }
1373
+ const negotiation = {
1374
+ destination: info.ilpAddress,
1375
+ peerId: info.ilpAddress.split(".").at(-1) ?? info.ilpAddress,
1376
+ chain: family,
1377
+ chainKey,
1378
+ // EVM chainKeys are `evm:<network>:<chainId>`; non-EVM carry no numeric id.
1379
+ chainId: family === "evm" ? Number(chainKey.split(":")[2] ?? 0) : 0,
1380
+ settlementAddress,
1381
+ ...info.preferredTokens?.[chainKey] ? { tokenAddress: info.preferredTokens[chainKey] } : {},
1382
+ ...info.tokenNetworks?.[chainKey] ? { tokenNetwork: info.tokenNetworks[chainKey] } : {}
1383
+ };
1384
+ return {
1385
+ btpUrl,
1386
+ negotiation,
1387
+ ...opts.childPeers && opts.childPeers.length > 0 ? { apexChildPeers: opts.childPeers } : {}
1388
+ };
1389
+ }
1390
+ function delay(ms) {
1391
+ return new Promise((r) => setTimeout(r, ms));
1392
+ }
1393
+
1242
1394
  // src/daemon/client-runner.ts
1395
+ var MERGED_BUFFER = 5e3;
1243
1396
  var ClientRunner = class {
1244
1397
  config;
1245
- client;
1246
- relay;
1398
+ createClient;
1399
+ createRelay;
1247
1400
  startReadProxy;
1248
1401
  log;
1402
+ targetsPath;
1249
1403
  startedAt = Date.now();
1250
- bootstrapping = false;
1251
- ready = false;
1252
- lastError;
1253
- /** Channel opened against the default apex destination. */
1254
- apexChannelId;
1255
- /** Teardown for a daemon-managed read proxy (btp-direct + relay-`.anyone`). */
1404
+ /** Apex write targets, keyed by btpUrl. */
1405
+ apexes = /* @__PURE__ */ new Map();
1406
+ /** Relay read targets, keyed by relayUrl. */
1407
+ relays = /* @__PURE__ */ new Map();
1408
+ /** Runner-level merged read buffer across all relays (de-duped by event.id). */
1409
+ merged = [];
1410
+ mergedSeen = /* @__PURE__ */ new Set();
1411
+ mergedSeq = 0;
1412
+ /**
1413
+ * Fan-out subscriptions (no relayUrl restriction): replayed onto relays added
1414
+ * later so a new relay immediately participates in existing reads.
1415
+ */
1416
+ fanoutSubs = /* @__PURE__ */ new Map();
1417
+ subIdCounter = 0;
1418
+ defaultBtpUrl;
1419
+ defaultRelayUrl;
1420
+ /** Teardown for the daemon-managed read proxy (btp-direct + relay-`.anyone`). */
1256
1421
  stopReadProxy;
1257
- /** Error from the read proxy (kept separate from the paid-path `lastError`). */
1258
1422
  readProxyError;
1259
1423
  stopped = false;
1424
+ started = false;
1260
1425
  constructor(deps) {
1261
1426
  this.config = deps.config;
1262
- this.client = deps.createClient();
1263
- this.relay = deps.createRelay?.() ?? new RelaySubscription({
1264
- relayUrl: deps.config.relayUrl,
1265
- socksProxy: deps.config.socksProxy,
1266
- logger: deps.logger,
1267
- // The TOON relay sends events TOON-encoded (text) on reads, not as JSON.
1427
+ this.createClient = deps.createClient;
1428
+ this.log = deps.logger ?? (() => void 0);
1429
+ if (deps.targetsPath !== void 0) this.targetsPath = deps.targetsPath;
1430
+ this.defaultBtpUrl = deps.config.toonClientConfig.btpUrl ?? "";
1431
+ this.defaultRelayUrl = deps.config.relayUrl;
1432
+ this.createRelay = deps.createRelay ?? ((opts) => new RelaySubscription({
1433
+ relayUrl: opts.relayUrl,
1434
+ ...opts.socksProxy ? { socksProxy: opts.socksProxy } : {},
1435
+ ...opts.logger ? { logger: opts.logger } : {},
1436
+ onEvent: opts.onEvent,
1437
+ // The TOON relay sends events TOON-encoded (text) on reads, not JSON.
1268
1438
  decodeEvent: (raw) => decodeEventFromToon(new TextEncoder().encode(raw))
1269
- });
1439
+ }));
1270
1440
  this.startReadProxy = deps.startReadProxy ?? ((opts) => startManagedAnonProxy({
1271
1441
  socksPort: opts.socksPort,
1272
1442
  ...opts.log ? { log: opts.log } : {}
1273
1443
  }));
1274
- this.log = deps.logger ?? (() => void 0);
1444
+ this.registerRelay(this.defaultRelayUrl, this.config.socksProxy);
1445
+ const defaultApex = this.makeApex({
1446
+ btpUrl: this.defaultBtpUrl,
1447
+ client: this.createClient(this.config.toonClientConfig),
1448
+ ...this.config.apex ? { negotiation: this.config.apex } : {},
1449
+ childPeers: this.config.apexChildPeers ?? [],
1450
+ destination: this.config.destination,
1451
+ chain: this.config.chain,
1452
+ channelStorePath: this.config.toonClientConfig.channelStorePath ?? this.apexChannelStorePathFor(this.defaultBtpUrl),
1453
+ feePerEvent: this.config.feePerEvent,
1454
+ isDefault: true
1455
+ });
1456
+ this.apexes.set(defaultApex.btpUrl, defaultApex);
1275
1457
  }
1276
1458
  /**
1277
- * Begin bootstrapping in the background. Resolves once kicked off; the
1278
- * connection becomes ready asynchronously. Awaitable for tests via the
1279
- * returned promise of the underlying work.
1459
+ * Start the live connections: the shared read proxy, every relay socket, the
1460
+ * default apex bootstrap (non-blocking), then replay persisted dynamic
1461
+ * targets. Returns immediately; apexes become ready asynchronously.
1280
1462
  */
1281
1463
  start() {
1282
- if (this.bootstrapping || this.ready) return;
1283
- this.bootstrapping = true;
1464
+ if (this.started) return;
1465
+ this.started = true;
1284
1466
  if (this.config.manageReadProxy) void this.bringUpReadProxy();
1285
- this.relay.start();
1467
+ for (const relay of this.relays.values()) relay.start();
1286
1468
  void this.bootstrap();
1469
+ this.replayPersistedTargets();
1470
+ }
1471
+ /** Await the default apex's bootstrap (kicking it off if not already running). */
1472
+ bootstrap() {
1473
+ const apex = this.apexes.get(this.defaultBtpUrl);
1474
+ if (!apex) return Promise.resolve();
1475
+ return this.bootstrapApex(apex);
1476
+ }
1477
+ // ── Relays (reads) ─────────────────────────────────────────────────────────
1478
+ /**
1479
+ * Build + register a relay (idempotent by URL), wiring its events into the
1480
+ * merged buffer and replaying active fan-out subscriptions. Does NOT start the
1481
+ * socket — callers start it (so construction stays side-effect-free for tests).
1482
+ */
1483
+ registerRelay(relayUrl, socksProxy) {
1484
+ const existing = this.relays.get(relayUrl);
1485
+ if (existing) return existing;
1486
+ const relay = this.createRelay({
1487
+ relayUrl,
1488
+ ...socksProxy ? { socksProxy } : {},
1489
+ logger: this.log,
1490
+ onEvent: (subId, event) => this.pushMerged(relayUrl, subId, event)
1491
+ });
1492
+ this.relays.set(relayUrl, relay);
1493
+ for (const [subId, filters] of this.fanoutSubs)
1494
+ relay.subscribe(filters, subId);
1495
+ return relay;
1496
+ }
1497
+ /**
1498
+ * Add a relay read target at runtime. `.anyone` relays reuse the managed read
1499
+ * proxy (started here if needed). Persisted unless `persist` is false.
1500
+ */
1501
+ async addRelay(relayUrl, persist = true) {
1502
+ if (this.relays.has(relayUrl)) return;
1503
+ let socksProxy = this.config.socksProxy;
1504
+ if (isAnyoneHost(relayUrl) && !socksProxy) {
1505
+ await this.ensureReadProxy();
1506
+ socksProxy = `socks5h://127.0.0.1:${this.config.readProxySocksPort ?? 9050}`;
1507
+ }
1508
+ const relay = this.registerRelay(relayUrl, socksProxy);
1509
+ relay.start();
1510
+ if (persist) saveRelayTarget(relayUrl, this.targetsPath);
1511
+ }
1512
+ /** Remove a relay read target. The config-seeded default cannot be removed. */
1513
+ removeRelay(relayUrl) {
1514
+ if (relayUrl === this.defaultRelayUrl) {
1515
+ throw new TargetError("Cannot remove the default (config-seeded) relay.");
1516
+ }
1517
+ const relay = this.relays.get(relayUrl);
1518
+ if (!relay) throw new TargetError(`No such relay: ${relayUrl}`);
1519
+ relay.close();
1520
+ this.relays.delete(relayUrl);
1521
+ this.merged = this.merged.filter((m) => {
1522
+ if (m.relayUrl === relayUrl) {
1523
+ this.mergedSeen.delete(m.event.id);
1524
+ return false;
1525
+ }
1526
+ return true;
1527
+ });
1528
+ removeRelayTarget(relayUrl, this.targetsPath);
1529
+ }
1530
+ /** Mirror a newly-buffered relay event into the merged cross-relay buffer. */
1531
+ pushMerged(relayUrl, subId, event) {
1532
+ if (this.mergedSeen.has(event.id)) return;
1533
+ this.mergedSeen.add(event.id);
1534
+ this.merged.push({ seq: ++this.mergedSeq, relayUrl, subId, event });
1535
+ if (this.merged.length > MERGED_BUFFER) {
1536
+ const evicted = this.merged.shift();
1537
+ if (evicted) this.mergedSeen.delete(evicted.event.id);
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Register a free-read subscription. With no `relayUrl` it FANS OUT across
1542
+ * every relay (and onto relays added later); with one it targets that relay.
1543
+ */
1544
+ subscribe(req) {
1545
+ const subId = req.subId ?? `sub-${++this.subIdCounter}`;
1546
+ const filters = Array.isArray(req.filters) ? req.filters : [req.filters];
1547
+ const targets = req.relayUrl ? [req.relayUrl] : [...this.relays.keys()];
1548
+ if (req.relayUrl && !this.relays.has(req.relayUrl)) {
1549
+ throw new TargetError(`No such relay: ${req.relayUrl}`);
1550
+ }
1551
+ if (!req.relayUrl) this.fanoutSubs.set(subId, filters);
1552
+ for (const url of targets) this.relays.get(url)?.subscribe(filters, subId);
1553
+ return { subId, relays: targets };
1554
+ }
1555
+ /** Drain merged events newer than the cursor (free read), optionally scoped. */
1556
+ getEvents(query) {
1557
+ const after = query.cursor ?? 0;
1558
+ const limit = query.limit ?? 200;
1559
+ const matches = this.merged.filter(
1560
+ (m) => m.seq > after && (query.subId === void 0 || m.subId === query.subId) && (query.relayUrl === void 0 || m.relayUrl === query.relayUrl)
1561
+ );
1562
+ const page = matches.slice(0, limit);
1563
+ const hasMore = matches.length > page.length;
1564
+ const last = page.at(-1);
1565
+ return {
1566
+ events: page.map((m) => m.event),
1567
+ cursor: last ? last.seq : after,
1568
+ hasMore
1569
+ };
1570
+ }
1571
+ // ── Apexes (writes) ──────────────────────────────────────────────────────
1572
+ makeApex(init) {
1573
+ return {
1574
+ ...init,
1575
+ ready: false,
1576
+ bootstrapping: false
1577
+ };
1578
+ }
1579
+ /**
1580
+ * Bootstrap one apex (memoized): start, inject negotiation, open/resume the
1581
+ * channel, route child peers. Concurrent callers await the same in-flight
1582
+ * work rather than re-running it.
1583
+ */
1584
+ bootstrapApex(apex) {
1585
+ if (apex.ready) return Promise.resolve();
1586
+ if (!apex.bootstrapPromise) {
1587
+ apex.bootstrapPromise = this.doBootstrapApex(apex);
1588
+ }
1589
+ return apex.bootstrapPromise;
1590
+ }
1591
+ async doBootstrapApex(apex) {
1592
+ apex.bootstrapping = true;
1593
+ try {
1594
+ await apex.client.start();
1595
+ this.injectApexNegotiation(apex);
1596
+ apex.apexChannelId = await this.openOrResumeApexChannel(apex);
1597
+ this.routeChildPeersThroughApexChannel(apex);
1598
+ apex.ready = true;
1599
+ apex.lastError = void 0;
1600
+ this.log(
1601
+ `[runner] apex ${apex.btpUrl} ready; channel ${apex.apexChannelId}`
1602
+ );
1603
+ } catch (err) {
1604
+ apex.lastError = err instanceof Error ? err.message : String(err);
1605
+ this.log(
1606
+ `[runner] apex ${apex.btpUrl} bootstrap failed: ${apex.lastError}`
1607
+ );
1608
+ } finally {
1609
+ apex.bootstrapping = false;
1610
+ }
1611
+ }
1612
+ /**
1613
+ * Add an apex write target. Settlement params are discovered by reading the
1614
+ * apex's kind:10032 off the given relay (added first if unknown). Persisted.
1615
+ */
1616
+ async addApex(req) {
1617
+ await this.addRelay(req.relayUrl);
1618
+ const relay = this.relays.get(req.relayUrl);
1619
+ if (!relay) throw new TargetError(`Relay unavailable: ${req.relayUrl}`);
1620
+ const discovered = await discoverApex({
1621
+ relay,
1622
+ ilpAddress: req.ilpAddress,
1623
+ ...req.pubkey ? { pubkey: req.pubkey } : {},
1624
+ ...req.chain ? { chain: req.chain } : {},
1625
+ ...req.childPeers ? { childPeers: req.childPeers } : {}
1626
+ });
1627
+ const feePerEvent = req.feePerEvent !== void 0 ? BigInt(req.feePerEvent) : this.config.feePerEvent;
1628
+ await this.instantiateApex(
1629
+ {
1630
+ btpUrl: discovered.btpUrl,
1631
+ negotiation: discovered.negotiation,
1632
+ ...discovered.apexChildPeers ? { apexChildPeers: discovered.apexChildPeers } : {},
1633
+ feePerEvent: req.feePerEvent ?? feePerEvent.toString(),
1634
+ discoveredFrom: req.relayUrl
1635
+ },
1636
+ true
1637
+ );
1638
+ const apex = this.apexes.get(discovered.btpUrl);
1639
+ if (!apex) {
1640
+ throw new TargetError(
1641
+ `Apex ${discovered.btpUrl} failed to register after discovery.`
1642
+ );
1643
+ }
1644
+ return {
1645
+ btpUrl: apex.btpUrl,
1646
+ destination: apex.destination,
1647
+ chain: apex.chain,
1648
+ ready: apex.ready
1649
+ };
1650
+ }
1651
+ /** Build + register + bootstrap an apex from a (persisted) target record. */
1652
+ async instantiateApex(target, persist) {
1653
+ if (this.apexes.has(target.btpUrl)) return;
1654
+ const clientConfig = this.deriveApexClientConfig(
1655
+ target.btpUrl,
1656
+ target.negotiation.destination
1657
+ );
1658
+ const apex = this.makeApex({
1659
+ btpUrl: target.btpUrl,
1660
+ client: this.createClient(clientConfig),
1661
+ negotiation: target.negotiation,
1662
+ childPeers: target.apexChildPeers ?? [],
1663
+ destination: target.negotiation.destination,
1664
+ chain: target.negotiation.chain,
1665
+ channelStorePath: this.apexChannelStorePathFor(target.btpUrl),
1666
+ feePerEvent: BigInt(target.feePerEvent ?? this.config.feePerEvent),
1667
+ isDefault: false
1668
+ });
1669
+ this.apexes.set(apex.btpUrl, apex);
1670
+ if (persist) saveApexTarget(target, this.targetsPath);
1671
+ await this.bootstrapApex(apex);
1672
+ }
1673
+ /** Remove an apex write target. The config-seeded default cannot be removed. */
1674
+ async removeApex(btpUrl) {
1675
+ if (btpUrl === this.defaultBtpUrl) {
1676
+ throw new TargetError("Cannot remove the default (config-seeded) apex.");
1677
+ }
1678
+ const apex = this.apexes.get(btpUrl);
1679
+ if (!apex) throw new TargetError(`No such apex: ${btpUrl}`);
1680
+ try {
1681
+ await apex.client.stop();
1682
+ } catch (err) {
1683
+ this.log(
1684
+ `[runner] apex ${btpUrl} stop error: ${err instanceof Error ? err.message : String(err)}`
1685
+ );
1686
+ }
1687
+ this.apexes.delete(btpUrl);
1688
+ removeApexTarget(btpUrl, this.targetsPath);
1689
+ }
1690
+ /** Derive a per-apex ToonClientConfig from the default (shared identity/transport). */
1691
+ deriveApexClientConfig(btpUrl, destination) {
1692
+ const base = this.config.toonClientConfig;
1693
+ return {
1694
+ ...base,
1695
+ btpUrl,
1696
+ destinationAddress: destination,
1697
+ // Distinct nonce-watermark store per apex so parallel ChannelManagers in
1698
+ // this process never race a shared channels.json.
1699
+ channelStorePath: this.apexChannelStorePathFor(btpUrl),
1700
+ ilpInfo: { ...base.ilpInfo, btpEndpoint: btpUrl }
1701
+ };
1702
+ }
1703
+ apexChannelStorePathFor(btpUrl) {
1704
+ return `${configDir()}/channels-${sanitize(btpUrl)}.json`;
1705
+ }
1706
+ // ── Persisted-target replay ────────────────────────────────────────────────
1707
+ replayPersistedTargets() {
1708
+ let store;
1709
+ try {
1710
+ store = loadTargets(this.targetsPath);
1711
+ } catch (err) {
1712
+ this.log(
1713
+ `[runner] failed to load targets store: ${err instanceof Error ? err.message : String(err)}`
1714
+ );
1715
+ return;
1716
+ }
1717
+ for (const r of store.relays) {
1718
+ if (r.relayUrl === this.defaultRelayUrl) continue;
1719
+ void this.addRelay(r.relayUrl, false).catch(
1720
+ (err) => this.log(`[runner] replay relay ${r.relayUrl} failed: ${errMsg2(err)}`)
1721
+ );
1722
+ }
1723
+ for (const a of store.apexes) {
1724
+ if (a.btpUrl === this.defaultBtpUrl) continue;
1725
+ void this.instantiateApex(a, false).catch(
1726
+ (err) => this.log(`[runner] replay apex ${a.btpUrl} failed: ${errMsg2(err)}`)
1727
+ );
1728
+ }
1729
+ }
1730
+ // ── Shared read proxy ──────────────────────────────────────────────────────
1731
+ async ensureReadProxy() {
1732
+ if (this.stopReadProxy) return;
1733
+ await this.bringUpReadProxy();
1287
1734
  }
1288
- /** Start the daemon-managed `anon` read proxy (best-effort; logs on failure). */
1289
1735
  async bringUpReadProxy() {
1736
+ if (this.stopReadProxy) return;
1290
1737
  const socksPort = this.config.readProxySocksPort ?? 9050;
1291
1738
  try {
1292
1739
  this.log(
@@ -1308,36 +1755,13 @@ var ClientRunner = class {
1308
1755
  this.log(`[runner] managed read proxy failed: ${this.readProxyError}`);
1309
1756
  }
1310
1757
  }
1311
- /** The background bootstrap routine (exposed for awaiting in tests). */
1312
- async bootstrap() {
1313
- try {
1314
- await this.client.start();
1315
- this.injectApexNegotiation(this.config.apex);
1316
- this.apexChannelId = await this.openOrResumeApexChannel();
1317
- this.routeChildPeersThroughApexChannel();
1318
- this.ready = true;
1319
- this.lastError = void 0;
1320
- this.log(`[runner] ready; apex channel ${this.apexChannelId}`);
1321
- } catch (err) {
1322
- this.lastError = err instanceof Error ? err.message : String(err);
1323
- this.log(`[runner] bootstrap failed: ${this.lastError}`);
1324
- } finally {
1325
- this.bootstrapping = false;
1326
- }
1327
- }
1328
- /**
1329
- * Open the apex channel — or, on a restart, RESUME the existing one.
1330
- *
1331
- * `ChannelManager` persists the nonce watermark (by channelId) but not the
1332
- * peer→channelId mapping, so a naive `openChannel()` after restart re-deposits
1333
- * into a fresh channel and reverts on-chain. We persist the channelId here and,
1334
- * when present, `trackChannel()` the live channel (which rehydrates the nonce
1335
- * from the channel store) — no on-chain write, watermark continues.
1336
- */
1337
- async openOrResumeApexChannel() {
1338
- const { destination, chain, apexChannelStorePath } = this.config;
1758
+ // ── Channel / negotiation helpers (per-apex) ───────────────────────────────
1759
+ /** Open the apex channel — or, on a restart, RESUME the existing one. */
1760
+ async openOrResumeApexChannel(apex) {
1761
+ const { destination, chain } = apex;
1762
+ const { apexChannelStorePath } = this.config;
1339
1763
  const saved = loadApexChannel(apexChannelStorePath, destination, chain);
1340
- const cm = this.client.channelManager;
1764
+ const cm = apex.client.channelManager;
1341
1765
  if (saved && cm && typeof cm.trackChannel === "function") {
1342
1766
  cm.trackChannel(saved.channelId, saved.context);
1343
1767
  this.log(
@@ -1345,62 +1769,47 @@ var ClientRunner = class {
1345
1769
  );
1346
1770
  return saved.channelId;
1347
1771
  }
1348
- const channelId = await this.client.openChannel(destination);
1349
- if (this.config.apex) {
1350
- const a = this.config.apex;
1772
+ const channelId = await apex.client.openChannel(destination);
1773
+ if (apex.negotiation) {
1774
+ const a = apex.negotiation;
1351
1775
  saveApexChannel(apexChannelStorePath, destination, chain, {
1352
1776
  channelId,
1353
1777
  context: {
1354
1778
  chainType: a.chain,
1355
1779
  chainId: a.chainId,
1356
1780
  tokenNetworkAddress: a.tokenNetwork ?? "",
1357
- tokenAddress: a.tokenAddress,
1781
+ ...a.tokenAddress ? { tokenAddress: a.tokenAddress } : {},
1358
1782
  recipient: a.settlementAddress
1359
1783
  }
1360
1784
  });
1361
1785
  }
1362
1786
  return channelId;
1363
1787
  }
1364
- /**
1365
- * Inject the apex settlement negotiation directly into the ToonClient when
1366
- * configured. Required for HS / direct-apex mode where bootstrap discovers 0
1367
- * peers (no relay-based discovery). Mirrors the docker entrypoint's approach.
1368
- */
1788
+ /** Inject the apex settlement negotiation directly into its ToonClient. */
1369
1789
  injectApexNegotiation(apex) {
1370
- if (!apex) return;
1371
- const negotiations = this.client.peerNegotiations;
1790
+ const a = apex.negotiation;
1791
+ if (!a) return;
1792
+ const negotiations = apex.client.peerNegotiations;
1372
1793
  if (!(negotiations instanceof Map)) {
1373
1794
  throw new Error(
1374
1795
  "ToonClient.peerNegotiations layout changed \u2014 cannot inject apex negotiation"
1375
1796
  );
1376
1797
  }
1377
- negotiations.set(apex.peerId, {
1378
- chain: apex.chainKey,
1379
- chainType: apex.chain,
1380
- chainId: apex.chainId,
1381
- settlementAddress: apex.settlementAddress,
1382
- tokenAddress: apex.tokenAddress,
1383
- tokenNetwork: apex.tokenNetwork
1798
+ negotiations.set(a.peerId, {
1799
+ chain: a.chainKey,
1800
+ chainType: a.chain,
1801
+ chainId: a.chainId,
1802
+ settlementAddress: a.settlementAddress,
1803
+ tokenAddress: a.tokenAddress,
1804
+ tokenNetwork: a.tokenNetwork
1384
1805
  });
1385
- this.log(`[runner] injected apex negotiation for peer "${apex.peerId}"`);
1806
+ this.log(`[runner] injected apex negotiation for peer "${a.peerId}"`);
1386
1807
  }
1387
- /**
1388
- * Route additional apex CHILD peers (e.g. `dvm`, `mill`) through the SAME
1389
- * apex payment channel. In the parent→child apex model the client holds ONE
1390
- * channel with the apex (g.townhouse) and pays via it regardless of which
1391
- * child the ILP destination addresses; but `ToonClient.resolvePeerId` keys off
1392
- * the destination's last segment (`town`/`dvm`/`mill`), so without this each
1393
- * child would (a) fail the "no negotiation for peer" guard and (b) try to open
1394
- * a SECOND on-chain channel to the same apex receive (which reverts —
1395
- * channel-exists). So: inject the same apex negotiation under each child peer
1396
- * AND pre-map its peer→channel to the already-open apex channel so
1397
- * `ensureChannel` reuses it (no second open; one shared nonce sequence).
1398
- */
1399
- routeChildPeersThroughApexChannel() {
1400
- const apex = this.config.apex;
1401
- const children = this.config.apexChildPeers ?? [];
1402
- if (!apex || !this.apexChannelId || children.length === 0) return;
1403
- const client = this.client;
1808
+ /** Route apex CHILD peers (dvm/mill) through the SAME apex payment channel. */
1809
+ routeChildPeersThroughApexChannel(apex) {
1810
+ const a = apex.negotiation;
1811
+ if (!a || !apex.apexChannelId || apex.childPeers.length === 0) return;
1812
+ const client = apex.client;
1404
1813
  const negotiations = client.peerNegotiations;
1405
1814
  const peerChannels = client.channelManager?.peerChannels;
1406
1815
  if (!(negotiations instanceof Map) || !(peerChannels instanceof Map)) {
@@ -1409,70 +1818,103 @@ var ClientRunner = class {
1409
1818
  );
1410
1819
  return;
1411
1820
  }
1412
- for (const peer of children) {
1413
- if (peer === apex.peerId) continue;
1821
+ for (const peer of apex.childPeers) {
1822
+ if (peer === a.peerId) continue;
1414
1823
  negotiations.set(peer, {
1415
- chain: apex.chainKey,
1416
- chainType: apex.chain,
1417
- chainId: apex.chainId,
1418
- settlementAddress: apex.settlementAddress,
1419
- tokenAddress: apex.tokenAddress,
1420
- tokenNetwork: apex.tokenNetwork
1824
+ chain: a.chainKey,
1825
+ chainType: a.chain,
1826
+ chainId: a.chainId,
1827
+ settlementAddress: a.settlementAddress,
1828
+ tokenAddress: a.tokenAddress,
1829
+ tokenNetwork: a.tokenNetwork
1421
1830
  });
1422
- peerChannels.set(peer, this.apexChannelId);
1831
+ peerChannels.set(peer, apex.apexChannelId);
1423
1832
  this.log(
1424
- `[runner] routed child peer "${peer}" through apex channel ${this.apexChannelId}`
1833
+ `[runner] routed child peer "${peer}" through apex channel ${apex.apexChannelId}`
1425
1834
  );
1426
1835
  }
1427
1836
  }
1837
+ // ── Status ─────────────────────────────────────────────────────────────────
1838
+ defaultApex() {
1839
+ return this.apexes.get(this.defaultBtpUrl);
1840
+ }
1841
+ /** Whether any apex has finished bootstrapping. */
1428
1842
  isReady() {
1429
- return this.ready;
1843
+ return [...this.apexes.values()].some((a) => a.ready);
1430
1844
  }
1431
1845
  isBootstrapping() {
1432
- return this.bootstrapping;
1846
+ return [...this.apexes.values()].some((a) => a.bootstrapping);
1433
1847
  }
1434
1848
  getStatus() {
1435
- const net = this.client.getNetworkStatus();
1849
+ const apex = this.defaultApex();
1850
+ const client = apex?.client;
1851
+ const net = client?.getNetworkStatus();
1436
1852
  const network = net ? ["evm", "solana", "mina"].map((c) => ({
1437
1853
  chain: c,
1438
1854
  ready: net[c] === "configured",
1439
1855
  detail: net[c]
1440
1856
  })) : void 0;
1857
+ const relay = this.relays.get(this.defaultRelayUrl);
1441
1858
  return {
1442
1859
  uptimeMs: Date.now() - this.startedAt,
1443
- bootstrapping: this.bootstrapping,
1444
- ready: this.ready,
1860
+ bootstrapping: apex?.bootstrapping ?? false,
1861
+ ready: apex?.ready ?? false,
1445
1862
  settlementChain: this.config.chain,
1446
1863
  identity: {
1447
- nostrPubkey: safe(() => this.client.getPublicKey()) ?? "",
1448
- evmAddress: safe(() => this.client.getEvmAddress()),
1449
- solanaAddress: safe(() => this.client.getSolanaAddress()),
1450
- minaAddress: safe(() => this.client.getMinaAddress())
1864
+ nostrPubkey: safe(() => client?.getPublicKey()) ?? "",
1865
+ evmAddress: safe(() => client?.getEvmAddress()),
1866
+ solanaAddress: safe(() => client?.getSolanaAddress()),
1867
+ minaAddress: safe(() => client?.getMinaAddress())
1451
1868
  },
1452
1869
  transport: {
1453
1870
  type: this.config.socksProxy ? "socks5" : "direct",
1454
- socksProxy: this.config.socksProxy,
1455
- btpUrl: this.config.toonClientConfig.btpUrl
1871
+ ...this.config.socksProxy ? { socksProxy: this.config.socksProxy } : {},
1872
+ ...apex ? { btpUrl: apex.btpUrl } : {}
1456
1873
  },
1457
1874
  relay: {
1458
- url: this.config.relayUrl,
1459
- connected: this.relay.isConnected(),
1460
- buffered: this.relay.bufferedCount(),
1461
- subscriptions: this.relay.activeSubscriptions(),
1875
+ url: this.defaultRelayUrl,
1876
+ connected: relay?.isConnected() ?? false,
1877
+ buffered: relay?.bufferedCount() ?? 0,
1878
+ subscriptions: relay?.activeSubscriptions() ?? [],
1462
1879
  ...this.readProxyError ? { proxyError: this.readProxyError } : {}
1463
1880
  },
1464
1881
  ...network ? { network } : {},
1465
- ...this.lastError ? { lastError: this.lastError } : {}
1882
+ ...apex?.lastError ? { lastError: apex.lastError } : {}
1466
1883
  };
1467
1884
  }
1468
- /** Pay-to-write a single event. Throws NOT_READY while bootstrapping. */
1885
+ /** Full registry of relay + apex targets with per-target status. */
1886
+ getTargets() {
1887
+ const relays = [...this.relays.entries()].map(
1888
+ ([relayUrl, r]) => ({
1889
+ relayUrl,
1890
+ connected: r.isConnected(),
1891
+ buffered: r.bufferedCount(),
1892
+ subscriptions: r.activeSubscriptions(),
1893
+ isDefault: relayUrl === this.defaultRelayUrl
1894
+ })
1895
+ );
1896
+ const apexes = [...this.apexes.values()].map((a) => ({
1897
+ btpUrl: a.btpUrl,
1898
+ destination: a.destination,
1899
+ chain: a.chain,
1900
+ ready: a.ready,
1901
+ bootstrapping: a.bootstrapping,
1902
+ ...a.apexChannelId ? { channelId: a.apexChannelId } : {},
1903
+ ...a.lastError ? { lastError: a.lastError } : {},
1904
+ isDefault: a.isDefault
1905
+ }));
1906
+ return { relays, apexes };
1907
+ }
1908
+ // ── Paid operations ──────────────────────────────────────────────────────
1909
+ /** Pay-to-write a single event through the selected (or default) apex. */
1469
1910
  async publish(req) {
1470
- this.assertReady();
1471
- const channelId = this.apexChannelId ?? await this.client.openChannel(req.destination);
1472
- const fee = req.fee !== void 0 ? BigInt(req.fee) : this.config.feePerEvent;
1473
- const claim = await this.client.signBalanceProof(channelId, fee);
1474
- const result = await this.client.publishEvent(req.event, {
1475
- destination: req.destination,
1911
+ const apex = this.selectApex(req.btpUrl);
1912
+ this.assertApexReady(apex);
1913
+ const channelId = apex.apexChannelId ?? await apex.client.openChannel(req.destination);
1914
+ const fee = req.fee !== void 0 ? BigInt(req.fee) : apex.feePerEvent;
1915
+ const claim = await apex.client.signBalanceProof(channelId, fee);
1916
+ const result = await apex.client.publishEvent(req.event, {
1917
+ ...req.destination ? { destination: req.destination } : {},
1476
1918
  claim,
1477
1919
  ilpAmount: fee
1478
1920
  });
@@ -1481,63 +1923,47 @@ var ClientRunner = class {
1481
1923
  }
1482
1924
  return {
1483
1925
  eventId: result.eventId ?? req.event.id,
1484
- data: result.data,
1926
+ ...result.data !== void 0 ? { data: result.data } : {},
1485
1927
  channelId,
1486
- nonce: this.client.getChannelNonce(channelId)
1928
+ nonce: apex.client.getChannelNonce(channelId)
1487
1929
  };
1488
1930
  }
1489
- /** Register a free-read subscription (does not require the paid path). */
1490
- subscribe(req) {
1491
- const subId = this.relay.subscribe(req.filters, req.subId);
1492
- return { subId };
1493
- }
1494
- /** Drain buffered events newer than the cursor (free read). */
1495
- getEvents(query) {
1496
- const opts = {};
1497
- if (query.subId !== void 0) opts.subId = query.subId;
1498
- if (query.cursor !== void 0) opts.cursor = query.cursor;
1499
- if (query.limit !== void 0) opts.limit = query.limit;
1500
- const { events, cursor, hasMore } = this.relay.getEvents(opts);
1501
- return { events, cursor, hasMore };
1502
- }
1503
- /** Open (or return) a payment channel for a destination. */
1504
- async openChannel(destination) {
1505
- this.assertReady();
1506
- const channelId = await this.client.openChannel(
1507
- destination ?? this.config.destination
1931
+ /** Open (or return) a payment channel on the selected (or default) apex. */
1932
+ async openChannel(destination, btpUrl) {
1933
+ const apex = this.selectApex(btpUrl);
1934
+ this.assertApexReady(apex);
1935
+ const channelId = await apex.client.openChannel(
1936
+ destination ?? apex.destination
1508
1937
  );
1509
- if (!destination || destination === this.config.destination) {
1510
- this.apexChannelId = channelId;
1938
+ if (!destination || destination === apex.destination) {
1939
+ apex.apexChannelId = channelId;
1511
1940
  }
1512
1941
  return { channelId };
1513
1942
  }
1514
- /** List tracked channels with their nonce watermark + cumulative amount. */
1943
+ /** List tracked channels across ALL apexes with nonce + cumulative amount. */
1515
1944
  getChannels() {
1516
- const channels = this.client.getTrackedChannels().map((channelId) => ({
1517
- channelId,
1518
- nonce: this.client.getChannelNonce(channelId),
1519
- cumulativeAmount: this.client.getChannelCumulativeAmount(channelId).toString()
1520
- }));
1945
+ const seen = /* @__PURE__ */ new Set();
1946
+ const channels = [];
1947
+ for (const apex of this.apexes.values()) {
1948
+ for (const channelId of apex.client.getTrackedChannels()) {
1949
+ if (seen.has(channelId)) continue;
1950
+ seen.add(channelId);
1951
+ channels.push({
1952
+ channelId,
1953
+ nonce: apex.client.getChannelNonce(channelId),
1954
+ cumulativeAmount: apex.client.getChannelCumulativeAmount(channelId).toString()
1955
+ });
1956
+ }
1957
+ }
1521
1958
  return { channels };
1522
1959
  }
1523
- /**
1524
- * Swap source asset → target asset against a mill peer.
1525
- *
1526
- * Uses SDK `streamSwap`, which builds a NIP-59 gift-wrapped kind:20032 swap
1527
- * rumor per packet and sends it over the open BTP session. The source-asset
1528
- * balance proof is signed by the ToonClient's ChannelManager against the apex
1529
- * channel — so the mill peer MUST be routed via `apexChildPeers` (otherwise
1530
- * there is no channel to sign against and the mill rejects with F99).
1531
- *
1532
- * A fresh ephemeral gift-wrap key is generated per swap (used for sealing the
1533
- * rumor AND decrypting the FULFILL claims) — independent of the daemon's
1534
- * settlement identity, so callers never need to expose a key.
1535
- */
1960
+ /** Swap source→target asset against a mill peer via the selected apex. */
1536
1961
  async swap(req) {
1537
- this.assertReady();
1962
+ const apex = this.selectApex(req.btpUrl);
1963
+ this.assertApexReady(apex);
1538
1964
  const senderSecretKey = generateSecretKey2();
1539
1965
  const result = await streamSwap({
1540
- client: this.client,
1966
+ client: apex.client,
1541
1967
  millPubkey: req.millPubkey,
1542
1968
  millIlpAddress: req.destination,
1543
1969
  pair: req.pair,
@@ -1567,33 +1993,42 @@ var ClientRunner = class {
1567
1993
  ...firstReject ? { code: firstReject.code, message: firstReject.message } : {}
1568
1994
  };
1569
1995
  }
1570
- /** Graceful teardown of the relay subscription + ToonClient. */
1996
+ /** Graceful teardown: close every relay + stop every apex client + read proxy. */
1571
1997
  async stop() {
1572
1998
  if (this.stopped) return;
1573
1999
  this.stopped = true;
1574
- this.relay.close();
2000
+ for (const relay of this.relays.values()) relay.close();
1575
2001
  if (this.stopReadProxy) {
1576
2002
  try {
1577
2003
  await this.stopReadProxy();
1578
2004
  } catch (err) {
1579
- this.log(
1580
- `[runner] read proxy stop error: ${err instanceof Error ? err.message : String(err)}`
1581
- );
2005
+ this.log(`[runner] read proxy stop error: ${errMsg2(err)}`);
1582
2006
  }
1583
2007
  this.stopReadProxy = void 0;
1584
2008
  }
1585
- try {
1586
- await this.client.stop();
1587
- } catch (err) {
1588
- this.log(
1589
- `[runner] client stop error: ${err instanceof Error ? err.message : String(err)}`
1590
- );
2009
+ for (const apex of this.apexes.values()) {
2010
+ try {
2011
+ await apex.client.stop();
2012
+ } catch (err) {
2013
+ this.log(`[runner] client stop error (${apex.btpUrl}): ${errMsg2(err)}`);
2014
+ }
1591
2015
  }
1592
2016
  }
1593
- assertReady() {
1594
- if (!this.ready) {
2017
+ // ── internals ────────────────────────────────────────────────────────────
2018
+ selectApex(btpUrl) {
2019
+ if (btpUrl) {
2020
+ const apex = this.apexes.get(btpUrl);
2021
+ if (!apex) throw new TargetError(`No such apex: ${btpUrl}`);
2022
+ return apex;
2023
+ }
2024
+ const def = this.defaultApex();
2025
+ if (!def) throw new NotReadyError("No apex configured.");
2026
+ return def;
2027
+ }
2028
+ assertApexReady(apex) {
2029
+ if (!apex.ready) {
1595
2030
  throw new NotReadyError(
1596
- this.bootstrapping ? "Daemon is still bootstrapping (BTP/anon coming up) \u2014 retry shortly." : this.lastError ?? "Daemon is not ready."
2031
+ apex.bootstrapping ? "Apex is still bootstrapping (BTP/anon coming up) \u2014 retry shortly." : apex.lastError ?? "Apex is not ready."
1597
2032
  );
1598
2033
  }
1599
2034
  }
@@ -1611,6 +2046,12 @@ var PublishRejectedError = class extends Error {
1611
2046
  this.name = "PublishRejectedError";
1612
2047
  }
1613
2048
  };
2049
+ var TargetError = class extends Error {
2050
+ constructor(message) {
2051
+ super(message);
2052
+ this.name = "TargetError";
2053
+ }
2054
+ };
1614
2055
  function safe(fn) {
1615
2056
  try {
1616
2057
  return fn();
@@ -1618,6 +2059,19 @@ function safe(fn) {
1618
2059
  return void 0;
1619
2060
  }
1620
2061
  }
2062
+ function errMsg2(err) {
2063
+ return err instanceof Error ? err.message : String(err);
2064
+ }
2065
+ function sanitize(s) {
2066
+ return s.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
2067
+ }
2068
+ function isAnyoneHost(url) {
2069
+ try {
2070
+ return new URL(url).hostname.endsWith(".anyone");
2071
+ } catch {
2072
+ return false;
2073
+ }
2074
+ }
1621
2075
 
1622
2076
  // src/daemon/routes.ts
1623
2077
  function registerRoutes(app, runner) {
@@ -1642,19 +2096,21 @@ function registerRoutes(app, runner) {
1642
2096
  detail: "body.filters is required (a NIP-01 filter or array of filters)."
1643
2097
  });
1644
2098
  }
1645
- return runner.subscribe(body);
1646
- });
1647
- app.get(
1648
- "/events",
1649
- async (req) => {
1650
- const q = req.query;
1651
- const query = {};
1652
- if (q.subId) query.subId = q.subId;
1653
- if (q.cursor !== void 0) query.cursor = Number(q.cursor);
1654
- if (q.limit !== void 0) query.limit = Number(q.limit);
1655
- return runner.getEvents(query);
2099
+ try {
2100
+ return runner.subscribe(body);
2101
+ } catch (err) {
2102
+ return mapError(reply, err);
1656
2103
  }
1657
- );
2104
+ });
2105
+ app.get("/events", async (req) => {
2106
+ const q = req.query;
2107
+ const query = {};
2108
+ if (q.subId) query.subId = q.subId;
2109
+ if (q.cursor !== void 0) query.cursor = Number(q.cursor);
2110
+ if (q.limit !== void 0) query.limit = Number(q.limit);
2111
+ if (q.relayUrl) query.relayUrl = q.relayUrl;
2112
+ return runner.getEvents(query);
2113
+ });
1658
2114
  app.post("/channels", async (req, reply) => {
1659
2115
  try {
1660
2116
  return await runner.openChannel(req.body?.destination);
@@ -1676,6 +2132,62 @@ function registerRoutes(app, runner) {
1676
2132
  return mapError(reply, err);
1677
2133
  }
1678
2134
  });
2135
+ app.get("/targets", async () => runner.getTargets());
2136
+ app.post("/relays", async (req, reply) => {
2137
+ const url = req.body?.relayUrl;
2138
+ if (!url) {
2139
+ return sendError(reply, 400, "invalid_relay", {
2140
+ detail: "body.relayUrl is required."
2141
+ });
2142
+ }
2143
+ try {
2144
+ await runner.addRelay(url);
2145
+ return runner.getTargets();
2146
+ } catch (err) {
2147
+ return mapError(reply, err);
2148
+ }
2149
+ });
2150
+ app.delete("/relays", async (req, reply) => {
2151
+ const url = req.body?.relayUrl;
2152
+ if (!url) {
2153
+ return sendError(reply, 400, "invalid_relay", {
2154
+ detail: "body.relayUrl is required."
2155
+ });
2156
+ }
2157
+ try {
2158
+ runner.removeRelay(url);
2159
+ return runner.getTargets();
2160
+ } catch (err) {
2161
+ return mapError(reply, err);
2162
+ }
2163
+ });
2164
+ app.post("/apex", async (req, reply) => {
2165
+ const body = req.body;
2166
+ if (!body || !body.ilpAddress || !body.relayUrl) {
2167
+ return sendError(reply, 400, "invalid_apex", {
2168
+ detail: "body.ilpAddress and body.relayUrl are required."
2169
+ });
2170
+ }
2171
+ try {
2172
+ return await runner.addApex(body);
2173
+ } catch (err) {
2174
+ return mapError(reply, err);
2175
+ }
2176
+ });
2177
+ app.delete("/apex", async (req, reply) => {
2178
+ const url = req.body?.btpUrl;
2179
+ if (!url) {
2180
+ return sendError(reply, 400, "invalid_apex", {
2181
+ detail: "body.btpUrl is required."
2182
+ });
2183
+ }
2184
+ try {
2185
+ await runner.removeApex(url);
2186
+ return runner.getTargets();
2187
+ } catch (err) {
2188
+ return mapError(reply, err);
2189
+ }
2190
+ });
1679
2191
  }
1680
2192
  function isSignedEvent(event) {
1681
2193
  if (typeof event !== "object" || event === null) return false;
@@ -1692,6 +2204,10 @@ function mapError(reply, err) {
1692
2204
  if (err instanceof PublishRejectedError) {
1693
2205
  return sendError(reply, 502, "rejected", { detail: err.message });
1694
2206
  }
2207
+ if (err instanceof TargetError) {
2208
+ const status = /no such/i.test(err.message) ? 404 : 400;
2209
+ return sendError(reply, status, "invalid_target", { detail: err.message });
2210
+ }
1695
2211
  return sendError(reply, 500, "internal_error", {
1696
2212
  detail: err instanceof Error ? err.message : String(err)
1697
2213
  });
@@ -1714,4 +2230,4 @@ export {
1714
2230
  PublishRejectedError,
1715
2231
  registerRoutes
1716
2232
  };
1717
- //# sourceMappingURL=chunk-PRFPAEGG.js.map
2233
+ //# sourceMappingURL=chunk-AYZWVPNQ.js.map