@toon-protocol/client 0.10.0 → 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
@@ -3616,6 +3616,258 @@ async function requestBlobStorage(client, secretKey, params) {
3616
3616
  };
3617
3617
  }
3618
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
+
3619
3871
  // src/ToonClient.ts
3620
3872
  var ToonClient = class {
3621
3873
  config;
@@ -3971,6 +4223,46 @@ var ToonClient = class {
3971
4223
  );
3972
4224
  }
3973
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
+ }
3974
4266
  /**
3975
4267
  * Sends a raw swap ILP packet (Story 12.5) to a Mill peer with an attached
3976
4268
  * balance-proof claim. This is a lower-level surface than `publishEvent`:
@@ -6117,6 +6409,7 @@ export {
6117
6409
  EvmSigner,
6118
6410
  HS_HOSTNAME_MAX_LENGTH,
6119
6411
  HS_HOSTNAME_REGEX,
6412
+ Http402Client,
6120
6413
  HttpConnectorAdmin,
6121
6414
  HttpIlpClient,
6122
6415
  HttpRuntimeClient,
@@ -6157,14 +6450,18 @@ export {
6157
6450
  isRoutableHsHostname,
6158
6451
  loadKeystore,
6159
6452
  parseBackupPayload,
6453
+ parseHttpResponse,
6160
6454
  parsePetInteractionEvent,
6161
6455
  parsePetInteractionResult,
6162
6456
  parsePetListing,
6457
+ parseX402Body,
6458
+ parseX402Challenge,
6163
6459
  readDiscoveredIlpPeer,
6164
6460
  readMinaDepositTotal,
6165
6461
  requestBlobStorage,
6166
6462
  selectAnonAsset,
6167
6463
  selectIlpTransport,
6464
+ serializeHttpRequest,
6168
6465
  startManagedAnonProxy,
6169
6466
  validateConfig,
6170
6467
  validateMnemonic,