cordon-mcp 0.1.0 → 0.2.0
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 +271 -24
- package/SKILL.md +14 -9
- package/dist/cordon-mcp.js +677 -172
- package/package.json +7 -4
package/dist/cordon-mcp.js
CHANGED
|
@@ -9,13 +9,15 @@ import { join } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
// ../fixtures/src/gates.gen.ts
|
|
11
11
|
var GATE_RUNS = {
|
|
12
|
-
"G1": { "id": "G1", "status": "green", "tests":
|
|
12
|
+
"G1": { "id": "G1", "status": "green", "tests": 68, "passed": 68, "recordedAt": "2026-09-26" },
|
|
13
13
|
"G2": { "id": "G2", "status": "green", "tests": 8, "passed": 8, "recordedAt": "2026-09-11" },
|
|
14
14
|
"G3": { "id": "G3", "status": "green", "tests": 5, "passed": 5, "recordedAt": "2026-09-10" },
|
|
15
|
-
"G4": { "id": "G4", "status": "green", "tests": 1, "passed": 1, "recordedAt": "2026-09-
|
|
16
|
-
"G5": { "id": "G5", "status": "green", "tests": 12, "passed": 12, "recordedAt": "2026-09-
|
|
17
|
-
"G6": { "id": "G6", "status": "green", "tests":
|
|
18
|
-
"G7": { "id": "G7", "status": "green", "tests": 10, "passed": 10, "recordedAt": "2026-09-09" }
|
|
15
|
+
"G4": { "id": "G4", "status": "green", "tests": 1, "passed": 1, "recordedAt": "2026-09-26" },
|
|
16
|
+
"G5": { "id": "G5", "status": "green", "tests": 12, "passed": 12, "recordedAt": "2026-09-26" },
|
|
17
|
+
"G6": { "id": "G6", "status": "green", "tests": 16, "passed": 16, "recordedAt": "2026-09-26" },
|
|
18
|
+
"G7": { "id": "G7", "status": "green", "tests": 10, "passed": 10, "recordedAt": "2026-09-09" },
|
|
19
|
+
"G8": { "id": "G8", "status": "green", "tests": 14, "passed": 14, "recordedAt": "2026-09-26" },
|
|
20
|
+
"G9": { "id": "G9", "status": "green", "tests": 4, "passed": 4, "recordedAt": "2026-09-26" }
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
// ../fixtures/src/search.gen.ts
|
|
@@ -33,13 +35,13 @@ var SEARCH = {
|
|
|
33
35
|
|
|
34
36
|
// ../fixtures/src/deployment.gen.ts
|
|
35
37
|
var DEPLOYMENT = {
|
|
36
|
-
chainId:
|
|
37
|
-
registry: "
|
|
38
|
-
vault: "
|
|
39
|
-
record: "
|
|
40
|
-
fromBlock: "
|
|
41
|
-
commit: "
|
|
42
|
-
deployedAt: "2026-09-
|
|
38
|
+
chainId: 11155111,
|
|
39
|
+
registry: "0xe799edc4aa6bcaf6915c7a5eadbdc4e709aef4b2",
|
|
40
|
+
vault: "0x22d539bdf23e08a856fc80bc991e34948921ad46",
|
|
41
|
+
record: "0x5c026b1b129a9e2171e53c5024f76052006ef1e0",
|
|
42
|
+
fromBlock: "11784955",
|
|
43
|
+
commit: "6c83270702ecca055240055c5dbe9340fbfa867f",
|
|
44
|
+
deployedAt: "2026-09-26T08:09:50.086Z"
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
// ../fixtures/src/drill.gen.ts
|
|
@@ -79,6 +81,8 @@ var ARC = {
|
|
|
79
81
|
faucet: "https://faucet.circle.com",
|
|
80
82
|
/** Gas token is USDC, native, 18 decimals. */
|
|
81
83
|
nativeDecimals: 18,
|
|
84
|
+
nativeName: "USDC",
|
|
85
|
+
nativeSymbol: "USDC",
|
|
82
86
|
/**
|
|
83
87
|
* The ERC-20 view of the same balance. balanceOf truncates below 1e-6.
|
|
84
88
|
*
|
|
@@ -111,6 +115,97 @@ var ARC = {
|
|
|
111
115
|
multicall3: "0xcA11bde05977b3631167028862bE2a173976CA11",
|
|
112
116
|
mainnetLaunched: false
|
|
113
117
|
};
|
|
118
|
+
var SEPOLIA = {
|
|
119
|
+
chainId: 11155111,
|
|
120
|
+
name: "Ethereum Sepolia",
|
|
121
|
+
/* Answered `eth_getBalance`, `eth_getCode` and `eth_call` on 25 Sep 2026;
|
|
122
|
+
the other two public endpoints tried that day returned 404 and a paywall. */
|
|
123
|
+
rpc: "https://ethereum-sepolia-rpc.publicnode.com",
|
|
124
|
+
explorer: "https://sepolia.etherscan.io",
|
|
125
|
+
/** ETH, for gas. Arc's faucet pours the gas token, which there is USDC. */
|
|
126
|
+
faucet: "https://cloud.google.com/application/web3/faucet/ethereum/sepolia",
|
|
127
|
+
/** USDC, from Circle, and a separate errand from the gas above. */
|
|
128
|
+
usdcFaucet: "https://faucet.circle.com",
|
|
129
|
+
/** ETH's, and it has nothing to do with USDC on this chain. */
|
|
130
|
+
nativeDecimals: 18,
|
|
131
|
+
nativeName: "Ether",
|
|
132
|
+
nativeSymbol: "ETH",
|
|
133
|
+
/**
|
|
134
|
+
* USDC, and the only view of it. A real FiatTokenV2 with EIP-3009, so x402
|
|
135
|
+
* `exact` settles here: probed on 20 Sep 2026 for `version() -> "2"`,
|
|
136
|
+
* `DOMAIN_SEPARATOR()` and an `authorizationState` that answers rather than
|
|
137
|
+
* falls through. Carried code on 25 Sep 2026.
|
|
138
|
+
*/
|
|
139
|
+
erc20: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
|
|
140
|
+
erc20Decimals: 6,
|
|
141
|
+
/** Multicall3, at the address it has on every chain that has one. */
|
|
142
|
+
multicall3: "0xcA11bde05977b3631167028862bE2a173976CA11",
|
|
143
|
+
mainnetLaunched: false
|
|
144
|
+
};
|
|
145
|
+
var ANVIL = {
|
|
146
|
+
chainId: 31337,
|
|
147
|
+
name: "Anvil",
|
|
148
|
+
rpc: "http://127.0.0.1:8545",
|
|
149
|
+
explorer: "",
|
|
150
|
+
faucet: "",
|
|
151
|
+
nativeDecimals: 18,
|
|
152
|
+
nativeName: "Ether",
|
|
153
|
+
nativeSymbol: "ETH",
|
|
154
|
+
erc20: "0x0000000000000000000000000000000000000000",
|
|
155
|
+
erc20Decimals: 6,
|
|
156
|
+
mainnetLaunched: false
|
|
157
|
+
};
|
|
158
|
+
var CHAINS = {
|
|
159
|
+
[ARC.chainId]: ARC,
|
|
160
|
+
[SEPOLIA.chainId]: SEPOLIA,
|
|
161
|
+
[ANVIL.chainId]: ANVIL
|
|
162
|
+
};
|
|
163
|
+
var DEFAULT_CHAIN = SEPOLIA;
|
|
164
|
+
function chainFacts(chainId) {
|
|
165
|
+
return CHAINS[chainId];
|
|
166
|
+
}
|
|
167
|
+
var ENSV2 = {
|
|
168
|
+
chainId: SEPOLIA.chainId,
|
|
169
|
+
registry: "0x657ea849311d3d5823348dded7c2aaafb3ede09e",
|
|
170
|
+
rootRegistry: "0x9703dbd26dab89504490994138cf2c575251a9ce",
|
|
171
|
+
registrar: "0xabe76f6c8dfced81aa5a2bb8034202a7136b94ca",
|
|
172
|
+
universalResolver: "0x5d25c1d6acbb71b7a28aa7899618a3412a8303e3",
|
|
173
|
+
permissionedResolverImpl: "0x14f09fd05d4585759e54844dc9b00147131cf243",
|
|
174
|
+
/**
|
|
175
|
+
* How a name gets subnames: they live in a registry of their own, a
|
|
176
|
+
* UserRegistry proxy the parent deploys through this factory and then points
|
|
177
|
+
* at with `setSubregistry`. A mandate tree is a tree of these.
|
|
178
|
+
*/
|
|
179
|
+
verifiableFactory: "0x9e726eb570beb6bceb495ab8cda7df517d4e841c",
|
|
180
|
+
userRegistryImpl: "0xa80338aaa8d23831cea25e858d1774534abb0263",
|
|
181
|
+
/**
|
|
182
|
+
* What the registrar charges, and the reason this set is the right one.
|
|
183
|
+
*
|
|
184
|
+
* Sepolia carries two complete ENSv2 deployments, both live and both with
|
|
185
|
+
* names registered in them, because the contracts are an early preview and
|
|
186
|
+
* are redeployed as they change — a chain keeps every one of them. The other
|
|
187
|
+
* set is documented only in an unmerged docs build. This set is the one the
|
|
188
|
+
* published docs list, and it is self-consistent where it counts: the
|
|
189
|
+
* registrar at the address above answers `rentPriceOracle()` with exactly
|
|
190
|
+
* the oracle the docs list for it, and the root registry answers
|
|
191
|
+
* `getSubregistry("eth")` with exactly the registry above. Checked against
|
|
192
|
+
* the chain on 25 Sep 2026.
|
|
193
|
+
*/
|
|
194
|
+
rentPriceOracle: "0x9b0b9c65bdaf9794ff7697e4dcfb1f50581072bb",
|
|
195
|
+
/**
|
|
196
|
+
* What the registrar charges registration fees in, and a different token
|
|
197
|
+
* from the one a mandate spends.
|
|
198
|
+
*
|
|
199
|
+
* It mints to anyone who asks, with no access control, which is what makes
|
|
200
|
+
* it safe to demo with — and exactly why it may never reach a vault. The
|
|
201
|
+
* money Cordon bounds is `SEPOLIA.erc20`, whose supply nobody here can
|
|
202
|
+
* conjure. Confusing the two turns a budget into a suggestion.
|
|
203
|
+
*/
|
|
204
|
+
feeToken: "0x16f95d91dba7da3aca778ec053df0ff6c6a8aa8e",
|
|
205
|
+
feeTokenDecimals: 6,
|
|
206
|
+
/** The registrar takes either. Listed so the pair is not discovered late. */
|
|
207
|
+
feeTokenDai: "0x278053acc97888e63ec81c80fec641bf0bf19664"
|
|
208
|
+
};
|
|
114
209
|
var ERC8004 = {
|
|
115
210
|
identity: "0x8004A818BFB912233c491871b3d84c89A494BD9e",
|
|
116
211
|
reputation: "0x8004B663056A597Dffe9eCcC1965A193B7388713",
|
|
@@ -206,6 +301,19 @@ var GATEWAY = {
|
|
|
206
301
|
mintGas6: 2975n,
|
|
207
302
|
settlementVerifiedOn: "2026-09-11"
|
|
208
303
|
};
|
|
304
|
+
var DEMOS = {
|
|
305
|
+
5042002: {
|
|
306
|
+
owner: "0x736159a06C89Ea5b12eD88BE658741edCa64324D",
|
|
307
|
+
root: "0xd08820db0e1cd58426ba9dc8e78513b05d244b42cdaf841070b0a00d49b901ad",
|
|
308
|
+
agentId: "894124"
|
|
309
|
+
},
|
|
310
|
+
11155111: {
|
|
311
|
+
owner: "0x9F846D2054689a439DA8D0619f37F6c70Db03597",
|
|
312
|
+
root: "0x7a64b3d4db9223e41fa3e5ddd3c4512a44f47a9d48218f5a7224b1cf80dc18ff",
|
|
313
|
+
agentId: "10521"
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
var DEMO = DEMOS[5042002];
|
|
209
317
|
var PENDING_ADDRESS = "pending \u2014 written by the deploy script";
|
|
210
318
|
var PENDING_MANDATE = "pending \u2014 no mandate signed yet";
|
|
211
319
|
var MANDATE = {
|
|
@@ -342,6 +450,10 @@ var STRUCTURING = {
|
|
|
342
450
|
// ../daemon/src/config.ts
|
|
343
451
|
var ConfigError = class extends Error {
|
|
344
452
|
};
|
|
453
|
+
function isLoopback(bind) {
|
|
454
|
+
const host = bind.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
455
|
+
return host === "localhost" || host === "::1" || /^127\./.test(host);
|
|
456
|
+
}
|
|
345
457
|
var ENV = {
|
|
346
458
|
required: {
|
|
347
459
|
CORDON_VAULT: "TreeVault address, from deployments/<chainId>.json",
|
|
@@ -352,13 +464,15 @@ var ENV = {
|
|
|
352
464
|
optional: {
|
|
353
465
|
CORDON_RECORD: "ConductRecord, from deployments/<chainId>.json. Without it, refusals are enforced but never published",
|
|
354
466
|
CORDON_IDENTITY: `ERC-8004 Identity; defaults to ${ERC8004.identity}`,
|
|
355
|
-
CORDON_RPC: `defaults to ${
|
|
356
|
-
CORDON_CHAIN_ID: `defaults to ${
|
|
357
|
-
CORDON_USDC:
|
|
467
|
+
CORDON_RPC: `defaults to ${DEFAULT_CHAIN.rpc}`,
|
|
468
|
+
CORDON_CHAIN_ID: `defaults to ${DEFAULT_CHAIN.chainId} (${DEFAULT_CHAIN.name})`,
|
|
469
|
+
CORDON_USDC: `the 6-decimal ERC-20 view; defaults to ${DEFAULT_CHAIN.name}'s`,
|
|
358
470
|
CORDON_NETWORKS: "CAIP-2 ids this daemon will settle on",
|
|
359
471
|
CORDON_ASSETS: "assets it will pay in",
|
|
360
472
|
CORDON_PORT: "defaults to 8402",
|
|
361
|
-
|
|
473
|
+
CORDON_BIND: "the interface to listen on; defaults to 127.0.0.1. Anything else requires CORDON_TOKEN",
|
|
474
|
+
CORDON_TOKEN: "a shared secret every request must send as `Authorization: Bearer <token>`. Optional on loopback, required off it",
|
|
475
|
+
CORDON_POLL_MS: "receipt polling interval; 250 on Arc, matched to its sub-second finality, and 4,000 elsewhere \u2014 asking a public endpoint forty times per twelve-second block is how an address gets rate limited",
|
|
362
476
|
CORDON_KEY_FILE: "where a spawned child's key is written, before the spawn is sent; defaults to ~/.cordon/cordon.env"
|
|
363
477
|
}
|
|
364
478
|
};
|
|
@@ -385,18 +499,27 @@ function load(env = process.env) {
|
|
|
385
499
|
keys.push({ node: value, keyEnv });
|
|
386
500
|
}
|
|
387
501
|
if (keys.length === 0) throw new ConfigError("no CORDON_NODE_<label> is set");
|
|
502
|
+
const bind = env.CORDON_BIND ?? "127.0.0.1";
|
|
503
|
+
const token = env.CORDON_TOKEN || void 0;
|
|
504
|
+
if (!isLoopback(bind) && !token) {
|
|
505
|
+
throw new ConfigError(
|
|
506
|
+
`CORDON_BIND is ${bind}, which is reachable from off this machine, and CORDON_TOKEN is not set. This process holds the operator keys: bind to 127.0.0.1, or set a token.`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
388
509
|
return {
|
|
389
|
-
rpcUrl: env.CORDON_RPC ??
|
|
390
|
-
chainId: Number(env.CORDON_CHAIN_ID ??
|
|
510
|
+
rpcUrl: env.CORDON_RPC ?? DEFAULT_CHAIN.rpc,
|
|
511
|
+
chainId: Number(env.CORDON_CHAIN_ID ?? DEFAULT_CHAIN.chainId),
|
|
391
512
|
vault: address(env, "CORDON_VAULT"),
|
|
392
513
|
registry: address(env, "CORDON_REGISTRY"),
|
|
393
|
-
usdc: env.CORDON_USDC ??
|
|
514
|
+
usdc: env.CORDON_USDC ?? DEFAULT_CHAIN.erc20,
|
|
394
515
|
record: env.CORDON_RECORD ? address(env, "CORDON_RECORD") : void 0,
|
|
395
516
|
identity: env.CORDON_IDENTITY ?? ERC8004.identity,
|
|
396
|
-
networks: (env.CORDON_NETWORKS ?? `eip155:${
|
|
397
|
-
assets: (env.CORDON_ASSETS ??
|
|
517
|
+
networks: (env.CORDON_NETWORKS ?? `eip155:${DEFAULT_CHAIN.chainId}`).split(",").map((s) => s.trim()),
|
|
518
|
+
assets: (env.CORDON_ASSETS ?? DEFAULT_CHAIN.erc20).split(",").map((s) => s.trim()),
|
|
398
519
|
port: Number(env.CORDON_PORT ?? 8402),
|
|
399
|
-
|
|
520
|
+
bind,
|
|
521
|
+
token,
|
|
522
|
+
pollMs: Number(env.CORDON_POLL_MS ?? (Number(env.CORDON_CHAIN_ID ?? DEFAULT_CHAIN.chainId) === ARC.chainId ? 250 : 4e3)),
|
|
400
523
|
keys,
|
|
401
524
|
keyFile: env.CORDON_KEY_FILE ?? join(homedir(), ".cordon", "cordon.env")
|
|
402
525
|
};
|
|
@@ -498,6 +621,25 @@ var TreeVaultAbi = [
|
|
|
498
621
|
],
|
|
499
622
|
"stateMutability": "view"
|
|
500
623
|
},
|
|
624
|
+
{
|
|
625
|
+
"type": "function",
|
|
626
|
+
"name": "delivered",
|
|
627
|
+
"inputs": [
|
|
628
|
+
{
|
|
629
|
+
"name": "node",
|
|
630
|
+
"type": "bytes32",
|
|
631
|
+
"internalType": "bytes32"
|
|
632
|
+
}
|
|
633
|
+
],
|
|
634
|
+
"outputs": [
|
|
635
|
+
{
|
|
636
|
+
"name": "",
|
|
637
|
+
"type": "uint128",
|
|
638
|
+
"internalType": "uint128"
|
|
639
|
+
}
|
|
640
|
+
],
|
|
641
|
+
"stateMutability": "view"
|
|
642
|
+
},
|
|
501
643
|
{
|
|
502
644
|
"type": "function",
|
|
503
645
|
"name": "draw",
|
|
@@ -740,6 +882,25 @@ var TreeVaultAbi = [
|
|
|
740
882
|
"outputs": [],
|
|
741
883
|
"stateMutability": "nonpayable"
|
|
742
884
|
},
|
|
885
|
+
{
|
|
886
|
+
"type": "function",
|
|
887
|
+
"name": "releasedSpent",
|
|
888
|
+
"inputs": [
|
|
889
|
+
{
|
|
890
|
+
"name": "node",
|
|
891
|
+
"type": "bytes32",
|
|
892
|
+
"internalType": "bytes32"
|
|
893
|
+
}
|
|
894
|
+
],
|
|
895
|
+
"outputs": [
|
|
896
|
+
{
|
|
897
|
+
"name": "",
|
|
898
|
+
"type": "uint128",
|
|
899
|
+
"internalType": "uint128"
|
|
900
|
+
}
|
|
901
|
+
],
|
|
902
|
+
"stateMutability": "view"
|
|
903
|
+
},
|
|
743
904
|
{
|
|
744
905
|
"type": "function",
|
|
745
906
|
"name": "treasury6",
|
|
@@ -983,6 +1144,12 @@ var TreeVaultAbi = [
|
|
|
983
1144
|
"type": "uint128",
|
|
984
1145
|
"indexed": false,
|
|
985
1146
|
"internalType": "uint128"
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
"name": "released6",
|
|
1150
|
+
"type": "uint128",
|
|
1151
|
+
"indexed": false,
|
|
1152
|
+
"internalType": "uint128"
|
|
986
1153
|
}
|
|
987
1154
|
],
|
|
988
1155
|
"anonymous": false
|
|
@@ -1023,6 +1190,22 @@ var TreeVaultAbi = [
|
|
|
1023
1190
|
}
|
|
1024
1191
|
]
|
|
1025
1192
|
},
|
|
1193
|
+
{
|
|
1194
|
+
"type": "error",
|
|
1195
|
+
"name": "BranchIsCut",
|
|
1196
|
+
"inputs": [
|
|
1197
|
+
{
|
|
1198
|
+
"name": "id",
|
|
1199
|
+
"type": "uint256",
|
|
1200
|
+
"internalType": "uint256"
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
"name": "cutAt",
|
|
1204
|
+
"type": "bytes32",
|
|
1205
|
+
"internalType": "bytes32"
|
|
1206
|
+
}
|
|
1207
|
+
]
|
|
1208
|
+
},
|
|
1026
1209
|
{
|
|
1027
1210
|
"type": "error",
|
|
1028
1211
|
"name": "InsufficientTreasury",
|
|
@@ -1128,6 +1311,19 @@ var MandateRegistryAbi = [
|
|
|
1128
1311
|
],
|
|
1129
1312
|
"stateMutability": "view"
|
|
1130
1313
|
},
|
|
1314
|
+
{
|
|
1315
|
+
"type": "function",
|
|
1316
|
+
"name": "MAX_TREE_DEPTH",
|
|
1317
|
+
"inputs": [],
|
|
1318
|
+
"outputs": [
|
|
1319
|
+
{
|
|
1320
|
+
"name": "",
|
|
1321
|
+
"type": "uint8",
|
|
1322
|
+
"internalType": "uint8"
|
|
1323
|
+
}
|
|
1324
|
+
],
|
|
1325
|
+
"stateMutability": "view"
|
|
1326
|
+
},
|
|
1131
1327
|
{
|
|
1132
1328
|
"type": "function",
|
|
1133
1329
|
"name": "exists",
|
|
@@ -2371,6 +2567,48 @@ var Recorder = class {
|
|
|
2371
2567
|
}
|
|
2372
2568
|
};
|
|
2373
2569
|
|
|
2570
|
+
// ../daemon/src/repeats.ts
|
|
2571
|
+
var keyOf = (b) => `${b.node.toLowerCase()}|${b.counterparty.toLowerCase()}|${b.amount}|${b.reason}`;
|
|
2572
|
+
var RefusalRepeats = class {
|
|
2573
|
+
seen = /* @__PURE__ */ new Map();
|
|
2574
|
+
now;
|
|
2575
|
+
constructor(options = {}) {
|
|
2576
|
+
this.now = options.now ?? Date.now;
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* The refusal already on chain for exactly this bound, while it still
|
|
2580
|
+
* stands. A window that has rolled is a different window, and a refusal in
|
|
2581
|
+
* it is a new fact about a fresh budget — so the memory expires with it.
|
|
2582
|
+
*/
|
|
2583
|
+
recall(bound) {
|
|
2584
|
+
const hit = this.seen.get(keyOf(bound));
|
|
2585
|
+
if (!hit) return void 0;
|
|
2586
|
+
if (hit.until <= this.now()) {
|
|
2587
|
+
this.seen.delete(keyOf(bound));
|
|
2588
|
+
return void 0;
|
|
2589
|
+
}
|
|
2590
|
+
return hit.published;
|
|
2591
|
+
}
|
|
2592
|
+
/** Remember what was published, for as long as it stays the current answer. */
|
|
2593
|
+
remember(bound, published, holdsForMs) {
|
|
2594
|
+
if (holdsForMs <= 0) return;
|
|
2595
|
+
this.seen.set(keyOf(bound), { published, until: this.now() + holdsForMs });
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Drop what has expired. Nothing depends on this being called — `recall`
|
|
2599
|
+
* checks expiry itself — but a daemon runs for weeks and a tree that has
|
|
2600
|
+
* refused many distinct bounds should not keep all of them.
|
|
2601
|
+
*/
|
|
2602
|
+
prune() {
|
|
2603
|
+
const now = this.now();
|
|
2604
|
+
for (const [key, hit] of this.seen) if (hit.until <= now) this.seen.delete(key);
|
|
2605
|
+
}
|
|
2606
|
+
/** How many bounds are being remembered. For tests and for `/status`. */
|
|
2607
|
+
get size() {
|
|
2608
|
+
return this.seen.size;
|
|
2609
|
+
}
|
|
2610
|
+
};
|
|
2611
|
+
|
|
2374
2612
|
// ../fixtures/src/serial.ts
|
|
2375
2613
|
function serialiseByKey() {
|
|
2376
2614
|
const tails = /* @__PURE__ */ new Map();
|
|
@@ -2413,6 +2651,10 @@ var Gate = class {
|
|
|
2413
2651
|
* said it was in bounds. Keyed by node, so two nodes still draw at once.
|
|
2414
2652
|
*/
|
|
2415
2653
|
inTurn = serialiseByKey();
|
|
2654
|
+
/** Identical refusals already on chain, so the loop pays for one. */
|
|
2655
|
+
repeats = new RefusalRepeats();
|
|
2656
|
+
/** `windowSeconds` per node, which is immutable once the mandate exists. */
|
|
2657
|
+
windowCache = /* @__PURE__ */ new Map();
|
|
2416
2658
|
/**
|
|
2417
2659
|
* The record. It borrows this gate's signer rather than holding a copy of a
|
|
2418
2660
|
* key, so there is still exactly one place in the process where an operator
|
|
@@ -2421,10 +2663,20 @@ var Gate = class {
|
|
|
2421
2663
|
recorder;
|
|
2422
2664
|
constructor(config2) {
|
|
2423
2665
|
this.config = config2;
|
|
2666
|
+
const facts = chainFacts(config2.chainId);
|
|
2667
|
+
if (!facts) {
|
|
2668
|
+
throw new Error(
|
|
2669
|
+
`chain ${config2.chainId} is not one this build knows. Chains live in packages/fixtures/src/index.ts and nowhere else.`
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2424
2672
|
const chain = defineChain({
|
|
2425
|
-
id:
|
|
2426
|
-
name:
|
|
2427
|
-
nativeCurrency: {
|
|
2673
|
+
id: facts.chainId,
|
|
2674
|
+
name: facts.name,
|
|
2675
|
+
nativeCurrency: {
|
|
2676
|
+
name: facts.nativeName,
|
|
2677
|
+
symbol: facts.nativeSymbol,
|
|
2678
|
+
decimals: facts.nativeDecimals
|
|
2679
|
+
},
|
|
2428
2680
|
rpcUrls: { default: { http: [config2.rpcUrl] } }
|
|
2429
2681
|
});
|
|
2430
2682
|
this.chain = chain;
|
|
@@ -2557,6 +2809,31 @@ var Gate = class {
|
|
|
2557
2809
|
args: [node2]
|
|
2558
2810
|
});
|
|
2559
2811
|
}
|
|
2812
|
+
/**
|
|
2813
|
+
* How long a refusal for this node stays the current answer.
|
|
2814
|
+
*
|
|
2815
|
+
* The window, because that is exactly when the budget it was refused
|
|
2816
|
+
* against is replaced — a refusal in a fresh window is a new fact. Read
|
|
2817
|
+
* once per node and kept: `windowSeconds` is set when the mandate is opened
|
|
2818
|
+
* and no function raises it.
|
|
2819
|
+
*
|
|
2820
|
+
* A read that fails returns 0, which suppresses nothing. A daemon that
|
|
2821
|
+
* cannot reach the registry should send the transaction, not invent a
|
|
2822
|
+
* silence.
|
|
2823
|
+
*/
|
|
2824
|
+
async windowMs(node2) {
|
|
2825
|
+
const key = node2.toLowerCase();
|
|
2826
|
+
const cached = this.windowCache.get(key);
|
|
2827
|
+
if (cached !== void 0) return cached;
|
|
2828
|
+
try {
|
|
2829
|
+
const m = await this.mandate(node2);
|
|
2830
|
+
const ms = Number(m.windowSeconds) * 1e3;
|
|
2831
|
+
this.windowCache.set(key, ms);
|
|
2832
|
+
return ms;
|
|
2833
|
+
} catch {
|
|
2834
|
+
return 0;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2560
2837
|
async mandate(node2) {
|
|
2561
2838
|
return this.publicClient.readContract({
|
|
2562
2839
|
address: this.config.registry,
|
|
@@ -2650,11 +2927,36 @@ var Gate = class {
|
|
|
2650
2927
|
args: [node2, counterparty, amount],
|
|
2651
2928
|
account: wallet.account
|
|
2652
2929
|
});
|
|
2653
|
-
const [wouldRelease] = result;
|
|
2930
|
+
const [wouldRelease, , reasonCode] = result;
|
|
2931
|
+
if (!wouldRelease) {
|
|
2932
|
+
const bound = { node: node2, counterparty, amount, reason: REASONS[reasonCode] ?? UNRECOGNISED };
|
|
2933
|
+
const already = this.repeats.recall(bound);
|
|
2934
|
+
if (already) {
|
|
2935
|
+
return {
|
|
2936
|
+
released: false,
|
|
2937
|
+
reason: already.reason,
|
|
2938
|
+
breachedAt: already.breachedAt,
|
|
2939
|
+
refusalId: already.refusalId,
|
|
2940
|
+
txHash: already.txHash
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2654
2944
|
const txHash = await wallet.writeContract(request);
|
|
2655
2945
|
const outcome = { ...await this.outcomeFrom(txHash), txHash };
|
|
2656
2946
|
if (!wouldRelease) {
|
|
2657
2947
|
await this.publish(node2, outcome);
|
|
2948
|
+
if (outcome.refusalId !== void 0 && outcome.txHash !== void 0) {
|
|
2949
|
+
this.repeats.remember(
|
|
2950
|
+
{ node: node2, counterparty, amount, reason: outcome.reason },
|
|
2951
|
+
{
|
|
2952
|
+
refusalId: outcome.refusalId,
|
|
2953
|
+
txHash: outcome.txHash,
|
|
2954
|
+
reason: outcome.reason,
|
|
2955
|
+
breachedAt: outcome.breachedAt
|
|
2956
|
+
},
|
|
2957
|
+
await this.windowMs(node2)
|
|
2958
|
+
);
|
|
2959
|
+
}
|
|
2658
2960
|
}
|
|
2659
2961
|
return outcome;
|
|
2660
2962
|
}
|
|
@@ -2769,6 +3071,87 @@ var Gate = class {
|
|
|
2769
3071
|
// ../daemon/src/settle.ts
|
|
2770
3072
|
import { parseAbi as parseAbi2 } from "viem";
|
|
2771
3073
|
|
|
3074
|
+
// ../daemon/src/challenge.ts
|
|
3075
|
+
var ChallengeError = class extends Error {
|
|
3076
|
+
};
|
|
3077
|
+
var ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
3078
|
+
var CAIP2 = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
|
|
3079
|
+
function parseChallenge(status, body) {
|
|
3080
|
+
if (status !== 402) return null;
|
|
3081
|
+
if (typeof body !== "object" || body === null) {
|
|
3082
|
+
throw new ChallengeError("402 with no JSON body");
|
|
3083
|
+
}
|
|
3084
|
+
const raw = body;
|
|
3085
|
+
const version = raw.x402Version;
|
|
3086
|
+
if (typeof version !== "number") {
|
|
3087
|
+
throw new ChallengeError("402 body has no x402Version");
|
|
3088
|
+
}
|
|
3089
|
+
const accepts = raw.accepts;
|
|
3090
|
+
if (!Array.isArray(accepts) || accepts.length === 0) {
|
|
3091
|
+
throw new ChallengeError("402 body offers no way to pay");
|
|
3092
|
+
}
|
|
3093
|
+
return { x402Version: version, offers: accepts.map(parseOffer) };
|
|
3094
|
+
}
|
|
3095
|
+
function parseOffer(input, index) {
|
|
3096
|
+
if (typeof input !== "object" || input === null) {
|
|
3097
|
+
throw new ChallengeError(`accepts[${index}] is not an object`);
|
|
3098
|
+
}
|
|
3099
|
+
const o = input;
|
|
3100
|
+
const scheme = str(o.scheme, `accepts[${index}].scheme`);
|
|
3101
|
+
const network = str(o.network, `accepts[${index}].network`);
|
|
3102
|
+
if (!CAIP2.test(network)) {
|
|
3103
|
+
throw new ChallengeError(`accepts[${index}].network is not a CAIP-2 id: ${network}`);
|
|
3104
|
+
}
|
|
3105
|
+
const payTo = str(o.payTo, `accepts[${index}].payTo`);
|
|
3106
|
+
const asset = str(o.asset, `accepts[${index}].asset`);
|
|
3107
|
+
const amountRaw = o.amount;
|
|
3108
|
+
if (typeof amountRaw !== "string" || !/^\d+$/.test(amountRaw)) {
|
|
3109
|
+
throw new ChallengeError(`accepts[${index}].amount is not a base-unit string`);
|
|
3110
|
+
}
|
|
3111
|
+
const amount = BigInt(amountRaw);
|
|
3112
|
+
if (amount <= 0n) {
|
|
3113
|
+
throw new ChallengeError(`accepts[${index}].amount is not positive`);
|
|
3114
|
+
}
|
|
3115
|
+
return {
|
|
3116
|
+
scheme,
|
|
3117
|
+
network,
|
|
3118
|
+
asset,
|
|
3119
|
+
payTo,
|
|
3120
|
+
amount,
|
|
3121
|
+
maxTimeoutSeconds: typeof o.maxTimeoutSeconds === "number" ? o.maxTimeoutSeconds : void 0,
|
|
3122
|
+
extra: typeof o.extra === "object" && o.extra !== null ? o.extra : void 0
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
function str(v, where) {
|
|
3126
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
3127
|
+
throw new ChallengeError(`${where} is missing`);
|
|
3128
|
+
}
|
|
3129
|
+
return v;
|
|
3130
|
+
}
|
|
3131
|
+
var MAX_AUTH_SECONDS = 600;
|
|
3132
|
+
function selectOffer(challenge, ok) {
|
|
3133
|
+
const scheme = ok.scheme ?? "exact";
|
|
3134
|
+
const networks = new Set(ok.networks);
|
|
3135
|
+
const assets = new Set(ok.assets.map((a) => a.toLowerCase()));
|
|
3136
|
+
const match = challenge.offers.find(
|
|
3137
|
+
(o) => o.scheme === scheme && networks.has(o.network) && assets.has(o.asset.toLowerCase())
|
|
3138
|
+
);
|
|
3139
|
+
if (!match) {
|
|
3140
|
+
throw new ChallengeError(
|
|
3141
|
+
`no offer this daemon can settle: seller accepts ${challenge.offers.map((o) => `${o.scheme}/${o.network}`).join(", ")}`
|
|
3142
|
+
);
|
|
3143
|
+
}
|
|
3144
|
+
if (match.network.startsWith("eip155:") && !ADDRESS.test(match.payTo)) {
|
|
3145
|
+
throw new ChallengeError(`payTo is not an address: ${match.payTo}`);
|
|
3146
|
+
}
|
|
3147
|
+
if (match.maxTimeoutSeconds !== void 0 && match.maxTimeoutSeconds > MAX_AUTH_SECONDS) {
|
|
3148
|
+
throw new ChallengeError(
|
|
3149
|
+
`seller asks for an authorisation valid ${match.maxTimeoutSeconds}s; this daemon signs none longer than ${MAX_AUTH_SECONDS}s`
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
return match;
|
|
3153
|
+
}
|
|
3154
|
+
|
|
2772
3155
|
// ../daemon/src/gateway.ts
|
|
2773
3156
|
import { pad, toHex, parseAbi } from "viem";
|
|
2774
3157
|
var GATEWAY_EIP712_DOMAIN = { name: "GatewayWallet", version: "1" };
|
|
@@ -2986,56 +3369,83 @@ var CircleSettler = class {
|
|
|
2986
3369
|
* one that surfaces as a seller's revert against a signature that verified.
|
|
2987
3370
|
*/
|
|
2988
3371
|
async assertCanPay(payment, operator) {
|
|
2989
|
-
const
|
|
2990
|
-
address: token,
|
|
2991
|
-
abi: ERC20,
|
|
2992
|
-
functionName: "balanceOf",
|
|
2993
|
-
args: [holder]
|
|
2994
|
-
}));
|
|
2995
|
-
const held = await read(payment.asset, operator);
|
|
3372
|
+
const held = await readBalance(this.options, payment.asset, operator);
|
|
2996
3373
|
if (held >= payment.value) return;
|
|
2997
3374
|
throw new SettlementError(
|
|
2998
3375
|
`${operator} holds ${held} base units and this purchase authorises ${payment.value}. The burn returned the price less Circle's ${GATEWAY.baseFee6} fee, so an operator needs that difference in its own float \u2014 the same place its gas comes from, and outside the mandate either way. The draw has already happened, so the window is debited.`
|
|
2999
3376
|
);
|
|
3000
3377
|
}
|
|
3001
3378
|
/** The x402 `exact` payload: an EIP-3009 authorisation the seller submits. */
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
const authorization = {
|
|
3005
|
-
from: operator,
|
|
3006
|
-
to: payment.to,
|
|
3007
|
-
value: payment.value,
|
|
3008
|
-
validAfter: 0n,
|
|
3009
|
-
validBefore: BigInt(now + (payment.maxTimeoutSeconds ?? 300)),
|
|
3010
|
-
nonce: this.options.nonce?.() ?? randomNonce()
|
|
3011
|
-
};
|
|
3012
|
-
const signature = await wallet.signTypedData({
|
|
3013
|
-
account: wallet.account,
|
|
3014
|
-
domain: token,
|
|
3015
|
-
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
3016
|
-
primaryType: "TransferWithAuthorization",
|
|
3017
|
-
message: authorization
|
|
3018
|
-
});
|
|
3019
|
-
return Buffer.from(
|
|
3020
|
-
JSON.stringify({
|
|
3021
|
-
x402Version: 2,
|
|
3022
|
-
scheme: "exact",
|
|
3023
|
-
network: payment.network,
|
|
3024
|
-
asset: payment.asset,
|
|
3025
|
-
payload: {
|
|
3026
|
-
signature,
|
|
3027
|
-
authorization: {
|
|
3028
|
-
...authorization,
|
|
3029
|
-
value: authorization.value.toString(),
|
|
3030
|
-
validAfter: authorization.validAfter.toString(),
|
|
3031
|
-
validBefore: authorization.validBefore.toString()
|
|
3032
|
-
}
|
|
3033
|
-
}
|
|
3034
|
-
}),
|
|
3035
|
-
"utf8"
|
|
3036
|
-
).toString("base64");
|
|
3379
|
+
authorise(wallet, operator, payment, token) {
|
|
3380
|
+
return authoriseExact(wallet, operator, payment, token, this.options);
|
|
3037
3381
|
}
|
|
3038
3382
|
};
|
|
3383
|
+
var DirectSettler = class {
|
|
3384
|
+
options;
|
|
3385
|
+
constructor(options) {
|
|
3386
|
+
this.options = options;
|
|
3387
|
+
}
|
|
3388
|
+
async settle(node2, payment) {
|
|
3389
|
+
const wallet = this.options.walletFor(node2);
|
|
3390
|
+
const operator = wallet.account.address;
|
|
3391
|
+
const token = tokenDomain(payment, this.options.chainId);
|
|
3392
|
+
const held = await readBalance(this.options, payment.asset, operator);
|
|
3393
|
+
if (held < payment.value) {
|
|
3394
|
+
throw new SettlementError(
|
|
3395
|
+
`${operator} holds ${held} base units and this purchase authorises ${payment.value}. On this chain the vault deposits into the operator's own balance, so a draw that returned should have covered it \u2014 a shortfall here means the money left again. The draw has already happened, so the window is debited.`
|
|
3396
|
+
);
|
|
3397
|
+
}
|
|
3398
|
+
return { proof: await authoriseExact(wallet, operator, payment, token, this.options) };
|
|
3399
|
+
}
|
|
3400
|
+
};
|
|
3401
|
+
function readBalance(options, token, holder) {
|
|
3402
|
+
if (options.balanceOf) return options.balanceOf(token, holder);
|
|
3403
|
+
return options.publicClient.readContract({
|
|
3404
|
+
address: token,
|
|
3405
|
+
abi: ERC20,
|
|
3406
|
+
functionName: "balanceOf",
|
|
3407
|
+
args: [holder]
|
|
3408
|
+
});
|
|
3409
|
+
}
|
|
3410
|
+
async function authoriseExact(wallet, operator, payment, token, clock) {
|
|
3411
|
+
const now = Math.floor((clock.now?.() ?? Date.now()) / 1e3);
|
|
3412
|
+
const authorization = {
|
|
3413
|
+
from: operator,
|
|
3414
|
+
to: payment.to,
|
|
3415
|
+
value: payment.value,
|
|
3416
|
+
validAfter: 0n,
|
|
3417
|
+
/* Clamped at the signature, not only at the offer: this is the last point
|
|
3418
|
+
before a bearer instrument exists, and it is the one that must hold
|
|
3419
|
+
however the payment got here. */
|
|
3420
|
+
validBefore: BigInt(now + Math.min(payment.maxTimeoutSeconds ?? 300, MAX_AUTH_SECONDS)),
|
|
3421
|
+
nonce: clock.nonce?.() ?? randomNonce()
|
|
3422
|
+
};
|
|
3423
|
+
const signature = await wallet.signTypedData({
|
|
3424
|
+
account: wallet.account,
|
|
3425
|
+
domain: token,
|
|
3426
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
3427
|
+
primaryType: "TransferWithAuthorization",
|
|
3428
|
+
message: authorization
|
|
3429
|
+
});
|
|
3430
|
+
return Buffer.from(
|
|
3431
|
+
JSON.stringify({
|
|
3432
|
+
x402Version: 2,
|
|
3433
|
+
scheme: "exact",
|
|
3434
|
+
network: payment.network,
|
|
3435
|
+
asset: payment.asset,
|
|
3436
|
+
payload: {
|
|
3437
|
+
signature,
|
|
3438
|
+
authorization: {
|
|
3439
|
+
...authorization,
|
|
3440
|
+
value: authorization.value.toString(),
|
|
3441
|
+
validAfter: authorization.validAfter.toString(),
|
|
3442
|
+
validBefore: authorization.validBefore.toString()
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
}),
|
|
3446
|
+
"utf8"
|
|
3447
|
+
).toString("base64");
|
|
3448
|
+
}
|
|
3039
3449
|
function tokenDomain(payment, chainId) {
|
|
3040
3450
|
const extra = payment.extra ?? {};
|
|
3041
3451
|
const name = typeof extra.name === "string" ? extra.name : void 0;
|
|
@@ -3158,16 +3568,31 @@ CORDON_NODE_${label}=
|
|
|
3158
3568
|
}
|
|
3159
3569
|
|
|
3160
3570
|
// ../daemon/src/released.ts
|
|
3161
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3571
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3162
3572
|
import { dirname as dirname2 } from "node:path";
|
|
3163
3573
|
var ChainReleases = class {
|
|
3164
3574
|
options;
|
|
3165
3575
|
spent;
|
|
3576
|
+
/**
|
|
3577
|
+
* One claim at a time per node.
|
|
3578
|
+
*
|
|
3579
|
+
* `claimInTurn` reads the chain twice between finding an unspent release and
|
|
3580
|
+
* marking it spent, and an `await` is where another request gets to run. Two
|
|
3581
|
+
* concurrent `POST /fetch` for the same purchase both passed the `spent`
|
|
3582
|
+
* check, both reserved the same release, and one owner signature paid twice.
|
|
3583
|
+
*
|
|
3584
|
+
* `gate.draw` was already serialised per node for the nonce; this was not,
|
|
3585
|
+
* and `cordonFetch` calls it first. Same queue, same key.
|
|
3586
|
+
*/
|
|
3587
|
+
inTurn = serialiseByKey();
|
|
3166
3588
|
constructor(options) {
|
|
3167
3589
|
this.options = options;
|
|
3168
3590
|
this.spent = existsSync2(options.file) ? new Set(JSON.parse(readFileSync2(options.file, "utf8")).map(String)) : /* @__PURE__ */ new Set();
|
|
3169
3591
|
}
|
|
3170
|
-
|
|
3592
|
+
claim(node2, payTo, amount6) {
|
|
3593
|
+
return this.inTurn(node2, () => this.claimInTurn(node2, payTo, amount6));
|
|
3594
|
+
}
|
|
3595
|
+
async claimInTurn(node2, payTo, amount6) {
|
|
3171
3596
|
const count = await this.options.count();
|
|
3172
3597
|
const scan = BigInt(this.options.scan ?? 256);
|
|
3173
3598
|
const floor = count > scan ? count - scan : 0n;
|
|
@@ -3189,9 +3614,18 @@ var ChainReleases = class {
|
|
|
3189
3614
|
unclaim(refusalId) {
|
|
3190
3615
|
if (this.spent.delete(refusalId.toString())) this.save();
|
|
3191
3616
|
}
|
|
3617
|
+
/**
|
|
3618
|
+
* Written beside and renamed over, the way `keyfile.ts` writes.
|
|
3619
|
+
*
|
|
3620
|
+
* A crash during `writeFileSync` leaves a truncated file, and this one is
|
|
3621
|
+
* parsed in the constructor — so a half-written ledger of spent releases
|
|
3622
|
+
* does not cost a purchase, it stops the daemon starting at all.
|
|
3623
|
+
*/
|
|
3192
3624
|
save() {
|
|
3193
3625
|
mkdirSync2(dirname2(this.options.file), { recursive: true });
|
|
3194
|
-
|
|
3626
|
+
const staging = `${this.options.file}.tmp-${process.pid}`;
|
|
3627
|
+
writeFileSync2(staging, JSON.stringify([...this.spent], null, 2) + "\n");
|
|
3628
|
+
renameSync2(staging, this.options.file);
|
|
3195
3629
|
}
|
|
3196
3630
|
};
|
|
3197
3631
|
|
|
@@ -3203,80 +3637,9 @@ import { z } from "zod";
|
|
|
3203
3637
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3204
3638
|
import { generatePrivateKey } from "viem/accounts";
|
|
3205
3639
|
|
|
3206
|
-
// ../daemon/src/
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
var ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
3210
|
-
var CAIP2 = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
|
|
3211
|
-
function parseChallenge(status, body) {
|
|
3212
|
-
if (status !== 402) return null;
|
|
3213
|
-
if (typeof body !== "object" || body === null) {
|
|
3214
|
-
throw new ChallengeError("402 with no JSON body");
|
|
3215
|
-
}
|
|
3216
|
-
const raw = body;
|
|
3217
|
-
const version = raw.x402Version;
|
|
3218
|
-
if (typeof version !== "number") {
|
|
3219
|
-
throw new ChallengeError("402 body has no x402Version");
|
|
3220
|
-
}
|
|
3221
|
-
const accepts = raw.accepts;
|
|
3222
|
-
if (!Array.isArray(accepts) || accepts.length === 0) {
|
|
3223
|
-
throw new ChallengeError("402 body offers no way to pay");
|
|
3224
|
-
}
|
|
3225
|
-
return { x402Version: version, offers: accepts.map(parseOffer) };
|
|
3226
|
-
}
|
|
3227
|
-
function parseOffer(input, index) {
|
|
3228
|
-
if (typeof input !== "object" || input === null) {
|
|
3229
|
-
throw new ChallengeError(`accepts[${index}] is not an object`);
|
|
3230
|
-
}
|
|
3231
|
-
const o = input;
|
|
3232
|
-
const scheme = str(o.scheme, `accepts[${index}].scheme`);
|
|
3233
|
-
const network = str(o.network, `accepts[${index}].network`);
|
|
3234
|
-
if (!CAIP2.test(network)) {
|
|
3235
|
-
throw new ChallengeError(`accepts[${index}].network is not a CAIP-2 id: ${network}`);
|
|
3236
|
-
}
|
|
3237
|
-
const payTo = str(o.payTo, `accepts[${index}].payTo`);
|
|
3238
|
-
const asset = str(o.asset, `accepts[${index}].asset`);
|
|
3239
|
-
const amountRaw = o.amount;
|
|
3240
|
-
if (typeof amountRaw !== "string" || !/^\d+$/.test(amountRaw)) {
|
|
3241
|
-
throw new ChallengeError(`accepts[${index}].amount is not a base-unit string`);
|
|
3242
|
-
}
|
|
3243
|
-
const amount = BigInt(amountRaw);
|
|
3244
|
-
if (amount <= 0n) {
|
|
3245
|
-
throw new ChallengeError(`accepts[${index}].amount is not positive`);
|
|
3246
|
-
}
|
|
3247
|
-
return {
|
|
3248
|
-
scheme,
|
|
3249
|
-
network,
|
|
3250
|
-
asset,
|
|
3251
|
-
payTo,
|
|
3252
|
-
amount,
|
|
3253
|
-
maxTimeoutSeconds: typeof o.maxTimeoutSeconds === "number" ? o.maxTimeoutSeconds : void 0,
|
|
3254
|
-
extra: typeof o.extra === "object" && o.extra !== null ? o.extra : void 0
|
|
3255
|
-
};
|
|
3256
|
-
}
|
|
3257
|
-
function str(v, where) {
|
|
3258
|
-
if (typeof v !== "string" || v.length === 0) {
|
|
3259
|
-
throw new ChallengeError(`${where} is missing`);
|
|
3260
|
-
}
|
|
3261
|
-
return v;
|
|
3262
|
-
}
|
|
3263
|
-
function selectOffer(challenge, ok) {
|
|
3264
|
-
const scheme = ok.scheme ?? "exact";
|
|
3265
|
-
const networks = new Set(ok.networks);
|
|
3266
|
-
const assets = new Set(ok.assets.map((a) => a.toLowerCase()));
|
|
3267
|
-
const match = challenge.offers.find(
|
|
3268
|
-
(o) => o.scheme === scheme && networks.has(o.network) && assets.has(o.asset.toLowerCase())
|
|
3269
|
-
);
|
|
3270
|
-
if (!match) {
|
|
3271
|
-
throw new ChallengeError(
|
|
3272
|
-
`no offer this daemon can settle: seller accepts ${challenge.offers.map((o) => `${o.scheme}/${o.network}`).join(", ")}`
|
|
3273
|
-
);
|
|
3274
|
-
}
|
|
3275
|
-
if (match.network.startsWith("eip155:") && !ADDRESS.test(match.payTo)) {
|
|
3276
|
-
throw new ChallengeError(`payTo is not an address: ${match.payTo}`);
|
|
3277
|
-
}
|
|
3278
|
-
return match;
|
|
3279
|
-
}
|
|
3640
|
+
// ../daemon/src/fetch.ts
|
|
3641
|
+
import http2 from "node:http";
|
|
3642
|
+
import https from "node:https";
|
|
3280
3643
|
|
|
3281
3644
|
// ../daemon/src/egress.ts
|
|
3282
3645
|
import { lookup } from "node:dns/promises";
|
|
@@ -3342,11 +3705,65 @@ async function assertPublicUrl(raw, options = {}) {
|
|
|
3342
3705
|
}
|
|
3343
3706
|
return url;
|
|
3344
3707
|
}
|
|
3708
|
+
var publicAddressesOnly = (address2) => !isPrivateAddress(address2);
|
|
3709
|
+
function pinnedLookup(allow = publicAddressesOnly) {
|
|
3710
|
+
return (hostname, _options, callback) => {
|
|
3711
|
+
const literal = isIP(hostname);
|
|
3712
|
+
if (literal) {
|
|
3713
|
+
if (!allow(hostname)) {
|
|
3714
|
+
return callback(
|
|
3715
|
+
Object.assign(new EgressError(`${hostname} is not an address this daemon connects to`), {
|
|
3716
|
+
code: "ECONNREFUSED"
|
|
3717
|
+
})
|
|
3718
|
+
);
|
|
3719
|
+
}
|
|
3720
|
+
return callback(null, hostname, literal);
|
|
3721
|
+
}
|
|
3722
|
+
lookup(hostname, { all: true, verbatim: true }).then(
|
|
3723
|
+
(answers) => {
|
|
3724
|
+
for (const answer of answers) {
|
|
3725
|
+
if (!allow(answer.address)) {
|
|
3726
|
+
return callback(
|
|
3727
|
+
Object.assign(
|
|
3728
|
+
new EgressError(
|
|
3729
|
+
`${hostname} resolves to ${answer.address}, which is on this machine or its private network. The connection was refused at the socket, so no request was sent to it.`
|
|
3730
|
+
),
|
|
3731
|
+
{ code: "ECONNREFUSED" }
|
|
3732
|
+
)
|
|
3733
|
+
);
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
const chosen = answers[0];
|
|
3737
|
+
if (!chosen) {
|
|
3738
|
+
return callback(
|
|
3739
|
+
Object.assign(new EgressError(`${hostname} does not resolve, so there is nothing to ask`), {
|
|
3740
|
+
code: "ENOTFOUND"
|
|
3741
|
+
})
|
|
3742
|
+
);
|
|
3743
|
+
}
|
|
3744
|
+
return callback(null, chosen.address, chosen.family);
|
|
3745
|
+
},
|
|
3746
|
+
(error) => callback(Object.assign(error, { code: "ENOTFOUND" }))
|
|
3747
|
+
);
|
|
3748
|
+
};
|
|
3749
|
+
}
|
|
3345
3750
|
|
|
3346
3751
|
// ../daemon/src/fetch.ts
|
|
3752
|
+
var PURCHASE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST"]);
|
|
3753
|
+
var MethodRefused = class extends Error {
|
|
3754
|
+
};
|
|
3755
|
+
function assertPurchaseMethod(raw) {
|
|
3756
|
+
const method = raw.toUpperCase();
|
|
3757
|
+
if (!PURCHASE_METHODS.has(method)) {
|
|
3758
|
+
throw new MethodRefused(
|
|
3759
|
+
`${method} is not a method this daemon fetches with. A purchase is a request answered with 402 and retried with a signature, so GET, HEAD and POST are what that needs. A verb whose point is to leave state on someone else's server is an egress channel, and this tool is not one.`
|
|
3760
|
+
);
|
|
3761
|
+
}
|
|
3762
|
+
return method;
|
|
3763
|
+
}
|
|
3347
3764
|
async function cordonFetch(request, deps) {
|
|
3348
3765
|
const { gate: gate2, settler, acceptable, transport } = deps;
|
|
3349
|
-
const method = request.method ?? "GET";
|
|
3766
|
+
const method = assertPurchaseMethod(request.method ?? "GET");
|
|
3350
3767
|
const headers = { ...request.headers };
|
|
3351
3768
|
const first = await transport(request.url, { method, headers, body: request.body });
|
|
3352
3769
|
const challenge = parseChallenge(first.status, first.body);
|
|
@@ -3393,35 +3810,67 @@ async function cordonFetch(request, deps) {
|
|
|
3393
3810
|
};
|
|
3394
3811
|
}
|
|
3395
3812
|
var MAX_REDIRECTS = 3;
|
|
3396
|
-
var httpTransport = (url, init) => httpTransportWith(assertPublicUrl)(url, init);
|
|
3397
|
-
|
|
3813
|
+
var httpTransport = (url, init) => httpTransportWith(assertPublicUrl, publicAddressesOnly)(url, init);
|
|
3814
|
+
function requestOnce(target, init, allow) {
|
|
3815
|
+
const secure = target.protocol === "https:";
|
|
3816
|
+
const driver = secure ? https : http2;
|
|
3817
|
+
const agent = new driver.Agent({ lookup: pinnedLookup(allow) });
|
|
3818
|
+
return new Promise((resolve, reject) => {
|
|
3819
|
+
const req = driver.request(
|
|
3820
|
+
{
|
|
3821
|
+
protocol: target.protocol,
|
|
3822
|
+
hostname: target.hostname,
|
|
3823
|
+
port: target.port || (secure ? 443 : 80),
|
|
3824
|
+
path: `${target.pathname}${target.search}`,
|
|
3825
|
+
method: init.method,
|
|
3826
|
+
headers: init.headers,
|
|
3827
|
+
agent,
|
|
3828
|
+
/* TLS is still verified against the NAME, not the pinned address: the
|
|
3829
|
+
certificate has to be for the host the daemon meant to reach. */
|
|
3830
|
+
...secure ? { servername: target.hostname } : {}
|
|
3831
|
+
},
|
|
3832
|
+
(res) => {
|
|
3833
|
+
const chunks = [];
|
|
3834
|
+
res.on("data", (chunk) => {
|
|
3835
|
+
chunks.push(chunk);
|
|
3836
|
+
if (chunks.reduce((n, c) => n + c.length, 0) > MAX_BODY_BYTES) {
|
|
3837
|
+
req.destroy(new EgressError(`${target.origin} sent more than ${MAX_BODY_BYTES} bytes`));
|
|
3838
|
+
}
|
|
3839
|
+
});
|
|
3840
|
+
res.on("end", () => {
|
|
3841
|
+
const headers = {};
|
|
3842
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
3843
|
+
if (value === void 0) continue;
|
|
3844
|
+
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
3845
|
+
}
|
|
3846
|
+
resolve({ status: res.statusCode ?? 0, headers, body: Buffer.concat(chunks).toString("utf8") });
|
|
3847
|
+
});
|
|
3848
|
+
res.on("error", reject);
|
|
3849
|
+
}
|
|
3850
|
+
);
|
|
3851
|
+
req.setTimeout(3e4, () => req.destroy(new EgressError(`${target.origin} did not answer in 30s`)));
|
|
3852
|
+
req.on("error", reject);
|
|
3853
|
+
if (init.body !== void 0) req.write(init.body);
|
|
3854
|
+
req.end();
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
var MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
3858
|
+
var httpTransportWith = (assert, allow) => async (url, init) => {
|
|
3398
3859
|
let target = await assert(url);
|
|
3399
3860
|
const origin = target.origin;
|
|
3400
3861
|
const carriesPayment = Object.keys(init.headers).some(
|
|
3401
3862
|
(name) => name.toLowerCase() === "payment-signature" || name.toLowerCase() === "x-payment"
|
|
3402
3863
|
);
|
|
3403
3864
|
for (let hop = 0; ; hop++) {
|
|
3404
|
-
const response = await
|
|
3405
|
-
|
|
3406
|
-
headers: init.headers,
|
|
3407
|
-
body: init.body,
|
|
3408
|
-
/* Followed below instead, so each hop is checked. */
|
|
3409
|
-
redirect: "manual",
|
|
3410
|
-
signal: AbortSignal.timeout(3e4)
|
|
3411
|
-
});
|
|
3412
|
-
const location = response.headers.get("location");
|
|
3865
|
+
const response = await requestOnce(target, init, allow);
|
|
3866
|
+
const location = response.headers["location"];
|
|
3413
3867
|
if (response.status < 300 || response.status >= 400 || !location) {
|
|
3414
|
-
|
|
3415
|
-
let body = text2;
|
|
3868
|
+
let body = response.body;
|
|
3416
3869
|
try {
|
|
3417
|
-
body = JSON.parse(
|
|
3870
|
+
body = JSON.parse(response.body);
|
|
3418
3871
|
} catch {
|
|
3419
3872
|
}
|
|
3420
|
-
return {
|
|
3421
|
-
status: response.status,
|
|
3422
|
-
headers: Object.fromEntries(response.headers.entries()),
|
|
3423
|
-
body
|
|
3424
|
-
};
|
|
3873
|
+
return { status: response.status, headers: response.headers, body };
|
|
3425
3874
|
}
|
|
3426
3875
|
if (hop >= MAX_REDIRECTS) {
|
|
3427
3876
|
throw new EgressError(`${url} redirected more than ${MAX_REDIRECTS} times`);
|
|
@@ -3480,11 +3929,58 @@ function renderPaid(outcome, offer, body) {
|
|
|
3480
3929
|
].filter(Boolean).join("\n");
|
|
3481
3930
|
}
|
|
3482
3931
|
|
|
3932
|
+
// package.json
|
|
3933
|
+
var package_default = {
|
|
3934
|
+
name: "cordon-mcp",
|
|
3935
|
+
version: "0.2.0",
|
|
3936
|
+
description: "Cordon inside an MCP client: an agent that can buy things and cannot spend past the mandate its owner signed, bounded by contracts it cannot reach.",
|
|
3937
|
+
license: "MIT",
|
|
3938
|
+
author: "Youvandra Febrial",
|
|
3939
|
+
homepage: "https://getcordon.xyz",
|
|
3940
|
+
bugs: {
|
|
3941
|
+
url: "https://github.com/youvandra/cordon/issues"
|
|
3942
|
+
},
|
|
3943
|
+
repository: {
|
|
3944
|
+
type: "git",
|
|
3945
|
+
url: "git+https://github.com/youvandra/cordon.git",
|
|
3946
|
+
directory: "packages/mcp"
|
|
3947
|
+
},
|
|
3948
|
+
keywords: ["mcp", "mcp-server", "x402", "agents", "ai-agents", "agent-payments", "spending-limits", "arc", "sepolia", "circle", "usdc", "erc-8004", "ens", "ensv2"],
|
|
3949
|
+
type: "module",
|
|
3950
|
+
bin: {
|
|
3951
|
+
"cordon-mcp": "dist/cordon-mcp.js"
|
|
3952
|
+
},
|
|
3953
|
+
files: ["dist", "SKILL.md", "README.md", "LICENSE"],
|
|
3954
|
+
engines: {
|
|
3955
|
+
node: ">=22"
|
|
3956
|
+
},
|
|
3957
|
+
publishConfig: {
|
|
3958
|
+
access: "public"
|
|
3959
|
+
},
|
|
3960
|
+
scripts: {
|
|
3961
|
+
build: "tsc --noEmit",
|
|
3962
|
+
bundle: "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/cordon-mcp.js --external:viem --external:@modelcontextprotocol/sdk --external:zod",
|
|
3963
|
+
prepack: "npm run bundle",
|
|
3964
|
+
test: 'node --test "test/*.test.ts"',
|
|
3965
|
+
start: "node src/main.ts"
|
|
3966
|
+
},
|
|
3967
|
+
dependencies: {
|
|
3968
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
3969
|
+
viem: "^2.21.55",
|
|
3970
|
+
zod: "^4.5.4"
|
|
3971
|
+
},
|
|
3972
|
+
devDependencies: {
|
|
3973
|
+
"@types/node": "^22.10.2",
|
|
3974
|
+
esbuild: "^0.25.12",
|
|
3975
|
+
typescript: "^5.7.2"
|
|
3976
|
+
}
|
|
3977
|
+
};
|
|
3978
|
+
|
|
3483
3979
|
// src/server.ts
|
|
3484
3980
|
var text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
3485
3981
|
function createMcpServer(deps) {
|
|
3486
3982
|
const server2 = new McpServer(
|
|
3487
|
-
{ name: "cordon", version:
|
|
3983
|
+
{ name: "cordon", version: package_default.version },
|
|
3488
3984
|
{
|
|
3489
3985
|
instructions: "Cordon bounds what this agent can spend. Use cordon_fetch for any URL that may charge; it pays through a contract that can refuse. There is no tool here that sends money to an address, and a refusal is a final answer rather than an error to retry."
|
|
3490
3986
|
}
|
|
@@ -3627,6 +4123,19 @@ if (!node) {
|
|
|
3627
4123
|
);
|
|
3628
4124
|
process.exit(1);
|
|
3629
4125
|
}
|
|
4126
|
+
function settlerFor(chainId) {
|
|
4127
|
+
const options = {
|
|
4128
|
+
publicClient: gate.publicClientForSettlement,
|
|
4129
|
+
walletFor: (node2) => gate.signerFor(node2),
|
|
4130
|
+
chainId
|
|
4131
|
+
};
|
|
4132
|
+
return chainId === ARC.chainId ? new CircleSettler(options) : new DirectSettler(options);
|
|
4133
|
+
}
|
|
4134
|
+
function explorerFor(chainId) {
|
|
4135
|
+
if (chainId === ARC.chainId) return ARC.explorer;
|
|
4136
|
+
if (chainId === SEPOLIA.chainId) return SEPOLIA.explorer;
|
|
4137
|
+
return void 0;
|
|
4138
|
+
}
|
|
3630
4139
|
var server = createMcpServer({
|
|
3631
4140
|
gate,
|
|
3632
4141
|
keyFile: keyFileAt(config.keyFile),
|
|
@@ -3638,14 +4147,10 @@ var server = createMcpServer({
|
|
|
3638
4147
|
live: (node2) => gate.isLive(node2),
|
|
3639
4148
|
file: join2(dirname3(config.keyFile), "released-spent.json")
|
|
3640
4149
|
}),
|
|
3641
|
-
settler:
|
|
3642
|
-
publicClient: gate.publicClientForSettlement,
|
|
3643
|
-
walletFor: (node2) => gate.signerFor(node2),
|
|
3644
|
-
chainId: config.chainId
|
|
3645
|
-
}),
|
|
4150
|
+
settler: settlerFor(config.chainId),
|
|
3646
4151
|
acceptable: { networks: config.networks, assets: config.assets },
|
|
3647
4152
|
node,
|
|
3648
|
-
explorer: config.chainId
|
|
4153
|
+
explorer: explorerFor(config.chainId)
|
|
3649
4154
|
});
|
|
3650
4155
|
console.error(`cordon mcp: node ${node} on chain ${config.chainId}`);
|
|
3651
4156
|
await server.connect(new StdioServerTransport());
|