@x402jp/safe-buy 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kato9292929
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,106 @@
1
- # Temporary Holding Version
1
+ # @x402jp/safe-buy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ x402(HTTP 402・USDC)の売り手から、買い手エージェントが **1 回の関数呼び出しで安全に買う** ための SDK と MCP サーバー。
4
+
5
+ - 払う前に止める: Probe の過去記録、402 の入力要件、上限価格、カタログ価格との不一致
6
+ - 払った後に確かめる: 決済ヘッダが無くてもチェーン上で自分の支払いを探し、金額を照合
7
+ - 届かなかったら取り直す: Delivery Receipt 対応の売り手には、同じ支払いで再取得(refetch)して受領証を検証
8
+
9
+ 対応: Base(eip155:8453)と Solana mainnet の公式 USDC、`exact` スキームのみ。Node.js 20 以上。
10
+
11
+ ## インストール
12
+
13
+ ```sh
14
+ npm install @x402jp/safe-buy
15
+ ```
16
+
17
+ ## 使い方
18
+
19
+ ```ts
20
+ import { safeBuy } from "@x402jp/safe-buy";
21
+
22
+ const r = await safeBuy("https://seller.example/api/data", {
23
+ maxPrice: "$0.01", // 必須。既定値なし
24
+ chain: "base", // "base" | "solana" | "any"(既定 any = Base 優先)
25
+ signer: { evmPrivateKey: process.env.SAFE_BUY_EVM_PRIVATE_KEY },
26
+ onAttempt: (rec) => ledger.write(rec), // 送信「前」に呼ばれる。投げると送信しない
27
+ });
28
+
29
+ if (r.ok) console.log(new TextDecoder().decode(r.body!));
30
+ else console.log(r.outcome, r.stopped_at, r.reason);
31
+ ```
32
+
33
+ 鍵は呼び出し側が渡します(`signer` か、組み立て済みの `client: x402Client`)。SDK は鍵を保存もログ出力もしません。`onAttempt` と結果に入るのは支払いを後から探すための買い手側 ID だけです(Solana: 自分の署名、Base: EIP-3009 の nonce と署名の SHA-256。生の署名は出しません)。
34
+
35
+ ### 段階(`stages` にすべて記録)
36
+
37
+ | # | stage | 内容 | 止まる条件 |
38
+ |---|---|---|---|
39
+ | 1 | probe | `GET {probeUrl}/scores/{host}`(5 秒) | 直近の購入が `not_delivered` / `overcharged`、`buyable_without_input === false` で `input` なし。404・タイムアウト・5xx は続行 |
40
+ | 2 | 402 | 402 を取得し v2(`PAYMENT-REQUIRED`)/ v1(JSON 本文)を解析、`exact` + 公式 USDC を選ぶ | 402 でない、払える accept がない、必須入力があるのに `input` なし |
41
+ | 3 | price | 金額 ≤ `maxPrice`、Probe のカタログ価格と一致 | 上限超え(署名前)、`price_mismatch`(`allowCatalogMismatch` で許可可) |
42
+ | 4 | pay | `@x402/core` / `@x402/evm` / `@x402/svm` で署名、`onAttempt` | 署名失敗、`onAttempt` の例外 |
43
+ | 5 | reconcile | 有償リクエスト送信、決済ヘッダ読み取り、無ければチェーン検索(既定 90 秒)、送金額照合 | — |
44
+ | 6 | refetch | 支払い済みで未着かつ `/.well-known/x402-receipt.json` があれば、元の支払いヘッダ + `X-Payment-Refetch` で再取得 | — |
45
+ | 7 | receipt | 200 に `X-Delivery-Receipt` があれば検証 | — |
46
+ | 8 | report | `report: true` のときだけ、匿名の結果を `{probeUrl}/reports` に POST | 失敗しても購入には影響しない |
47
+
48
+ `outcome`: `delivered` / `delivered_unsettled`(届いたが支払いが見つからない)/ `not_delivered` / `overcharged`(提示額より多く引かれた)/ `amount_mismatch` / `rejected`(未払いで拒否)/ `failed` / `refused`(払う前に止めた)。`ok` は `delivered` と `delivered_unsettled` のとき true。
49
+
50
+ 報告(stage 8)に入るのは host、URL(クエリなし)、network、金額、結果、HTTP ステータス、レイテンシ、各種フラグだけ。支払者アドレス・tx・買い手側 ID・本文は送りません。
51
+
52
+ ### その他の関数
53
+
54
+ ```ts
55
+ import { checkSeller, verifyReceipt } from "@x402jp/safe-buy";
56
+
57
+ await checkSeller(url, { maxPrice: 0.01 }); // stage 1〜3 だけ。払わない
58
+ await verifyReceipt({ url, header, body, paymentId }); // well-known を取得して受領証を検証
59
+ ```
60
+
61
+ 主なオプション: `probeUrl`(既定 https://probe.x402jp.com)、`input: { method, query, body, headers }`、`rpc: { base, solana }`(既定は環境変数 `BASE_RPC_URL` / `SOLANA_RPC_URL`、無ければ公開 RPC)、`findTimeoutMs`、`report`。
62
+
63
+ ## MCP サーバー
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "x402-safe-buy": {
69
+ "command": "npx",
70
+ "args": ["-y", "-p", "@x402jp/safe-buy", "x402-safe-buy-mcp"],
71
+ "env": { "SAFE_BUY_EVM_PRIVATE_KEY": "0x...", "SAFE_BUY_MAX_PRICE_CAP": "0.05" }
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ ツール:
78
+
79
+ - `check_seller { url, chain?, maxPrice?, input? }` — 払わずに確認
80
+ - `safe_buy { url, maxPrice, chain?, input?, report? }` — 購入。本文は UTF-8 なら `body_text`(50 KB で切り詰め)、小さなバイナリは `body_base64`
81
+ - `verify_receipt { url, receipt, body_base64, payment_id }`
82
+
83
+ 鍵はサーバーの環境変数(`SAFE_BUY_EVM_PRIVATE_KEY` / `SAFE_BUY_SOLANA_SECRET_KEY`)からのみ読み、ツール引数では受け取りません。`safe_buy` の `maxPrice` は `SAFE_BUY_MAX_PRICE_CAP`(既定 $0.10)を超えると拒否します。
84
+
85
+ ## 試す(実際に支払います)
86
+
87
+ ```sh
88
+ SAFE_BUY_EVM_PRIVATE_KEY=0x... npx tsx scripts/try.ts https://seller.example/api/data --max 0.01 --chain base
89
+ ```
90
+
91
+ 本文と鍵は表示しません。`PROBE_EVM_PRIVATE_KEY` / `PROBE_SOLANA_SECRET_KEY` も読みます。
92
+
93
+ ## 開発
94
+
95
+ ```sh
96
+ npm install
97
+ npm run typecheck
98
+ npm test # すべてオフライン(ローカル HTTP サーバーと RPC モック、使い捨て鍵)
99
+ npm run build
100
+ ```
101
+
102
+ レシートの検証は npm の `@x402jp/delivery-receipt-verify`(kato9292929/delivery-receipt)を使います。
103
+
104
+ ## ライセンス
105
+
106
+ MIT
@@ -0,0 +1,24 @@
1
+ import { type ChainSearch, type TransferCheck } from "./rpc.js";
2
+ export declare const AUTHORIZATION_USED = "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5";
3
+ export declare const topicAddr: (a: string) => string;
4
+ /** The EIP-3009 authorization in an exact-EVM payload (Permit2 payloads are not supported). */
5
+ export declare function evmAuthorization(payload: any): {
6
+ nonce: string;
7
+ signature: string;
8
+ from: string;
9
+ to: string;
10
+ value: string;
11
+ };
12
+ /** Pure: how much USDC this receipt moved from payer (in total) and to payTo, vs the offered amount. */
13
+ export declare function checkBaseTransfer(receipt: any, w: {
14
+ payer: string;
15
+ payTo: string;
16
+ amount: bigint;
17
+ }): TransferCheck;
18
+ /** Poll for a known tx hash's receipt (it can take a moment to be visible). */
19
+ export declare function getBaseReceipt(txHash: string, s: ChainSearch): Promise<any | null>;
20
+ /** The tx that consumed our authorization nonce (searched in the last ~hour of blocks), if any. */
21
+ export declare function findBasePayment(payer: string, nonce: string, s: ChainSearch): Promise<{
22
+ hash: string;
23
+ receipt: any;
24
+ } | null>;
@@ -0,0 +1,70 @@
1
+ // Base mainnet: finding and checking an x402 exact-EVM payment (EIP-3009
2
+ // transferWithAuthorization). The payer never sends a tx; the facilitator does, so the
3
+ // payer-side key is the authorization nonce, consumed on chain by USDC's
4
+ // AuthorizationUsed(authorizer, nonce) event. Ported from seller-probe src/chains/base.ts.
5
+ import { USDC } from "../constants.js";
6
+ import { rpc, sleep } from "./rpc.js";
7
+ const TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
8
+ export const AUTHORIZATION_USED = "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5";
9
+ export const topicAddr = (a) => "0x" + a.toLowerCase().replace(/^0x/, "").padStart(64, "0");
10
+ /** The EIP-3009 authorization in an exact-EVM payload (Permit2 payloads are not supported). */
11
+ export function evmAuthorization(payload) {
12
+ const p = payload?.payload;
13
+ if (!p?.authorization?.nonce || !p?.signature)
14
+ throw new Error("EVM payload is not an EIP-3009 authorization");
15
+ return { nonce: p.authorization.nonce, signature: p.signature, from: p.authorization.from, to: p.authorization.to, value: String(p.authorization.value) };
16
+ }
17
+ /** Pure: how much USDC this receipt moved from payer (in total) and to payTo, vs the offered amount. */
18
+ export function checkBaseTransfer(receipt, w) {
19
+ if (!receipt)
20
+ return { ok: false, reason: "receipt not found", transferred: null, received_by_pay_to: null };
21
+ if (receipt.status !== "0x1")
22
+ return { ok: false, reason: "transaction reverted", transferred: "0", received_by_pay_to: "0" };
23
+ const usdcTransfers = (receipt.logs ?? []).filter((l) => l.address?.toLowerCase() === USDC.base.toLowerCase() && l.topics?.[0] === TRANSFER && l.topics?.[1]?.toLowerCase() === topicAddr(w.payer));
24
+ const sum = (ls) => ls.reduce((s, l) => s + BigInt(l.data), 0n);
25
+ const transferred = sum(usdcTransfers);
26
+ const toPayTo = sum(usdcTransfers.filter((l) => l.topics?.[2]?.toLowerCase() === topicAddr(w.payTo)));
27
+ const out = { transferred: transferred.toString(), received_by_pay_to: toPayTo.toString() };
28
+ if (transferred === 0n)
29
+ return { ok: false, reason: "no USDC Transfer from payer in receipt", ...out };
30
+ if (transferred !== w.amount)
31
+ return { ok: false, reason: `USDC transferred ${transferred} != offered ${w.amount}`, ...out };
32
+ if (toPayTo !== w.amount)
33
+ return { ok: false, reason: `USDC to payTo ${toPayTo} != offered ${w.amount}`, ...out };
34
+ return { ok: true, reason: "USDC transfer payer->payTo matches the 402", ...out };
35
+ }
36
+ /** Poll for a known tx hash's receipt (it can take a moment to be visible). */
37
+ export async function getBaseReceipt(txHash, s) {
38
+ const until = Date.now() + s.timeoutMs;
39
+ do {
40
+ const r = await rpc(s.rpcUrl, "eth_getTransactionReceipt", [txHash]).catch(() => null);
41
+ if (r)
42
+ return r;
43
+ if (Date.now() >= until)
44
+ break;
45
+ await sleep(s.pollMs);
46
+ } while (Date.now() < until);
47
+ return null;
48
+ }
49
+ /** The tx that consumed our authorization nonce (searched in the last ~hour of blocks), if any. */
50
+ export async function findBasePayment(payer, nonce, s) {
51
+ const until = Date.now() + s.timeoutMs;
52
+ do {
53
+ const latestHex = await rpc(s.rpcUrl, "eth_blockNumber", []).catch(() => null);
54
+ if (latestHex) {
55
+ const latest = parseInt(latestHex, 16);
56
+ const from = Math.max(0, latest - 1800);
57
+ const logs = await rpc(s.rpcUrl, "eth_getLogs", [
58
+ { address: USDC.base, fromBlock: "0x" + from.toString(16), toBlock: "0x" + latest.toString(16), topics: [AUTHORIZATION_USED, topicAddr(payer), nonce.toLowerCase()] },
59
+ ]).catch(() => []);
60
+ if (logs?.length) {
61
+ const hash = logs[0].transactionHash;
62
+ return { hash, receipt: await getBaseReceipt(hash, s) };
63
+ }
64
+ }
65
+ if (Date.now() >= until)
66
+ break;
67
+ await sleep(s.pollMs);
68
+ } while (Date.now() < until);
69
+ return null;
70
+ }
@@ -0,0 +1,17 @@
1
+ export declare function rpc<T = any>(url: string, method: string, params: unknown[], attempts?: number): Promise<T>;
2
+ export declare const sleep: (ms: number) => Promise<unknown>;
3
+ /** Where and how long to look on chain. */
4
+ export type ChainSearch = {
5
+ rpcUrl: string;
6
+ timeoutMs: number;
7
+ pollMs: number;
8
+ };
9
+ /** Outcome of checking one transaction against the offer. Amounts are atomic USDC strings. */
10
+ export type TransferCheck = {
11
+ ok: boolean;
12
+ reason: string;
13
+ /** USDC that left the payer in this tx (null when it could not be read). */
14
+ transferred: string | null;
15
+ /** USDC that reached payTo in this tx. */
16
+ received_by_pay_to: string | null;
17
+ };
@@ -0,0 +1,25 @@
1
+ // Minimal JSON-RPC over fetch, with a few retries.
2
+ export async function rpc(url, method, params, attempts = 3) {
3
+ let last;
4
+ for (let i = 0; i < attempts; i++) {
5
+ try {
6
+ const r = await fetch(url, {
7
+ method: "POST",
8
+ headers: { "content-type": "application/json" },
9
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
10
+ signal: AbortSignal.timeout(20_000),
11
+ });
12
+ const j = await r.json();
13
+ if (j.error)
14
+ throw new Error(`${method}: ${JSON.stringify(j.error)}`);
15
+ return j.result;
16
+ }
17
+ catch (e) {
18
+ last = e;
19
+ if (i < attempts - 1)
20
+ await sleep(300 * 2 ** i);
21
+ }
22
+ }
23
+ throw last;
24
+ }
25
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,29 @@
1
+ import { type ChainSearch, type TransferCheck } from "./rpc.js";
2
+ /** Build a signer from a base58 or JSON-array 64-byte secret key. The key is never kept elsewhere. */
3
+ export declare function solanaSignerFrom(secret: string): Promise<import("@solana/kit").KeyPairSigner<string>>;
4
+ /**
5
+ * The payer's address and signature inside payload.payload.transaction. The payer is the
6
+ * signer that has already signed (the facilitator's fee-payer slot is still empty), or
7
+ * `payer` when given.
8
+ */
9
+ export declare function payerSignature(payload: any, payer?: string): {
10
+ payer: string;
11
+ signature: string;
12
+ };
13
+ /** Pure: how much USDC a jsonParsed getTransaction result moved out of payer and into payTo. */
14
+ export declare function checkSolanaTransfer(tx: any, w: {
15
+ payer: string;
16
+ payTo: string;
17
+ amount: bigint;
18
+ }): TransferCheck;
19
+ /** Poll for a known signature (a settled tx can take a few seconds to be visible at `confirmed`). */
20
+ export declare function getSolanaTx(sig: string, s: ChainSearch): Promise<any | null>;
21
+ /**
22
+ * Look for a landed tx carrying our own signature among recent transactions that touch
23
+ * the payer. In an x402 exact-SVM tx the tx id is the fee payer's (facilitator's)
24
+ * signature, so our signature is matched against every signature of each candidate.
25
+ */
26
+ export declare function findSolanaPayment(payer: string, payerSig: string, s: ChainSearch, limit?: number): Promise<{
27
+ signature: string;
28
+ tx: any;
29
+ } | null>;
@@ -0,0 +1,93 @@
1
+ // Solana mainnet: the buyer's own signature in an x402 exact-SVM payment tx, finding that
2
+ // tx on chain, and checking the USDC transfer in it. Ported from seller-probe src/chains/solana.ts.
3
+ import bs58 from "bs58";
4
+ import { createKeyPairSignerFromBytes, getBase64Encoder, getTransactionDecoder } from "@solana/kit";
5
+ import { USDC } from "../constants.js";
6
+ import { rpc, sleep } from "./rpc.js";
7
+ /** Build a signer from a base58 or JSON-array 64-byte secret key. The key is never kept elsewhere. */
8
+ export async function solanaSignerFrom(secret) {
9
+ const t = secret.trim();
10
+ const bytes = t.startsWith("[") ? Uint8Array.from(JSON.parse(t)) : bs58.decode(t);
11
+ if (bytes.length !== 64)
12
+ throw new Error(`Solana secret key must be 64 bytes, got ${bytes.length}`);
13
+ return createKeyPairSignerFromBytes(bytes);
14
+ }
15
+ function decodeTx(payload) {
16
+ const b64 = payload?.payload?.transaction;
17
+ if (typeof b64 !== "string")
18
+ throw new Error("SVM payload has no payload.transaction");
19
+ return getTransactionDecoder().decode(getBase64Encoder().encode(b64));
20
+ }
21
+ const signed = (sig) => !!sig && !sig.every((b) => b === 0);
22
+ /**
23
+ * The payer's address and signature inside payload.payload.transaction. The payer is the
24
+ * signer that has already signed (the facilitator's fee-payer slot is still empty), or
25
+ * `payer` when given.
26
+ */
27
+ export function payerSignature(payload, payer) {
28
+ const tx = decodeTx(payload);
29
+ const sigs = tx.signatures;
30
+ const entry = payer ? [payer, sigs[payer]] : Object.entries(sigs).find(([, s]) => signed(s));
31
+ if (!entry || !signed(entry[1]))
32
+ throw new Error("payer signature missing in payment tx");
33
+ return { payer: entry[0], signature: bs58.encode(entry[1]) };
34
+ }
35
+ /** Pure: how much USDC a jsonParsed getTransaction result moved out of payer and into payTo. */
36
+ export function checkSolanaTransfer(tx, w) {
37
+ if (!tx)
38
+ return { ok: false, reason: "transaction not found", transferred: null, received_by_pay_to: null };
39
+ const delta = (owner) => {
40
+ const amt = (list) => (list ?? []).filter((b) => b.mint === USDC.solana && b.owner === owner).reduce((s, b) => s + BigInt(b.uiTokenAmount?.amount ?? "0"), 0n);
41
+ return amt(tx.meta?.postTokenBalances) - amt(tx.meta?.preTokenBalances);
42
+ };
43
+ if (tx.meta?.err)
44
+ return { ok: false, reason: `transaction failed: ${JSON.stringify(tx.meta.err)}`, transferred: "0", received_by_pay_to: "0" };
45
+ const transferred = -delta(w.payer);
46
+ const toPayTo = delta(w.payTo);
47
+ const out = { transferred: transferred.toString(), received_by_pay_to: toPayTo.toString() };
48
+ if (transferred !== w.amount)
49
+ return { ok: false, reason: `payer USDC out ${transferred} != offered ${w.amount}`, ...out };
50
+ if (toPayTo !== w.amount)
51
+ return { ok: false, reason: `payTo USDC in ${toPayTo} != offered ${w.amount}`, ...out };
52
+ return { ok: true, reason: "USDC transfer payer->payTo matches the 402", ...out };
53
+ }
54
+ async function getTx(sig, s) {
55
+ return rpc(s.rpcUrl, "getTransaction", [sig, { encoding: "jsonParsed", commitment: "confirmed", maxSupportedTransactionVersion: 0 }]);
56
+ }
57
+ /** Poll for a known signature (a settled tx can take a few seconds to be visible at `confirmed`). */
58
+ export async function getSolanaTx(sig, s) {
59
+ const until = Date.now() + s.timeoutMs;
60
+ do {
61
+ const tx = await getTx(sig, s).catch(() => null);
62
+ if (tx)
63
+ return tx;
64
+ if (Date.now() >= until)
65
+ break;
66
+ await sleep(s.pollMs);
67
+ } while (Date.now() < until);
68
+ return null;
69
+ }
70
+ /**
71
+ * Look for a landed tx carrying our own signature among recent transactions that touch
72
+ * the payer. In an x402 exact-SVM tx the tx id is the fee payer's (facilitator's)
73
+ * signature, so our signature is matched against every signature of each candidate.
74
+ */
75
+ export async function findSolanaPayment(payer, payerSig, s, limit = 20) {
76
+ const seen = new Set();
77
+ const until = Date.now() + s.timeoutMs;
78
+ do {
79
+ const sigs = await rpc(s.rpcUrl, "getSignaturesForAddress", [payer, { limit, commitment: "confirmed" }]).catch(() => []);
80
+ for (const x of sigs ?? []) {
81
+ if (seen.has(x.signature))
82
+ continue;
83
+ seen.add(x.signature);
84
+ const tx = await getTx(x.signature, s).catch(() => null);
85
+ if (tx?.transaction?.signatures?.includes(payerSig))
86
+ return { signature: tx.transaction.signatures[0] ?? x.signature, tx };
87
+ }
88
+ if (Date.now() >= until)
89
+ break;
90
+ await sleep(s.pollMs);
91
+ } while (Date.now() < until);
92
+ return null;
93
+ }
@@ -0,0 +1,21 @@
1
+ export type Chain = "base" | "solana";
2
+ export type ChainChoice = Chain | "any";
3
+ /** CAIP-2 ids (x402 v2) and v1 network names that count as each chain's mainnet. */
4
+ export declare const NETWORKS: Record<Chain, {
5
+ caip2: `${string}:${string}`;
6
+ v1: string[];
7
+ }>;
8
+ /** Official USDC (Circle). Compared case-insensitively on Base. */
9
+ export declare const USDC: Record<Chain, string>;
10
+ /** Order used when the caller asks for chain "any". */
11
+ export declare const CHAIN_PREFERENCE: Chain[];
12
+ export declare const DEFAULT_PROBE_URL = "https://probe.x402jp.com";
13
+ export declare const DEFAULT_RPC: Record<Chain, string>;
14
+ export declare const PROBE_TIMEOUT_MS = 5000;
15
+ export declare const REQUEST_TIMEOUT_MS = 60000;
16
+ export declare const DEFAULT_FIND_TIMEOUT_MS = 90000;
17
+ export declare function chainOf(network: string | undefined): Chain | null;
18
+ /** Package version, read once from package.json (works from both src/ and dist/). */
19
+ export declare const VERSION: string;
20
+ export declare const SDK_ID: string;
21
+ export declare const USER_AGENT: string;
@@ -0,0 +1,43 @@
1
+ // Chains, official USDC and defaults. Mainnet only: Base and Solana.
2
+ import { readFileSync } from "node:fs";
3
+ /** CAIP-2 ids (x402 v2) and v1 network names that count as each chain's mainnet. */
4
+ export const NETWORKS = {
5
+ base: { caip2: "eip155:8453", v1: ["base", "base-mainnet"] },
6
+ solana: { caip2: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", v1: ["solana", "solana-mainnet"] },
7
+ };
8
+ /** Official USDC (Circle). Compared case-insensitively on Base. */
9
+ export const USDC = {
10
+ base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
11
+ solana: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
12
+ };
13
+ /** Order used when the caller asks for chain "any". */
14
+ export const CHAIN_PREFERENCE = ["base", "solana"];
15
+ export const DEFAULT_PROBE_URL = "https://probe.x402jp.com";
16
+ export const DEFAULT_RPC = {
17
+ base: "https://mainnet.base.org",
18
+ solana: "https://api.mainnet-beta.solana.com",
19
+ };
20
+ export const PROBE_TIMEOUT_MS = 5_000;
21
+ export const REQUEST_TIMEOUT_MS = 60_000;
22
+ export const DEFAULT_FIND_TIMEOUT_MS = 90_000;
23
+ export function chainOf(network) {
24
+ if (!network)
25
+ return null;
26
+ const n = network.toLowerCase();
27
+ for (const c of Object.keys(NETWORKS)) {
28
+ if (n === NETWORKS[c].caip2.toLowerCase() || NETWORKS[c].v1.includes(n))
29
+ return c;
30
+ }
31
+ return null;
32
+ }
33
+ /** Package version, read once from package.json (works from both src/ and dist/). */
34
+ export const VERSION = (() => {
35
+ try {
36
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
37
+ }
38
+ catch {
39
+ return "0.0.0";
40
+ }
41
+ })();
42
+ export const SDK_ID = `@x402jp/safe-buy@${VERSION}`;
43
+ export const USER_AGENT = `x402jp-safe-buy/${VERSION}`;
@@ -0,0 +1,8 @@
1
+ export { safeBuy, checkSeller, type SafeBuyOptions, type SafeBuyResult, type CheckOptions, type CheckResult, type BuyInput, type Offer, type Outcome, type Stage, type StageName, type AttemptRecord, type ProbeInfo, } from "./safe-buy.js";
2
+ export { verifyReceipt, type VerifyReceiptArgs, type ReceiptCheck } from "./receipt.js";
3
+ export type { Signer, PayerKey } from "./pay.js";
4
+ export type { ProbeDoc } from "./probe.js";
5
+ export type { Report } from "./report.js";
6
+ export { type Price, usdToAtomic, atomicToUsd } from "./price.js";
7
+ export { type Chain, type ChainChoice, NETWORKS, USDC, VERSION } from "./constants.js";
8
+ export { parse402, inputRequirement, selectAccept, isOfficialUsdc, type Accept, type Parsed402, type InputRequirement } from "./x402.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // @x402jp/safe-buy: buy from an x402 (HTTP 402, USDC) seller safely.
2
+ export { safeBuy, checkSeller, } from "./safe-buy.js";
3
+ export { verifyReceipt } from "./receipt.js";
4
+ export { usdToAtomic, atomicToUsd } from "./price.js";
5
+ export { NETWORKS, USDC, VERSION } from "./constants.js";
6
+ export { parse402, inputRequirement, selectAccept, isOfficialUsdc } from "./x402.js";
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // stdio entry point of the x402-safe-buy MCP server. stdout carries the protocol only.
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createSafeBuyMcpServer } from "./mcp.js";
5
+ const server = createSafeBuyMcpServer();
6
+ await server.connect(new StdioServerTransport());
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type SafeBuyOptions, type SafeBuyResult } from "./safe-buy.js";
3
+ export declare const BODY_LIMIT_BYTES: number;
4
+ /** Default ceiling on any maxPrice an agent passes to safe_buy (USD). Override with SAFE_BUY_MAX_PRICE_CAP. */
5
+ export declare const DEFAULT_MAX_PRICE_CAP = "0.10";
6
+ export type McpConfig = {
7
+ /** Where keys and settings are read from (default process.env). */
8
+ env?: Record<string, string | undefined>;
9
+ /** Extra safeBuy options for every call (tests: rpc, findTimeoutMs, pollIntervalMs). Keys are not accepted here. */
10
+ defaults?: Omit<Partial<SafeBuyOptions>, "signer" | "client" | "maxPrice">;
11
+ };
12
+ /** Body as UTF-8 text (truncated) when it decodes, else base64 when small enough. */
13
+ export declare function bodyForAgent(body: Uint8Array | null): Record<string, unknown>;
14
+ /** The evidence fields of a purchase plus the body in agent-readable form. */
15
+ export declare function safeBuyView(r: SafeBuyResult): Record<string, unknown>;
16
+ export declare function createSafeBuyMcpServer(config?: McpConfig): McpServer;
package/dist/mcp.js ADDED
@@ -0,0 +1,131 @@
1
+ // MCP server exposing check_seller, safe_buy and verify_receipt. Signing keys come only
2
+ // from the server's environment (SAFE_BUY_EVM_PRIVATE_KEY / SAFE_BUY_SOLANA_SECRET_KEY),
3
+ // never from tool arguments, and are never echoed back.
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { z } from "zod";
6
+ import { VERSION } from "./constants.js";
7
+ import { atomicToUsd, usdToAtomic } from "./price.js";
8
+ import { verifyReceipt } from "./receipt.js";
9
+ import { checkSeller, safeBuy } from "./safe-buy.js";
10
+ export const BODY_LIMIT_BYTES = 50 * 1024;
11
+ /** Default ceiling on any maxPrice an agent passes to safe_buy (USD). Override with SAFE_BUY_MAX_PRICE_CAP. */
12
+ export const DEFAULT_MAX_PRICE_CAP = "0.10";
13
+ const chain = z.enum(["base", "solana", "any"]).optional().describe('Chain to pay on (default "any": Base, then Solana)');
14
+ const price = z.union([z.number().positive(), z.string()]);
15
+ const input = z
16
+ .object({
17
+ method: z.string().optional(),
18
+ query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
19
+ body: z.any().optional(),
20
+ headers: z.record(z.string(), z.string()).optional(),
21
+ })
22
+ .optional()
23
+ .describe("Request input for endpoints that need more than a bare GET");
24
+ const json = (v) => ({ content: [{ type: "text", text: JSON.stringify(v, null, 2) }] });
25
+ const fail = (msg) => ({ content: [{ type: "text", text: msg }], isError: true });
26
+ /** Body as UTF-8 text (truncated) when it decodes, else base64 when small enough. */
27
+ export function bodyForAgent(body) {
28
+ if (!body)
29
+ return { body_bytes: 0 };
30
+ const truncated = body.length > BODY_LIMIT_BYTES;
31
+ try {
32
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(body.subarray(0, BODY_LIMIT_BYTES));
33
+ return { body_bytes: body.length, body_text: text, body_truncated: truncated };
34
+ }
35
+ catch {
36
+ // A cut in the middle of a multi-byte character is still text.
37
+ if (truncated) {
38
+ const t = new TextDecoder("utf-8", { fatal: false }).decode(body.subarray(0, BODY_LIMIT_BYTES));
39
+ if (!/�/.test(t.slice(0, -1)))
40
+ return { body_bytes: body.length, body_text: t.replace(/�$/, ""), body_truncated: true };
41
+ }
42
+ return truncated
43
+ ? { body_bytes: body.length, body_binary: true, body_truncated: true }
44
+ : { body_bytes: body.length, body_base64: Buffer.from(body).toString("base64"), body_binary: true };
45
+ }
46
+ }
47
+ /** The evidence fields of a purchase plus the body in agent-readable form. */
48
+ export function safeBuyView(r) {
49
+ const { body, probe, ...rest } = r;
50
+ return { ...rest, probe_status: probe.status, ...bodyForAgent(body) };
51
+ }
52
+ export function createSafeBuyMcpServer(config = {}) {
53
+ const env = config.env ?? process.env;
54
+ const server = new McpServer({ name: "x402-safe-buy", version: VERSION });
55
+ const probeUrl = config.defaults?.probeUrl || env.SAFE_BUY_PROBE_URL || undefined;
56
+ server.registerTool("check_seller", {
57
+ title: "Check an x402 seller (no payment)",
58
+ description: "Looks up the seller at the x402 Seller Probe, fetches the 402 offer and checks price/input requirements. Never pays.",
59
+ inputSchema: { url: z.string().url(), chain, maxPrice: price.optional().describe("USD cap, e.g. 0.01 or \"$0.01\""), input },
60
+ annotations: { readOnlyHint: true, openWorldHint: true },
61
+ }, async (a) => {
62
+ try {
63
+ return json(await checkSeller(a.url, { ...config.defaults, probeUrl, chain: a.chain, maxPrice: a.maxPrice, input: a.input }));
64
+ }
65
+ catch (e) {
66
+ return fail(`check_seller failed: ${e?.message ?? e}`);
67
+ }
68
+ });
69
+ server.registerTool("safe_buy", {
70
+ title: "Buy one x402 call safely (spends USDC)",
71
+ description: "Pays an x402 seller in USDC only if the Probe record, the 402 offer and the price pass; reconciles the payment on chain, refetches with a Delivery Receipt if needed. Spends real money.",
72
+ inputSchema: {
73
+ url: z.string().url(),
74
+ maxPrice: price.describe("Required USD cap, e.g. 0.01 or \"$0.01\""),
75
+ chain,
76
+ input,
77
+ report: z.boolean().optional().describe("Send an anonymized outcome report to the Probe (default false)"),
78
+ },
79
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
80
+ }, async (a) => {
81
+ const signer = { evmPrivateKey: env.SAFE_BUY_EVM_PRIVATE_KEY || undefined, solanaSecretKey: env.SAFE_BUY_SOLANA_SECRET_KEY || undefined };
82
+ if (!signer.evmPrivateKey && !signer.solanaSecretKey)
83
+ return fail("safe_buy: no wallet configured on the server (SAFE_BUY_EVM_PRIVATE_KEY / SAFE_BUY_SOLANA_SECRET_KEY)");
84
+ let cap, max;
85
+ try {
86
+ cap = usdToAtomic(env.SAFE_BUY_MAX_PRICE_CAP || DEFAULT_MAX_PRICE_CAP);
87
+ max = usdToAtomic(a.maxPrice);
88
+ }
89
+ catch (e) {
90
+ return fail(`safe_buy: ${e?.message ?? e}`);
91
+ }
92
+ if (max > cap)
93
+ return fail(`safe_buy: maxPrice ${atomicToUsd(max)} is above the server cap ${atomicToUsd(cap)} (SAFE_BUY_MAX_PRICE_CAP)`);
94
+ try {
95
+ const r = await safeBuy(a.url, {
96
+ ...config.defaults,
97
+ probeUrl,
98
+ maxPrice: a.maxPrice,
99
+ chain: a.chain,
100
+ input: a.input,
101
+ report: a.report === true,
102
+ signer,
103
+ rpc: { base: env.BASE_RPC_URL, solana: env.SOLANA_RPC_URL, ...config.defaults?.rpc },
104
+ });
105
+ return json(safeBuyView(r));
106
+ }
107
+ catch (e) {
108
+ return fail(`safe_buy failed: ${e?.message ?? e}`);
109
+ }
110
+ });
111
+ server.registerTool("verify_receipt", {
112
+ title: "Verify an x402 Delivery Receipt",
113
+ description: "Fetches the seller's /.well-known/x402-receipt.json and verifies an X-Delivery-Receipt against the body bytes and payment id.",
114
+ inputSchema: {
115
+ url: z.string().url(),
116
+ receipt: z.string().min(1).describe("X-Delivery-Receipt header value"),
117
+ body_base64: z.string().describe("Exact response body, base64"),
118
+ payment_id: z.string().optional().describe("Solana tx signature or Base EIP-3009 nonce"),
119
+ },
120
+ annotations: { readOnlyHint: true, openWorldHint: true },
121
+ }, async (a) => {
122
+ try {
123
+ const r = await verifyReceipt({ url: a.url, header: a.receipt, body: new Uint8Array(Buffer.from(a.body_base64, "base64")), paymentId: a.payment_id ?? null });
124
+ return json(r);
125
+ }
126
+ catch (e) {
127
+ return fail(`verify_receipt failed: ${e?.message ?? e}`);
128
+ }
129
+ });
130
+ return server;
131
+ }