verify402-mcp 1.0.2 → 1.0.4
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 +82 -63
- package/index.mjs +58 -32
- package/log.mjs +198 -0
- package/package.json +5 -3
- package/paid-call.mjs +73 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ wallet that pays in USDC; the bridge never signs or sends a trade.
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `preflight_gate` | **free** | "Should I sign this payment request or transaction?" — PASS / CAUTION / BLOCK with reasons, `checked` / `notChecked`, and a short-lived receipt |
|
|
16
16
|
| `verify_arbitrage` | $0.05 | "Is this cross-venue arbitrage opportunity real, right now?" |
|
|
17
|
-
| `check_token_safety` | $0.10 | "Is this token
|
|
17
|
+
| `check_token_safety` | $0.10 | "Is this token sellable at a realistic size, and what does the round trip cost?" — TRUST / CAUTION / AVOID with evidence; not a code audit |
|
|
18
18
|
| `get_best_route` | $0.03 | "What would the chain actually pay for this swap?" — best route + every quoted venue |
|
|
19
19
|
| `verify_quote_fairness` | $0.05 | "A broker/bot offered me this number — is it honest?" fair / overpaying / too_good_to_be_true |
|
|
20
20
|
| `get_trade_depth` | $0.05 | "How big can I trade here before the round trip eats me?" — per-size impact, round-trip cost, capacity |
|
|
@@ -31,23 +31,24 @@ belongs in the executor that signs — verify the receipt, then sign. See
|
|
|
31
31
|
for a deny-by-default reference that refuses to call the signer without a
|
|
32
32
|
verified receipt.
|
|
33
33
|
|
|
34
|
-
## Run
|
|
34
|
+
## Distribution & Run
|
|
35
35
|
|
|
36
|
-
The package is
|
|
37
|
-
|
|
36
|
+
The package is officially published on npm as **[`verify402-mcp`](https://www.npmjs.com/package/verify402-mcp)** and indexed on the official [Model Context Protocol Registry](https://registry.modelcontextprotocol.io/).
|
|
37
|
+
|
|
38
|
+
Run directly via `npx` (stdio transport):
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
|
-
npx -y
|
|
41
|
-
--package=https://github.com/a32116150-ctrl/verify402/releases/download/mcp-v1.0.2/verify402-mcp-1.0.2.tgz \
|
|
42
|
-
verify402-mcp --help
|
|
41
|
+
npx -y verify402-mcp --help
|
|
43
42
|
```
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
You can also install it globally:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install -g verify402-mcp
|
|
48
|
+
verify402-mcp --help
|
|
49
|
+
```
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
Or run from source:
|
|
51
52
|
|
|
52
53
|
```bash
|
|
53
54
|
git clone https://github.com/a32116150-ctrl/verify402
|
|
@@ -56,47 +57,20 @@ npm ci
|
|
|
56
57
|
node index.mjs --help
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
---
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
`free preflight_gate only`. Nothing can be charged.
|
|
62
|
+
## AI Agent Integration (Step-by-Step)
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Set a wallet with USDC on the payment network. Use a wallet with only the money
|
|
67
|
-
you are willing to spend — never a main wallet. The key stays in your MCP client
|
|
68
|
-
configuration or environment; the package never contains it.
|
|
69
|
-
|
|
70
|
-
```
|
|
71
|
-
EVM_PRIVATE_KEY=0x... # optional; omit for free-only
|
|
72
|
-
VERIFY402_URL=https://verify402.onrender.com
|
|
73
|
-
MAX_PAYMENT_USD=0.25 # hard cap on ONE payment, enforced at signature time
|
|
74
|
-
MAX_DAILY_USD=0.50 # optional cap for the UTC day, persisted
|
|
75
|
-
SPEND_LEDGER_PATH= # optional ledger path (default .spend-ledger.json)
|
|
76
|
-
VERIFY402_NETWORKS=eip155:8453 # paid calls only; comma-separated EVM networks
|
|
77
|
-
VERIFY402_PAY_TO=0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb
|
|
78
|
-
# VERIFY402_RESOURCE_PREFIX=https://verify402.onrender.com
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
The paid client refuses a 402 that names another network or payee, a resource
|
|
82
|
-
outside the configured server, or a non-paid route. It also refuses redirects.
|
|
83
|
-
The amount cap and the USDC-only default-asset allowlist are enforced inside
|
|
84
|
-
the x402 client, before a signature exists.
|
|
85
|
-
|
|
86
|
-
### Claude Desktop / Cursor
|
|
87
|
-
|
|
88
|
-
`claude_desktop_config.json`, or `.cursor/mcp.json`:
|
|
64
|
+
### Step 1: Free Mode (Zero Config, No Wallet)
|
|
65
|
+
To give your AI agent the free **`preflight_gate`** (checks transaction calldata, payment challenges, payee/amount, approvals, and block freshness before signing), paste this into your client configuration. The paid `check_token_safety` check adds a quote-based sellability check; it does not execute a transfer and is not a honeypot guarantee.
|
|
89
66
|
|
|
67
|
+
**Claude Desktop** (`claude_desktop_config.json`) or **Cursor** (`.cursor/mcp.json`):
|
|
90
68
|
```json
|
|
91
69
|
{
|
|
92
70
|
"mcpServers": {
|
|
93
71
|
"verify402": {
|
|
94
72
|
"command": "npx",
|
|
95
|
-
"args": [
|
|
96
|
-
"-y",
|
|
97
|
-
"--package=https://github.com/a32116150-ctrl/verify402/releases/download/mcp-v1.0.2/verify402-mcp-1.0.2.tgz",
|
|
98
|
-
"verify402-mcp"
|
|
99
|
-
],
|
|
73
|
+
"args": ["-y", "verify402-mcp"],
|
|
100
74
|
"env": {
|
|
101
75
|
"VERIFY402_URL": "https://verify402.onrender.com"
|
|
102
76
|
}
|
|
@@ -105,14 +79,41 @@ the x402 client, before a signature exists.
|
|
|
105
79
|
}
|
|
106
80
|
```
|
|
107
81
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
them.
|
|
82
|
+
### Step 2: Paid Mode (Setting Up the Hot Wallet)
|
|
83
|
+
To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_safety`, `get_best_route`, `verify_quote_fairness`, `get_trade_depth`), configure a dedicated hot wallet that pays per call in USDC via x402:
|
|
111
84
|
|
|
112
|
-
|
|
85
|
+
1. **Create a Dedicated Hot Wallet**:
|
|
86
|
+
- Create a brand new wallet in MetaMask, Rabby, or via `cast wallet new`.
|
|
87
|
+
- ⚠️ **SECURITY WARNING**: Never use your primary personal wallet, cold storage, or vault.
|
|
88
|
+
2. **Fund the Wallet on Base (EIP-155:8453)**:
|
|
89
|
+
- **USDC**: Transfer $2 to $10 of USDC on Base (the bridge pays per call: $0.03 to $0.10).
|
|
90
|
+
- **Gas (ETH on Base)**: Transfer ~$0.50 worth of ETH on Base for minimal gas allowances if needed.
|
|
91
|
+
3. **Configure Environment Variables in Your MCP Client**:
|
|
92
|
+
- Set `EVM_PRIVATE_KEY` to the 64-character private key of the hot wallet.
|
|
93
|
+
- Set safety caps `MAX_PAYMENT_USD` (e.g., `"0.25"`) and `MAX_DAILY_USD` (e.g., `"1.00"`).
|
|
113
94
|
|
|
114
|
-
|
|
95
|
+
**Paid Configuration Block (Claude Desktop, Cursor, Cline, Windsurf)**:
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"verify402": {
|
|
100
|
+
"command": "npx",
|
|
101
|
+
"args": ["-y", "verify402-mcp"],
|
|
102
|
+
"env": {
|
|
103
|
+
"VERIFY402_URL": "https://verify402.onrender.com",
|
|
104
|
+
"EVM_PRIVATE_KEY": "0xYOUR_HOT_WALLET_PRIVATE_KEY",
|
|
105
|
+
"MAX_PAYMENT_USD": "0.25",
|
|
106
|
+
"MAX_DAILY_USD": "1.00",
|
|
107
|
+
"VERIFY402_NETWORKS": "eip155:8453",
|
|
108
|
+
"VERIFY402_PAY_TO": "0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
115
114
|
|
|
115
|
+
### OpenCode V2 Configuration
|
|
116
|
+
In `opencode.jsonc` (or `~/.config/opencode/opencode.jsonc`):
|
|
116
117
|
```jsonc
|
|
117
118
|
{
|
|
118
119
|
"$schema": "https://opencode.ai/config.json",
|
|
@@ -120,14 +121,12 @@ them.
|
|
|
120
121
|
"servers": {
|
|
121
122
|
"verify402": {
|
|
122
123
|
"type": "local",
|
|
123
|
-
"command": [
|
|
124
|
-
"npx",
|
|
125
|
-
"-y",
|
|
126
|
-
"--package=https://github.com/a32116150-ctrl/verify402/releases/download/mcp-v1.0.2/verify402-mcp-1.0.2.tgz",
|
|
127
|
-
"verify402-mcp"
|
|
128
|
-
],
|
|
124
|
+
"command": ["npx", "-y", "verify402-mcp"],
|
|
129
125
|
"environment": {
|
|
130
|
-
"VERIFY402_URL": "https://verify402.onrender.com"
|
|
126
|
+
"VERIFY402_URL": "https://verify402.onrender.com",
|
|
127
|
+
"EVM_PRIVATE_KEY": "{env:EVM_PRIVATE_KEY}",
|
|
128
|
+
"MAX_PAYMENT_USD": "0.25",
|
|
129
|
+
"MAX_DAILY_USD": "1.00"
|
|
131
130
|
}
|
|
132
131
|
}
|
|
133
132
|
}
|
|
@@ -135,13 +134,31 @@ them.
|
|
|
135
134
|
}
|
|
136
135
|
```
|
|
137
136
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## How AI Agents Use verify402 (Prompt Examples)
|
|
140
|
+
|
|
141
|
+
Once configured, your agent automatically sees the tools in its context. You can prompt it directly:
|
|
142
|
+
|
|
143
|
+
- **Preflight Gate (Free)**:
|
|
144
|
+
> *"Before signing this transaction, call `preflight_gate` on the payment request / calldata. If it returns BLOCK, abort and explain why."*
|
|
145
|
+
- **Token Quote-Based Sellability Check ($0.10, not a code audit)**:
|
|
146
|
+
> *"Analyze token `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` using `check_token_safety`. Can it be sold at a realistic size on the configured venues, what is the round-trip cost, and is the result TRUST / CAUTION / AVOID? Read notChecked for transfer restrictions."*
|
|
147
|
+
- **Arbitrage Verification ($0.05)**:
|
|
148
|
+
> *"Use `verify_arbitrage` to verify if there is an arbitrage opportunity between Uniswap V3 and Sushiswap for WETH/USDC on chain 1 at the current block."*
|
|
149
|
+
- **Quote Fairness Audit ($0.05)**:
|
|
150
|
+
> *"I was offered 0.35 WETH for 1,000 USDC. Run `verify_quote_fairness` to see if this is fair or if I'm overpaying."*
|
|
151
|
+
- **Market Depth & Capacity ($0.05)**:
|
|
152
|
+
> *"Check `get_trade_depth` for WETH on Uniswap V3 to see how much slippage and impact a $50,000 swap would suffer."*
|
|
141
153
|
|
|
142
154
|
## Safety
|
|
143
155
|
|
|
144
156
|
- The bridge signs **x402 payments only**. It never signs or broadcasts a trade.
|
|
157
|
+
- Each paid tool call mints one `crypto.randomUUID()` idempotency key and sends it
|
|
158
|
+
as `x-idempotency-key`. A timeout or lost response is retried with the **same**
|
|
159
|
+
key, so the server replays the settled verdict (`recovery.replayed: true`)
|
|
160
|
+
instead of charging twice. A 402 or 409 is never auto-retried; the error is
|
|
161
|
+
surfaced to the MCP client. The key is a secret and is never logged or returned.
|
|
145
162
|
- Before signing, the paid client requires the 402 to come from the configured
|
|
146
163
|
server and a known paid route, on an allowlisted EVM network, to the
|
|
147
164
|
verify402 payee. It accepts only recognized default assets (USDC), caps the
|
|
@@ -150,11 +167,13 @@ the secret out of the config file.
|
|
|
150
167
|
`VERIFY402_NETWORKS`, `VERIFY402_PAY_TO`, and `VERIFY402_RESOURCE_PREFIX`.
|
|
151
168
|
Defaults are Base mainnet and the public verify402 payee.
|
|
152
169
|
- `MAX_PAYMENT_USD` is enforced inside the x402 client, before a signature
|
|
153
|
-
exists; removing it is a security regression.
|
|
170
|
+
exists; removing it is a security regression. The caps limit what the bridge
|
|
171
|
+
can authorize; they do **not** protect a compromised private key.
|
|
154
172
|
- `preflight_gate` is a check, not a guarantee. Read `notChecked`: it does not
|
|
155
173
|
screen recipient reputation or sanctions, audit contracts, simulate full
|
|
156
|
-
balance deltas,
|
|
157
|
-
|
|
174
|
+
balance deltas, audit the counterparty, scan tool descriptions or responses
|
|
175
|
+
for prompt injection, or protect against MEV. It catches the measurable
|
|
176
|
+
signing mistakes, not every scam.
|
|
158
177
|
- Use the [reference executor](https://github.com/a32116150-ctrl/verify402/blob/master/examples/preflight-guard.mjs)
|
|
159
178
|
when you want enforcement rather than advice.
|
|
160
179
|
|
package/index.mjs
CHANGED
|
@@ -29,15 +29,26 @@ import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
|
29
29
|
import { privateKeyToAccount } from "viem/accounts";
|
|
30
30
|
import { config } from "dotenv";
|
|
31
31
|
import { z } from "zod";
|
|
32
|
+
import { installConsoleRedaction, logInfo, logError } from "./log.mjs";
|
|
33
|
+
import { paidPost } from "./paid-call.mjs";
|
|
32
34
|
|
|
35
|
+
installConsoleRedaction();
|
|
33
36
|
config();
|
|
34
37
|
|
|
38
|
+
const VERSION = "1.0.4";
|
|
35
39
|
const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY;
|
|
36
40
|
const SERVER_URL = process.env.VERIFY402_URL || "https://verify402.onrender.com";
|
|
41
|
+
// Tell the server which client made the call. This is a label, not an identity:
|
|
42
|
+
// it helps separate MCP use from generic HTTP probes without collecting keys or
|
|
43
|
+
// wallet addresses.
|
|
44
|
+
const CLIENT_HEADERS = {
|
|
45
|
+
"User-Agent": `verify402-mcp/${VERSION}`,
|
|
46
|
+
"X-Verify402-Client": `mcp/${VERSION}`,
|
|
47
|
+
};
|
|
37
48
|
// Preflight is free, so it does not go through the paying client. Keeping the
|
|
38
49
|
// two clients separate means a misconfigured 402 on a free route can never
|
|
39
50
|
// turn into a signature.
|
|
40
|
-
const freeApi = axios.create({ baseURL: SERVER_URL, timeout: 30_000, maxRedirects: 0 });
|
|
51
|
+
const freeApi = axios.create({ baseURL: SERVER_URL, timeout: 30_000, maxRedirects: 0, headers: CLIENT_HEADERS });
|
|
41
52
|
// Spend guard: the x402 client refuses to SIGN any payment above this, before
|
|
42
53
|
// a signature exists. Enforced inside x402Client (applySpendControls), not by
|
|
43
54
|
// our own request code - so it cannot be bypassed by a caller.
|
|
@@ -73,7 +84,7 @@ The bridge signs x402 payments only; it never signs or sends a trade.
|
|
|
73
84
|
process.exit(0);
|
|
74
85
|
}
|
|
75
86
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
76
|
-
console.log(
|
|
87
|
+
console.log(VERSION);
|
|
77
88
|
process.exit(0);
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -98,12 +109,12 @@ async function createPaidClient() {
|
|
|
98
109
|
client.register(network, new ExactEvmScheme(account));
|
|
99
110
|
}
|
|
100
111
|
client.onBeforePaymentCreation((context) => policy.checkBeforeSign(context));
|
|
101
|
-
|
|
112
|
+
logInfo(
|
|
102
113
|
`paid policy: networks=${policy.allowedNetworks.join(",")} ` +
|
|
103
114
|
`payee=${policy.allowedPayTo} resources=${policy.resourcePrefix} ` +
|
|
104
115
|
`{${policy.paidRoutes.join(",")}}`,
|
|
105
116
|
);
|
|
106
|
-
const axiosInstance = axios.create({ baseURL: SERVER_URL, timeout: 120_000, maxRedirects: 0 });
|
|
117
|
+
const axiosInstance = axios.create({ baseURL: SERVER_URL, timeout: 120_000, maxRedirects: 0, headers: CLIENT_HEADERS });
|
|
107
118
|
return wrapAxiosWithPayment(axiosInstance, client);
|
|
108
119
|
}
|
|
109
120
|
|
|
@@ -119,7 +130,13 @@ const dailyBudget = createDailyBudget({
|
|
|
119
130
|
|
|
120
131
|
async function guardedPaidCall(route, run) {
|
|
121
132
|
if (dailyBudget.enabled) dailyBudget.assert(route);
|
|
122
|
-
|
|
133
|
+
const res = await run();
|
|
134
|
+
if (res?.data?.recovery?.replayed) {
|
|
135
|
+
// Visible to the operator without exposing the bearer key. The same flag
|
|
136
|
+
// is already in the JSON returned to the MCP client.
|
|
137
|
+
logInfo(`${route}: recovery.replayed=true (settled verdict returned without a second charge)`);
|
|
138
|
+
}
|
|
139
|
+
return res;
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
// ------------------------------------------------------------------ tools ---
|
|
@@ -130,26 +147,31 @@ const DESCRIPTIONS = {
|
|
|
130
147
|
"the checks that did not, and a short-lived receipt bound to the exact intent + block. " +
|
|
131
148
|
"If sign_allowed is false, DO NOT SIGN. If requires_ack is true, surface the reasons to the " +
|
|
132
149
|
"operator before signing. The receipt expires (default 90s): call again rather than reuse it. " +
|
|
133
|
-
"Free. Pass `from` (the wallet that will sign) to enable simulation and balance checks."
|
|
150
|
+
"Free. Pass `from` (the wallet that will sign) to enable simulation and balance checks. For " +
|
|
151
|
+
"kind=transaction, chainId is REQUIRED (400 missing_chain without it). For payment intents " +
|
|
152
|
+
"with no `from` or no proof, the response is PASS with structural_only:true and names what was not checked.",
|
|
134
153
|
verify_arbitrage:
|
|
135
154
|
"Ask verify402 whether a cross-venue arbitrage opportunity is REAL, right now. " +
|
|
136
155
|
"Quotes come from Uniswap's canonical QuoterV2 and each DEX router at one pinned block. " +
|
|
137
156
|
"The honest answer is usually 'no_edge' - that IS the product. Costs $0.05 per call.",
|
|
138
157
|
check_token_safety:
|
|
139
|
-
"Ask verify402
|
|
158
|
+
"Ask verify402 for a quote-based sellability check on a token BEFORE the agent risks capital. " +
|
|
140
159
|
"Returns TRUST/CAUTION/AVOID with evidence: live contract facts, market liquidity, " +
|
|
141
|
-
"a USD-sized sell
|
|
142
|
-
"intended size, and phantom-price detection.
|
|
160
|
+
"a USD-sized sell QUOTED through the configured venues' own routers (direct and WETH-routed), " +
|
|
161
|
+
"round-trip cost at your intended size, and phantom-price detection. It does NOT execute or " +
|
|
162
|
+
"simulate a transfer: transfer tax, blacklist, pause, and max-tx restrictions are listed in " +
|
|
163
|
+
"notChecked, not tested. Unconfigured venues are also named in notChecked. Costs $0.10 per call.",
|
|
143
164
|
get_best_route:
|
|
144
|
-
"Ask verify402 what the chain would ACTUALLY pay for a swap: the full
|
|
145
|
-
"
|
|
165
|
+
"Ask verify402 what the chain would ACTUALLY pay for a swap: the full book from every " +
|
|
166
|
+
"configured venue (direct and WETH-routed) at one pinned block, best first. Aggregators and " +
|
|
167
|
+
"unconfigured DEXs are not included. Costs $0.03 per call.",
|
|
146
168
|
verify_quote_fairness:
|
|
147
169
|
"Audit a swap quote someone else gave you (aggregator, broker, bot) BEFORE signing. " +
|
|
148
|
-
"Returns fair / overpaying / too_good_to_be_true with the
|
|
170
|
+
"Returns fair / overpaying / too_good_to_be_true with the configured-venue book as evidence. " +
|
|
149
171
|
"Ask this before accepting ANY number you did not produce yourself. Costs $0.05 per call.",
|
|
150
172
|
get_trade_depth:
|
|
151
173
|
"Position sizing: measure how much of a token the chain ACTUALLY lets you trade - " +
|
|
152
|
-
"entry and exit across every venue at a ladder of USD sizes, per-size impact and " +
|
|
174
|
+
"entry and exit across every configured venue at a ladder of USD sizes, per-size impact and " +
|
|
153
175
|
"round-trip cost, plus the largest size under your cost tolerance. Ask BEFORE choosing " +
|
|
154
176
|
"a position size. Costs $0.05 per call.",
|
|
155
177
|
};
|
|
@@ -157,7 +179,7 @@ const DESCRIPTIONS = {
|
|
|
157
179
|
async function main() {
|
|
158
180
|
const paidApi = PAID_ENABLED ? await createPaidClient() : null;
|
|
159
181
|
|
|
160
|
-
const server = new McpServer({ name: "verify402", version:
|
|
182
|
+
const server = new McpServer({ name: "verify402", version: VERSION });
|
|
161
183
|
|
|
162
184
|
server.tool(
|
|
163
185
|
"preflight_gate",
|
|
@@ -221,7 +243,11 @@ async function main() {
|
|
|
221
243
|
isError: true,
|
|
222
244
|
content: [{
|
|
223
245
|
type: "text",
|
|
224
|
-
text: `preflight failed${status ? ` (${status})` : ""}: ${JSON.stringify(body ?? e.message)}.
|
|
246
|
+
text: `preflight failed${status ? ` (${status})` : ""}: ${JSON.stringify(body ?? e.message)}. ${
|
|
247
|
+
status === 503
|
|
248
|
+
? "No receipt was issued; stop and retry later."
|
|
249
|
+
: "Do not sign without a verifiable receipt."
|
|
250
|
+
}`,
|
|
225
251
|
}],
|
|
226
252
|
};
|
|
227
253
|
}
|
|
@@ -236,13 +262,13 @@ async function main() {
|
|
|
236
262
|
DESCRIPTIONS.verify_arbitrage,
|
|
237
263
|
{
|
|
238
264
|
target: z.string().describe('Token to buy and sell back, e.g. "WETH"'),
|
|
239
|
-
chainId: z.number().
|
|
265
|
+
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
240
266
|
base: z.string().optional().describe('Token to trade from, e.g. "USDC" (default USDC)'),
|
|
241
267
|
size: z.number().optional().describe("Trade size in whole base units, e.g. 100000 (default 100000)"),
|
|
242
268
|
},
|
|
243
269
|
async (args) => {
|
|
244
|
-
const res = await guardedPaidCall("/verify", () => paidApi
|
|
245
|
-
chainId: args.chainId
|
|
270
|
+
const res = await guardedPaidCall("/verify", () => paidPost(paidApi, "/verify", {
|
|
271
|
+
chainId: args.chainId,
|
|
246
272
|
target: String(args.target ?? "WETH").toUpperCase(),
|
|
247
273
|
base: String(args.base ?? "USDC").toUpperCase(),
|
|
248
274
|
size: args.size ?? 100000,
|
|
@@ -256,13 +282,13 @@ async function main() {
|
|
|
256
282
|
DESCRIPTIONS.check_token_safety,
|
|
257
283
|
{
|
|
258
284
|
tokenAddress: z.string().describe("ERC-20 contract address, or a known symbol like WETH"),
|
|
259
|
-
chainId: z.number().
|
|
285
|
+
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
260
286
|
intendedUsd: z.number().optional().describe("Size the agent plans to buy, in USD (default 500)"),
|
|
261
287
|
offeredPriceUsd: z.number().optional().describe("Optional price per token someone offered you"),
|
|
262
288
|
},
|
|
263
289
|
async (args) => {
|
|
264
|
-
const res = await guardedPaidCall("/safety", () => paidApi
|
|
265
|
-
chainId: args.chainId
|
|
290
|
+
const res = await guardedPaidCall("/safety", () => paidPost(paidApi, "/safety", {
|
|
291
|
+
chainId: args.chainId,
|
|
266
292
|
tokenAddress: String(args.tokenAddress ?? "").trim(),
|
|
267
293
|
intendedUsd: args.intendedUsd ?? 500,
|
|
268
294
|
offeredPriceUsd: args.offeredPriceUsd ?? null,
|
|
@@ -278,11 +304,11 @@ async function main() {
|
|
|
278
304
|
tokenIn: z.string().describe("Token symbol (e.g. USDC) or 0x address"),
|
|
279
305
|
tokenOut: z.string().describe("Token symbol (e.g. WETH) or 0x address"),
|
|
280
306
|
amountIn: z.string().describe("RAW base units of tokenIn, wei-scale, as a string"),
|
|
281
|
-
chainId: z.number().
|
|
307
|
+
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
282
308
|
},
|
|
283
309
|
async (args) => {
|
|
284
|
-
const res = await guardedPaidCall("/route", () => paidApi
|
|
285
|
-
chainId: args.chainId
|
|
310
|
+
const res = await guardedPaidCall("/route", () => paidPost(paidApi, "/route", {
|
|
311
|
+
chainId: args.chainId,
|
|
286
312
|
tokenIn: args.tokenIn,
|
|
287
313
|
tokenOut: args.tokenOut,
|
|
288
314
|
amountIn: args.amountIn,
|
|
@@ -300,11 +326,11 @@ async function main() {
|
|
|
300
326
|
amountIn: z.string().describe("RAW units of tokenIn, as in the offer"),
|
|
301
327
|
quotedOut: z.string().describe("RAW units of tokenOut you were OFFERED"),
|
|
302
328
|
venueClaimed: z.string().optional().describe("Optional: who gave you the offer"),
|
|
303
|
-
chainId: z.number().
|
|
329
|
+
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
304
330
|
},
|
|
305
331
|
async (args) => {
|
|
306
|
-
const res = await guardedPaidCall("/quotecheck", () => paidApi
|
|
307
|
-
chainId: args.chainId
|
|
332
|
+
const res = await guardedPaidCall("/quotecheck", () => paidPost(paidApi, "/quotecheck", {
|
|
333
|
+
chainId: args.chainId,
|
|
308
334
|
tokenIn: args.tokenIn,
|
|
309
335
|
tokenOut: args.tokenOut,
|
|
310
336
|
amountIn: args.amountIn,
|
|
@@ -320,14 +346,14 @@ async function main() {
|
|
|
320
346
|
DESCRIPTIONS.get_trade_depth,
|
|
321
347
|
{
|
|
322
348
|
tokenAddress: z.string().describe("Token you plan to buy (symbol or 0x address)"),
|
|
323
|
-
chainId: z.number().
|
|
349
|
+
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
324
350
|
baseSymbol: z.string().optional().describe("What you pay with (default USDC)"),
|
|
325
351
|
sizesUsd: z.array(z.number()).optional().describe("Ladder of sizes in USD, max 6 (default 100, 1000, 10000, 100000)"),
|
|
326
352
|
maxRoundTripPct: z.number().optional().describe("Your round-trip cost tolerance, default 1%"),
|
|
327
353
|
},
|
|
328
354
|
async (args) => {
|
|
329
|
-
const res = await guardedPaidCall("/depth", () => paidApi
|
|
330
|
-
chainId: args.chainId
|
|
355
|
+
const res = await guardedPaidCall("/depth", () => paidPost(paidApi, "/depth", {
|
|
356
|
+
chainId: args.chainId,
|
|
331
357
|
tokenAddress: args.tokenAddress,
|
|
332
358
|
baseSymbol: args.baseSymbol ?? "USDC",
|
|
333
359
|
sizesUsd: args.sizesUsd ?? null,
|
|
@@ -340,7 +366,7 @@ async function main() {
|
|
|
340
366
|
|
|
341
367
|
const transport = new StdioServerTransport();
|
|
342
368
|
await server.connect(transport);
|
|
343
|
-
|
|
369
|
+
logInfo(
|
|
344
370
|
paidApi
|
|
345
371
|
? `verify402 MCP bridge connected -> ${SERVER_URL} (paid tools on, max payment $${MAX_PAYMENT_USD})`
|
|
346
372
|
: `verify402 MCP bridge connected -> ${SERVER_URL} (free preflight_gate only; set EVM_PRIVATE_KEY for paid tools)`,
|
|
@@ -348,6 +374,6 @@ async function main() {
|
|
|
348
374
|
}
|
|
349
375
|
|
|
350
376
|
main().catch((e) => {
|
|
351
|
-
|
|
377
|
+
logError("bridge failed", e);
|
|
352
378
|
process.exit(1);
|
|
353
379
|
});
|
package/log.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Central logging and redaction (MCP bridge copy).
|
|
2
|
+
// Keep in sync with ../src/log.mjs: the MCP bridge is published as a standalone
|
|
3
|
+
// package and cannot import files outside its own package root.
|
|
4
|
+
//
|
|
5
|
+
// Every runtime log line goes through this module. The goal is that an RPC URL
|
|
6
|
+
// path, query string, basic-auth credential, header value, API key, or
|
|
7
|
+
// idempotency key cannot reach stdout/stderr even when the source is a raw
|
|
8
|
+
// viem/HttpRequestError, a stack trace, or request metadata.
|
|
9
|
+
//
|
|
10
|
+
// The public error boundary already keeps these values out of HTTP responses.
|
|
11
|
+
// This module closes the other half: the log sink.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
const ORIGINAL = {
|
|
16
|
+
log: console.log.bind(console),
|
|
17
|
+
info: console.info.bind(console),
|
|
18
|
+
warn: console.warn.bind(console),
|
|
19
|
+
error: console.error.bind(console),
|
|
20
|
+
debug: console.debug.bind(console),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SENSITIVE_KEY_RE =
|
|
24
|
+
/^(?:x-payment|payment-signature|authorization|proxy-authorization|x-api-key|api[-_]?key|apikey|secret|password|passwd|token|access[-_]?token|refresh[-_]?token|credential|private[-_]?key|idempotency[-_]?key|x-idempotency-key)$/i;
|
|
25
|
+
const IDEMPOTENCY_KEY_RE = /^(?:x-)?idempotency[-_]?key$/i;
|
|
26
|
+
const HEADER_OR_PARAM_RE =
|
|
27
|
+
/(x-payment|payment-signature|authorization|proxy-authorization|x-api-key|api[-_]?key|apikey|secret|password|passwd|access[-_]?token|refresh[-_]?token|private[-_]?key)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r,;]+)/gi;
|
|
28
|
+
const IDEMPOTENCY_HEADER_RE =
|
|
29
|
+
/(x-idempotency-key|idempotency-key)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi;
|
|
30
|
+
|
|
31
|
+
let installed = false;
|
|
32
|
+
|
|
33
|
+
function stripQuotes(value) {
|
|
34
|
+
return String(value ?? "").replace(/^["']|["']$/g, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Stable 8-character hash prefix for a caller-supplied idempotency key. */
|
|
38
|
+
export function idempotencyPrefix(value) {
|
|
39
|
+
if (value == null || String(value) === "") return null;
|
|
40
|
+
return `idem:${createHash("sha256").update(String(value)).digest("hex").slice(0, 8)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitizeUrl(raw) {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(raw);
|
|
46
|
+
if (!/^(?:https?|wss?):$/i.test(u.protocol)) return raw;
|
|
47
|
+
return `${u.protocol}//${u.host}/***`;
|
|
48
|
+
} catch {
|
|
49
|
+
// Best effort for malformed URLs: drop userinfo and everything after the
|
|
50
|
+
// authority, but never echo the original path/query.
|
|
51
|
+
return String(raw)
|
|
52
|
+
.replace(/\/\/[^/@\s]+@/, "//")
|
|
53
|
+
.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#\s]+).*$/i, "$1/***");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Redact a string. `idempotencyKey` is optional context: when the caller's key
|
|
59
|
+
* is known, any literal occurrence is replaced with its 8-character hash
|
|
60
|
+
* prefix instead of a generic marker.
|
|
61
|
+
*/
|
|
62
|
+
export function redactText(value, { idempotencyKey = null } = {}) {
|
|
63
|
+
let s = String(value ?? "");
|
|
64
|
+
if (idempotencyKey) {
|
|
65
|
+
const key = String(idempotencyKey);
|
|
66
|
+
s = s.split(key).join(idempotencyPrefix(key));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Full URLs: keep scheme + host (including port), replace the rest.
|
|
70
|
+
s = s.replace(/\b(?:https?|wss?):\/\/[^\s"'<>]+/gi, (match) => {
|
|
71
|
+
const trailing = match.match(/[),.;:!?]+$/)?.[0] ?? "";
|
|
72
|
+
const core = trailing ? match.slice(0, -trailing.length) : match;
|
|
73
|
+
return sanitizeUrl(core) + trailing;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Idempotency keys get a stable 8-character hash prefix, not a generic
|
|
77
|
+
// marker, so a replay can still be correlated without exposing the key.
|
|
78
|
+
s = s.replace(IDEMPOTENCY_HEADER_RE, (match, key, sep, rawValue) => {
|
|
79
|
+
const value = stripQuotes(rawValue);
|
|
80
|
+
if (/^idem:[0-9a-f]{8}$/.test(value)) return match;
|
|
81
|
+
return `${key}${sep}${idempotencyPrefix(value)}`;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Other headers and key=value query parameters: redact the whole value.
|
|
85
|
+
// This intentionally over-redacts an inline header line rather than risk
|
|
86
|
+
// leaving a fragment of "Bearer <token>" or a multi-word credential behind.
|
|
87
|
+
s = s.replace(HEADER_OR_PARAM_RE, (match, key, sep) => {
|
|
88
|
+
return `${key}${sep}[REDACTED]`;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function safeStringify(value, depth = 0, seen = new WeakSet()) {
|
|
95
|
+
if (value == null) return String(value);
|
|
96
|
+
if (typeof value === "bigint") return value.toString();
|
|
97
|
+
if (typeof value === "function") return "[Function]";
|
|
98
|
+
if (typeof value !== "object") return JSON.stringify(value);
|
|
99
|
+
if (value instanceof Error) return formatError(value);
|
|
100
|
+
if (depth > 5) return "[MaxDepth]";
|
|
101
|
+
if (seen.has(value)) return "[Circular]";
|
|
102
|
+
seen.add(value);
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
const out = value.slice(0, 50).map((v) => safeStringify(v, depth + 1, seen));
|
|
105
|
+
if (value.length > 50) out.push(`[+${value.length - 50} more]`);
|
|
106
|
+
return `[${out.join(",")}]`;
|
|
107
|
+
}
|
|
108
|
+
const pairs = [];
|
|
109
|
+
for (const [key, v] of Object.entries(value)) {
|
|
110
|
+
if (SENSITIVE_KEY_RE.test(key)) {
|
|
111
|
+
pairs.push(`${JSON.stringify(key)}:${JSON.stringify(IDEMPOTENCY_KEY_RE.test(key) ? idempotencyPrefix(v) : "[REDACTED]")}`);
|
|
112
|
+
} else {
|
|
113
|
+
pairs.push(`${JSON.stringify(key)}:${safeStringify(v, depth + 1, seen)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return `{${pairs.join(",")}}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function redactValue(value, { idempotencyKey = null } = {}) {
|
|
120
|
+
if (value instanceof Error) return formatError(value, { idempotencyKey });
|
|
121
|
+
if (typeof value === "string") return redactText(value, { idempotencyKey });
|
|
122
|
+
if (value == null || typeof value !== "object") return value;
|
|
123
|
+
if (Array.isArray(value)) return value.map((v) => redactValue(v, { idempotencyKey }));
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const [key, v] of Object.entries(value)) {
|
|
126
|
+
if (SENSITIVE_KEY_RE.test(key)) {
|
|
127
|
+
out[key] = IDEMPOTENCY_KEY_RE.test(key)
|
|
128
|
+
? idempotencyPrefix(v)
|
|
129
|
+
: "[REDACTED]";
|
|
130
|
+
} else {
|
|
131
|
+
out[key] = redactValue(v, { idempotencyKey });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Format an Error without ever dumping its raw enumerable properties. */
|
|
138
|
+
export function formatError(error, { idempotencyKey = null } = {}) {
|
|
139
|
+
if (error == null) return "";
|
|
140
|
+
if (typeof error === "string") return redactText(error, { idempotencyKey });
|
|
141
|
+
if (!(error instanceof Error)) {
|
|
142
|
+
return redactText(safeStringify(error), { idempotencyKey });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const head = redactText(`${error.name || "Error"}: ${error.message || ""}`, { idempotencyKey });
|
|
146
|
+
const cause = error.cause
|
|
147
|
+
? `\ncause: ${formatError(error.cause, { idempotencyKey })}`
|
|
148
|
+
: "";
|
|
149
|
+
const stack = error.stack
|
|
150
|
+
? `\n${redactText(error.stack, { idempotencyKey })}`
|
|
151
|
+
: "";
|
|
152
|
+
return `${head}${cause}${stack}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function emit(level, message, error = null, context = null) {
|
|
156
|
+
const parts = [redactText(message)];
|
|
157
|
+
if (context && typeof context === "object" && Object.keys(context).length) {
|
|
158
|
+
parts.push(redactText(safeStringify(context)));
|
|
159
|
+
}
|
|
160
|
+
const detail = error == null ? "" : formatError(error);
|
|
161
|
+
const line = `${parts.join(" ")}${detail ? `\n${detail}` : ""}`;
|
|
162
|
+
// Use the current console method so tests and embedders can still capture
|
|
163
|
+
// structured events. installConsoleRedaction() makes this idempotent.
|
|
164
|
+
(console[level] || ORIGINAL[level])(line);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function logInfo(message, error = null, context = null) {
|
|
168
|
+
emit("log", message, error, context);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function logWarn(message, error = null, context = null) {
|
|
172
|
+
emit("warn", message, error, context);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function logError(message, error = null, context = null) {
|
|
176
|
+
emit("error", message, error, context);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Defence in depth: wrap console.* so a future direct console call cannot dump
|
|
181
|
+
* a raw Error or an unredacted URL. Safe to call more than once.
|
|
182
|
+
*/
|
|
183
|
+
export function installConsoleRedaction() {
|
|
184
|
+
if (installed) return;
|
|
185
|
+
installed = true;
|
|
186
|
+
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
187
|
+
console[level] = (...args) => {
|
|
188
|
+
const safe = args.map((arg) =>
|
|
189
|
+
arg instanceof Error
|
|
190
|
+
? formatError(arg)
|
|
191
|
+
: typeof arg === "string"
|
|
192
|
+
? redactText(arg)
|
|
193
|
+
: arg,
|
|
194
|
+
);
|
|
195
|
+
ORIGINAL[level](...safe);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verify402-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"mcpName": "io.github.a32116150-ctrl/verify402-mcp",
|
|
5
5
|
"description": "MCP server for verify402: a free, fail-closed preflight gate before signing, plus five paid on-chain checks paid per call via x402.",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"index.mjs",
|
|
13
13
|
"budget.mjs",
|
|
14
14
|
"payment-policy.mjs",
|
|
15
|
+
"paid-call.mjs",
|
|
16
|
+
"log.mjs",
|
|
15
17
|
"README.md",
|
|
16
18
|
".env.example",
|
|
17
19
|
"LICENSE",
|
|
@@ -52,8 +54,8 @@
|
|
|
52
54
|
},
|
|
53
55
|
"dependencies": {
|
|
54
56
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
55
|
-
"@x402/axios": "
|
|
56
|
-
"@x402/evm": "
|
|
57
|
+
"@x402/axios": "2.28.0",
|
|
58
|
+
"@x402/evm": "2.28.0",
|
|
57
59
|
"axios": "^1.13.2",
|
|
58
60
|
"dotenv": "^16.4.5",
|
|
59
61
|
"viem": "^2.39.0",
|
package/paid-call.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One paid operation = one idempotency key, reused on every retry.
|
|
2
|
+
//
|
|
3
|
+
// The key is a bearer credential (see README/recovery docs), so it is never
|
|
4
|
+
// logged or returned to the MCP client. The bridge only needs it to survive a
|
|
5
|
+
// lost response: the server returns the settled verdict with
|
|
6
|
+
// recovery.replayed=true instead of charging a second time.
|
|
7
|
+
//
|
|
8
|
+
// Retry rules:
|
|
9
|
+
// * network/timeout/5xx-gateway failures: retry with the SAME key
|
|
10
|
+
// * 402: never retry here (the x402 wrapper owns the payment handshake)
|
|
11
|
+
// * 409: never retry (a conflicting body will not become valid)
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_RETRIES = 2;
|
|
16
|
+
|
|
17
|
+
const RETRYABLE_CODES = new Set([
|
|
18
|
+
"ECONNABORTED",
|
|
19
|
+
"ECONNRESET",
|
|
20
|
+
"EHOSTUNREACH",
|
|
21
|
+
"ENETUNREACH",
|
|
22
|
+
"EPIPE",
|
|
23
|
+
"ETIMEDOUT",
|
|
24
|
+
"EAI_AGAIN",
|
|
25
|
+
"ERR_NETWORK",
|
|
26
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
27
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
28
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
29
|
+
"UND_ERR_SOCKET",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
33
|
+
|
|
34
|
+
export function isRetryable(error) {
|
|
35
|
+
const status = error?.response?.status;
|
|
36
|
+
if (status != null) return RETRYABLE_STATUS.has(status);
|
|
37
|
+
if (RETRYABLE_CODES.has(error?.code)) return true;
|
|
38
|
+
// Axios tags transport failures as isAxiosError with no response. Anything
|
|
39
|
+
// else (bad config, programmer error) is not retried.
|
|
40
|
+
return error?.isAxiosError === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* POST a paid operation with one generated idempotency key.
|
|
47
|
+
*
|
|
48
|
+
* @param {{post: Function}} paidApi - x402-wrapped axios instance (or a fake)
|
|
49
|
+
* @param {string} route
|
|
50
|
+
* @param {object} body
|
|
51
|
+
* @param {{idempotencyKey?: string, retries?: number, sleep?: Function}} options
|
|
52
|
+
* @returns {Promise<object>} the final axios response
|
|
53
|
+
*/
|
|
54
|
+
export async function paidPost(paidApi, route, body, {
|
|
55
|
+
idempotencyKey = randomUUID(),
|
|
56
|
+
retries = DEFAULT_RETRIES,
|
|
57
|
+
sleep = delay,
|
|
58
|
+
} = {}) {
|
|
59
|
+
let lastError;
|
|
60
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
return await paidApi.post(route, body, {
|
|
63
|
+
headers: { "x-idempotency-key": idempotencyKey },
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
lastError = error;
|
|
67
|
+
if (!isRetryable(error) || attempt === retries) throw error;
|
|
68
|
+
// Keep the same key: a retry after a lost response must replay, not pay.
|
|
69
|
+
await sleep(250 * (attempt + 1));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw lastError;
|
|
73
|
+
}
|