@paidfor/x402-safe-fetch 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +112 -2
- package/dist/body.d.ts +11 -0
- package/dist/body.js +84 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +28 -0
- package/dist/fsutil.d.ts +2 -0
- package/dist/fsutil.js +17 -0
- package/dist/index-client.d.ts +31 -0
- package/dist/index-client.js +70 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2 -0
- package/dist/intents.d.ts +46 -0
- package/dist/intents.js +146 -0
- package/dist/price.d.ts +23 -0
- package/dist/price.js +87 -0
- package/dist/receipt.d.ts +76 -0
- package/dist/receipt.js +9 -0
- package/dist/safe-fetch.d.ts +59 -0
- package/dist/safe-fetch.js +511 -0
- package/dist/taxonomy.d.ts +14 -0
- package/dist/taxonomy.js +13 -0
- package/dist/telemetry.d.ts +26 -0
- package/dist/telemetry.js +60 -0
- package/dist/validators/content_type.d.ts +2 -0
- package/dist/validators/content_type.js +36 -0
- package/dist/validators/http_status.d.ts +3 -0
- package/dist/validators/http_status.js +19 -0
- package/dist/validators/index.d.ts +11 -0
- package/dist/validators/index.js +31 -0
- package/dist/validators/non_empty.d.ts +2 -0
- package/dist/validators/non_empty.js +14 -0
- package/dist/validators/not_truncated.d.ts +2 -0
- package/dist/validators/not_truncated.js +35 -0
- package/dist/validators/types.d.ts +69 -0
- package/dist/validators/types.js +1 -0
- package/dist/validators/util.d.ts +22 -0
- package/dist/validators/util.js +45 -0
- package/dist/x402/http.d.ts +20 -0
- package/dist/x402/http.js +84 -0
- package/dist/x402/quote.d.ts +54 -0
- package/dist/x402/quote.js +71 -0
- package/dist/x402/settlement.d.ts +12 -0
- package/dist/x402/settlement.js +36 -0
- package/dist/x402/types.d.ts +147 -0
- package/dist/x402/types.js +218 -0
- package/docs/telemetry.md +61 -0
- package/examples/conway-topup.ts +87 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sarmad Siddiki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,113 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @paidfor/x402-safe-fetch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A drop-in wrapper around the official [`@x402/fetch`](https://www.npmjs.com/package/@x402/fetch) client (2.28.x)
|
|
4
|
+
that protects the buyer before and after paying. It refuses overpriced quotes and sellers rated "avoid", never
|
|
5
|
+
pays twice for the same intent, and checks what it bought.
|
|
6
|
+
|
|
7
|
+
This is the client-side half of the x402 seller index at [paidfor.dev](https://paidfor.dev).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @paidfor/x402-safe-fetch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { safeFetch } from "@paidfor/x402-safe-fetch";
|
|
15
|
+
|
|
16
|
+
const fetchWithPay = safeFetch(walletClient, {
|
|
17
|
+
maxPerCallUsd: 0.50,
|
|
18
|
+
maxOverListedRatio: 1.1, // refuse quotes more than 10% above the listed/known price
|
|
19
|
+
refuseVerdicts: ["avoid"], // consult the index; "caution" is allowed but logged
|
|
20
|
+
indexUrl: "https://paidfor.dev/v1",
|
|
21
|
+
validators: ["http_status", "non_empty", "content_type", "not_truncated"],
|
|
22
|
+
telemetry: "anonymous", // or "off" (the default)
|
|
23
|
+
receiptsDir: "./.x402-receipts",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const res = await fetchWithPay(url, init, { intentId: "topup:acct_42:2026-10" });
|
|
27
|
+
console.log(res.status, res.receipt.outcome, res.receipt.tx_hash);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`walletClient` is a viem account (`privateKeyToAccount(...)`) or a viem `WalletClient` with an account attached.
|
|
31
|
+
Payments are exact-scheme USDC on Base mainnet by default (`network: "eip155:84532"` for Base Sepolia).
|
|
32
|
+
|
|
33
|
+
## What happens on every call
|
|
34
|
+
|
|
35
|
+
1. **Before paying.** The unpaid request gets the 402 quote. The client asks the index
|
|
36
|
+
(`GET {indexUrl}/check?url=`, query string stripped) for the seller verdict and the resource's listed price,
|
|
37
|
+
with a 2-second timeout. It compares the quote to the listed price, or, when the index has none, to the last
|
|
38
|
+
price this client paid for the same resource. It refuses with a `PaymentRefusedError` when the seller verdict
|
|
39
|
+
is in `refuseVerdicts`, the quote exceeds `maxOverListedRatio` times the reference, or the amount exceeds
|
|
40
|
+
`maxPerCallUsd`. The error says why and links the seller page. The index is advisory: if it is down, slow or
|
|
41
|
+
answers `unknown`, the call proceeds on the local checks and the receipt says `index: "unavailable"`.
|
|
42
|
+
2. **Idempotency.** Every call has an `intentId`, yours or a generated one. The request hash is written to the
|
|
43
|
+
receipts directory before signing, and the signed nonce is written before the payment is sent. A retry with
|
|
44
|
+
the same `intentId` and request returns the stored response and never signs again. A retry with the same
|
|
45
|
+
`intentId` and a different request throws `IntentConflictError`. If a payment was signed but no response was
|
|
46
|
+
stored, a retry throws `IntentIndeterminateError` instead of paying again.
|
|
47
|
+
3. **After the response.** The configured validators run on the paid response. `res.receipt` carries the intent
|
|
48
|
+
id, quote, amount, reference price and its source, seller verdict, validator results, the settlement header,
|
|
49
|
+
and the on-chain tx hash when the header carries it. The same receipt is written to the receipts directory as
|
|
50
|
+
one JSON file per call.
|
|
51
|
+
4. **Telemetry, when enabled.** One outcome-only POST, fire-and-forget. See below.
|
|
52
|
+
|
|
53
|
+
## What it blocks and what it does not
|
|
54
|
+
|
|
55
|
+
| Blocks | Does not block |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Paying a seller the index rates `avoid` (or any verdict you list) | A bad seller the index has not rated yet, or any seller while the index is unreachable |
|
|
58
|
+
| A quote more than `maxOverListedRatio` above the index listed price | An overpriced first purchase from a resource with no listed price and no local history |
|
|
59
|
+
| A quote more than `maxOverListedRatio` above the last price this client paid | Slow price creep, if you keep accepting each step |
|
|
60
|
+
| Any single payment above `maxPerCallUsd` | Total spend across many calls: there is no daily or per-seller budget |
|
|
61
|
+
| Paying twice for the same `intentId`, across restarts and concurrent processes | Duplicates when each retry uses a new `intentId` (pass a stable one) |
|
|
62
|
+
| Re-signing after a timeout or crash once a payment was signed | Recovering that money: the call fails with the nonce so you can check the chain |
|
|
63
|
+
| Non-USDC assets, other networks, non-`exact` schemes, and redirects of the paid request | A seller that settles and then serves wrong or stale content that still passes the four validators |
|
|
64
|
+
| Silent non-delivery: a paid 4xx/5xx, empty, wrong content type or truncated body is flagged in the receipt | Getting the money back: validators report failures, they do not refund or dispute |
|
|
65
|
+
|
|
66
|
+
## Receipts directory
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
.x402-receipts/
|
|
70
|
+
2026-10-28T12-00-00-000Z-a1b2c3.json one receipt per call
|
|
71
|
+
intents/<sha256(intentId)>.json idempotency state and the stored response
|
|
72
|
+
prices.json last price paid per resource (method + origin + path)
|
|
73
|
+
debug.log telemetry failures, local only
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Keep this directory if you want the idempotency guarantee to survive restarts.
|
|
77
|
+
|
|
78
|
+
## Telemetry
|
|
79
|
+
|
|
80
|
+
Off by default. With `telemetry: "anonymous"`, each call that got a 402 posts exactly this to
|
|
81
|
+
`{indexUrl}/telemetry`, with a 1-second timeout, never awaited, every failure swallowed:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{ "v": 1, "host": "api.example.com", "path_hash": "<sha256 hex of the path>", "outcome": "ok",
|
|
85
|
+
"amount_usdc": "0.010000", "latency_ms": 412, "validator_failures": [], "client": "x402-safe-fetch/0.1.0" }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
No bodies, no wallet address, no request parameters, no intent id. The field list and the privacy promise are in
|
|
89
|
+
[docs/telemetry.md](docs/telemetry.md).
|
|
90
|
+
|
|
91
|
+
## Errors
|
|
92
|
+
|
|
93
|
+
| Error | `code` | Paid? |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `PaymentRefusedError` | `refused_verdict`, `refused_ratio`, `refused_max_per_call` | No |
|
|
96
|
+
| `IntentConflictError` | `intent_conflict` | No |
|
|
97
|
+
| `IntentIndeterminateError` | `intent_indeterminate` | Maybe, earlier; never again |
|
|
98
|
+
| `SafeFetchError` | `paid_no_response` | Signed and possibly settled; `receipt.nonce` identifies it |
|
|
99
|
+
| `SafeFetchError` | `no_acceptable_quote`, `intent_in_flight`, `network`, `sdk` | No |
|
|
100
|
+
|
|
101
|
+
## Example
|
|
102
|
+
|
|
103
|
+
[examples/conway-topup.ts](examples/conway-topup.ts) reproduces an auto-top-up that was paid, got a 500, and
|
|
104
|
+
retried every 60 seconds, and shows the idempotency guard and the `http_status` validator stopping it.
|
|
105
|
+
|
|
106
|
+
## Provenance
|
|
107
|
+
|
|
108
|
+
The 402 parsing, settlement decoding, pre-sign and post-sign checks, price ratio arithmetic and the four
|
|
109
|
+
validators are ported from the x402-audit measurement harness behind paidfor.dev, with their unit tests.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/dist/body.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Like x402-audit readBodyCapped (src/x402/http.ts), but the caller still gets the whole body: the first
|
|
3
|
+
* `maxBytes` are buffered for the validators and the replay store, and anything past that is streamed
|
|
4
|
+
* through untouched.
|
|
5
|
+
*/
|
|
6
|
+
import type { BodyReadResult } from "./x402/http.js";
|
|
7
|
+
export interface TeeResult extends BodyReadResult {
|
|
8
|
+
/** What to hand back to the caller: the bytes, or a stream of buffered prefix + rest. */
|
|
9
|
+
callerBody: Uint8Array | ReadableStream<Uint8Array> | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function readBodyTee(res: Response, maxBytes: number): Promise<TeeResult>;
|
package/dist/body.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export async function readBodyTee(res, maxBytes) {
|
|
2
|
+
const cl = res.headers.get("content-length");
|
|
3
|
+
const declared = cl !== null && /^\d+$/.test(cl.trim()) ? Number(cl.trim()) : undefined;
|
|
4
|
+
const encoding = (res.headers.get("content-encoding") ?? "identity").trim().toLowerCase();
|
|
5
|
+
const chunks = [];
|
|
6
|
+
let total = 0;
|
|
7
|
+
let truncatedByUs = false;
|
|
8
|
+
let incomplete = false;
|
|
9
|
+
let streamError;
|
|
10
|
+
let overflow;
|
|
11
|
+
let reader;
|
|
12
|
+
if (res.body) {
|
|
13
|
+
reader = res.body.getReader();
|
|
14
|
+
try {
|
|
15
|
+
for (;;) {
|
|
16
|
+
const { done, value } = await reader.read();
|
|
17
|
+
if (done)
|
|
18
|
+
break;
|
|
19
|
+
if (!value || value.byteLength === 0)
|
|
20
|
+
continue;
|
|
21
|
+
if (total + value.byteLength > maxBytes) {
|
|
22
|
+
const keep = maxBytes - total;
|
|
23
|
+
if (keep > 0)
|
|
24
|
+
chunks.push(value.subarray(0, keep));
|
|
25
|
+
overflow = value.subarray(Math.max(keep, 0));
|
|
26
|
+
total += Math.max(keep, 0);
|
|
27
|
+
truncatedByUs = true;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
chunks.push(value);
|
|
31
|
+
total += value.byteLength;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
incomplete = true;
|
|
36
|
+
streamError = e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!truncatedByUs && !incomplete && declared !== undefined && (encoding === "identity" || encoding === "") && total < declared) {
|
|
40
|
+
incomplete = true;
|
|
41
|
+
}
|
|
42
|
+
const body = new Uint8Array(total);
|
|
43
|
+
let off = 0;
|
|
44
|
+
for (const c of chunks) {
|
|
45
|
+
body.set(c, off);
|
|
46
|
+
off += c.byteLength;
|
|
47
|
+
}
|
|
48
|
+
let callerBody = res.body ? body : null;
|
|
49
|
+
if (truncatedByUs && reader) {
|
|
50
|
+
const r = reader;
|
|
51
|
+
const head = [body, overflow ?? new Uint8Array(0)];
|
|
52
|
+
callerBody = new ReadableStream({
|
|
53
|
+
start(c) {
|
|
54
|
+
for (const h of head)
|
|
55
|
+
if (h.byteLength)
|
|
56
|
+
c.enqueue(h);
|
|
57
|
+
},
|
|
58
|
+
async pull(c) {
|
|
59
|
+
try {
|
|
60
|
+
const { done, value } = await r.read();
|
|
61
|
+
if (done)
|
|
62
|
+
c.close();
|
|
63
|
+
else
|
|
64
|
+
c.enqueue(value);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
c.error(e);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
cancel(reason) {
|
|
71
|
+
return r.cancel(reason);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else if (reader && !incomplete) {
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
const out = { body, truncatedByUs, incomplete, callerBody };
|
|
79
|
+
if (declared !== undefined)
|
|
80
|
+
out.declaredLength = declared;
|
|
81
|
+
if (streamError !== undefined)
|
|
82
|
+
out.streamError = streamError;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Receipt } from "./receipt.js";
|
|
2
|
+
export type SafeFetchErrorCode = "refused_verdict" | "refused_ratio" | "refused_max_per_call" | "no_acceptable_quote" | "intent_conflict" | "intent_indeterminate" | "intent_in_flight" | "paid_no_response" | "network" | "sdk";
|
|
3
|
+
/** Every error this package throws. `receipt` is set whenever a call got far enough to write one. */
|
|
4
|
+
export declare class SafeFetchError extends Error {
|
|
5
|
+
readonly code: SafeFetchErrorCode;
|
|
6
|
+
readonly receipt?: Receipt | undefined;
|
|
7
|
+
name: string;
|
|
8
|
+
constructor(code: SafeFetchErrorCode, message: string, receipt?: Receipt | undefined, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/** Refused before any signature: seller verdict, price ratio, or per-call cap. Nothing was paid. */
|
|
13
|
+
export declare class PaymentRefusedError extends SafeFetchError {
|
|
14
|
+
readonly sellerUrl: string | null;
|
|
15
|
+
name: string;
|
|
16
|
+
constructor(code: "refused_verdict" | "refused_ratio" | "refused_max_per_call", message: string, sellerUrl: string | null, receipt?: Receipt);
|
|
17
|
+
}
|
|
18
|
+
/** Same intentId, different request. Nothing was sent. */
|
|
19
|
+
export declare class IntentConflictError extends SafeFetchError {
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
/** A payment for this intent was signed (and may have been sent) but no result was stored. Never re-signed. */
|
|
23
|
+
export declare class IntentIndeterminateError extends SafeFetchError {
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Every error this package throws. `receipt` is set whenever a call got far enough to write one. */
|
|
2
|
+
export class SafeFetchError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
receipt;
|
|
5
|
+
name = "SafeFetchError";
|
|
6
|
+
constructor(code, message, receipt, options) {
|
|
7
|
+
super(message, options);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.receipt = receipt;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Refused before any signature: seller verdict, price ratio, or per-call cap. Nothing was paid. */
|
|
13
|
+
export class PaymentRefusedError extends SafeFetchError {
|
|
14
|
+
sellerUrl;
|
|
15
|
+
name = "PaymentRefusedError";
|
|
16
|
+
constructor(code, message, sellerUrl, receipt) {
|
|
17
|
+
super(code, message, receipt);
|
|
18
|
+
this.sellerUrl = sellerUrl;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Same intentId, different request. Nothing was sent. */
|
|
22
|
+
export class IntentConflictError extends SafeFetchError {
|
|
23
|
+
name = "IntentConflictError";
|
|
24
|
+
}
|
|
25
|
+
/** A payment for this intent was signed (and may have been sent) but no result was stored. Never re-signed. */
|
|
26
|
+
export class IntentIndeterminateError extends SafeFetchError {
|
|
27
|
+
name = "IntentIndeterminateError";
|
|
28
|
+
}
|
package/dist/fsutil.d.ts
ADDED
package/dist/fsutil.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, open, rename } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
/** Write JSON via temp file + fsync + rename, so a crash leaves either the old or the new file. */
|
|
5
|
+
export async function writeJsonDurable(file, value) {
|
|
6
|
+
await mkdir(dirname(file), { recursive: true });
|
|
7
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
8
|
+
const fh = await open(tmp, "w");
|
|
9
|
+
try {
|
|
10
|
+
await fh.writeFile(JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
11
|
+
await fh.sync();
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
await fh.close();
|
|
15
|
+
}
|
|
16
|
+
await rename(tmp, file);
|
|
17
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only file that knows the shape of GET {indexUrl}/check?url=. Contract (paidfor-index lib/check.ts):
|
|
3
|
+
* { input, host, verdict: "avoid"|"caution"|"ok"|"unknown", verdict_text, seller: {..., verdict, url} | null,
|
|
4
|
+
* resource: { url, listed_price_usdc: "0.002000" | null, modal_quote_usdc, ... } | null,
|
|
5
|
+
* probe, seller_url, evidence_url, methodology_url, generated_at }
|
|
6
|
+
* Every field is optional here. Advisory only: this never throws, and anything other than a parseable answer
|
|
7
|
+
* with a known verdict inside the timeout comes back as { available: false }.
|
|
8
|
+
*/
|
|
9
|
+
export type Verdict = "avoid" | "caution" | "ok" | "unknown";
|
|
10
|
+
export interface IndexCheck {
|
|
11
|
+
available: boolean;
|
|
12
|
+
/** Why the index could not be used (timeout, http 503, unknown verdict, ...). */
|
|
13
|
+
note?: string;
|
|
14
|
+
host: string | null;
|
|
15
|
+
verdict: Verdict;
|
|
16
|
+
verdict_text: string | null;
|
|
17
|
+
seller_url: string | null;
|
|
18
|
+
evidence_url: string | null;
|
|
19
|
+
/** Listed price for this exact resource, USDC decimal string, when the index has one. */
|
|
20
|
+
listed_price_usdc: string | null;
|
|
21
|
+
}
|
|
22
|
+
/** Seller page on the site that hosts the index: https://paidfor.dev/v1 -> https://paidfor.dev/seller/<host>. */
|
|
23
|
+
export declare function sellerPageUrl(indexUrl: string, host: string): string;
|
|
24
|
+
/** Strip query and fragment: the index matches on host + path and never needs request parameters. */
|
|
25
|
+
export declare function lookupUrl(resourceUrl: string): string;
|
|
26
|
+
export declare function parseCheck(body: unknown, indexUrl: string, resourceUrl: string): IndexCheck;
|
|
27
|
+
export interface CheckOptions {
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
fetchImpl?: typeof globalThis.fetch;
|
|
30
|
+
}
|
|
31
|
+
export declare function checkIndex(indexUrl: string, resourceUrl: string, o?: CheckOptions): Promise<IndexCheck>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const VERDICTS = ["avoid", "caution", "ok", "unknown"];
|
|
2
|
+
const str = (v) => (typeof v === "string" && v.trim() !== "" ? v : null);
|
|
3
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
|
|
4
|
+
/** Seller page on the site that hosts the index: https://paidfor.dev/v1 -> https://paidfor.dev/seller/<host>. */
|
|
5
|
+
export function sellerPageUrl(indexUrl, host) {
|
|
6
|
+
return `${new URL(indexUrl).origin}/seller/${encodeURIComponent(host)}`;
|
|
7
|
+
}
|
|
8
|
+
/** Strip query and fragment: the index matches on host + path and never needs request parameters. */
|
|
9
|
+
export function lookupUrl(resourceUrl) {
|
|
10
|
+
const u = new URL(resourceUrl);
|
|
11
|
+
return `${u.origin}${u.pathname}`;
|
|
12
|
+
}
|
|
13
|
+
export function parseCheck(body, indexUrl, resourceUrl) {
|
|
14
|
+
const b = obj(body) ?? {};
|
|
15
|
+
const seller = obj(b.seller);
|
|
16
|
+
const resource = obj(b.resource);
|
|
17
|
+
let host = str(b.host) ?? str(seller?.domain);
|
|
18
|
+
if (!host) {
|
|
19
|
+
try {
|
|
20
|
+
host = new URL(resourceUrl).hostname.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
host = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const rawVerdict = str(b.verdict) ?? str(seller?.verdict);
|
|
27
|
+
const verdict = rawVerdict && VERDICTS.includes(rawVerdict) ? rawVerdict : "unknown";
|
|
28
|
+
const out = {
|
|
29
|
+
available: verdict !== "unknown",
|
|
30
|
+
host,
|
|
31
|
+
verdict,
|
|
32
|
+
verdict_text: str(b.verdict_text),
|
|
33
|
+
seller_url: str(b.seller_url) ?? str(seller?.url) ?? (host ? sellerPageUrl(indexUrl, host) : null),
|
|
34
|
+
evidence_url: str(b.evidence_url) ?? str(seller?.evidence_url),
|
|
35
|
+
listed_price_usdc: str(resource?.listed_price_usdc),
|
|
36
|
+
};
|
|
37
|
+
if (verdict === "unknown")
|
|
38
|
+
out.note = rawVerdict ? "verdict unknown" : "no verdict in response";
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export async function checkIndex(indexUrl, resourceUrl, o = {}) {
|
|
42
|
+
const fetchImpl = o.fetchImpl ?? globalThis.fetch;
|
|
43
|
+
const unavailable = (note) => {
|
|
44
|
+
let host = null;
|
|
45
|
+
try {
|
|
46
|
+
host = new URL(resourceUrl).hostname.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
return { available: false, note, host, verdict: "unknown", verdict_text: null, seller_url: host ? sellerPageUrl(indexUrl, host) : null, evidence_url: null, listed_price_usdc: null };
|
|
52
|
+
};
|
|
53
|
+
const timeoutMs = o.timeoutMs ?? 2000;
|
|
54
|
+
try {
|
|
55
|
+
const url = `${indexUrl.replace(/\/$/, "")}/check?url=${encodeURIComponent(lookupUrl(resourceUrl))}`;
|
|
56
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
57
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal });
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
res.body?.cancel().catch(() => undefined);
|
|
60
|
+
return unavailable(`http ${res.status}`);
|
|
61
|
+
}
|
|
62
|
+
// The timeout signal also bounds the body read.
|
|
63
|
+
const body = await res.json();
|
|
64
|
+
return parseCheck(body, indexUrl, resourceUrl);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
const name = e?.name;
|
|
68
|
+
return unavailable(name === "TimeoutError" || name === "AbortError" ? `timeout after ${timeoutMs}ms` : `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { safeFetch, hashRequest, type SafeFetch, type SafeFetchOptions, type SafeResponse, type CallOptions, type WalletLike, type SafeNetwork } from "./safe-fetch.js";
|
|
2
|
+
export { SafeFetchError, PaymentRefusedError, IntentConflictError, IntentIndeterminateError, type SafeFetchErrorCode } from "./errors.js";
|
|
3
|
+
export type { Receipt, Outcome } from "./receipt.js";
|
|
4
|
+
export type { Verdict, IndexCheck } from "./index-client.js";
|
|
5
|
+
export type { ValidatorName } from "./taxonomy.js";
|
|
6
|
+
export type { ValidatorResult } from "./validators/types.js";
|
|
7
|
+
export type { TelemetryPayload } from "./telemetry.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type IntentPhase = "reserved" | "signing" | "signed" | "completed" | "unpaid" | "refused" | "failed_unpaid";
|
|
2
|
+
export interface StoredResponse {
|
|
3
|
+
url: string;
|
|
4
|
+
status: number;
|
|
5
|
+
statusText: string;
|
|
6
|
+
headers: [string, string][];
|
|
7
|
+
body_base64: string;
|
|
8
|
+
/** True when the body was larger than the stored cap; a replay then returns only the stored prefix. */
|
|
9
|
+
body_truncated: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface IntentRecord {
|
|
12
|
+
v: 1;
|
|
13
|
+
intent_id: string;
|
|
14
|
+
request_hash: string;
|
|
15
|
+
phase: IntentPhase;
|
|
16
|
+
attempts: number;
|
|
17
|
+
created_at: string;
|
|
18
|
+
updated_at: string;
|
|
19
|
+
nonce?: string;
|
|
20
|
+
amount_atomic?: string;
|
|
21
|
+
pay_to?: string;
|
|
22
|
+
receipt_file?: string;
|
|
23
|
+
response?: StoredResponse;
|
|
24
|
+
}
|
|
25
|
+
export interface IntentLease {
|
|
26
|
+
record: IntentRecord;
|
|
27
|
+
/** Set when the intent already completed: return this, do not contact the seller. */
|
|
28
|
+
replay: boolean;
|
|
29
|
+
update(patch: Partial<IntentRecord>): Promise<IntentRecord>;
|
|
30
|
+
release(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare class IntentStore {
|
|
33
|
+
private readonly dir;
|
|
34
|
+
private readonly held;
|
|
35
|
+
constructor(receiptsDir: string);
|
|
36
|
+
private paths;
|
|
37
|
+
private lock;
|
|
38
|
+
/** Cross-process lock. A lock file left by a dead process, or by this pid (only possible after a crash), is stale. */
|
|
39
|
+
private lockFile;
|
|
40
|
+
read(intentId: string): Promise<IntentRecord | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Lock the intent and decide what this call may do. Writes the request hash durably before returning,
|
|
43
|
+
* so it is on disk before any signature. Throws on conflict, indeterminate, or in-flight.
|
|
44
|
+
*/
|
|
45
|
+
acquire(intentId: string, requestHash: string, now?: () => Date): Promise<IntentLease>;
|
|
46
|
+
}
|
package/dist/intents.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency. Follows the ledger rule of x402-audit src/pipeline/paidCall.ts ("ledger row BEFORE signing; if we
|
|
3
|
+
* crash after this, the row says money may have moved"), with the SQLite attempts table replaced by one JSON file
|
|
4
|
+
* per intent under <receiptsDir>/intents/.
|
|
5
|
+
*
|
|
6
|
+
* Phases, in order:
|
|
7
|
+
* reserved request hash stored; nothing signed -> a retry may proceed
|
|
8
|
+
* signing all checks passed; signature about to be made -> a retry may proceed (nothing left the process:
|
|
9
|
+
* `signed` is written before the paid request is sent, and a failed write aborts the send)
|
|
10
|
+
* signed authorization signed; paid request about to go -> a retry throws IntentIndeterminateError, never re-signs
|
|
11
|
+
* completed paid response stored -> a retry returns the stored response, never re-signs
|
|
12
|
+
* unpaid / refused / failed_unpaid -> a retry may proceed (nothing was signed)
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { IntentConflictError, IntentIndeterminateError, SafeFetchError } from "./errors.js";
|
|
18
|
+
import { writeJsonDurable } from "./fsutil.js";
|
|
19
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
20
|
+
function pidAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return e.code === "EPERM";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class IntentStore {
|
|
30
|
+
dir;
|
|
31
|
+
held = new Set();
|
|
32
|
+
constructor(receiptsDir) {
|
|
33
|
+
this.dir = join(receiptsDir, "intents");
|
|
34
|
+
}
|
|
35
|
+
paths(intentId) {
|
|
36
|
+
const base = join(this.dir, sha256(intentId).slice(0, 40));
|
|
37
|
+
return { file: `${base}.json`, lock: `${base}.lock` };
|
|
38
|
+
}
|
|
39
|
+
async lock(intentId, lockFile) {
|
|
40
|
+
// Claim in-process synchronously, before any await, so two concurrent calls here cannot both get past this line.
|
|
41
|
+
if (this.held.has(intentId))
|
|
42
|
+
throw new SafeFetchError("intent_in_flight", `intent ${intentId} is already in flight in this process`);
|
|
43
|
+
this.held.add(intentId);
|
|
44
|
+
try {
|
|
45
|
+
await this.lockFile(intentId, lockFile);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
this.held.delete(intentId);
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Cross-process lock. A lock file left by a dead process, or by this pid (only possible after a crash), is stale. */
|
|
53
|
+
async lockFile(intentId, lockFile) {
|
|
54
|
+
await mkdir(this.dir, { recursive: true });
|
|
55
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
const fh = await open(lockFile, "wx");
|
|
58
|
+
await fh.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
59
|
+
await fh.close();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (e.code !== "EEXIST")
|
|
64
|
+
throw e;
|
|
65
|
+
let owner;
|
|
66
|
+
try {
|
|
67
|
+
owner = JSON.parse(await readFile(lockFile, "utf8")).pid;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* half-written lock: treat as stale */
|
|
71
|
+
}
|
|
72
|
+
if (owner !== undefined && owner !== process.pid && pidAlive(owner)) {
|
|
73
|
+
throw new SafeFetchError("intent_in_flight", `intent ${intentId} is in flight in process ${owner}`);
|
|
74
|
+
}
|
|
75
|
+
await unlink(lockFile).catch(() => undefined); // stale lock from a dead process (or ourselves after a crash)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new SafeFetchError("intent_in_flight", `could not lock intent ${intentId}`);
|
|
79
|
+
}
|
|
80
|
+
async read(intentId) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(await readFile(this.paths(intentId).file, "utf8"));
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (e.code === "ENOENT")
|
|
86
|
+
return undefined;
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Lock the intent and decide what this call may do. Writes the request hash durably before returning,
|
|
92
|
+
* so it is on disk before any signature. Throws on conflict, indeterminate, or in-flight.
|
|
93
|
+
*/
|
|
94
|
+
async acquire(intentId, requestHash, now = () => new Date()) {
|
|
95
|
+
if (typeof intentId !== "string" || intentId.length === 0 || intentId.length > 256) {
|
|
96
|
+
throw new TypeError("intentId must be a non-empty string of at most 256 characters");
|
|
97
|
+
}
|
|
98
|
+
const { file, lock } = this.paths(intentId);
|
|
99
|
+
await this.lock(intentId, lock);
|
|
100
|
+
const release = async () => {
|
|
101
|
+
this.held.delete(intentId);
|
|
102
|
+
await unlink(lock).catch(() => undefined);
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
const prior = await this.read(intentId);
|
|
106
|
+
if (prior && prior.request_hash !== requestHash) {
|
|
107
|
+
throw new IntentConflictError("intent_conflict", `intent ${intentId} was already used for a different request (stored hash ${prior.request_hash.slice(0, 12)}, this request ${requestHash.slice(0, 12)}); use a new intentId`);
|
|
108
|
+
}
|
|
109
|
+
if (prior?.phase === "signed") {
|
|
110
|
+
throw new IntentIndeterminateError("intent_indeterminate", `intent ${intentId}: a payment was signed${prior.nonce ? ` (nonce ${prior.nonce})` : ""} but no response was stored, so it may have settled. Not signing again; check the chain or the seller before using a new intentId.`);
|
|
111
|
+
}
|
|
112
|
+
let record;
|
|
113
|
+
if (prior?.phase === "completed") {
|
|
114
|
+
record = prior;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const ts = now().toISOString();
|
|
118
|
+
record = {
|
|
119
|
+
v: 1,
|
|
120
|
+
intent_id: intentId,
|
|
121
|
+
request_hash: requestHash,
|
|
122
|
+
phase: "reserved",
|
|
123
|
+
attempts: (prior?.attempts ?? 0) + 1,
|
|
124
|
+
created_at: prior?.created_at ?? ts,
|
|
125
|
+
updated_at: ts,
|
|
126
|
+
};
|
|
127
|
+
await writeJsonDurable(file, record);
|
|
128
|
+
}
|
|
129
|
+
let current = record;
|
|
130
|
+
return {
|
|
131
|
+
record,
|
|
132
|
+
replay: record.phase === "completed",
|
|
133
|
+
async update(patch) {
|
|
134
|
+
current = { ...current, ...patch, updated_at: now().toISOString() };
|
|
135
|
+
await writeJsonDurable(file, current);
|
|
136
|
+
return current;
|
|
137
|
+
},
|
|
138
|
+
release,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
await release();
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/price.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const USDC_DECIMALS = 6;
|
|
2
|
+
/** USDC contract per CAIP-2 network. Amounts are converted to USD only for these assets. */
|
|
3
|
+
export declare const USDC_BY_NETWORK: Readonly<Record<string, string>>;
|
|
4
|
+
/** Dollars (as configured by the caller) to USDC atomic units, rounding down so a cap is never loosened. */
|
|
5
|
+
export declare function usdToAtomic(usd: number): bigint;
|
|
6
|
+
/** "0.002000" (index format) to atomic units; undefined when unparseable or finer than 6 decimals. */
|
|
7
|
+
export declare function usdcStringToAtomic(s: unknown): bigint | undefined;
|
|
8
|
+
export declare function atomicToUsdc(a: bigint | string): string;
|
|
9
|
+
export declare function quoteRatio(quote: bigint, ref: bigint): number;
|
|
10
|
+
/** Exact comparison: quote > ref * maxRatio, with maxRatio taken to 6 decimal places. */
|
|
11
|
+
export declare function exceedsRatio(quote: bigint, ref: bigint, maxRatio: number): boolean;
|
|
12
|
+
/** Resource identity for the local price book: method + origin + path, query string dropped. */
|
|
13
|
+
export declare function resourceKey(method: string, url: string): string;
|
|
14
|
+
export type ReferenceSource = "index_listed" | "local_last_paid";
|
|
15
|
+
/** Last price this client paid (and got a 2xx for) per resource, in <receiptsDir>/prices.json. */
|
|
16
|
+
export declare class PriceBook {
|
|
17
|
+
private readonly file;
|
|
18
|
+
private cache;
|
|
19
|
+
constructor(dir: string);
|
|
20
|
+
private load;
|
|
21
|
+
get(key: string): Promise<bigint | undefined>;
|
|
22
|
+
set(key: string, amount: bigint, now?: Date): Promise<void>;
|
|
23
|
+
}
|