@quackai/q402-mcp 0.3.10 → 0.3.12
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 +14 -3
- package/dist/index.js +115 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,13 +42,24 @@ Restart Claude Desktop and ask:
|
|
|
42
42
|
|
|
43
43
|
### OpenAI Codex CLI
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Three install paths — pick the one that matches your workflow.
|
|
46
|
+
|
|
47
|
+
**(a) Codex plugin marketplace** (recommended — bundles the MCP config so users don't write TOML):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
codex plugin marketplace add bitgett/q402-mcp
|
|
51
|
+
codex /plugins # browse and install "q402"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This repo carries a Codex plugin manifest at [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json) and a marketplace catalog at [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json), so any signed-in Codex user can register it as a marketplace source and install with one click.
|
|
55
|
+
|
|
56
|
+
**(b) Single MCP server via `codex mcp add`** (no plugin wrapper — just register the stdio server):
|
|
46
57
|
|
|
47
58
|
```bash
|
|
48
59
|
codex mcp add q402 -- npx -y @quackai/q402-mcp
|
|
49
60
|
```
|
|
50
61
|
|
|
51
|
-
|
|
62
|
+
**(c) Direct `~/.codex/config.toml` edit** (`.codex/config.toml` for per-project scope):
|
|
52
63
|
|
|
53
64
|
```toml
|
|
54
65
|
[mcp_servers.q402]
|
|
@@ -89,7 +100,7 @@ The server has no client-specific code. If your client speaks stdio MCP, point i
|
|
|
89
100
|
| `q402_quote` | none | Compare gas cost and supported tokens across chains. Read-only. |
|
|
90
101
|
| `q402_balance` | API key | Verify the API key and report its plan tier + remaining quota credits (live vs sandbox). |
|
|
91
102
|
| `q402_pay` | API key + private key + flag | Send a gasless payment to a single recipient. **Sandbox by default** — see [Sandbox vs live mode](#sandbox-vs-live-mode). |
|
|
92
|
-
| `q402_batch_pay` | API key + private key + flag | Send a gasless payment to **multiple** recipients in one call on a single chain × token. Trial keys: 5 rows max. Paid keys: 20 rows max. Same sandbox gating as `q402_pay`. |
|
|
103
|
+
| `q402_batch_pay` | API key + private key + flag | Send a gasless payment to **multiple** recipients in one call on a single chain × token. Trial keys: 5 rows max. Paid keys: 20 rows max. **Supported chains: avax, bnb, eth, mantle, injective** (default EIP-7702 mode). xlayer + stable are NOT batchable — use `q402_pay` in a loop for those. Same sandbox gating as `q402_pay`. **Rate-limit note:** the inner `/api/relay` budget (30/min per key) is consumed per row, so a paid 20-row batch leaves ~10 inner slots for the next minute. |
|
|
93
104
|
| `q402_receipt` | none | Look up a Trust Receipt by `rct_…` id and locally verify its ECDSA signature against the relayer EOA. Returns the public settlement record + a `verified` boolean. *receiptId-only today; tx-hash lookup reserved for a future release.* |
|
|
94
105
|
|
|
95
106
|
`q402_pay` and `q402_batch_pay` follow a "confirm in chat first" contract: the tool description instructs the model to never call it without explicit user approval of the recipient address(es), amount(s), chain, and token. For batch calls the user must approve the **full batch**, not the individual rows.
|
package/dist/index.js
CHANGED
|
@@ -445,28 +445,44 @@ var Q402NodeClient = class _Q402NodeClient {
|
|
|
445
445
|
* first transfer must succeed (it installs / re-confirms the
|
|
446
446
|
* delegation), after which the remaining transfers are surfaced in
|
|
447
447
|
* the result array even if individual ones fail.
|
|
448
|
+
*
|
|
449
|
+
* Signature shape: `{ token, recipients }`. The previous revision took
|
|
450
|
+
* `PayInput[]` (with token on each row), which read as if rows could
|
|
451
|
+
* carry different tokens — but the request body only ships one token
|
|
452
|
+
* field, so the per-row token on rows 1..N was silently ignored. The
|
|
453
|
+
* new shape surfaces the constraint in the type so consumers can't
|
|
454
|
+
* accidentally build a "mixed-token batch" that quietly drops the
|
|
455
|
+
* second token. Same chain + same token across one batch, full stop.
|
|
448
456
|
*/
|
|
449
|
-
async batchPay(
|
|
450
|
-
|
|
457
|
+
async batchPay(input) {
|
|
458
|
+
const { token, recipients: rows } = input;
|
|
459
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
451
460
|
throw new Error("batchPay requires at least one recipient");
|
|
452
461
|
}
|
|
462
|
+
if (typeof token !== "string") {
|
|
463
|
+
throw new Error("batchPay({ token, recipients }): token must be a string");
|
|
464
|
+
}
|
|
453
465
|
const { chain, relayBaseUrl, apiKey, privateKey } = this.opts;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
466
|
+
const tokenCfg = tokenFor(chain, token);
|
|
467
|
+
if (chain.supportedTokens && !chain.supportedTokens.includes(token)) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`token ${token} is not supported on chain ${chain.key}. Supported: ${chain.supportedTokens.join(", ")}.`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
for (let i = 0; i < rows.length; i++) {
|
|
462
473
|
try {
|
|
463
|
-
toRawAmount(
|
|
474
|
+
toRawAmount(rows[i].amount, tokenCfg.decimals);
|
|
464
475
|
} catch (e) {
|
|
465
476
|
throw new Error(
|
|
466
477
|
`recipient[${i}]: ${e instanceof Error ? e.message : String(e)}`
|
|
467
478
|
);
|
|
468
479
|
}
|
|
469
480
|
}
|
|
481
|
+
if (chain.key === "xlayer" || chain.key === "stable") {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`batchPay does not yet support chain "${chain.key}". Supported batch chains: avax, bnb, eth, mantle, injective (default EIP-7702 mode). For "${chain.key}" use pay() in a client-side loop.`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
470
486
|
const rpcUrl = this.opts.rpcUrl ?? DEFAULT_RPC[chain.chainId];
|
|
471
487
|
if (!rpcUrl) {
|
|
472
488
|
throw new Error(
|
|
@@ -479,11 +495,10 @@ var Q402NodeClient = class _Q402NodeClient {
|
|
|
479
495
|
const facilitator = await this.fetchFacilitator();
|
|
480
496
|
const baseAuthNonce = await provider.getTransactionCount(owner);
|
|
481
497
|
const deadline = Math.floor(Date.now() / 1e3) + 600;
|
|
482
|
-
const
|
|
483
|
-
for (let i = 0; i <
|
|
484
|
-
const
|
|
485
|
-
const
|
|
486
|
-
const amountRaw = toRawAmount(inp.amount, tokenCfg.decimals);
|
|
498
|
+
const signedRows = [];
|
|
499
|
+
for (let i = 0; i < rows.length; i++) {
|
|
500
|
+
const row = rows[i];
|
|
501
|
+
const amountRaw = toRawAmount(row.amount, tokenCfg.decimals);
|
|
487
502
|
const paymentNonce = toBigInt(randomBytes(32));
|
|
488
503
|
const witnessSig = await wallet.signTypedData(
|
|
489
504
|
{
|
|
@@ -497,7 +512,7 @@ var Q402NodeClient = class _Q402NodeClient {
|
|
|
497
512
|
owner,
|
|
498
513
|
facilitator,
|
|
499
514
|
token: tokenCfg.address,
|
|
500
|
-
recipient:
|
|
515
|
+
recipient: row.to,
|
|
501
516
|
amount: BigInt(amountRaw),
|
|
502
517
|
nonce: paymentNonce,
|
|
503
518
|
deadline: BigInt(deadline)
|
|
@@ -508,16 +523,15 @@ var Q402NodeClient = class _Q402NodeClient {
|
|
|
508
523
|
address: chain.implContract,
|
|
509
524
|
nonce: baseAuthNonce + i
|
|
510
525
|
});
|
|
511
|
-
|
|
526
|
+
signedRows.push({
|
|
512
527
|
from: owner,
|
|
513
|
-
to:
|
|
528
|
+
to: row.to,
|
|
514
529
|
amount: amountRaw,
|
|
530
|
+
nonce: paymentNonce.toString(),
|
|
515
531
|
deadline,
|
|
516
532
|
witnessSig,
|
|
517
533
|
authorization
|
|
518
|
-
};
|
|
519
|
-
const row = chain.key === "xlayer" ? { ...baseRow, xlayerNonce: paymentNonce.toString() } : chain.key === "stable" ? { ...baseRow, stableNonce: paymentNonce.toString() } : { ...baseRow, nonce: paymentNonce.toString() };
|
|
520
|
-
recipients.push(row);
|
|
534
|
+
});
|
|
521
535
|
}
|
|
522
536
|
const resp = await fetch(`${relayBaseUrl.replace(/\/$/, "")}/relay/batch`, {
|
|
523
537
|
method: "POST",
|
|
@@ -525,15 +539,29 @@ var Q402NodeClient = class _Q402NodeClient {
|
|
|
525
539
|
body: JSON.stringify({
|
|
526
540
|
apiKey,
|
|
527
541
|
chain: chain.key,
|
|
528
|
-
token
|
|
529
|
-
// all recipients must share token; pre-validated above
|
|
542
|
+
token,
|
|
530
543
|
facilitator,
|
|
531
|
-
recipients
|
|
544
|
+
recipients: signedRows
|
|
532
545
|
})
|
|
533
546
|
});
|
|
534
547
|
const data = await resp.json();
|
|
535
|
-
if (!resp.ok) {
|
|
536
|
-
|
|
548
|
+
if (!resp.ok || data.ok === false) {
|
|
549
|
+
const err = new BatchPayError(
|
|
550
|
+
data.aborted ? `Batch aborted: recipient[0] failed (${data.results?.[0]?.error ?? "unknown"}). No transfers landed.` : data.totalFailed > 0 ? `Batch completed with ${data.totalFailed}/${data.results?.length ?? "?"} failed rows.` : data.error ?? `relay/batch failed (HTTP ${resp.status})`,
|
|
551
|
+
{
|
|
552
|
+
aborted: !!data.aborted,
|
|
553
|
+
// Preserve the server's scope/limit so the MCP tool surface can
|
|
554
|
+
// report the actual tier (trial vs paid) rather than guessing.
|
|
555
|
+
// Falls back to "paid" / row count only when the failure didn't
|
|
556
|
+
// come from the relay route (e.g. network-level error).
|
|
557
|
+
scope: data.scope ?? "paid",
|
|
558
|
+
limit: data.limit ?? signedRows.length,
|
|
559
|
+
totalSuccess: data.totalSuccess ?? 0,
|
|
560
|
+
totalFailed: data.totalFailed ?? signedRows.length,
|
|
561
|
+
results: data.results ?? []
|
|
562
|
+
}
|
|
563
|
+
);
|
|
564
|
+
throw err;
|
|
537
565
|
}
|
|
538
566
|
data.results = data.results.map((r) => ({
|
|
539
567
|
...r,
|
|
@@ -542,6 +570,24 @@ var Q402NodeClient = class _Q402NodeClient {
|
|
|
542
570
|
return data;
|
|
543
571
|
}
|
|
544
572
|
};
|
|
573
|
+
var BatchPayError = class extends Error {
|
|
574
|
+
aborted;
|
|
575
|
+
scope;
|
|
576
|
+
limit;
|
|
577
|
+
totalSuccess;
|
|
578
|
+
totalFailed;
|
|
579
|
+
results;
|
|
580
|
+
constructor(message, details) {
|
|
581
|
+
super(message);
|
|
582
|
+
this.name = "BatchPayError";
|
|
583
|
+
this.aborted = details.aborted;
|
|
584
|
+
this.scope = details.scope;
|
|
585
|
+
this.limit = details.limit;
|
|
586
|
+
this.totalSuccess = details.totalSuccess;
|
|
587
|
+
this.totalFailed = details.totalFailed;
|
|
588
|
+
this.results = details.results;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
545
591
|
function sandboxPay(chain, input) {
|
|
546
592
|
const tokenCfg = tokenFor(chain, input.token);
|
|
547
593
|
const tokenAmount = toRawAmount(input.amount, tokenCfg.decimals);
|
|
@@ -678,7 +724,7 @@ var RECIPIENT_LIMIT_TRIAL = 5;
|
|
|
678
724
|
var RECIPIENT_LIMIT_PAID = 20;
|
|
679
725
|
var CLIENT_RECIPIENT_CAP = RECIPIENT_LIMIT_PAID;
|
|
680
726
|
var BatchPayInputSchema = z3.object({
|
|
681
|
-
chain: z3.enum(["avax", "bnb", "eth", "
|
|
727
|
+
chain: z3.enum(["avax", "bnb", "eth", "mantle", "injective"]),
|
|
682
728
|
token: z3.enum(["USDC", "USDT", "RLUSD"]).describe(
|
|
683
729
|
"Stablecoin symbol. USDC / USDT supported on most chains (Injective is USDT-only). RLUSD (Ripple USD, NY DFS regulated, decimals 18) is Ethereum-only. The same token applies to every recipient in the batch."
|
|
684
730
|
),
|
|
@@ -741,6 +787,7 @@ async function runBatchPay(input) {
|
|
|
741
787
|
guardsApplied.push("mode=sandbox");
|
|
742
788
|
return {
|
|
743
789
|
mode: "sandbox",
|
|
790
|
+
status: "sandbox",
|
|
744
791
|
result: { sandbox: sandboxResults, reason: describeSandboxReason2() },
|
|
745
792
|
guardsApplied,
|
|
746
793
|
setupHint: describeSandboxReason2()
|
|
@@ -752,13 +799,39 @@ async function runBatchPay(input) {
|
|
|
752
799
|
chain,
|
|
753
800
|
relayBaseUrl: CONFIG.relayBaseUrl
|
|
754
801
|
});
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
802
|
+
try {
|
|
803
|
+
const result = await client.batchPay({
|
|
804
|
+
token: input.token,
|
|
805
|
+
recipients: input.recipients.map((r) => ({ to: r.to, amount: r.amount }))
|
|
806
|
+
});
|
|
807
|
+
guardsApplied.push("mode=live");
|
|
808
|
+
guardsApplied.push(`scope=${result.scope} (server enforced)`);
|
|
809
|
+
guardsApplied.push(`batch_size=${input.recipients.length}/${result.limit}`);
|
|
810
|
+
return { mode: "live", status: "success", result, guardsApplied };
|
|
811
|
+
} catch (err) {
|
|
812
|
+
if (err instanceof BatchPayError) {
|
|
813
|
+
guardsApplied.push("mode=live");
|
|
814
|
+
guardsApplied.push(`scope=${err.scope} (server enforced)`);
|
|
815
|
+
guardsApplied.push(`batch_${err.aborted ? "aborted" : "partial_failure"}`);
|
|
816
|
+
const status = err.aborted ? "aborted" : "partial_failure";
|
|
817
|
+
return {
|
|
818
|
+
mode: "live",
|
|
819
|
+
status,
|
|
820
|
+
result: {
|
|
821
|
+
ok: false,
|
|
822
|
+
scope: err.scope,
|
|
823
|
+
limit: err.limit,
|
|
824
|
+
totalSuccess: err.totalSuccess,
|
|
825
|
+
totalFailed: err.totalFailed,
|
|
826
|
+
aborted: err.aborted,
|
|
827
|
+
results: err.results
|
|
828
|
+
},
|
|
829
|
+
guardsApplied,
|
|
830
|
+
error: err.message
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
throw err;
|
|
834
|
+
}
|
|
762
835
|
}
|
|
763
836
|
function describeSandboxReason2() {
|
|
764
837
|
const missing = [];
|
|
@@ -770,14 +843,17 @@ function describeSandboxReason2() {
|
|
|
770
843
|
}
|
|
771
844
|
var BATCH_PAY_TOOL = {
|
|
772
845
|
name: "q402_batch_pay",
|
|
773
|
-
description: `Send gasless payments to MULTIPLE recipients on a single chain \xD7 token in one call. Trial keys (q402_live_* with plan='trial'): max ${RECIPIENT_LIMIT_TRIAL} recipients per call, BNB Chain + USDC/USDT only. Paid keys: max ${RECIPIENT_LIMIT_PAID} recipients per call
|
|
846
|
+
description: `Send gasless payments to MULTIPLE recipients on a single chain \xD7 token in one call. Trial keys (q402_live_* with plan='trial'): max ${RECIPIENT_LIMIT_TRIAL} recipients per call, BNB Chain + USDC/USDT only. Paid keys: max ${RECIPIENT_LIMIT_PAID} recipients per call across 5 EIP-7702 default chains (avax, bnb, eth, mantle, injective). xlayer + stable are NOT batchable \u2014 use q402_pay in a loop. SANDBOX BY DEFAULT \u2014 real on-chain TX only when Q402_API_KEY (live), Q402_PRIVATE_KEY, and Q402_ENABLE_REAL_PAYMENTS=1 are all set. Every recipient receives the full amount; the sender pays $0 in gas for the entire batch. ALWAYS get explicit user confirmation of the complete recipient + amount list, chain, and token in conversation immediately before calling this tool \u2014 the user must approve the full batch, not the individual rows.`,
|
|
774
847
|
inputSchema: {
|
|
775
848
|
type: "object",
|
|
776
849
|
properties: {
|
|
777
850
|
chain: {
|
|
778
851
|
type: "string",
|
|
779
|
-
|
|
780
|
-
|
|
852
|
+
// Narrower than the full chain set — xlayer and stable are NOT batchable
|
|
853
|
+
// (chain-specific nonce field shapes). Use q402_pay in a loop for
|
|
854
|
+
// those chains.
|
|
855
|
+
enum: ["avax", "bnb", "eth", "mantle", "injective"],
|
|
856
|
+
description: "Target chain. Applies to every recipient in the batch. xlayer + stable are NOT supported here \u2014 use q402_pay in a loop."
|
|
781
857
|
},
|
|
782
858
|
token: {
|
|
783
859
|
type: "string",
|
|
@@ -1028,7 +1104,7 @@ var RECEIPT_TOOL = {
|
|
|
1028
1104
|
|
|
1029
1105
|
// src/index.ts
|
|
1030
1106
|
var PACKAGE_NAME = "@quackai/q402-mcp";
|
|
1031
|
-
var PACKAGE_VERSION = "0.3.
|
|
1107
|
+
var PACKAGE_VERSION = "0.3.12";
|
|
1032
1108
|
function jsonText(value) {
|
|
1033
1109
|
return { type: "text", text: JSON.stringify(value, null, 2) };
|
|
1034
1110
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quackai/q402-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "MCP server for Q402 — gasless USDC, USDT, and RLUSD payments across 7 EVM chains, callable from Claude (Desktop / Code), OpenAI Codex CLI, and any other Model Context Protocol client.",
|
|
5
5
|
"mcpName": "io.github.bitgett/q402-mcp",
|
|
6
6
|
"keywords": [
|