@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.d.ts +440 -210
- package/dist/index.js +297 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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,
|