@circuit-llm/x402 0.2.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 +21 -0
- package/README.md +34 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +317 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Circuit LLM
|
|
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
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @circuit-llm/x402
|
|
2
|
+
|
|
3
|
+
> The payment spine of the Circuit network: pay any x402-gated endpoint in CIRC (client) and verify on-chain CIRC payments (server). **Zero runtime dependencies.**
|
|
4
|
+
|
|
5
|
+
Part of the **[Circuit SDK](https://github.com/Circuit-LLM/circuit-sdk)** — every paid call in the ecosystem (inference, data) runs on this. [Full guide →](https://github.com/Circuit-LLM/circuit-sdk/blob/main/docs/x402.md)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @circuit-llm/x402
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Pay (client)
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { X402Client } from '@circuit-llm/x402';
|
|
17
|
+
|
|
18
|
+
const client = new X402Client({ wallet }); // wallet: a PaymentWallet — see @circuit-llm/wallet
|
|
19
|
+
const res = await client.fetch('https://gateway.circuitllm.xyz/v1/chat/completions', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
|
|
22
|
+
});
|
|
23
|
+
// On HTTP 402 it reads the CIRC price, pays on-chain, and retries with the tx signature — transparently.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Verify (server)
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { verifyPaymentTx } from '@circuit-llm/x402';
|
|
30
|
+
|
|
31
|
+
const ok = await verifyPaymentTx(signature, { expectRaw, recipient, rpcUrl });
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Built-in spend caps (`maxSpendRaw`, `maxTotalSpendRaw`), replay protection, and a CIRC/USD oracle. CIRC is a Token-2022 mint (`8fQ…pump`, 6 decimals).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
declare const CIRC_MINT = "8fQgfsRnRkKSeNUhevT7wp8mhNvMSJdLn1fJi4oVpump";
|
|
2
|
+
declare const CIRC_TOKEN_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
3
|
+
declare const CIRC_DECIMALS = 6;
|
|
4
|
+
/** A payment signature is single-use and expires this long after the tx timestamp. */
|
|
5
|
+
declare const MAX_TX_AGE_MS: number;
|
|
6
|
+
declare const JUPITER_PRICE_URL = "https://api.jup.ag/price/v3";
|
|
7
|
+
/** Deep fallback CIRC/USD only when the oracle is down AND no last-known price is fresh. */
|
|
8
|
+
declare const FALLBACK_CIRC_USD = 0.0001;
|
|
9
|
+
|
|
10
|
+
/** Format raw CIRC base units as a human string, e.g. 300000000n → "300.00". */
|
|
11
|
+
declare function formatCirc(raw: bigint): string;
|
|
12
|
+
/** Raw CIRC base units required for a USD price at a given CIRC/USD rate. Rounds UP in RAW units (NOT to
|
|
13
|
+
* a whole CIRC token) so a request is charged its fair value, never bumped to the next token boundary —
|
|
14
|
+
* byte-identical to the server (circuit-data-api/lib/pricing.js) + circuit-py. Pure + deterministic. */
|
|
15
|
+
declare function circRawFromUsd(usdPrice: number, circUsd: number): bigint;
|
|
16
|
+
interface PaymentQuote {
|
|
17
|
+
recipient: string;
|
|
18
|
+
amountRaw: bigint;
|
|
19
|
+
amountDisplay: string;
|
|
20
|
+
token: string;
|
|
21
|
+
tokenDecimals: number;
|
|
22
|
+
usdEquivalent?: number;
|
|
23
|
+
network?: string;
|
|
24
|
+
/** The endpoint path, if known (for logging / approval hooks). */
|
|
25
|
+
path?: string;
|
|
26
|
+
/** The raw `payment` block as received. */
|
|
27
|
+
raw: unknown;
|
|
28
|
+
}
|
|
29
|
+
/** Parse a 402 response body's `payment` block into a typed quote, or null if it
|
|
30
|
+
* lacks usable requirements (no recipient / amountRaw). */
|
|
31
|
+
declare function parse402(body: unknown, path?: string): PaymentQuote | null;
|
|
32
|
+
interface OracleOptions {
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
jupiterKey?: string;
|
|
35
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
cacheTtlMs?: number;
|
|
38
|
+
lastKnownTtlMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** Live CIRC/USD price with the same 60s cache + 15-min last-known-good fallback
|
|
41
|
+
* as the server, so a single Jupiter hiccup never over-charges. Injectable for tests. */
|
|
42
|
+
declare class CircPriceOracle {
|
|
43
|
+
private cached;
|
|
44
|
+
private cachedAt;
|
|
45
|
+
private lastKnown;
|
|
46
|
+
private lastKnownAt;
|
|
47
|
+
private readonly fetchImpl;
|
|
48
|
+
private readonly jupiterKey;
|
|
49
|
+
private readonly now;
|
|
50
|
+
private readonly cacheTtlMs;
|
|
51
|
+
private readonly lastKnownTtlMs;
|
|
52
|
+
constructor(opts?: OracleOptions);
|
|
53
|
+
/** Current CIRC/USD, or null if the oracle is down and no fresh last-known exists. */
|
|
54
|
+
get(): Promise<number | null>;
|
|
55
|
+
/** Raw CIRC required for a USD price, using the live (or last-known/fallback) rate. */
|
|
56
|
+
requiredRaw(usdPrice: number): Promise<bigint>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The only thing the payment spine needs from a wallet. @circuit-llm/wallet (Phase 1)
|
|
60
|
+
* is one implementation; any object with this shape works (structural typing). */
|
|
61
|
+
interface PaymentWallet {
|
|
62
|
+
/** Send `amountRaw` base units of CIRC to `recipient`; resolve to the tx signature. */
|
|
63
|
+
sendCirc(recipient: string, amountRaw: bigint): Promise<string>;
|
|
64
|
+
/** Optional payer address, for logging / spend tracking (null when read-only). */
|
|
65
|
+
readonly address?: string | null;
|
|
66
|
+
}
|
|
67
|
+
declare class PaymentRequiredError extends Error {
|
|
68
|
+
readonly quote: PaymentQuote | null;
|
|
69
|
+
constructor(quote: PaymentQuote | null);
|
|
70
|
+
}
|
|
71
|
+
declare class SpendCapError extends Error {
|
|
72
|
+
readonly quote: PaymentQuote;
|
|
73
|
+
readonly capRaw: bigint;
|
|
74
|
+
constructor(quote: PaymentQuote, capRaw: bigint);
|
|
75
|
+
}
|
|
76
|
+
declare class RecipientNotAllowedError extends Error {
|
|
77
|
+
readonly quote: PaymentQuote;
|
|
78
|
+
constructor(quote: PaymentQuote);
|
|
79
|
+
}
|
|
80
|
+
declare class BudgetExceededError extends Error {
|
|
81
|
+
readonly quote: PaymentQuote;
|
|
82
|
+
constructor(quote: PaymentQuote, spentRaw: bigint, budgetRaw: bigint);
|
|
83
|
+
}
|
|
84
|
+
declare class X402RequestError extends Error {
|
|
85
|
+
readonly status: number;
|
|
86
|
+
readonly body: unknown;
|
|
87
|
+
constructor(status: number, body: unknown, url: string);
|
|
88
|
+
}
|
|
89
|
+
interface X402JsonResult<T> {
|
|
90
|
+
data: T;
|
|
91
|
+
status: number;
|
|
92
|
+
paymentTx: string | null;
|
|
93
|
+
quote: PaymentQuote | null;
|
|
94
|
+
}
|
|
95
|
+
interface X402Options {
|
|
96
|
+
wallet?: PaymentWallet;
|
|
97
|
+
/** Hard ceiling (raw CIRC base units) per call — refuse to pay a higher quote. */
|
|
98
|
+
maxSpendRaw?: bigint;
|
|
99
|
+
/** Pin the payment recipient: refuse any 402 whose recipient isn't in this set (the Circuit
|
|
100
|
+
* treasury / known payee). Without it, a malicious endpoint dictates where your CIRC goes. */
|
|
101
|
+
allowedRecipients?: string[];
|
|
102
|
+
/** Cumulative ceiling (raw CIRC base units) across ALL calls this client makes — the real drain
|
|
103
|
+
* guard, since maxSpendRaw alone lets a hostile endpoint take the cap on every request. */
|
|
104
|
+
maxTotalSpendRaw?: bigint;
|
|
105
|
+
/** Approval/notification hook; may be async. Throw inside it to abort the payment. */
|
|
106
|
+
onPay?: (quote: PaymentQuote) => void | Promise<void>;
|
|
107
|
+
fetchImpl?: typeof fetch;
|
|
108
|
+
/** ms before the single transient-error retry (after the CIRC was already spent). */
|
|
109
|
+
retryDelayMs?: number;
|
|
110
|
+
}
|
|
111
|
+
interface X402Result {
|
|
112
|
+
resp: Response;
|
|
113
|
+
paymentTx: string | null;
|
|
114
|
+
quote: PaymentQuote | null;
|
|
115
|
+
}
|
|
116
|
+
declare class X402Client {
|
|
117
|
+
private readonly wallet?;
|
|
118
|
+
private readonly maxSpendRaw?;
|
|
119
|
+
private readonly allowedRecipients?;
|
|
120
|
+
private readonly maxTotalSpendRaw?;
|
|
121
|
+
private readonly onPay?;
|
|
122
|
+
private readonly fetchImpl;
|
|
123
|
+
private readonly retryDelayMs;
|
|
124
|
+
private spentRaw;
|
|
125
|
+
constructor(opts?: X402Options);
|
|
126
|
+
/** Total CIRC (raw base units) this client has paid so far. */
|
|
127
|
+
get totalSpentRaw(): bigint;
|
|
128
|
+
/** Generic pay-and-retry. `requestFn(extraHeaders)` performs one request; on 402 it
|
|
129
|
+
* is called a second time with the X-Payment-Signature header. */
|
|
130
|
+
request(requestFn: (extraHeaders: Record<string, string>) => Promise<Response>): Promise<X402Result>;
|
|
131
|
+
/** Convenience around a URL + RequestInit (merges the X-Payment-Signature header). */
|
|
132
|
+
fetch(url: string | URL, init?: RequestInit): Promise<Response>;
|
|
133
|
+
/** Pay-and-parse-JSON: like fetch() but parses the body and throws X402RequestError
|
|
134
|
+
* on a non-2xx final response. NOT for streaming responses — use request() for those. */
|
|
135
|
+
json<T = unknown>(url: string | URL, init?: RequestInit): Promise<X402JsonResult<T>>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Minimal shape of a parsed token balance (subset of @solana/web3.js). */
|
|
139
|
+
interface TokenBalance {
|
|
140
|
+
accountIndex: number;
|
|
141
|
+
mint: string;
|
|
142
|
+
owner?: string;
|
|
143
|
+
programId?: string;
|
|
144
|
+
uiTokenAmount?: {
|
|
145
|
+
amount?: string;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** Minimal shape of a parsed transaction. */
|
|
149
|
+
interface ParsedTx {
|
|
150
|
+
blockTime?: number | null;
|
|
151
|
+
meta?: {
|
|
152
|
+
err?: unknown;
|
|
153
|
+
preTokenBalances?: TokenBalance[] | null;
|
|
154
|
+
postTokenBalances?: TokenBalance[] | null;
|
|
155
|
+
} | null;
|
|
156
|
+
}
|
|
157
|
+
/** Just the method verifyPaymentTx needs — satisfied by a @solana/web3.js Connection. */
|
|
158
|
+
interface ParsedTxConnection {
|
|
159
|
+
getParsedTransaction(signature: string, opts: {
|
|
160
|
+
commitment: string;
|
|
161
|
+
maxSupportedTransactionVersion?: number;
|
|
162
|
+
}): Promise<ParsedTx | null>;
|
|
163
|
+
}
|
|
164
|
+
/** Replay guard — each signature is single-use. In-memory default; pass your own
|
|
165
|
+
* (e.g. disk- or Redis-backed) for multi-process services. */
|
|
166
|
+
interface ReplayStore {
|
|
167
|
+
has(sig: string): boolean;
|
|
168
|
+
add(sig: string): void;
|
|
169
|
+
}
|
|
170
|
+
declare class MemoryReplayStore implements ReplayStore {
|
|
171
|
+
private readonly seen;
|
|
172
|
+
has(sig: string): boolean;
|
|
173
|
+
add(sig: string): void;
|
|
174
|
+
}
|
|
175
|
+
declare class FileReplayStore implements ReplayStore {
|
|
176
|
+
private readonly dir;
|
|
177
|
+
constructor(dir: string);
|
|
178
|
+
private p;
|
|
179
|
+
has(sig: string): boolean;
|
|
180
|
+
add(sig: string): void;
|
|
181
|
+
}
|
|
182
|
+
/** Sum CIRC base units that landed in `treasury` within a parsed tx (post − pre). Pure. */
|
|
183
|
+
declare function circReceived(tx: ParsedTx, treasury: string, mint?: string): bigint;
|
|
184
|
+
interface VerifyOptions {
|
|
185
|
+
connection: ParsedTxConnection;
|
|
186
|
+
/** The wallet that must have received the CIRC. */
|
|
187
|
+
treasury: string;
|
|
188
|
+
replay?: ReplayStore;
|
|
189
|
+
maxAgeMs?: number;
|
|
190
|
+
now?: () => number;
|
|
191
|
+
/** RPC fetch retries (for propagation lag). */
|
|
192
|
+
retries?: number;
|
|
193
|
+
retryDelayMs?: number;
|
|
194
|
+
}
|
|
195
|
+
interface VerifyResult {
|
|
196
|
+
received: bigint;
|
|
197
|
+
required: bigint;
|
|
198
|
+
}
|
|
199
|
+
/** Verify that `txSignature` is a confirmed, recent CIRC payment to `treasury`
|
|
200
|
+
* covering `requiredRaw`. Throws on any failure; marks the signature used on success. */
|
|
201
|
+
declare function verifyPaymentTx(txSignature: string, requiredRaw: bigint, opts: VerifyOptions): Promise<VerifyResult>;
|
|
202
|
+
|
|
203
|
+
export { BudgetExceededError, CIRC_DECIMALS, CIRC_MINT, CIRC_TOKEN_PROGRAM, CircPriceOracle, FALLBACK_CIRC_USD, FileReplayStore, JUPITER_PRICE_URL, MAX_TX_AGE_MS, MemoryReplayStore, type OracleOptions, type ParsedTx, type ParsedTxConnection, type PaymentQuote, PaymentRequiredError, type PaymentWallet, RecipientNotAllowedError, type ReplayStore, SpendCapError, type TokenBalance, type VerifyOptions, type VerifyResult, X402Client, type X402JsonResult, type X402Options, X402RequestError, type X402Result, circRawFromUsd, circReceived, formatCirc, parse402, verifyPaymentTx };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var CIRC_MINT = "8fQgfsRnRkKSeNUhevT7wp8mhNvMSJdLn1fJi4oVpump";
|
|
3
|
+
var CIRC_TOKEN_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
4
|
+
var CIRC_DECIMALS = 6;
|
|
5
|
+
var MAX_TX_AGE_MS = 5 * 6e4;
|
|
6
|
+
var JUPITER_PRICE_URL = "https://api.jup.ag/price/v3";
|
|
7
|
+
var FALLBACK_CIRC_USD = 1e-4;
|
|
8
|
+
|
|
9
|
+
// src/quote.ts
|
|
10
|
+
function formatCirc(raw) {
|
|
11
|
+
return (Number(raw) / 10 ** CIRC_DECIMALS).toFixed(2);
|
|
12
|
+
}
|
|
13
|
+
function circRawFromUsd(usdPrice, circUsd) {
|
|
14
|
+
const rate = circUsd > 0 ? circUsd : FALLBACK_CIRC_USD;
|
|
15
|
+
return BigInt(Math.ceil(usdPrice / rate * 10 ** CIRC_DECIMALS));
|
|
16
|
+
}
|
|
17
|
+
function parse402(body, path) {
|
|
18
|
+
const pay = body?.payment;
|
|
19
|
+
if (!pay || !pay.recipient || pay.amountRaw == null) return null;
|
|
20
|
+
let amountRaw;
|
|
21
|
+
try {
|
|
22
|
+
amountRaw = BigInt(pay.amountRaw);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
recipient: String(pay.recipient),
|
|
28
|
+
amountRaw,
|
|
29
|
+
amountDisplay: pay.amountDisplay ?? `${formatCirc(amountRaw)} CIRC`,
|
|
30
|
+
token: pay.token ?? CIRC_MINT,
|
|
31
|
+
tokenDecimals: pay.tokenDecimals ?? CIRC_DECIMALS,
|
|
32
|
+
usdEquivalent: pay.usdEquivalent,
|
|
33
|
+
network: pay.network,
|
|
34
|
+
path,
|
|
35
|
+
raw: pay
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
var CircPriceOracle = class {
|
|
39
|
+
cached = null;
|
|
40
|
+
cachedAt = 0;
|
|
41
|
+
lastKnown = null;
|
|
42
|
+
lastKnownAt = 0;
|
|
43
|
+
fetchImpl;
|
|
44
|
+
jupiterKey;
|
|
45
|
+
now;
|
|
46
|
+
cacheTtlMs;
|
|
47
|
+
lastKnownTtlMs;
|
|
48
|
+
constructor(opts = {}) {
|
|
49
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
50
|
+
this.jupiterKey = opts.jupiterKey ?? "";
|
|
51
|
+
this.now = opts.now ?? Date.now;
|
|
52
|
+
this.cacheTtlMs = opts.cacheTtlMs ?? 6e4;
|
|
53
|
+
this.lastKnownTtlMs = opts.lastKnownTtlMs ?? 15 * 6e4;
|
|
54
|
+
}
|
|
55
|
+
/** Current CIRC/USD, or null if the oracle is down and no fresh last-known exists. */
|
|
56
|
+
async get() {
|
|
57
|
+
const t = this.now();
|
|
58
|
+
if (this.cached !== null && t - this.cachedAt < this.cacheTtlMs) return this.cached;
|
|
59
|
+
try {
|
|
60
|
+
const headers = this.jupiterKey ? { "x-api-key": this.jupiterKey } : void 0;
|
|
61
|
+
const resp = await this.fetchImpl(`${JUPITER_PRICE_URL}?ids=${CIRC_MINT}`, {
|
|
62
|
+
headers,
|
|
63
|
+
signal: AbortSignal.timeout(5e3)
|
|
64
|
+
});
|
|
65
|
+
if (!resp.ok) throw new Error(`Jupiter ${resp.status}`);
|
|
66
|
+
const data = await resp.json();
|
|
67
|
+
const price = data[CIRC_MINT]?.usdPrice ?? null;
|
|
68
|
+
if (price && price > 0) {
|
|
69
|
+
this.cached = price;
|
|
70
|
+
this.cachedAt = t;
|
|
71
|
+
this.lastKnown = price;
|
|
72
|
+
this.lastKnownAt = t;
|
|
73
|
+
return price;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
if (this.lastKnown && this.now() - this.lastKnownAt < this.lastKnownTtlMs) return this.lastKnown;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/** Raw CIRC required for a USD price, using the live (or last-known/fallback) rate. */
|
|
81
|
+
async requiredRaw(usdPrice) {
|
|
82
|
+
const circUsd = await this.get();
|
|
83
|
+
return circRawFromUsd(usdPrice, circUsd && circUsd > 0 ? circUsd : FALLBACK_CIRC_USD);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// src/client.ts
|
|
88
|
+
var PaymentRequiredError = class extends Error {
|
|
89
|
+
quote;
|
|
90
|
+
constructor(quote) {
|
|
91
|
+
super(`Payment required: ${quote?.amountDisplay ?? "?"} (no wallet configured)`);
|
|
92
|
+
this.name = "PaymentRequiredError";
|
|
93
|
+
this.quote = quote;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var SpendCapError = class extends Error {
|
|
97
|
+
quote;
|
|
98
|
+
capRaw;
|
|
99
|
+
constructor(quote, capRaw) {
|
|
100
|
+
super(`Quoted ${quote.amountDisplay} (${quote.amountRaw} raw) exceeds the spend cap of ${capRaw} raw CIRC`);
|
|
101
|
+
this.name = "SpendCapError";
|
|
102
|
+
this.quote = quote;
|
|
103
|
+
this.capRaw = capRaw;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var RecipientNotAllowedError = class extends Error {
|
|
107
|
+
quote;
|
|
108
|
+
constructor(quote) {
|
|
109
|
+
super(`402 recipient ${quote.recipient} is not in the allowed treasury set \u2014 refusing to pay`);
|
|
110
|
+
this.name = "RecipientNotAllowedError";
|
|
111
|
+
this.quote = quote;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
var BudgetExceededError = class extends Error {
|
|
115
|
+
quote;
|
|
116
|
+
constructor(quote, spentRaw, budgetRaw) {
|
|
117
|
+
super(`Paying ${quote.amountRaw} would exceed the session budget (${spentRaw}/${budgetRaw} raw CIRC spent)`);
|
|
118
|
+
this.name = "BudgetExceededError";
|
|
119
|
+
this.quote = quote;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var X402RequestError = class extends Error {
|
|
123
|
+
status;
|
|
124
|
+
body;
|
|
125
|
+
constructor(status, body, url) {
|
|
126
|
+
super(`HTTP ${status} on ${url}`);
|
|
127
|
+
this.name = "X402RequestError";
|
|
128
|
+
this.status = status;
|
|
129
|
+
this.body = body;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
var TRANSIENT = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
133
|
+
var X402Client = class {
|
|
134
|
+
wallet;
|
|
135
|
+
maxSpendRaw;
|
|
136
|
+
allowedRecipients;
|
|
137
|
+
maxTotalSpendRaw;
|
|
138
|
+
onPay;
|
|
139
|
+
fetchImpl;
|
|
140
|
+
retryDelayMs;
|
|
141
|
+
spentRaw = 0n;
|
|
142
|
+
// cumulative CIRC paid by this client
|
|
143
|
+
constructor(opts = {}) {
|
|
144
|
+
this.wallet = opts.wallet;
|
|
145
|
+
this.maxSpendRaw = opts.maxSpendRaw;
|
|
146
|
+
this.allowedRecipients = opts.allowedRecipients ? new Set(opts.allowedRecipients) : void 0;
|
|
147
|
+
this.maxTotalSpendRaw = opts.maxTotalSpendRaw;
|
|
148
|
+
this.onPay = opts.onPay;
|
|
149
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
150
|
+
this.retryDelayMs = opts.retryDelayMs ?? 2e3;
|
|
151
|
+
}
|
|
152
|
+
/** Total CIRC (raw base units) this client has paid so far. */
|
|
153
|
+
get totalSpentRaw() {
|
|
154
|
+
return this.spentRaw;
|
|
155
|
+
}
|
|
156
|
+
/** Generic pay-and-retry. `requestFn(extraHeaders)` performs one request; on 402 it
|
|
157
|
+
* is called a second time with the X-Payment-Signature header. */
|
|
158
|
+
async request(requestFn) {
|
|
159
|
+
let resp = await requestFn({});
|
|
160
|
+
if (resp.ok || resp.status !== 402) return { resp, paymentTx: null, quote: null };
|
|
161
|
+
const body = await resp.clone().json().catch(() => ({}));
|
|
162
|
+
const quote = parse402(body);
|
|
163
|
+
if (!quote) throw new Error("Endpoint returned 402 without usable payment requirements");
|
|
164
|
+
if (!this.wallet) throw new PaymentRequiredError(quote);
|
|
165
|
+
if (this.maxSpendRaw != null && quote.amountRaw > this.maxSpendRaw) {
|
|
166
|
+
throw new SpendCapError(quote, this.maxSpendRaw);
|
|
167
|
+
}
|
|
168
|
+
if (this.allowedRecipients && !this.allowedRecipients.has(quote.recipient)) {
|
|
169
|
+
throw new RecipientNotAllowedError(quote);
|
|
170
|
+
}
|
|
171
|
+
if (this.maxTotalSpendRaw != null && this.spentRaw + quote.amountRaw > this.maxTotalSpendRaw) {
|
|
172
|
+
throw new BudgetExceededError(quote, this.spentRaw, this.maxTotalSpendRaw);
|
|
173
|
+
}
|
|
174
|
+
await this.onPay?.(quote);
|
|
175
|
+
const txSig = await this.wallet.sendCirc(quote.recipient, quote.amountRaw);
|
|
176
|
+
this.spentRaw += quote.amountRaw;
|
|
177
|
+
resp = await requestFn({ "X-Payment-Signature": txSig });
|
|
178
|
+
if (!resp.ok && TRANSIENT.has(resp.status)) {
|
|
179
|
+
await new Promise((r) => setTimeout(r, this.retryDelayMs));
|
|
180
|
+
resp = await requestFn({ "X-Payment-Signature": txSig });
|
|
181
|
+
}
|
|
182
|
+
return { resp, paymentTx: txSig, quote };
|
|
183
|
+
}
|
|
184
|
+
/** Convenience around a URL + RequestInit (merges the X-Payment-Signature header). */
|
|
185
|
+
async fetch(url, init = {}) {
|
|
186
|
+
const baseHeaders = init.headers ?? {};
|
|
187
|
+
const { resp } = await this.request(
|
|
188
|
+
(extra) => this.fetchImpl(url, { ...init, headers: { ...baseHeaders, ...extra } })
|
|
189
|
+
);
|
|
190
|
+
return resp;
|
|
191
|
+
}
|
|
192
|
+
/** Pay-and-parse-JSON: like fetch() but parses the body and throws X402RequestError
|
|
193
|
+
* on a non-2xx final response. NOT for streaming responses — use request() for those. */
|
|
194
|
+
async json(url, init = {}) {
|
|
195
|
+
const baseHeaders = init.headers ?? {};
|
|
196
|
+
const { resp, paymentTx, quote } = await this.request(
|
|
197
|
+
(extra) => this.fetchImpl(url, { ...init, headers: { ...baseHeaders, ...extra } })
|
|
198
|
+
);
|
|
199
|
+
const text = await resp.text();
|
|
200
|
+
let body;
|
|
201
|
+
try {
|
|
202
|
+
body = text ? JSON.parse(text) : null;
|
|
203
|
+
} catch {
|
|
204
|
+
body = text;
|
|
205
|
+
}
|
|
206
|
+
if (!resp.ok) throw new X402RequestError(resp.status, body, String(url));
|
|
207
|
+
return { data: body, status: resp.status, paymentTx, quote };
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/verify.ts
|
|
212
|
+
import { mkdirSync, existsSync, writeFileSync } from "fs";
|
|
213
|
+
import { join } from "path";
|
|
214
|
+
var MemoryReplayStore = class {
|
|
215
|
+
seen = /* @__PURE__ */ new Set();
|
|
216
|
+
has(sig) {
|
|
217
|
+
return this.seen.has(sig);
|
|
218
|
+
}
|
|
219
|
+
add(sig) {
|
|
220
|
+
this.seen.add(sig);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
var FileReplayStore = class {
|
|
224
|
+
dir;
|
|
225
|
+
constructor(dir) {
|
|
226
|
+
this.dir = dir;
|
|
227
|
+
mkdirSync(dir, { recursive: true });
|
|
228
|
+
}
|
|
229
|
+
p(sig) {
|
|
230
|
+
return join(this.dir, sig.replace(/[^A-Za-z0-9_-]/g, "_"));
|
|
231
|
+
}
|
|
232
|
+
has(sig) {
|
|
233
|
+
return existsSync(this.p(sig));
|
|
234
|
+
}
|
|
235
|
+
add(sig) {
|
|
236
|
+
try {
|
|
237
|
+
writeFileSync(this.p(sig), "", { flag: "wx" });
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
function circReceived(tx, treasury, mint = CIRC_MINT) {
|
|
243
|
+
const pre = tx.meta?.preTokenBalances ?? [];
|
|
244
|
+
const post = tx.meta?.postTokenBalances ?? [];
|
|
245
|
+
let received = 0n;
|
|
246
|
+
for (const p of post) {
|
|
247
|
+
if (p.mint !== mint) continue;
|
|
248
|
+
if (p.owner !== treasury) continue;
|
|
249
|
+
const preBal = pre.find((b) => b.accountIndex === p.accountIndex);
|
|
250
|
+
const preAmt = BigInt(preBal?.uiTokenAmount?.amount ?? "0");
|
|
251
|
+
const postAmt = BigInt(p.uiTokenAmount?.amount ?? "0");
|
|
252
|
+
if (postAmt > preAmt) received += postAmt - preAmt;
|
|
253
|
+
}
|
|
254
|
+
return received;
|
|
255
|
+
}
|
|
256
|
+
async function verifyPaymentTx(txSignature, requiredRaw, opts) {
|
|
257
|
+
const { connection, treasury, replay } = opts;
|
|
258
|
+
const maxAgeMs = opts.maxAgeMs ?? MAX_TX_AGE_MS;
|
|
259
|
+
const now = opts.now ?? Date.now;
|
|
260
|
+
const retries = opts.retries ?? 4;
|
|
261
|
+
const retryDelayMs = opts.retryDelayMs ?? 2500;
|
|
262
|
+
if (replay?.has(txSignature)) {
|
|
263
|
+
throw new Error("Transaction signature already used \u2014 each signature is single-use");
|
|
264
|
+
}
|
|
265
|
+
let tx = null;
|
|
266
|
+
let lastErr = new Error("Transaction not found on chain");
|
|
267
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
268
|
+
if (attempt > 0) await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
269
|
+
try {
|
|
270
|
+
tx = await connection.getParsedTransaction(txSignature, {
|
|
271
|
+
commitment: "confirmed",
|
|
272
|
+
maxSupportedTransactionVersion: 0
|
|
273
|
+
});
|
|
274
|
+
if (tx) break;
|
|
275
|
+
lastErr = new Error("Transaction not found on chain (may not be confirmed yet)");
|
|
276
|
+
} catch (e) {
|
|
277
|
+
lastErr = new Error(`RPC error: ${e.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!tx) throw lastErr;
|
|
281
|
+
if (tx.meta?.err) throw new Error(`Transaction failed on chain: ${JSON.stringify(tx.meta.err)}`);
|
|
282
|
+
if (tx.blockTime != null) {
|
|
283
|
+
if (now() - tx.blockTime * 1e3 > maxAgeMs) throw new Error("Transaction is too old (outside the max age window)");
|
|
284
|
+
} else if (!replay) {
|
|
285
|
+
throw new Error("Payment tx has no blockTime and no replay store is configured \u2014 refusing (use a durable ReplayStore)");
|
|
286
|
+
}
|
|
287
|
+
const received = circReceived(tx, treasury);
|
|
288
|
+
if (received < requiredRaw) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Insufficient payment: received ${formatCirc(received)} CIRC, required ${formatCirc(requiredRaw)} CIRC`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
replay?.add(txSignature);
|
|
294
|
+
return { received, required: requiredRaw };
|
|
295
|
+
}
|
|
296
|
+
export {
|
|
297
|
+
BudgetExceededError,
|
|
298
|
+
CIRC_DECIMALS,
|
|
299
|
+
CIRC_MINT,
|
|
300
|
+
CIRC_TOKEN_PROGRAM,
|
|
301
|
+
CircPriceOracle,
|
|
302
|
+
FALLBACK_CIRC_USD,
|
|
303
|
+
FileReplayStore,
|
|
304
|
+
JUPITER_PRICE_URL,
|
|
305
|
+
MAX_TX_AGE_MS,
|
|
306
|
+
MemoryReplayStore,
|
|
307
|
+
PaymentRequiredError,
|
|
308
|
+
RecipientNotAllowedError,
|
|
309
|
+
SpendCapError,
|
|
310
|
+
X402Client,
|
|
311
|
+
X402RequestError,
|
|
312
|
+
circRawFromUsd,
|
|
313
|
+
circReceived,
|
|
314
|
+
formatCirc,
|
|
315
|
+
parse402,
|
|
316
|
+
verifyPaymentTx
|
|
317
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@circuit-llm/x402",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Circuit SDK payment spine — pay any x402 endpoint in CIRC (client) + verify on-chain CIRC payments (server). Zero runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"development": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --experimental-strip-types --conditions=development --test test/*.test.ts",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
17
|
+
"build": "tsup src/index.ts --format esm --dts --clean --out-dir dist",
|
|
18
|
+
"prepack": "tsup src/index.ts --format esm --dts --clean --out-dir dist"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Circuit-LLM/circuit-sdk.git",
|
|
31
|
+
"directory": "packages/x402"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/Circuit-LLM/circuit-sdk/tree/main/packages/x402#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Circuit-LLM/circuit-sdk/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
}
|
|
40
|
+
}
|