@toon-protocol/client 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-WHAEQLIW.js";
7
7
 
8
8
  // src/ToonClient.ts
9
- import { generateSecretKey as generateSecretKey3, getPublicKey as getPublicKey2 } from "nostr-tools/pure";
9
+ import { generateSecretKey as generateSecretKey3, getPublicKey as getPublicKey2, finalizeEvent } from "nostr-tools/pure";
10
10
 
11
11
  // src/config.ts
12
12
  import { generateSecretKey as generateSecretKey2 } from "nostr-tools/pure";
@@ -21,6 +21,7 @@ var ToonClientError = class extends Error {
21
21
  this.code = code;
22
22
  this.name = "ToonClientError";
23
23
  }
24
+ code;
24
25
  };
25
26
  var NetworkError = class extends ToonClientError {
26
27
  constructor(message, cause) {
@@ -1352,6 +1353,232 @@ var BtpRuntimeClient = class {
1352
1353
  }
1353
1354
  };
1354
1355
 
1356
+ // src/adapters/HttpIlpClient.ts
1357
+ var ILP_CLAIM_HEADER = "ILP-Payment-Channel-Claim";
1358
+ var ILP_CLAIM_WRAPPED_HEADER = "ILP-Payment-Channel-Claim-Wrapped";
1359
+ var ILP_PEER_ID_HEADER = "ILP-Peer-Id";
1360
+ var HttpIlpClient = class {
1361
+ httpEndpoint;
1362
+ peerId;
1363
+ authToken;
1364
+ timeout;
1365
+ retryConfig;
1366
+ httpClient;
1367
+ createWebSocket;
1368
+ constructor(config) {
1369
+ this.httpEndpoint = config.httpEndpoint;
1370
+ this.peerId = config.peerId;
1371
+ this.authToken = config.authToken;
1372
+ this.timeout = config.timeout ?? 3e4;
1373
+ this.retryConfig = {
1374
+ maxRetries: config.maxRetries ?? 3,
1375
+ retryDelay: config.retryDelay ?? 1e3
1376
+ };
1377
+ this.httpClient = config.httpClient ?? fetch;
1378
+ this.createWebSocket = config.createWebSocket;
1379
+ }
1380
+ /**
1381
+ * Send an ILP PREPARE via `POST /ilp` WITHOUT a claim. The connector accepts
1382
+ * this only on free/zero-amount routes; paid writes must use
1383
+ * {@link sendIlpPacketWithClaim}. Satisfies the IlpClient interface.
1384
+ */
1385
+ async sendIlpPacket(params) {
1386
+ return withRetry(() => this.postPrepare(params), {
1387
+ maxRetries: this.retryConfig.maxRetries,
1388
+ retryDelay: this.retryConfig.retryDelay,
1389
+ exponentialBackoff: true,
1390
+ shouldRetry: (error) => error instanceof NetworkError
1391
+ });
1392
+ }
1393
+ /**
1394
+ * Send an ILP PREPARE via `POST /ilp` with the payment-channel claim attached
1395
+ * as the `ILP-Payment-Channel-Claim` header. `claim` is the SAME JSON object
1396
+ * the BTP path attaches as the `payment-channel-claim` protocolData entry —
1397
+ * we base64(JSON.stringify(claim)) it, byte-for-byte identical to BTP.
1398
+ */
1399
+ async sendIlpPacketWithClaim(params, claim) {
1400
+ return withRetry(() => this.postPrepare(params, claim), {
1401
+ maxRetries: this.retryConfig.maxRetries,
1402
+ retryDelay: this.retryConfig.retryDelay,
1403
+ exponentialBackoff: true,
1404
+ shouldRetry: (error) => error instanceof NetworkError
1405
+ });
1406
+ }
1407
+ /**
1408
+ * Upgrade to a duplex BTP session over the SAME endpoint.
1409
+ *
1410
+ * Derives the `ws(s)://` URL from `httpEndpoint`, opens a WebSocket with
1411
+ * `Sec-WebSocket-Protocol: btp` and the same `ILP-Peer-Id` + `Authorization`
1412
+ * headers, and returns a connected {@link BtpRuntimeClient}. When auth headers
1413
+ * are present the connector pre-authenticates the session (no in-band auth
1414
+ * frame); without them the BtpRuntimeClient falls back to the normal BTP
1415
+ * auth-frame flow.
1416
+ *
1417
+ * NOTE: passing per-connection headers + a subprotocol to a WebSocket is
1418
+ * Node-only (the `ws` package). Browsers cannot set arbitrary request headers
1419
+ * on a WebSocket handshake, so a browser consumer must use the gateway
1420
+ * transport or BTP-with-auth-frame instead.
1421
+ */
1422
+ async upgradeToBtp() {
1423
+ const btpUrl = httpEndpointToBtpUrl(this.httpEndpoint);
1424
+ const createWebSocket = this.createWebSocket ?? await makeBtpWebSocketFactory(this.authHeaders());
1425
+ const client = new BtpRuntimeClient({
1426
+ btpUrl,
1427
+ // BtpRuntimeClient sends an auth frame using these; when the connector
1428
+ // pre-authenticated via Upgrade headers it accepts the (redundant) frame.
1429
+ peerId: this.peerId ?? "client",
1430
+ authToken: this.authToken ?? "",
1431
+ createWebSocket
1432
+ });
1433
+ await client.connect();
1434
+ return client;
1435
+ }
1436
+ // ─── Private ──────────────────────────────────────────────────────────────
1437
+ authHeaders() {
1438
+ const headers = {};
1439
+ if (this.peerId) headers[ILP_PEER_ID_HEADER] = this.peerId;
1440
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
1441
+ return headers;
1442
+ }
1443
+ /**
1444
+ * Single attempt: serialize the PREPARE, POST it, and map the response.
1445
+ * @throws {NetworkError} On connection/timeout failures (retried).
1446
+ * @throws {ConnectorError} On non-retryable transport errors (5xx / unexpected).
1447
+ */
1448
+ async postPrepare(params, claim) {
1449
+ const requestTimeout = params.timeout ?? this.timeout;
1450
+ const prepare = serializeIlpPrepare({
1451
+ type: ILPPacketType.PREPARE,
1452
+ amount: BigInt(params.amount),
1453
+ destination: params.destination,
1454
+ executionCondition: new Uint8Array(32),
1455
+ expiresAt: new Date(Date.now() + requestTimeout),
1456
+ data: fromBase64(params.data)
1457
+ });
1458
+ const headers = {
1459
+ "Content-Type": "application/octet-stream",
1460
+ ...this.authHeaders()
1461
+ };
1462
+ if (claim !== void 0) {
1463
+ headers[ILP_CLAIM_HEADER] = toBase64(
1464
+ encodeUtf8(JSON.stringify(claim))
1465
+ );
1466
+ }
1467
+ const controller = new AbortController();
1468
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
1469
+ try {
1470
+ const response = await this.httpClient(this.httpEndpoint, {
1471
+ method: "POST",
1472
+ headers,
1473
+ // Copy into a fresh ArrayBuffer so fetch sees a clean body, not a view.
1474
+ body: prepare.slice(),
1475
+ signal: controller.signal
1476
+ });
1477
+ clearTimeout(timeoutId);
1478
+ return await this.mapResponse(response);
1479
+ } catch (error) {
1480
+ clearTimeout(timeoutId);
1481
+ throw this.mapTransportError(error, requestTimeout);
1482
+ }
1483
+ }
1484
+ /**
1485
+ * Map a `200 OK` body (OER FULFILL/REJECT) to an IlpSendResult; map a non-2xx
1486
+ * to a transport error. Per the wire contract, ILP-level rejects arrive as a
1487
+ * 200 + REJECT body — only HTTP non-2xx means a transport-layer failure.
1488
+ */
1489
+ async mapResponse(response) {
1490
+ if (response.ok) {
1491
+ const buf = new Uint8Array(await response.arrayBuffer());
1492
+ if (buf.length === 0) {
1493
+ throw new ConnectorError("Empty 200 body from /ilp (expected OER ILP response)");
1494
+ }
1495
+ const ilp = deserializeIlpPacket(buf);
1496
+ if (ilp.type === ILPPacketType.FULFILL) {
1497
+ return {
1498
+ accepted: true,
1499
+ data: ilp.data.length > 0 ? toBase64(ilp.data) : void 0
1500
+ };
1501
+ }
1502
+ return {
1503
+ accepted: false,
1504
+ code: ilp.code,
1505
+ message: ilp.message,
1506
+ data: ilp.data.length > 0 ? toBase64(ilp.data) : void 0
1507
+ };
1508
+ }
1509
+ const body = await response.text().catch(() => "");
1510
+ const detail = body ? `: ${body}` : "";
1511
+ if (response.status >= 500) {
1512
+ throw new ConnectorError(
1513
+ `Connector transport error (${response.status} ${response.statusText})${detail}`
1514
+ );
1515
+ }
1516
+ throw new ConnectorError(
1517
+ `ILP-over-HTTP request rejected (${response.status} ${response.statusText})${detail}`
1518
+ );
1519
+ }
1520
+ mapTransportError(error, requestTimeout) {
1521
+ if (error instanceof ConnectorError || error instanceof NetworkError) {
1522
+ return error;
1523
+ }
1524
+ if (error instanceof Error && error.name === "AbortError") {
1525
+ return new NetworkError(`Request timeout after ${requestTimeout}ms`, error);
1526
+ }
1527
+ if (error instanceof TypeError && (error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED") || error.message.includes("ECONNRESET") || error.message.includes("ETIMEDOUT") || error.message.includes("network"))) {
1528
+ return new NetworkError(`Network connection failed: ${error.message}`, error);
1529
+ }
1530
+ return new ConnectorError(
1531
+ `Unexpected error during ILP-over-HTTP request: ${error instanceof Error ? error.message : String(error)}`,
1532
+ error instanceof Error ? error : void 0
1533
+ );
1534
+ }
1535
+ };
1536
+ function httpEndpointToBtpUrl(httpEndpoint) {
1537
+ return httpEndpoint.replace(/^https:\/\//i, "wss://").replace(/^http:\/\//i, "ws://");
1538
+ }
1539
+ async function makeBtpWebSocketFactory(headers) {
1540
+ const { createRequire } = await import("module");
1541
+ const require2 = createRequire(import.meta.url);
1542
+ const WS = require2("ws");
1543
+ const ws = WS;
1544
+ const WSClass = typeof ws === "function" ? ws : typeof ws.default === "function" ? ws.default : typeof ws.WebSocket === "function" ? ws.WebSocket : null;
1545
+ if (WSClass === null) {
1546
+ throw new Error(
1547
+ "makeBtpWebSocketFactory: require('ws') did not yield a constructor on .default, .WebSocket, or the module root."
1548
+ );
1549
+ }
1550
+ return (url) => (
1551
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1552
+ new WSClass(url, "btp", { headers })
1553
+ );
1554
+ }
1555
+
1556
+ // src/adapters/selectIlpTransport.ts
1557
+ function readDiscoveredIlpPeer(peer) {
1558
+ const p = peer ?? {};
1559
+ return {
1560
+ btpEndpoint: typeof p["btpEndpoint"] === "string" ? p["btpEndpoint"] : void 0,
1561
+ httpEndpoint: typeof p["httpEndpoint"] === "string" ? p["httpEndpoint"] : void 0,
1562
+ supportsUpgrade: typeof p["supportsUpgrade"] === "boolean" ? p["supportsUpgrade"] : void 0
1563
+ };
1564
+ }
1565
+ function selectIlpTransport(peer, options = {}) {
1566
+ const needsDuplex = options.needsDuplex ?? false;
1567
+ const http2 = peer.httpEndpoint?.trim() || void 0;
1568
+ const btp = peer.btpEndpoint?.trim() || void 0;
1569
+ const canUpgrade = peer.supportsUpgrade === true;
1570
+ if (needsDuplex) {
1571
+ if (btp) return { kind: "btp", btpEndpoint: btp };
1572
+ if (http2 && canUpgrade) return { kind: "http-upgradable", httpEndpoint: http2 };
1573
+ throw new Error(
1574
+ "Duplex transport required but peer exposes neither a btpEndpoint nor an upgradable httpEndpoint"
1575
+ );
1576
+ }
1577
+ if (http2) return { kind: "http", httpEndpoint: http2, canUpgrade };
1578
+ if (btp) return { kind: "btp", btpEndpoint: btp };
1579
+ throw new Error("Peer exposes neither an httpEndpoint nor a btpEndpoint");
1580
+ }
1581
+
1355
1582
  // src/channel/OnChainChannelClient.ts
1356
1583
  import {
1357
1584
  createPublicClient,
@@ -2548,6 +2775,12 @@ async function initializeHttpMode(config) {
2548
2775
  const effectiveBtpUrl = transport.btpUrl ?? config.btpUrl;
2549
2776
  const effectiveConnectorUrl = transport.connectorUrl ?? config.connectorUrl;
2550
2777
  const settlementInfo = buildSettlementInfo(config);
2778
+ const discoveredPeer = readDiscoveredIlpPeer({
2779
+ btpEndpoint: effectiveBtpUrl,
2780
+ httpEndpoint: config.connectorHttpEndpoint,
2781
+ supportsUpgrade: config.connectorSupportsUpgrade
2782
+ });
2783
+ const transportChoice = discoveredPeer.httpEndpoint || discoveredPeer.btpEndpoint ? selectIlpTransport(discoveredPeer, { needsDuplex: false }) : null;
2551
2784
  let btpClient = null;
2552
2785
  if (effectiveBtpUrl) {
2553
2786
  btpClient = new BtpRuntimeClient({
@@ -2558,7 +2791,20 @@ async function initializeHttpMode(config) {
2558
2791
  });
2559
2792
  await btpClient.connect();
2560
2793
  }
2561
- const runtimeClient = btpClient ?? new HttpRuntimeClient({
2794
+ let httpIlpClient = null;
2795
+ if (transportChoice && (transportChoice.kind === "http" || transportChoice.kind === "http-upgradable")) {
2796
+ httpIlpClient = new HttpIlpClient({
2797
+ httpEndpoint: transportChoice.httpEndpoint,
2798
+ ...config.btpPeerId !== void 0 ? { peerId: config.btpPeerId } : {},
2799
+ ...config.btpAuthToken !== void 0 ? { authToken: config.btpAuthToken } : {},
2800
+ timeout: config.queryTimeout,
2801
+ maxRetries: config.maxRetries,
2802
+ retryDelay: config.retryDelay,
2803
+ ...transport.httpClient !== void 0 ? { httpClient: transport.httpClient } : {},
2804
+ ...transport.createWebSocket !== void 0 ? { createWebSocket: transport.createWebSocket } : {}
2805
+ });
2806
+ }
2807
+ const runtimeClient = httpIlpClient ?? btpClient ?? new HttpRuntimeClient({
2562
2808
  connectorUrl: effectiveConnectorUrl,
2563
2809
  timeout: config.queryTimeout,
2564
2810
  maxRetries: config.maxRetries,
@@ -3304,6 +3550,324 @@ var JsonFileChannelStore = class {
3304
3550
  }
3305
3551
  };
3306
3552
 
3553
+ // src/blob-storage.ts
3554
+ import { buildBlobStorageRequest } from "@toon-protocol/core";
3555
+ var ARWEAVE_TX_ID_REGEX = /^[A-Za-z0-9_-]{43}$/;
3556
+ async function requestBlobStorage(client, secretKey, params) {
3557
+ const bid = params.bid ?? (params.ilpAmount !== void 0 ? String(params.ilpAmount) : void 0);
3558
+ if (bid === void 0 || bid === "") {
3559
+ return {
3560
+ success: false,
3561
+ error: "requestBlobStorage requires a bid (or ilpAmount to derive it)"
3562
+ };
3563
+ }
3564
+ const blobBuffer = Buffer.from(
3565
+ params.blobData.buffer,
3566
+ params.blobData.byteOffset,
3567
+ params.blobData.byteLength
3568
+ );
3569
+ let event;
3570
+ try {
3571
+ event = buildBlobStorageRequest(
3572
+ {
3573
+ blobData: blobBuffer,
3574
+ contentType: params.contentType,
3575
+ bid
3576
+ },
3577
+ secretKey
3578
+ );
3579
+ } catch (error) {
3580
+ return {
3581
+ success: false,
3582
+ error: error instanceof Error ? error.message : String(error)
3583
+ };
3584
+ }
3585
+ const result = await client.publishEvent(event, {
3586
+ destination: params.destination,
3587
+ claim: params.claim,
3588
+ ilpAmount: params.ilpAmount
3589
+ });
3590
+ if (!result.success) {
3591
+ return {
3592
+ success: false,
3593
+ eventId: result.eventId ?? event.id,
3594
+ error: result.error ?? "Blob storage request rejected"
3595
+ };
3596
+ }
3597
+ if (!result.data) {
3598
+ return {
3599
+ success: false,
3600
+ eventId: event.id,
3601
+ error: "FULFILL contained no data; expected base64-encoded Arweave tx ID"
3602
+ };
3603
+ }
3604
+ const txId = decodeUtf8(fromBase64(result.data));
3605
+ if (!ARWEAVE_TX_ID_REGEX.test(txId)) {
3606
+ return {
3607
+ success: false,
3608
+ eventId: event.id,
3609
+ error: `Decoded FULFILL data is not a valid Arweave tx ID: "${txId}"`
3610
+ };
3611
+ }
3612
+ return {
3613
+ success: true,
3614
+ txId,
3615
+ eventId: event.id
3616
+ };
3617
+ }
3618
+
3619
+ // src/adapters/Http402Client.ts
3620
+ var Http402Client = class {
3621
+ fetchImpl;
3622
+ resolveClaim;
3623
+ createIlpClient;
3624
+ needsDuplex;
3625
+ constructor(config = {}) {
3626
+ this.fetchImpl = config.fetch ?? fetch;
3627
+ this.resolveClaim = config.resolveClaim;
3628
+ this.createIlpClient = config.createIlpClient ?? ((httpEndpoint) => new HttpIlpClient({ httpEndpoint }));
3629
+ this.needsDuplex = config.needsDuplex ?? false;
3630
+ }
3631
+ /**
3632
+ * `fetch()`-like entry point. Issues the request; on `402` parses the x402
3633
+ * challenge and — when a usable `toon-channel` offer is present and a claim
3634
+ * resolver is configured — pays over TOON and returns the reconstructed
3635
+ * `Response`. Otherwise returns the original 402 unchanged (AC5).
3636
+ */
3637
+ async fetch(url, opts = {}) {
3638
+ const method = (opts.method ?? "GET").toUpperCase();
3639
+ const probe = await this.fetchImpl(url, {
3640
+ method,
3641
+ ...opts.headers ? { headers: opts.headers } : {},
3642
+ ...opts.body !== void 0 ? { body: opts.body } : {},
3643
+ ...opts.timeout !== void 0 ? { signal: AbortSignal.timeout(opts.timeout) } : {}
3644
+ });
3645
+ if (probe.status !== 402) return probe;
3646
+ const challenge = await parseX402Challenge(probe.clone());
3647
+ const accept = challenge.toonChannel;
3648
+ if (!accept || !this.resolveClaim) return probe;
3649
+ return this.payOverToon(url, method, opts, accept, this.resolveClaim);
3650
+ }
3651
+ /**
3652
+ * Open/reuse a channel (via the injected claim resolver), serialize the HTTP
3653
+ * request into the ILP packet `data`, send it to `POST /ilp` with the claim,
3654
+ * and reconstruct the origin `Response` from the FULFILL `data`.
3655
+ */
3656
+ async payOverToon(url, method, opts, accept, resolveClaim) {
3657
+ const destination = opts.destination ?? accept.destination;
3658
+ const claim = await resolveClaim(destination, accept.amount);
3659
+ const requestBytes = serializeHttpRequest({
3660
+ method,
3661
+ url,
3662
+ headers: opts.headers,
3663
+ body: opts.body
3664
+ });
3665
+ const peer = {
3666
+ httpEndpoint: accept.httpEndpoint,
3667
+ supportsUpgrade: accept.supportsUpgrade
3668
+ };
3669
+ const choice = selectIlpTransport(peer, {
3670
+ needsDuplex: this.needsDuplex
3671
+ });
3672
+ const ilpClient = this.createIlpClient(accept.httpEndpoint);
3673
+ const result = await this.sendOverChoice(
3674
+ ilpClient,
3675
+ choice,
3676
+ {
3677
+ destination,
3678
+ amount: String(accept.amount),
3679
+ data: toBase64(requestBytes),
3680
+ ...opts.timeout !== void 0 ? { timeout: opts.timeout } : {}
3681
+ },
3682
+ claim
3683
+ );
3684
+ if (!result.accepted) {
3685
+ throw new ConnectorError(
3686
+ `h402 payment rejected by connector: ${result.code ?? "F00"} ${result.message ?? ""}`.trim()
3687
+ );
3688
+ }
3689
+ if (!result.data) {
3690
+ throw new ConnectorError(
3691
+ "h402 FULFILL carried no data (expected an HTTP response payload)"
3692
+ );
3693
+ }
3694
+ return parseHttpResponse(fromBase64(result.data));
3695
+ }
3696
+ /**
3697
+ * Send the serialized HTTP-in-ILP PREPARE over the selected transport.
3698
+ *
3699
+ * - `http` / `http-upgradable`: stateless one-shot `POST /ilp` with the claim.
3700
+ * - `http-upgradable` additionally exercises {@link HttpIlpClient.upgradeToBtp}
3701
+ * for the duplex/streaming path (AC4). v1 still drives the actual write over
3702
+ * the one-shot HTTP method even after upgrading — full duplex body streaming
3703
+ * is a documented follow-up — but the upgrade call path is wired here.
3704
+ * - `btp`: not reachable from h402 (the x402 offer only carries an
3705
+ * `httpEndpoint`); guarded for completeness.
3706
+ */
3707
+ async sendOverChoice(ilpClient, choice, params, claim) {
3708
+ if (choice.kind === "http-upgradable") {
3709
+ const btp = await ilpClient.upgradeToBtp();
3710
+ try {
3711
+ return await btp.sendIlpPacketWithClaim(
3712
+ params,
3713
+ claim
3714
+ );
3715
+ } finally {
3716
+ await btp.disconnect().catch(() => {
3717
+ });
3718
+ }
3719
+ }
3720
+ if (choice.kind === "btp") {
3721
+ throw new ToonClientError(
3722
+ "h402 offer resolved to a BTP-only transport; the x402 toon-channel entry must advertise an httpEndpoint",
3723
+ "INVALID_STATE"
3724
+ );
3725
+ }
3726
+ return ilpClient.sendIlpPacketWithClaim(params, claim);
3727
+ }
3728
+ };
3729
+ function readString(obj, keys) {
3730
+ for (const k of keys) {
3731
+ const v = obj[k];
3732
+ if (typeof v === "string" && v.trim().length > 0) return v.trim();
3733
+ }
3734
+ return void 0;
3735
+ }
3736
+ function readAmount(obj, keys) {
3737
+ for (const k of keys) {
3738
+ const v = obj[k];
3739
+ if (typeof v === "bigint") return v;
3740
+ if (typeof v === "number" && Number.isFinite(v)) return BigInt(Math.trunc(v));
3741
+ if (typeof v === "string" && /^\d+$/.test(v.trim())) return BigInt(v.trim());
3742
+ }
3743
+ return void 0;
3744
+ }
3745
+ async function parseX402Challenge(response) {
3746
+ let body;
3747
+ try {
3748
+ body = await response.json();
3749
+ } catch {
3750
+ return {};
3751
+ }
3752
+ return parseX402Body(body);
3753
+ }
3754
+ function parseX402Body(body) {
3755
+ if (typeof body !== "object" || body === null) return {};
3756
+ const b = body;
3757
+ const version = typeof b["x402Version"] === "number" ? b["x402Version"] : void 0;
3758
+ const accepts = Array.isArray(b["accepts"]) ? b["accepts"] : [];
3759
+ for (const raw of accepts) {
3760
+ if (typeof raw !== "object" || raw === null) continue;
3761
+ const entry = raw;
3762
+ const scheme = readString(entry, ["scheme"]);
3763
+ if (scheme !== "toon-channel") continue;
3764
+ const destination = readString(entry, [
3765
+ "destination",
3766
+ "ilpAddress",
3767
+ "payTo"
3768
+ ]);
3769
+ const httpEndpoint = readString(entry, [
3770
+ "httpEndpoint",
3771
+ "ilpEndpoint",
3772
+ "endpoint"
3773
+ ]);
3774
+ const amount = readAmount(entry, ["amount", "price", "maxAmountRequired"]);
3775
+ if (!destination || !httpEndpoint || amount === void 0) continue;
3776
+ const network = readString(entry, ["network", "chain"]);
3777
+ const supportsUpgrade = entry["supportsUpgrade"] === true || entry["upgradable"] === true;
3778
+ return {
3779
+ ...version !== void 0 ? { x402Version: version } : {},
3780
+ toonChannel: {
3781
+ scheme: "toon-channel",
3782
+ ...network !== void 0 ? { network } : {},
3783
+ destination,
3784
+ amount,
3785
+ httpEndpoint,
3786
+ supportsUpgrade
3787
+ }
3788
+ };
3789
+ }
3790
+ return version !== void 0 ? { x402Version: version } : {};
3791
+ }
3792
+ var CRLF = "\r\n";
3793
+ function concatHeadAndBody(head, body) {
3794
+ const headBytes = encodeUtf8(head);
3795
+ const out = new Uint8Array(headBytes.length + body.length);
3796
+ out.set(headBytes, 0);
3797
+ out.set(body, headBytes.length);
3798
+ return out;
3799
+ }
3800
+ function bodyToBytes(body) {
3801
+ if (body === void 0) return new Uint8Array(0);
3802
+ return typeof body === "string" ? encodeUtf8(body) : body;
3803
+ }
3804
+ function serializeHttpRequest(req) {
3805
+ const u = new URL(req.url);
3806
+ const target = `${u.pathname}${u.search}` || "/";
3807
+ const bodyBytes = bodyToBytes(req.body);
3808
+ const headers = /* @__PURE__ */ new Map();
3809
+ const put = (name, value) => headers.set(name.toLowerCase(), `${name}: ${value}`);
3810
+ const has = (name) => headers.has(name.toLowerCase());
3811
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
3812
+ put(name, value);
3813
+ }
3814
+ if (!has("host")) put("Host", u.host);
3815
+ if (bodyBytes.length > 0 && !has("content-length")) {
3816
+ put("Content-Length", String(bodyBytes.length));
3817
+ }
3818
+ const lines = [
3819
+ `${req.method.toUpperCase()} ${target} HTTP/1.1`,
3820
+ ...headers.values()
3821
+ ];
3822
+ const head = lines.join(CRLF) + CRLF + CRLF;
3823
+ return concatHeadAndBody(head, bodyBytes);
3824
+ }
3825
+ function findHeaderEnd(bytes) {
3826
+ for (let i = 0; i + 3 < bytes.length; i++) {
3827
+ if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10) {
3828
+ return i + 4;
3829
+ }
3830
+ }
3831
+ return -1;
3832
+ }
3833
+ function parseHttpResponse(bytes) {
3834
+ const headerEnd = findHeaderEnd(bytes);
3835
+ const headBytes = headerEnd === -1 ? bytes : bytes.subarray(0, headerEnd - 2);
3836
+ const body = headerEnd === -1 ? new Uint8Array(0) : bytes.subarray(headerEnd);
3837
+ const headText = decodeUtf8(headBytes);
3838
+ const lines = headText.split(CRLF).filter((l) => l.length > 0);
3839
+ const statusLine = lines.shift();
3840
+ if (!statusLine) {
3841
+ throw new ConnectorError(
3842
+ "h402 response payload had no HTTP status line"
3843
+ );
3844
+ }
3845
+ const match = /^HTTP\/\d\.\d\s+(\d{3})(?:\s+(.*))?$/.exec(statusLine.trim());
3846
+ if (!match) {
3847
+ throw new ConnectorError(
3848
+ `h402 response payload had a malformed status line: "${statusLine}"`
3849
+ );
3850
+ }
3851
+ const status = parseInt(match[1], 10);
3852
+ const statusText = match[2] ?? "";
3853
+ const headers = new Headers();
3854
+ for (const line of lines) {
3855
+ const idx = line.indexOf(":");
3856
+ if (idx === -1) continue;
3857
+ const name = line.slice(0, idx).trim();
3858
+ const value = line.slice(idx + 1).trim();
3859
+ if (name.length === 0) continue;
3860
+ headers.append(name, value);
3861
+ }
3862
+ const nullBodyStatus = status === 101 || status === 204 || status === 205 || status === 304;
3863
+ const init = { status, headers };
3864
+ if (statusText) init.statusText = statusText;
3865
+ return new Response(
3866
+ nullBodyStatus || body.length === 0 ? null : body.slice(),
3867
+ init
3868
+ );
3869
+ }
3870
+
3307
3871
  // src/ToonClient.ts
3308
3872
  var ToonClient = class {
3309
3873
  config;
@@ -3355,6 +3919,28 @@ var ToonClient = class {
3355
3919
  getPublicKey() {
3356
3920
  return getPublicKey2(this.config.secretKey);
3357
3921
  }
3922
+ /**
3923
+ * Sign an unsigned Nostr event template with the client's Nostr secret key,
3924
+ * returning a fully-signed event (id + pubkey + sig).
3925
+ *
3926
+ * This is the key primitive behind the daemon's sign-and-publish path: a UI
3927
+ * or agent supplies only `{ kind, content, tags, created_at }` and never holds
3928
+ * the private key — signing happens here, inside the key owner.
3929
+ */
3930
+ signEvent(template) {
3931
+ return finalizeEvent(template, this.config.secretKey);
3932
+ }
3933
+ /**
3934
+ * Upload bytes to Arweave via the kind:5094 blob-storage DVM (single-packet),
3935
+ * signing the request with this client's Nostr key and paying through its
3936
+ * existing channel. Returns the Arweave tx id on success.
3937
+ *
3938
+ * Backs the daemon's `upload-media` path: the key and claim/channel plumbing
3939
+ * stay inside the client; callers pass only the bytes.
3940
+ */
3941
+ async uploadBlob(params) {
3942
+ return requestBlobStorage(this, this.config.secretKey, params);
3943
+ }
3358
3944
  /**
3359
3945
  * Per-chain settlement readiness for the configured `network` tier, mirroring
3360
3946
  * the townhouse node's status. Returns `undefined` when no named `network` is
@@ -3637,6 +4223,46 @@ var ToonClient = class {
3637
4223
  );
3638
4224
  }
3639
4225
  }
4226
+ /**
4227
+ * Payment-aware HTTP fetch over TOON (issue #50). A `fetch()`-like method that
4228
+ * makes paying for an HTTP resource transparent:
4229
+ *
4230
+ * 1. Issues the HTTP request to `url`.
4231
+ * 2. On `402`, parses the x402 `accepts` array and selects the
4232
+ * `toon-channel` entry (see {@link Http402Client} for the wire shape).
4233
+ * 3. Opens/reuses a payment channel for the entry's ILP destination (via
4234
+ * ChannelManager), signs a balance proof for the demanded price, and
4235
+ * re-sends the SAME HTTP request as a transparent HTTP-in-ILP packet to
4236
+ * the connector's `POST /ilp` (via {@link HttpIlpClient}), with the claim
4237
+ * in the `ILP-Payment-Channel-Claim` header.
4238
+ * 4. Reconstructs and returns a standard Web `Response` from the FULFILL
4239
+ * `data`. The caller never sees ILP.
4240
+ *
4241
+ * If the origin offers no `toon-channel` entry, the original `402` Response is
4242
+ * returned unchanged (the caller sees the vanilla x402 challenge).
4243
+ *
4244
+ * The channel/claim plumbing is wired to the live ChannelManager + per-chain
4245
+ * signer via `resolveClaimForDestination` — identical to `publishEvent`. The
4246
+ * `amount` paid comes from the selected x402 entry (the resource's price).
4247
+ *
4248
+ * @throws {ToonClientError} If the client is not started.
4249
+ * @throws {ConnectorError} If the connector rejects the payment or returns no
4250
+ * HTTP payload.
4251
+ */
4252
+ async h402Fetch(url, opts) {
4253
+ if (!this.state) {
4254
+ throw new ToonClientError(
4255
+ "Client not started. Call start() first.",
4256
+ "INVALID_STATE"
4257
+ );
4258
+ }
4259
+ const client = new Http402Client({
4260
+ ...this.channelManager ? {
4261
+ resolveClaim: (destination, amount) => this.resolveClaimForDestination(destination, amount)
4262
+ } : {}
4263
+ });
4264
+ return client.fetch(url, opts);
4265
+ }
3640
4266
  /**
3641
4267
  * Sends a raw swap ILP packet (Story 12.5) to a Mill peer with an attached
3642
4268
  * balance-proof claim. This is a lower-level surface than `publishEvent`:
@@ -4735,74 +5361,8 @@ function buildPetPurchaseRequest(params) {
4735
5361
  };
4736
5362
  }
4737
5363
 
4738
- // src/blob-storage.ts
4739
- import { buildBlobStorageRequest } from "@toon-protocol/core";
4740
- var ARWEAVE_TX_ID_REGEX = /^[A-Za-z0-9_-]{43}$/;
4741
- async function requestBlobStorage(client, secretKey, params) {
4742
- const bid = params.bid ?? (params.ilpAmount !== void 0 ? String(params.ilpAmount) : void 0);
4743
- if (bid === void 0 || bid === "") {
4744
- return {
4745
- success: false,
4746
- error: "requestBlobStorage requires a bid (or ilpAmount to derive it)"
4747
- };
4748
- }
4749
- const blobBuffer = Buffer.from(
4750
- params.blobData.buffer,
4751
- params.blobData.byteOffset,
4752
- params.blobData.byteLength
4753
- );
4754
- let event;
4755
- try {
4756
- event = buildBlobStorageRequest(
4757
- {
4758
- blobData: blobBuffer,
4759
- contentType: params.contentType,
4760
- bid
4761
- },
4762
- secretKey
4763
- );
4764
- } catch (error) {
4765
- return {
4766
- success: false,
4767
- error: error instanceof Error ? error.message : String(error)
4768
- };
4769
- }
4770
- const result = await client.publishEvent(event, {
4771
- destination: params.destination,
4772
- claim: params.claim,
4773
- ilpAmount: params.ilpAmount
4774
- });
4775
- if (!result.success) {
4776
- return {
4777
- success: false,
4778
- eventId: result.eventId ?? event.id,
4779
- error: result.error ?? "Blob storage request rejected"
4780
- };
4781
- }
4782
- if (!result.data) {
4783
- return {
4784
- success: false,
4785
- eventId: event.id,
4786
- error: "FULFILL contained no data; expected base64-encoded Arweave tx ID"
4787
- };
4788
- }
4789
- const txId = decodeUtf8(fromBase64(result.data));
4790
- if (!ARWEAVE_TX_ID_REGEX.test(txId)) {
4791
- return {
4792
- success: false,
4793
- eventId: event.id,
4794
- error: `Decoded FULFILL data is not a valid Arweave tx ID: "${txId}"`
4795
- };
4796
- }
4797
- return {
4798
- success: true,
4799
- txId,
4800
- eventId: event.id
4801
- };
4802
- }
4803
-
4804
5364
  // src/keys/KeyManager.ts
4805
- import { finalizeEvent } from "nostr-tools/pure";
5365
+ import { finalizeEvent as finalizeEvent2 } from "nostr-tools/pure";
4806
5366
  import { nip19 } from "nostr-tools";
4807
5367
 
4808
5368
  // src/keys/PasskeyAuth.ts
@@ -5588,7 +6148,7 @@ var KeyManager = class {
5588
6148
  this.vault,
5589
6149
  this.identity.nostr.secretKey
5590
6150
  );
5591
- const signedEvent = finalizeEvent(
6151
+ const signedEvent = finalizeEvent2(
5592
6152
  eventTemplate,
5593
6153
  this.identity.nostr.secretKey
5594
6154
  );
@@ -5849,8 +6409,13 @@ export {
5849
6409
  EvmSigner,
5850
6410
  HS_HOSTNAME_MAX_LENGTH,
5851
6411
  HS_HOSTNAME_REGEX,
6412
+ Http402Client,
5852
6413
  HttpConnectorAdmin,
6414
+ HttpIlpClient,
5853
6415
  HttpRuntimeClient,
6416
+ ILP_CLAIM_HEADER,
6417
+ ILP_CLAIM_WRAPPED_HEADER,
6418
+ ILP_PEER_ID_HEADER,
5854
6419
  KeyManager,
5855
6420
  MinaSigner,
5856
6421
  NetworkError,
@@ -5879,17 +6444,24 @@ export {
5879
6444
  generateMnemonic,
5880
6445
  generateRandomIdentity,
5881
6446
  getNetworkStatus,
6447
+ httpEndpointToBtpUrl,
5882
6448
  importKeystore,
5883
6449
  isPrfSupported,
5884
6450
  isRoutableHsHostname,
5885
6451
  loadKeystore,
5886
6452
  parseBackupPayload,
6453
+ parseHttpResponse,
5887
6454
  parsePetInteractionEvent,
5888
6455
  parsePetInteractionResult,
5889
6456
  parsePetListing,
6457
+ parseX402Body,
6458
+ parseX402Challenge,
6459
+ readDiscoveredIlpPeer,
5890
6460
  readMinaDepositTotal,
5891
6461
  requestBlobStorage,
5892
6462
  selectAnonAsset,
6463
+ selectIlpTransport,
6464
+ serializeHttpRequest,
5893
6465
  startManagedAnonProxy,
5894
6466
  validateConfig,
5895
6467
  validateMnemonic,