@cmdoss/suipay 0.1.0 → 0.1.1-dev.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/README.md +4 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +154 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,8 +37,10 @@ const quote = await client.quote(url, { body: JSON.stringify({ prompt: '...' })
|
|
|
37
37
|
const result = await client.pay(url, { quote });
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Passing `quote` pins `payTo
|
|
41
|
-
|
|
40
|
+
Passing `quote` pins `payTo` and replays that quote's method, body, and
|
|
41
|
+
content type. A later offer cannot swap the recipient or the coin. `maxAmount`
|
|
42
|
+
can only tighten the quoted price, not raise it. A different body or content
|
|
43
|
+
type is refused before anything is signed.
|
|
42
44
|
|
|
43
45
|
## Which package?
|
|
44
46
|
|
package/dist/index.d.ts
CHANGED
|
@@ -16,12 +16,19 @@ interface Quote {
|
|
|
16
16
|
method: 'GET' | 'POST';
|
|
17
17
|
/** Exact POST bytes the probe sent. Absent for GET. */
|
|
18
18
|
body?: string;
|
|
19
|
+
/** Content type the probe sent. Null when the probe sent none. */
|
|
20
|
+
contentType: string | null;
|
|
19
21
|
}
|
|
20
22
|
interface QuoteRequest {
|
|
21
23
|
/** HTTP method the resource is sold at. Defaults to POST, same as the shared pay-flow. */
|
|
22
24
|
method?: 'GET' | 'POST';
|
|
23
25
|
/** POST probe body. Ignored for GET. Defaults to `{}`. */
|
|
24
26
|
body?: string;
|
|
27
|
+
/**
|
|
28
|
+
* POST content type. Undefined means `application/json`. Null means the
|
|
29
|
+
* probe sends no content type, which must match the later payment.
|
|
30
|
+
*/
|
|
31
|
+
contentType?: string | null;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
interface ClientOptions {
|
|
@@ -47,8 +54,8 @@ interface PayRequest {
|
|
|
47
54
|
maxAmount?: string;
|
|
48
55
|
asset?: string;
|
|
49
56
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
57
|
+
* Omitted with no body is GET, including the price probe. A body makes it
|
|
58
|
+
* POST. A quote's method wins when this is omitted.
|
|
52
59
|
*/
|
|
53
60
|
method?: 'GET' | 'POST';
|
|
54
61
|
dialect?: Quote['dialect'];
|
package/dist/index.js
CHANGED
|
@@ -6478,18 +6478,25 @@ async function createPayer(options) {
|
|
|
6478
6478
|
}
|
|
6479
6479
|
|
|
6480
6480
|
// src/quote.ts
|
|
6481
|
-
function probeInit(method, body) {
|
|
6482
|
-
if (method === "GET") return { method: "GET" };
|
|
6481
|
+
function probeInit(method, body, contentType) {
|
|
6482
|
+
if (method === "GET") return { method: "GET", redirect: "manual" };
|
|
6483
|
+
const headers = {};
|
|
6484
|
+
if (contentType) headers["content-type"] = contentType;
|
|
6483
6485
|
return {
|
|
6484
6486
|
method: "POST",
|
|
6485
|
-
|
|
6487
|
+
redirect: "manual",
|
|
6488
|
+
headers,
|
|
6486
6489
|
body
|
|
6487
6490
|
};
|
|
6488
6491
|
}
|
|
6489
6492
|
async function quoteResource(url, fetchImpl = fetch, request = {}) {
|
|
6490
6493
|
const method = request.method === "GET" ? "GET" : "POST";
|
|
6491
|
-
const body = request.body ?? "{}";
|
|
6492
|
-
const
|
|
6494
|
+
const body = method === "POST" ? request.body ?? "{}" : "";
|
|
6495
|
+
const contentType = method === "GET" ? null : request.contentType === void 0 ? "application/json" : request.contentType;
|
|
6496
|
+
const response = await fetchImpl(url, probeInit(method, body, contentType));
|
|
6497
|
+
if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
|
|
6498
|
+
throw new Error("quote refused a redirect");
|
|
6499
|
+
}
|
|
6493
6500
|
if (response.status !== 402) {
|
|
6494
6501
|
throw new Error(`quote expected HTTP 402, got ${response.status}`);
|
|
6495
6502
|
}
|
|
@@ -6509,7 +6516,8 @@ async function quoteResource(url, fetchImpl = fetch, request = {}) {
|
|
|
6509
6516
|
dialect: mppOffer ? "mpp" : "x402",
|
|
6510
6517
|
decimals: offer.decimals,
|
|
6511
6518
|
method,
|
|
6512
|
-
...method === "POST" ? { body } : {}
|
|
6519
|
+
...method === "POST" ? { body } : {},
|
|
6520
|
+
contentType
|
|
6513
6521
|
};
|
|
6514
6522
|
}
|
|
6515
6523
|
|
|
@@ -6545,18 +6553,86 @@ function contentTypeFrom(headers) {
|
|
|
6545
6553
|
}
|
|
6546
6554
|
return contentType;
|
|
6547
6555
|
}
|
|
6548
|
-
function
|
|
6549
|
-
|
|
6550
|
-
|
|
6556
|
+
function assertGatewayResource(resourceUrl, gatewayUrl) {
|
|
6557
|
+
let resource;
|
|
6558
|
+
let gateway;
|
|
6559
|
+
try {
|
|
6560
|
+
resource = new URL(resourceUrl);
|
|
6561
|
+
gateway = new URL(gatewayUrl);
|
|
6562
|
+
} catch {
|
|
6563
|
+
throw new Error("resource URL must be absolute http(s) on the gateway origin");
|
|
6564
|
+
}
|
|
6565
|
+
if (resource.protocol !== "http:" && resource.protocol !== "https:") {
|
|
6566
|
+
throw new Error("resource URL must be absolute http(s) on the gateway origin");
|
|
6567
|
+
}
|
|
6568
|
+
if (resource.origin !== gateway.origin) {
|
|
6569
|
+
throw new Error(
|
|
6570
|
+
`resource origin ${resource.origin} does not match gateway ${gateway.origin}`
|
|
6571
|
+
);
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
function isPlainObject(value) {
|
|
6575
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
6576
|
+
const prototype = Object.getPrototypeOf(value);
|
|
6577
|
+
return prototype === Object.prototype || prototype === null;
|
|
6578
|
+
}
|
|
6579
|
+
function canonicalJson(value, seen = /* @__PURE__ */ new Set()) {
|
|
6580
|
+
if (value === null) return "null";
|
|
6581
|
+
const type = typeof value;
|
|
6582
|
+
if (type === "boolean") return value ? "true" : "false";
|
|
6583
|
+
if (type === "number") {
|
|
6584
|
+
if (!Number.isFinite(value)) {
|
|
6585
|
+
throw new Error("pay json must be finite; NaN and Infinity are not payable");
|
|
6586
|
+
}
|
|
6587
|
+
return JSON.stringify(value);
|
|
6588
|
+
}
|
|
6589
|
+
if (type === "string") return JSON.stringify(value);
|
|
6590
|
+
if (type === "bigint" || type === "undefined" || type === "function" || type === "symbol") {
|
|
6591
|
+
throw new Error(`pay json must not contain ${type}`);
|
|
6592
|
+
}
|
|
6593
|
+
const object = value;
|
|
6594
|
+
if (typeof object.toJSON === "function") {
|
|
6595
|
+
throw new Error("pay json must be a plain object; toJSON is not payable");
|
|
6596
|
+
}
|
|
6597
|
+
if (seen.has(object)) throw new Error("pay json must not be cyclic");
|
|
6598
|
+
seen.add(object);
|
|
6599
|
+
try {
|
|
6600
|
+
if (Array.isArray(object)) {
|
|
6601
|
+
return `[${object.map((item) => canonicalJson(item, seen)).join(",")}]`;
|
|
6602
|
+
}
|
|
6603
|
+
if (!isPlainObject(object)) throw new Error("pay json must be a plain object");
|
|
6604
|
+
const entries = Object.entries(object).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
6605
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item, seen)}`).join(",")}}`;
|
|
6606
|
+
} finally {
|
|
6607
|
+
seen.delete(object);
|
|
6551
6608
|
}
|
|
6552
|
-
|
|
6609
|
+
}
|
|
6610
|
+
function jsonBody(value) {
|
|
6611
|
+
if (!isPlainObject(value)) throw new Error("pay json must be a plain object");
|
|
6612
|
+
return canonicalJson(value);
|
|
6553
6613
|
}
|
|
6554
6614
|
function hasBytes(body) {
|
|
6555
6615
|
if (body === void 0) return false;
|
|
6556
6616
|
if (typeof body === "string") return body.length > 0;
|
|
6557
6617
|
return body.byteLength > 0;
|
|
6558
6618
|
}
|
|
6559
|
-
function
|
|
6619
|
+
function sameBytes(left, right) {
|
|
6620
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
6621
|
+
for (let i = 0; i < left.byteLength; i += 1) {
|
|
6622
|
+
if (left[i] !== right[i]) return false;
|
|
6623
|
+
}
|
|
6624
|
+
return true;
|
|
6625
|
+
}
|
|
6626
|
+
function asBytes(body) {
|
|
6627
|
+
return typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
6628
|
+
}
|
|
6629
|
+
function integerAmount(value, label) {
|
|
6630
|
+
if (!/^[0-9]+$/.test(value)) {
|
|
6631
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
6632
|
+
}
|
|
6633
|
+
return BigInt(value);
|
|
6634
|
+
}
|
|
6635
|
+
function freezePay(url, input) {
|
|
6560
6636
|
const quoted = input?.quote;
|
|
6561
6637
|
if (quoted && quoted.resource !== url) {
|
|
6562
6638
|
throw new Error("pay quote.resource must match the URL being paid");
|
|
@@ -6564,39 +6640,79 @@ function resolvePay(url, input) {
|
|
|
6564
6640
|
if (input?.json !== void 0 && input.body !== void 0) {
|
|
6565
6641
|
throw new Error("pay accepts json or body, not both");
|
|
6566
6642
|
}
|
|
6567
|
-
|
|
6643
|
+
const headerType = contentTypeFrom(input?.headers) ?? null;
|
|
6568
6644
|
let body;
|
|
6645
|
+
let contentType = headerType;
|
|
6569
6646
|
if (input?.json !== void 0) {
|
|
6570
6647
|
body = jsonBody(input.json);
|
|
6571
|
-
contentType =
|
|
6648
|
+
contentType = headerType ?? "application/json";
|
|
6649
|
+
} else if (typeof input?.body === "string") {
|
|
6650
|
+
body = input.body;
|
|
6651
|
+
contentType = headerType ?? "application/json";
|
|
6572
6652
|
} else if (input?.body !== void 0) {
|
|
6573
6653
|
body = input.body;
|
|
6574
6654
|
} else if (typeof quoted?.body === "string") {
|
|
6575
6655
|
body = quoted.body;
|
|
6576
|
-
|
|
6656
|
+
contentType = headerType ?? quoted.contentType;
|
|
6657
|
+
}
|
|
6658
|
+
const method = input?.method ?? quoted?.method ?? (body !== void 0 ? "POST" : "GET");
|
|
6659
|
+
if (quoted && input?.method && input.method !== quoted.method) {
|
|
6660
|
+
throw new Error("pay method must match the quote");
|
|
6577
6661
|
}
|
|
6578
|
-
const method = input?.method ?? quoted?.method ?? (body !== void 0 ? "POST" : void 0);
|
|
6579
6662
|
if (method === "GET" && hasBytes(body)) {
|
|
6580
6663
|
throw new Error('pay GET cannot carry a body; pass method: "POST"');
|
|
6581
6664
|
}
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
function toPayInput(url, input, resolved) {
|
|
6585
|
-
const quoted = resolved.quote;
|
|
6586
|
-
const maxAmount = input?.maxAmount?.trim() || quoted?.amount;
|
|
6587
|
-
const asset = input?.asset?.trim() || quoted?.asset;
|
|
6588
|
-
if (!maxAmount || !asset) {
|
|
6589
|
-
throw new Error("pay requires maxAmount and asset (or a quote that carries them)");
|
|
6665
|
+
if (method === "GET" && contentType) {
|
|
6666
|
+
throw new Error("pay GET cannot declare a content type");
|
|
6590
6667
|
}
|
|
6591
|
-
|
|
6592
|
-
|
|
6668
|
+
if (quoted) {
|
|
6669
|
+
if ((contentType ?? null) !== (quoted.contentType ?? null)) {
|
|
6670
|
+
throw new Error("pay content type must match the quote");
|
|
6671
|
+
}
|
|
6672
|
+
if (quoted.body !== void 0 || body !== void 0) {
|
|
6673
|
+
const quotedBytes = new TextEncoder().encode(quoted.body ?? "");
|
|
6674
|
+
const paidBytes = body === void 0 ? new Uint8Array() : asBytes(body);
|
|
6675
|
+
if (!sameBytes(quotedBytes, paidBytes)) {
|
|
6676
|
+
throw new Error("pay body must match the quoted bytes");
|
|
6677
|
+
}
|
|
6678
|
+
}
|
|
6679
|
+
}
|
|
6680
|
+
return { method, body, contentType, quote: quoted };
|
|
6681
|
+
}
|
|
6682
|
+
function spendCeiling(input, quote2) {
|
|
6683
|
+
if (!quote2) {
|
|
6684
|
+
const maxAmount2 = input?.maxAmount?.trim();
|
|
6685
|
+
const asset = input?.asset?.trim();
|
|
6686
|
+
if (!maxAmount2 || !asset) {
|
|
6687
|
+
throw new Error("pay requires maxAmount and asset (or a quote that carries them)");
|
|
6688
|
+
}
|
|
6689
|
+
integerAmount(maxAmount2, "maxAmount");
|
|
6690
|
+
return { maxAmount: maxAmount2, asset };
|
|
6691
|
+
}
|
|
6692
|
+
const callerAsset = input?.asset?.trim();
|
|
6693
|
+
if (callerAsset && callerAsset !== quote2.asset) {
|
|
6694
|
+
throw new Error("pay asset must match the quote");
|
|
6695
|
+
}
|
|
6696
|
+
const quoted = integerAmount(quote2.amount.trim(), "quote.amount");
|
|
6697
|
+
let maxAmount = quote2.amount.trim();
|
|
6698
|
+
const callerRaw = input?.maxAmount?.trim();
|
|
6699
|
+
if (callerRaw) {
|
|
6700
|
+
const caller = integerAmount(callerRaw, "maxAmount");
|
|
6701
|
+
if (caller < quoted) maxAmount = callerRaw;
|
|
6702
|
+
}
|
|
6703
|
+
return { maxAmount, asset: quote2.asset };
|
|
6704
|
+
}
|
|
6705
|
+
function toPayInput(resolved, quote2, input) {
|
|
6706
|
+
const { maxAmount, asset } = spendCeiling(input, quote2);
|
|
6707
|
+
const dialect = input?.dialect ?? quote2?.dialect;
|
|
6708
|
+
const recipient = quote2?.payTo;
|
|
6593
6709
|
return {
|
|
6594
6710
|
intent: {
|
|
6595
6711
|
maxAmount,
|
|
6596
6712
|
asset,
|
|
6597
6713
|
...recipient ? { recipient } : {}
|
|
6598
6714
|
},
|
|
6599
|
-
|
|
6715
|
+
method: resolved.method,
|
|
6600
6716
|
...dialect ? { dialect } : {},
|
|
6601
6717
|
...resolved.body !== void 0 ? { body: resolved.body } : {},
|
|
6602
6718
|
...resolved.contentType ? { contentType: resolved.contentType } : {}
|
|
@@ -6617,23 +6733,26 @@ async function createClient(options) {
|
|
|
6617
6733
|
const payer = created.value;
|
|
6618
6734
|
return {
|
|
6619
6735
|
quote(url, request) {
|
|
6736
|
+
assertGatewayResource(url, gatewayUrl);
|
|
6620
6737
|
return quoteResource(url, fetchImpl ?? fetch, request);
|
|
6621
6738
|
},
|
|
6622
6739
|
async pay(url, input) {
|
|
6623
|
-
|
|
6624
|
-
const
|
|
6740
|
+
assertGatewayResource(url, gatewayUrl);
|
|
6741
|
+
const frozen = freezePay(url, input);
|
|
6742
|
+
const priced = input?.maxAmount?.trim() || input?.asset?.trim() || frozen.quote;
|
|
6743
|
+
let quote2 = frozen.quote;
|
|
6625
6744
|
if (!priced) {
|
|
6626
|
-
if (
|
|
6745
|
+
if (frozen.body instanceof Uint8Array) {
|
|
6627
6746
|
throw new Error("pay requires maxAmount and asset when body is raw bytes");
|
|
6628
6747
|
}
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
...typeof
|
|
6748
|
+
quote2 = await quoteResource(url, fetchImpl ?? fetch, {
|
|
6749
|
+
method: frozen.method,
|
|
6750
|
+
...typeof frozen.body === "string" ? { body: frozen.body } : {},
|
|
6751
|
+
contentType: frozen.contentType
|
|
6632
6752
|
});
|
|
6633
|
-
resolved = resolvePay(url, { ...input, quote: quote2 });
|
|
6634
6753
|
}
|
|
6635
|
-
const paid = await payer.pay(url, toPayInput(
|
|
6636
|
-
return
|
|
6754
|
+
const paid = await payer.pay(url, toPayInput(frozen, quote2, input));
|
|
6755
|
+
return quote2 ? { ...paid, quote: quote2 } : paid;
|
|
6637
6756
|
}
|
|
6638
6757
|
};
|
|
6639
6758
|
}
|