mcp-server-madeonsol 2.0.0 → 3.0.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/README.md +39 -3
- package/dist/index.js +7 -12
- package/dist/solana-payment.d.ts +59 -0
- package/dist/solana-payment.js +259 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/glama.json +458 -458
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ MCP server for [MadeOnSol](https://madeonsol.com) Solana KOL intelligence API. U
|
|
|
15
15
|
|
|
16
16
|
> Real-time Solana trading intelligence: track 2,000+ KOL wallets with <3s latency on paid keys and x402 pay-per-call (free-tier live feeds are 5-min delayed), score 85K+ Pump.fun deployers, surface deshred deploy signals **~500ms before on-chain confirmation**, detect multi-KOL coordination, surface bundle-cohort holdings (which same-slot wallets still hold a token's supply), verify any wallet's CURRENT on-chain holdings straight from its token accounts, and stream every DEX trade across 9+ programs. Free tier: 200 requests/day across 40+ endpoints (live feeds 5-min delayed) — no signup payment. Get a key at [madeonsol.com/pricing](https://madeonsol.com/pricing).
|
|
17
17
|
|
|
18
|
+
> **New in 3.0.0 — BREAKING for x402 (keyless) mode only: five env vars are now required and startup FAILS instead of silently degrading (security fix, SDK-01).** Set `X402_PAY_TO`, `X402_FEE_PAYER`, `X402_MAX_AMOUNT_ATOMIC`, `X402_MAX_TOTAL_AMOUNT_ATOMIC` and `SVM_RPC_URL` alongside `SVM_PRIVATE_KEY`. Before, keyless mode signed whatever Solana USDC amount, recipient and fee payer a 402 challenge asked for. Now every challenge is checked BEFORE signing against a trusted merchant `payTo`, a trusted facilitator `feePayer` (which must differ from your wallet), the USDC mint, `solana:5eykt…` mainnet, the `exact` scheme, a per-call cap and a lifetime cap. Use the canonical values in the keyless section below; caps must be at least `20000` (0.02 USDC) per call to reach every endpoint. The budget is per client instance / process: not wallet-wide, not shared between processes, reset on a new instance or restart. Keyless requires the base URL exactly `https://madeonsol.com`. **API-key (`msk_`) users: no change, no new config.** HTTP mode still refuses wallet signers (SEC-02, unchanged).
|
|
19
|
+
|
|
18
20
|
> **New in 2.0.0 — BREAKING: HTTP mode now requires a separate token (security fix, SEC-02).** If you run this server with `MCP_TRANSPORT=http`, you must now also set `MCP_HTTP_TOKEN` (a random secret you generate) and send it as `Authorization: Bearer <token>` on **every** request, including `GET /health` and the server-card endpoint — previously, anything that could reach the bound port could call every tool using the operator's own API key with no authentication at all. HTTP mode is now hard-restricted to literal loopback (`127.0.0.1` / `::1`), rejects `Origin`/`X-Forwarded-*` headers and duplicate `Authorization`/`Host` headers outright, exposes only `POST /mcp` plus the two GET routes, and **refuses to start** if a wallet/payment signer is configured — HTTP mode can never carry a payer key. **If you use the default `stdio` transport (Claude Desktop, Cursor, most MCP clients), nothing changes — no action needed.** This is a single shared-operator-token fix, not a new multi-user or OAuth system; each HTTP caller still shares the operator's own MadeOnSol API key. Full writeup: `docs/audit/SEC02_PRIVATE_HTTP_MCP.md`.
|
|
19
21
|
>
|
|
20
22
|
> **New in 1.27.1 — the server now reports MCP `instructions`.** The `initialize` response's `instructions` field (operational guidance for the calling agent — distinct from this README/package description) was never set; directories that introspect the live server (Glama) reported "no recorded MCP instructions." No new tools; still 104 total.
|
|
@@ -99,7 +101,7 @@ Restart Claude Desktop and ask: *"What are KOLs buying right now?"*
|
|
|
99
101
|
|
|
100
102
|
## AI agent quickstart (x402 / pay-per-call)
|
|
101
103
|
|
|
102
|
-
Building an autonomous agent? Skip the signup.
|
|
104
|
+
Building an autonomous agent? Skip the signup. Configure a **funded Solana wallet** and an explicit payment budget. Paid tool calls authorize micropayments within that budget over [x402](https://x402.org) — no API key, no account, no rate-limit dance.
|
|
103
105
|
|
|
104
106
|
```json
|
|
105
107
|
{
|
|
@@ -107,7 +109,12 @@ Building an autonomous agent? Skip the signup. Point a **funded Solana wallet**
|
|
|
107
109
|
"madeonsol": {
|
|
108
110
|
"command": "mcp-server-madeonsol",
|
|
109
111
|
"env": {
|
|
110
|
-
"SVM_PRIVATE_KEY": "<base58 solana private key>"
|
|
112
|
+
"SVM_PRIVATE_KEY": "<base58 solana private key>",
|
|
113
|
+
"X402_PAY_TO": "GLu63pRCYrp4BJu5P5ciYKxgeZFW9c8TJ8jWzK3TB9AR",
|
|
114
|
+
"X402_FEE_PAYER": "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4",
|
|
115
|
+
"X402_MAX_AMOUNT_ATOMIC": "20000",
|
|
116
|
+
"X402_MAX_TOTAL_AMOUNT_ATOMIC": "1000000",
|
|
117
|
+
"SVM_RPC_URL": "https://<your-trusted-solana-rpc>"
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
120
|
}
|
|
@@ -116,7 +123,7 @@ Building an autonomous agent? Skip the signup. Point a **funded Solana wallet**
|
|
|
116
123
|
|
|
117
124
|
How it works:
|
|
118
125
|
|
|
119
|
-
- The wallet behind `SVM_PRIVATE_KEY` settles each request as a **USDC micropayment on Solana** (~$0.005–$0.02 per call, settled on-chain). No subscription
|
|
126
|
+
- The wallet behind `SVM_PRIVATE_KEY` settles each request as a **USDC micropayment on Solana** (~$0.005–$0.02 per call, settled on-chain). No subscription; the configured authorization budget bounds spending.
|
|
120
127
|
- The keyless catalog covers **25 endpoints** — the latest additions: token candles ($0.01), almost-bonded ($0.01), top-traders ($0.02), cap-table ($0.02), sniper recent deploys ($0.01), token flow ($0.01), and deployer trajectory ($0.01).
|
|
121
128
|
- The free **`madeonsol_discovery`** tool needs no auth and returns every endpoint with its exact per-call price — call it first to see what each tool costs.
|
|
122
129
|
- Install the x402 peer deps alongside the server (only required for this mode):
|
|
@@ -129,6 +136,34 @@ How it works:
|
|
|
129
136
|
|
|
130
137
|
Prefer a fixed monthly bill, free tier, or no wallet? Use the developer path below.
|
|
131
138
|
|
|
139
|
+
## Required payment policy (breaking keyless upgrade)
|
|
140
|
+
|
|
141
|
+
Keyless Solana payments require an explicit trusted merchant, facilitator and authorization budget. API-key mode is unchanged and takes precedence over a configured wallet.
|
|
142
|
+
|
|
143
|
+
Only exact payments in mainnet USDC are permitted. Set the two addresses from your trusted operator configuration, independently of a server challenge. `SVM_RPC_URL` must be your trusted HTTPS RPC; there is no public RPC fallback. The agent wallet cannot also be the facilitator fee payer.
|
|
144
|
+
|
|
145
|
+
| Setting | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `X402_PAY_TO` | Trusted merchant wallet receiving USDC |
|
|
148
|
+
| `X402_FEE_PAYER` | Trusted facilitator wallet paying transaction fees |
|
|
149
|
+
| `X402_MAX_AMOUNT_ATOMIC` | Maximum per payment, as a positive integer string |
|
|
150
|
+
| `X402_MAX_TOTAL_AMOUNT_ATOMIC` | Lifetime authorization allowance, as a positive integer string |
|
|
151
|
+
| `SVM_RPC_URL` | Explicit trusted HTTPS Solana RPC URL |
|
|
152
|
+
|
|
153
|
+
USDC uses 6 decimals: `20000` = 0.02 USDC and `1000000` = 1 USDC. Choose limits that cover the endpoints you intend to use; these examples are not a price guarantee.
|
|
154
|
+
|
|
155
|
+
**Canonical MadeOnSol values (Solana mainnet USDC).** Pinned here (GitHub + npm README) so you do not have to take them from a 402:
|
|
156
|
+
- merchant `payTo` / `X402_PAY_TO`: `GLu63pRCYrp4BJu5P5ciYKxgeZFW9c8TJ8jWzK3TB9AR` (also shown on https://madeonsol.com/x402 and https://madeonsol.com/.well-known/x402)
|
|
157
|
+
- facilitator `feePayer` / `X402_FEE_PAYER`: `2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4`. This is the fee payer of **PayAI**, the third-party facilitator MadeOnSol's Solana rail uses. If PayAI rotates it, keyless calls fail closed (the client refuses to sign) until you update this value; a MadeOnSol release will announce the new one.
|
|
158
|
+
- prices: Solana legs are 5000–20000 atomic (0.005–0.02 USDC), so `maxAmountAtomic` / `X402_MAX_AMOUNT_ATOMIC` must be at least `20000` to reach every endpoint.
|
|
159
|
+
|
|
160
|
+
The budget is per client instance / process: not wallet-wide, not shared between processes, reset when a new instance or process starts. Keyless mode requires the base URL exactly `https://madeonsol.com`.
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
The allowance is reserved before concurrent calls can approve/sign. An unsigned denial releases it; entering payment creation retains it even if RPC, signing or the paid response fails. It measures **authorized attempts, not settled spend**. There is no automatic refund or payment replay. A timeout cannot undo a proof already sent.
|
|
164
|
+
|
|
165
|
+
The allowance is per server process; restarting starts a new allowance. Reuse the running process. Multiple MCP servers or other clients sharing a wallet need an external shared budget. Missing/invalid keyless settings stop startup; wallet signing remains restricted to stdio. The payment attempt timeout defaults to 30 seconds.
|
|
166
|
+
|
|
132
167
|
## Authentication
|
|
133
168
|
|
|
134
169
|
Two options (in priority order):
|
|
@@ -389,3 +424,4 @@ Free tier returns the full REST response shape on 40+ endpoints — real wallets
|
|
|
389
424
|
## License
|
|
390
425
|
|
|
391
426
|
MIT
|
|
427
|
+
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { SolanaPaymentBudget, createSolanaPaidFetch, solanaPaymentPolicyFromConfig } from "./solana-payment.js";
|
|
6
7
|
import { VERSION } from "./version.js";
|
|
7
8
|
import { createPrivateHttpServer, readHttpConfig } from "./http-security.js";
|
|
8
9
|
// MCP `initialize` response `instructions` field (ServerOptions.instructions in
|
|
@@ -55,22 +56,16 @@ async function initAuth() {
|
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
if (mode === "x402" && PRIVATE_KEY) {
|
|
59
|
+
const budget = new SolanaPaymentBudget(solanaPaymentPolicyFromConfig(key => process.env[key]));
|
|
58
60
|
try {
|
|
59
|
-
|
|
60
|
-
const { x402Client } = await import("@x402/core/client");
|
|
61
|
-
const { ExactSvmScheme } = await import("@x402/svm/exact/client");
|
|
62
|
-
const { createKeyPairSignerFromBytes } = await import("@solana/kit");
|
|
63
|
-
const { base58 } = await import("@scure/base");
|
|
64
|
-
const signer = await createKeyPairSignerFromBytes(base58.decode(PRIVATE_KEY));
|
|
65
|
-
const client = new x402Client();
|
|
66
|
-
client.register("solana:*", new ExactSvmScheme(signer));
|
|
67
|
-
paidFetch = wrapFetchWithPayment(fetch, client);
|
|
61
|
+
paidFetch = await createSolanaPaidFetch(PRIVATE_KEY, budget, BASE_URL);
|
|
68
62
|
authMode = "x402";
|
|
69
|
-
console.error(
|
|
63
|
+
console.error("[madeonsol-mcp] x402 payments enabled with an explicit authorization budget");
|
|
70
64
|
return;
|
|
71
65
|
}
|
|
72
66
|
catch (err) {
|
|
73
|
-
|
|
67
|
+
// Never silently fall back to unauthenticated mode after a policy/setup failure.
|
|
68
|
+
throw new Error("[madeonsol-mcp] x402 setup failed", { cause: err });
|
|
74
69
|
}
|
|
75
70
|
}
|
|
76
71
|
console.error("\n[madeonsol-mcp] No auth configured — every tool call will fail.\n" +
|
|
@@ -735,7 +730,7 @@ function registerTools(server) {
|
|
|
735
730
|
server.tool("madeonsol_token_buyer_quality", "0–100 buyer-quality score for a token's first-buyer cohort. 5-min cached. Full breakdown on all tiers, incl. dump_cluster_count (3+ dump-cluster wallets in the first-20 → 94% historical dump rate vs 61% base) and recycled_early_buyer_count.", { mint: z.string().describe("Token mint address (base58)") }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ mint }) => ({
|
|
736
731
|
content: [{ type: "text", text: await restQuery("GET", `/tokens/${encodeURIComponent(mint)}/buyer-quality`) }],
|
|
737
732
|
}));
|
|
738
|
-
server.tool("madeonsol_token_risk", "Transparent 0–100 token rug-risk/safety score (higher = riskier). Returns a band (safe/caution/danger), an explainable factors[] array (mint authority, freeze authority, liquidity, transfer fee, token-2022, burn, launch cohort, deployer bond rate, KOL signal, blacklist) each with status/points/detail, and the raw inputs that produced the score. inputs now includes sniper_footprint — the slot-window launch-snipe rollup ({ buys, buyers, sol, supply_pct|null, sniper_wallet_buys, data_available, as_of } | null; buys landing in slots deploy-1..deploy+3). data_available=false means the mint isn't observable in the trade pipeline — NOT zero snipes; null means no rollup yet. Also returns a top-level dev block (deployer self-activity, null when the mint has no deployer-pipeline row): wallet, launchpad, deployed_at, create-tx self-buy snapshot (buy_sol / buy_tokens / buy_supply_pct), post-create rollup (bought_tokens_after — catches the same-second-separate-tx dev buy — sold_tokens, sold_sol, first_sell_at, last_sell_at), LIVE on-chain holdings (holdings_tokens, holdings_supply_pct — pump.fun 1B denominator, null elsewhere — wallet_empty: is the dev wallet empty NOW), and
|
|
733
|
+
server.tool("madeonsol_token_risk", "Transparent 0–100 token rug-risk/safety score (higher = riskier). Returns a band (safe/caution/danger), an explainable factors[] array (mint authority, freeze authority, liquidity, transfer fee, token-2022, burn, launch cohort, deployer bond rate, KOL signal, blacklist) each with status/points/detail, and the raw inputs that produced the score. inputs now includes sniper_footprint — the slot-window launch-snipe rollup ({ buys, buyers, sol, supply_pct|null, sniper_wallet_buys, data_available, as_of } | null; buys landing in slots deploy-1..deploy+3). data_available=false means the mint isn't observable in the trade pipeline — NOT zero snipes; null means no rollup yet. Also returns a top-level dev block (deployer self-activity, null when the mint has no deployer-pipeline row): wallet, launchpad, deployed_at, create-tx self-buy snapshot (buy_sol / buy_tokens / buy_supply_pct), post-create rollup (bought_tokens_after — catches the same-second-separate-tx dev buy — sold_tokens, sold_sol, first_sell_at, last_sell_at), LIVE on-chain holdings (holdings_tokens, holdings_supply_pct — pump.fun 1B denominator, null elsewhere — wallet_empty: is the dev wallet empty NOW), and transfer_status (suspected / none_detected / unknown; transferred_out is its deprecated boolean view). score_version v2 (2026-09-21): the LP factor is 'unknown' unless LP custody is verified — a token-supply burn (token_supply_burn_detected, alias burn_detected) is never LP evidence; assessment.unknown_inputs lists inputs that could not be observed (the band is then never 'safe') and assessment.not_assessed lists dimensions not measured for this token. A failed score-critical read returns HTTP 503 code risk_inputs_unavailable (retry) instead of a score. PRO/ULTRA only — BASIC receives HTTP 403.", { mint: z.string().describe("Token mint address (base58)") }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ mint }) => ({
|
|
739
734
|
content: [{ type: "text", text: await restQuery("GET", `/tokens/${encodeURIComponent(mint)}/risk`) }],
|
|
740
735
|
}));
|
|
741
736
|
server.tool("madeonsol_token_bundle", "Bundle-cohort holdings for a token — which same-slot bundle wallets bought it and how much of supply they still hold (held_pct_of_supply). Rug/insider signal. Returns a `bundle` block (wallet_count, bundle_kind atomic_tx/same_slot/none, held_ratio, held_pct_of_supply [the headline — net held / circulating supply, null if unknown], fully_exited, buy_volume, tokens_held) plus a `wallets[]` array (rank, wallet, held_ratio, has_sold, atomic, is_kol). BASIC get the bundle block only (empty wallets[]); PRO adds top-10 flags-only wallets; ULTRA returns the full cohort with enriched identities (kol_name, win_rate, bot_confidence, tokens_held).", { mint: z.string().describe("Token mint address (base58)") }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ mint }) => ({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const SOLANA_PAYMENT_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
2
|
+
export declare const SOLANA_PAYMENT_ASSET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
3
|
+
interface ApprovedOffer {
|
|
4
|
+
scheme: "exact";
|
|
5
|
+
network: typeof SOLANA_PAYMENT_NETWORK;
|
|
6
|
+
asset: string;
|
|
7
|
+
payTo: string;
|
|
8
|
+
amount: string;
|
|
9
|
+
maxTimeoutSeconds: number;
|
|
10
|
+
extra: {
|
|
11
|
+
feePayer: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
interface ApprovedChallenge {
|
|
15
|
+
x402Version: 2;
|
|
16
|
+
resource: {
|
|
17
|
+
url: string;
|
|
18
|
+
};
|
|
19
|
+
accepts: ApprovedOffer[];
|
|
20
|
+
}
|
|
21
|
+
export interface SolanaPaymentProposal {
|
|
22
|
+
readonly url: string;
|
|
23
|
+
readonly network: string;
|
|
24
|
+
readonly asset: string;
|
|
25
|
+
readonly payTo: string;
|
|
26
|
+
readonly feePayer: string;
|
|
27
|
+
readonly amountAtomic: string;
|
|
28
|
+
}
|
|
29
|
+
export interface SolanaPaymentPolicy {
|
|
30
|
+
/** Trusted merchant wallet, obtained independently of a payment challenge. */
|
|
31
|
+
payTo: string;
|
|
32
|
+
/** Trusted facilitator wallet; must not be the paying agent's wallet. */
|
|
33
|
+
feePayer: string;
|
|
34
|
+
/** Positive atomic USDC integers (6 decimals); numbers are not accepted. */
|
|
35
|
+
maxAmountAtomic: string | bigint;
|
|
36
|
+
maxTotalAmountAtomic: string | bigint;
|
|
37
|
+
/** Explicit trusted HTTPS RPC; there is no public RPC fallback. */
|
|
38
|
+
rpcUrl: string;
|
|
39
|
+
/** Whole payment attempt deadline, default 30 seconds. */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
/** Optional additional approval; only literal true permits signing. */
|
|
42
|
+
beforePayment?: (proposal: SolanaPaymentProposal) => boolean | Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
/** Lifetime authorization allowance, not settled spend. Share one long-lived instance.
|
|
45
|
+
* Retained after entering payment creation, including uncertain/failed outcomes.
|
|
46
|
+
*/
|
|
47
|
+
export declare class SolanaPaymentBudget {
|
|
48
|
+
#private;
|
|
49
|
+
readonly policy: Readonly<SolanaPaymentPolicy>;
|
|
50
|
+
constructor(policy: SolanaPaymentPolicy);
|
|
51
|
+
get authorizedAmountAtomic(): string;
|
|
52
|
+
select(envelope: unknown, requestUrl: string): ApprovedChallenge;
|
|
53
|
+
/** Atomic synchronous reservation before any approval/signing await. */
|
|
54
|
+
reserve(atomic: string): () => void;
|
|
55
|
+
}
|
|
56
|
+
export declare function solanaPaymentPolicyFromConfig(get: (key: string) => string | undefined): SolanaPaymentPolicy;
|
|
57
|
+
/** One challenge and at most one paid request. No automatic payment replay. */
|
|
58
|
+
export declare function createSolanaPaidFetch(privateKey: string, budget: SolanaPaymentBudget, baseUrl: string, transport?: typeof fetch): Promise<typeof fetch>;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Canonical source. Mirrored into the two Solana adapters by
|
|
2
|
+
// packages/sync-solana-payment.mjs; CI rejects drift between published copies.
|
|
3
|
+
export const SOLANA_PAYMENT_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
4
|
+
export const SOLANA_PAYMENT_ASSET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
5
|
+
function fail(message) { throw new Error(`Solana payment policy: ${message}`); }
|
|
6
|
+
const MAX_AMOUNT = (1n << 64n) - 1n;
|
|
7
|
+
function amount(value) {
|
|
8
|
+
if (typeof value !== "bigint" && (typeof value !== "string" || !/^[1-9][0-9]{0,19}$/.test(value))) {
|
|
9
|
+
return fail("amount must be a positive atomic integer string or bigint");
|
|
10
|
+
}
|
|
11
|
+
const parsed = BigInt(value);
|
|
12
|
+
if (parsed <= 0n || parsed > MAX_AMOUNT)
|
|
13
|
+
return fail("amount exceeds uint64 range");
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
function wallet(value) {
|
|
17
|
+
if (typeof value !== "string" || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value))
|
|
18
|
+
return fail("invalid wallet address");
|
|
19
|
+
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
20
|
+
let decoded = 0n;
|
|
21
|
+
for (const char of value)
|
|
22
|
+
decoded = decoded * 58n + BigInt(alphabet.indexOf(char));
|
|
23
|
+
const bytes = decoded === 0n ? 0 : Math.ceil(decoded.toString(16).length / 2);
|
|
24
|
+
if (decoded === 0n || bytes + (value.match(/^1*/)?.[0].length ?? 0) !== 32)
|
|
25
|
+
return fail("invalid wallet address");
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function httpsUrl(value) {
|
|
29
|
+
if (typeof value !== "string")
|
|
30
|
+
return fail("invalid HTTPS URL");
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(value);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return fail("invalid HTTPS URL");
|
|
37
|
+
}
|
|
38
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash)
|
|
39
|
+
return fail("HTTPS URL without credentials or fragment required");
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
/** Lifetime authorization allowance, not settled spend. Share one long-lived instance.
|
|
43
|
+
* Retained after entering payment creation, including uncertain/failed outcomes.
|
|
44
|
+
*/
|
|
45
|
+
export class SolanaPaymentBudget {
|
|
46
|
+
policy;
|
|
47
|
+
#authorized = 0n;
|
|
48
|
+
#max;
|
|
49
|
+
#total;
|
|
50
|
+
constructor(policy) {
|
|
51
|
+
if (!policy)
|
|
52
|
+
fail("paymentPolicy is required for keyless payments");
|
|
53
|
+
const payTo = wallet(policy.payTo), feePayer = wallet(policy.feePayer);
|
|
54
|
+
this.#max = amount(policy.maxAmountAtomic);
|
|
55
|
+
this.#total = amount(policy.maxTotalAmountAtomic);
|
|
56
|
+
const rpcUrl = httpsUrl(policy.rpcUrl).href;
|
|
57
|
+
const timeoutMs = policy.timeoutMs ?? 30_000;
|
|
58
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
|
|
59
|
+
fail("invalid timeoutMs");
|
|
60
|
+
if (policy.beforePayment !== undefined && typeof policy.beforePayment !== "function")
|
|
61
|
+
fail("invalid beforePayment hook");
|
|
62
|
+
this.policy = Object.freeze({ payTo, feePayer, rpcUrl, timeoutMs,
|
|
63
|
+
maxAmountAtomic: this.#max.toString(), maxTotalAmountAtomic: this.#total.toString(), beforePayment: policy.beforePayment });
|
|
64
|
+
}
|
|
65
|
+
get authorizedAmountAtomic() { return this.#authorized.toString(); }
|
|
66
|
+
select(envelope, requestUrl) {
|
|
67
|
+
const challenge = envelope;
|
|
68
|
+
if (!challenge || challenge.x402Version !== 2 || !Array.isArray(challenge.accepts) ||
|
|
69
|
+
challenge.accepts.length < 1 || challenge.accepts.length > 32)
|
|
70
|
+
fail("unsupported payment challenge");
|
|
71
|
+
const request = httpsUrl(requestUrl);
|
|
72
|
+
if (challenge.resource !== undefined) {
|
|
73
|
+
const resource = httpsUrl(challenge.resource?.url);
|
|
74
|
+
// The current server advertises its path without the request query.
|
|
75
|
+
if (resource.origin !== request.origin || resource.pathname !== request.pathname ||
|
|
76
|
+
(resource.search && resource.search !== request.search))
|
|
77
|
+
fail("resource mismatch");
|
|
78
|
+
}
|
|
79
|
+
for (const offer of challenge.accepts) {
|
|
80
|
+
if (!offer || offer.scheme !== "exact" || offer.network !== SOLANA_PAYMENT_NETWORK ||
|
|
81
|
+
offer.asset !== SOLANA_PAYMENT_ASSET || offer.payTo !== this.policy.payTo ||
|
|
82
|
+
offer.extra?.feePayer !== this.policy.feePayer)
|
|
83
|
+
continue;
|
|
84
|
+
if (typeof offer.amount !== "string")
|
|
85
|
+
continue;
|
|
86
|
+
let cost;
|
|
87
|
+
try {
|
|
88
|
+
cost = amount(offer.amount);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (cost > this.#max || !Number.isSafeInteger(offer.maxTimeoutSeconds) || offer.maxTimeoutSeconds <= 0)
|
|
94
|
+
continue;
|
|
95
|
+
// Rebuild instead of forwarding arbitrary remote fields/extensions to a signer.
|
|
96
|
+
const accepted = Object.freeze({ scheme: "exact", network: SOLANA_PAYMENT_NETWORK,
|
|
97
|
+
asset: SOLANA_PAYMENT_ASSET, payTo: this.policy.payTo, amount: cost.toString(),
|
|
98
|
+
maxTimeoutSeconds: offer.maxTimeoutSeconds, extra: Object.freeze({ feePayer: this.policy.feePayer }) });
|
|
99
|
+
return { x402Version: 2, resource: { url: request.href }, accepts: [accepted] };
|
|
100
|
+
}
|
|
101
|
+
return fail("no permitted exact mainnet USDC offer");
|
|
102
|
+
}
|
|
103
|
+
/** Atomic synchronous reservation before any approval/signing await. */
|
|
104
|
+
reserve(atomic) {
|
|
105
|
+
const cost = amount(atomic);
|
|
106
|
+
if (cost > this.#max || this.#authorized + cost > this.#total)
|
|
107
|
+
fail("authorization budget exceeded");
|
|
108
|
+
this.#authorized += cost;
|
|
109
|
+
let released = false;
|
|
110
|
+
return () => { if (!released) {
|
|
111
|
+
this.#authorized -= cost;
|
|
112
|
+
released = true;
|
|
113
|
+
} };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function solanaPaymentPolicyFromConfig(get) {
|
|
117
|
+
const required = (name) => get(name) || fail(`${name} is required in keyless mode`);
|
|
118
|
+
return {
|
|
119
|
+
payTo: required("X402_PAY_TO"), feePayer: required("X402_FEE_PAYER"),
|
|
120
|
+
maxAmountAtomic: required("X402_MAX_AMOUNT_ATOMIC"), maxTotalAmountAtomic: required("X402_MAX_TOTAL_AMOUNT_ATOMIC"),
|
|
121
|
+
rpcUrl: required("SVM_RPC_URL"),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function challengeBody(response) {
|
|
125
|
+
const header = response.headers.get("PAYMENT-REQUIRED");
|
|
126
|
+
if (header) {
|
|
127
|
+
if (header.length > 65_536)
|
|
128
|
+
fail("payment header too large");
|
|
129
|
+
// The protocol library decodes the header. Do not buffer an unrelated body.
|
|
130
|
+
void response.body?.cancel().catch(() => { });
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (!response.body)
|
|
134
|
+
return undefined;
|
|
135
|
+
const reader = response.body.getReader();
|
|
136
|
+
let size = 0;
|
|
137
|
+
const chunks = [];
|
|
138
|
+
try {
|
|
139
|
+
while (true) {
|
|
140
|
+
const { value, done } = await reader.read();
|
|
141
|
+
if (done)
|
|
142
|
+
break;
|
|
143
|
+
size += value.byteLength;
|
|
144
|
+
if (size > 65_536)
|
|
145
|
+
fail("payment challenge too large");
|
|
146
|
+
chunks.push(value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
void reader.cancel().catch(() => { });
|
|
151
|
+
}
|
|
152
|
+
const bytes = new Uint8Array(size);
|
|
153
|
+
let offset = 0;
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
bytes.set(chunk, offset);
|
|
156
|
+
offset += chunk.length;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return fail("invalid payment challenge JSON");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** One challenge and at most one paid request. No automatic payment replay. */
|
|
166
|
+
export async function createSolanaPaidFetch(privateKey, budget, baseUrl, transport = fetch) {
|
|
167
|
+
const origin = httpsUrl(baseUrl).origin;
|
|
168
|
+
const { x402Client, x402HTTPClient } = await import("@x402/core/client");
|
|
169
|
+
const { ExactSvmScheme } = await import("@x402/svm/exact/client");
|
|
170
|
+
const { createKeyPairSignerFromBytes } = await import("@solana/kit");
|
|
171
|
+
const { base58 } = await import("@scure/base");
|
|
172
|
+
const signer = await createKeyPairSignerFromBytes(base58.decode(privateKey));
|
|
173
|
+
if (signer.address === budget.policy.feePayer)
|
|
174
|
+
fail("the agent wallet cannot pay facilitator gas");
|
|
175
|
+
return async (input, init) => {
|
|
176
|
+
const original = new Request(input, init);
|
|
177
|
+
if (httpsUrl(original.url).origin !== origin)
|
|
178
|
+
fail("request origin mismatch");
|
|
179
|
+
if (original.headers.has("PAYMENT-SIGNATURE") || original.headers.has("X-PAYMENT"))
|
|
180
|
+
fail("payment already attempted");
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const deadline = Date.now() + budget.policy.timeoutMs;
|
|
183
|
+
const check = () => {
|
|
184
|
+
if (Date.now() >= deadline && !controller.signal.aborted)
|
|
185
|
+
controller.abort(new Error("Solana payment timeout"));
|
|
186
|
+
controller.signal.throwIfAborted();
|
|
187
|
+
};
|
|
188
|
+
const abort = () => controller.abort(original.signal.reason);
|
|
189
|
+
original.signal.addEventListener("abort", abort, { once: true });
|
|
190
|
+
if (original.signal.aborted)
|
|
191
|
+
abort();
|
|
192
|
+
const timer = setTimeout(() => controller.abort(new Error("Solana payment timeout")), budget.policy.timeoutMs);
|
|
193
|
+
let onAbort = () => { };
|
|
194
|
+
const cancelled = new Promise((_, reject) => {
|
|
195
|
+
onAbort = () => reject(controller.signal.reason);
|
|
196
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
197
|
+
});
|
|
198
|
+
const bounded = async (work) => {
|
|
199
|
+
const result = await Promise.race([work, cancelled]);
|
|
200
|
+
check();
|
|
201
|
+
return result;
|
|
202
|
+
};
|
|
203
|
+
let release;
|
|
204
|
+
let creating = false;
|
|
205
|
+
try {
|
|
206
|
+
check();
|
|
207
|
+
const request = new Request(original, { redirect: "error", signal: controller.signal });
|
|
208
|
+
const retry = request.clone();
|
|
209
|
+
const response = await bounded(transport(request));
|
|
210
|
+
if (response.status !== 402)
|
|
211
|
+
return response;
|
|
212
|
+
const client = new x402Client();
|
|
213
|
+
const http = new x402HTTPClient(client);
|
|
214
|
+
const body = await bounded(challengeBody(response));
|
|
215
|
+
// v2 normally uses PAYMENT-REQUIRED; accept the same validated envelope
|
|
216
|
+
// in JSON too. The upstream HTTP decoder's body fallback is v1-only.
|
|
217
|
+
let envelope;
|
|
218
|
+
try {
|
|
219
|
+
envelope = response.headers.has("PAYMENT-REQUIRED")
|
|
220
|
+
? http.getPaymentRequiredResponse(name => response.headers.get(name))
|
|
221
|
+
: body;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return fail("invalid payment challenge header");
|
|
225
|
+
}
|
|
226
|
+
const required = budget.select(envelope, request.url);
|
|
227
|
+
const offer = required.accepts[0];
|
|
228
|
+
release = budget.reserve(offer.amount);
|
|
229
|
+
if (budget.policy.beforePayment) {
|
|
230
|
+
const approved = await bounded(Promise.resolve(budget.policy.beforePayment(Object.freeze({ url: request.url,
|
|
231
|
+
network: offer.network, asset: offer.asset, payTo: offer.payTo,
|
|
232
|
+
feePayer: budget.policy.feePayer, amountAtomic: offer.amount }))));
|
|
233
|
+
if (approved !== true)
|
|
234
|
+
fail("payment declined");
|
|
235
|
+
}
|
|
236
|
+
check();
|
|
237
|
+
// RPC work can finish after our deadline. Guard the actual signer too so
|
|
238
|
+
// an abandoned payment creation cannot sign later in the background.
|
|
239
|
+
const guardedSigner = { ...signer, signTransactions: async (...args) => {
|
|
240
|
+
check();
|
|
241
|
+
return signer.signTransactions(...args);
|
|
242
|
+
} };
|
|
243
|
+
client.register(SOLANA_PAYMENT_NETWORK, new ExactSvmScheme(guardedSigner, { rpcUrl: budget.policy.rpcUrl }));
|
|
244
|
+
creating = true;
|
|
245
|
+
const payload = await bounded(client.createPaymentPayload(required));
|
|
246
|
+
check();
|
|
247
|
+
for (const [name, value] of Object.entries(http.encodePaymentSignatureHeader(payload)))
|
|
248
|
+
retry.headers.set(name, value);
|
|
249
|
+
return await bounded(transport(retry));
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (!creating)
|
|
253
|
+
release?.();
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
original.signal.removeEventListener("abort", abort);
|
|
256
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "
|
|
1
|
+
export declare const VERSION = "3.0.1";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED by ../gen-version.mjs (npm prebuild) from package.json. Do not edit.
|
|
2
|
-
export const VERSION = "
|
|
2
|
+
export const VERSION = "3.0.1";
|