ceaser-mcp 1.0.1 → 1.1.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/bin/ceaser-mcp.js +16 -4
- package/package.json +1 -1
- package/src/cli.js +287 -0
- package/src/tools/unshield.js +1 -1
package/bin/ceaser-mcp.js
CHANGED
|
@@ -3,13 +3,25 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Ceaser MCP Server - CLI entry point.
|
|
5
5
|
*
|
|
6
|
-
* Starts the MCP server with stdio transport
|
|
6
|
+
* Starts the MCP server with stdio transport, or runs CLI subcommands directly.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* npx ceaser-mcp
|
|
10
|
-
*
|
|
9
|
+
* npx ceaser-mcp Start MCP stdio server
|
|
10
|
+
* npx ceaser-mcp --stdio Start MCP stdio server (explicit)
|
|
11
|
+
* npx ceaser-mcp shield 0.001 Shield 0.001 ETH
|
|
12
|
+
* npx ceaser-mcp unshield <noteId> <addr> Unshield to address
|
|
13
|
+
* npx ceaser-mcp notes List unspent notes
|
|
14
|
+
* npx ceaser-mcp import <backup> Import note from backup
|
|
15
|
+
* npx ceaser-mcp help Print usage
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { startStdio } from '../src/transport/stdio.js';
|
|
19
|
+
import { runCli } from '../src/cli.js';
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
const subcommand = process.argv[2];
|
|
22
|
+
|
|
23
|
+
if (subcommand && subcommand !== '--stdio') {
|
|
24
|
+
runCli(subcommand, process.argv.slice(3));
|
|
25
|
+
} else {
|
|
26
|
+
startStdio();
|
|
27
|
+
}
|
package/package.json
CHANGED
package/src/cli.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI subcommand dispatcher for ceaser-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Allows bash-only agents (e.g. OpenClaw) to shield/unshield ETH without MCP.
|
|
5
|
+
* All output is JSON: stdout on success, stderr on failure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseEther, Interface } from 'ethers';
|
|
9
|
+
import { randomField, toBytes32 } from './lib/crypto.js';
|
|
10
|
+
import { generateShieldProof, generateBurnProof } from './lib/prover.js';
|
|
11
|
+
import { addNote, listNotes, getNote, markSpent, exportNote, parseBackup } from './lib/noteStore.js';
|
|
12
|
+
import { buildTreeFromIndexer } from './tools/unshield.js';
|
|
13
|
+
import * as api from './lib/api.js';
|
|
14
|
+
import { config } from './lib/config.js';
|
|
15
|
+
import {
|
|
16
|
+
DENOMINATIONS,
|
|
17
|
+
ZKWRAPPER_ABI,
|
|
18
|
+
PROTOCOL_FEE_BPS,
|
|
19
|
+
BPS_DENOMINATOR,
|
|
20
|
+
} from './constants.js';
|
|
21
|
+
|
|
22
|
+
function outputSuccess(data) {
|
|
23
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function outputError(message) {
|
|
28
|
+
process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// -- Subcommand handlers --
|
|
33
|
+
|
|
34
|
+
async function handleShield(args) {
|
|
35
|
+
const amount = args[0];
|
|
36
|
+
if (!amount) {
|
|
37
|
+
outputError(`Missing amount. Usage: ceaser-mcp shield <amount>\nValid denominations: ${DENOMINATIONS.map((d) => d.value).join(', ')} ETH`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const denom = DENOMINATIONS.find((d) => d.value === amount);
|
|
41
|
+
if (!denom) {
|
|
42
|
+
outputError(`Invalid denomination: ${amount}. Valid: ${DENOMINATIONS.map((d) => d.value).join(', ')} ETH`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const amountWei = parseEther(amount);
|
|
46
|
+
const secret = randomField();
|
|
47
|
+
const nullifier = randomField();
|
|
48
|
+
const assetId = 0n;
|
|
49
|
+
|
|
50
|
+
const { proofHex, commitment, commitmentHex } = await generateShieldProof(
|
|
51
|
+
secret, nullifier, amountWei, assetId
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const iface = new Interface(ZKWRAPPER_ABI);
|
|
55
|
+
const data = iface.encodeFunctionData('shieldETH', [
|
|
56
|
+
proofHex,
|
|
57
|
+
commitmentHex,
|
|
58
|
+
amountWei,
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const fee = (amountWei * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR;
|
|
62
|
+
const totalValue = amountWei + fee;
|
|
63
|
+
|
|
64
|
+
const note = addNote({
|
|
65
|
+
secret,
|
|
66
|
+
nullifier,
|
|
67
|
+
commitment: commitment.toString(),
|
|
68
|
+
amount,
|
|
69
|
+
amountWei: amountWei.toString(),
|
|
70
|
+
assetId: '0',
|
|
71
|
+
leafIndex: null,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const backup = exportNote(note);
|
|
75
|
+
|
|
76
|
+
outputSuccess({
|
|
77
|
+
note: {
|
|
78
|
+
id: note.id,
|
|
79
|
+
amount,
|
|
80
|
+
amountWei: amountWei.toString(),
|
|
81
|
+
commitment: commitmentHex,
|
|
82
|
+
assetId: '0',
|
|
83
|
+
leafIndex: null,
|
|
84
|
+
},
|
|
85
|
+
unsignedTx: {
|
|
86
|
+
to: config.contractAddress,
|
|
87
|
+
data,
|
|
88
|
+
value: totalValue.toString(),
|
|
89
|
+
chainId: config.chainId,
|
|
90
|
+
},
|
|
91
|
+
backup,
|
|
92
|
+
instructions: [
|
|
93
|
+
'Sign and send the unsignedTx from your wallet.',
|
|
94
|
+
'The value includes the 0.25% protocol fee.',
|
|
95
|
+
`Total to send: ${totalValue.toString()} wei (${amount} ETH + fee).`,
|
|
96
|
+
'CRITICAL: Save the backup string securely. It contains private keys for this note.',
|
|
97
|
+
'After the transaction confirms, update the leaf index with: ceaser-mcp import <backup>',
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function handleUnshield(args) {
|
|
103
|
+
const [noteId, recipient] = args;
|
|
104
|
+
if (!noteId || !recipient) {
|
|
105
|
+
outputError('Missing arguments. Usage: ceaser-mcp unshield <noteId> <recipient>');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(recipient)) {
|
|
109
|
+
outputError('Invalid recipient address. Must be 0x-prefixed 40-char hex.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const note = getNote(noteId);
|
|
113
|
+
if (!note) {
|
|
114
|
+
outputError(`Note ${noteId} not found. Run: ceaser-mcp notes`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (note.spent) {
|
|
118
|
+
outputError(`Note ${noteId} has already been spent.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (note.leafIndex === null || note.leafIndex === undefined) {
|
|
122
|
+
outputError('Note does not have a leaf index. The shield transaction may not have confirmed yet.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Rebuild Merkle tree from indexer
|
|
126
|
+
process.stderr.write('[ceaser-mcp] Building Merkle tree from indexer...\n');
|
|
127
|
+
const tree = await buildTreeFromIndexer();
|
|
128
|
+
|
|
129
|
+
if (note.leafIndex >= tree.getLeafCount()) {
|
|
130
|
+
outputError(`Note leaf index ${note.leafIndex} not found in tree (tree has ${tree.getLeafCount()} leaves). Indexer may not be fully synced.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { pathElements, pathIndices } = await tree.getProof(note.leafIndex);
|
|
134
|
+
const root = tree.getRoot();
|
|
135
|
+
|
|
136
|
+
// Generate burn proof
|
|
137
|
+
process.stderr.write('[ceaser-mcp] Generating unshield proof...\n');
|
|
138
|
+
const { proofHex, nullifierHashHex, rootHex } = await generateBurnProof(
|
|
139
|
+
note, recipient, pathElements, pathIndices, root
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Fee calculation
|
|
143
|
+
const amountWei = BigInt(note.amountWei);
|
|
144
|
+
const fee = (amountWei * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR;
|
|
145
|
+
const netAmount = amountWei - fee;
|
|
146
|
+
|
|
147
|
+
// Settle via facilitator
|
|
148
|
+
process.stderr.write('[ceaser-mcp] Settling via facilitator...\n');
|
|
149
|
+
const settleResult = await api.settle(
|
|
150
|
+
proofHex,
|
|
151
|
+
nullifierHashHex,
|
|
152
|
+
amountWei.toString(),
|
|
153
|
+
(note.assetId || '0').toString(),
|
|
154
|
+
recipient,
|
|
155
|
+
rootHex
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
markSpent(noteId, settleResult.txHash);
|
|
159
|
+
|
|
160
|
+
outputSuccess({
|
|
161
|
+
success: true,
|
|
162
|
+
txHash: settleResult.txHash,
|
|
163
|
+
recipient,
|
|
164
|
+
grossAmount: amountWei.toString(),
|
|
165
|
+
feeWei: fee.toString(),
|
|
166
|
+
netAmount: netAmount.toString(),
|
|
167
|
+
noteId,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleNotes(args) {
|
|
172
|
+
const showAll = args.includes('--all');
|
|
173
|
+
const notes = listNotes(showAll);
|
|
174
|
+
|
|
175
|
+
const safeNotes = notes.map((n) => ({
|
|
176
|
+
id: n.id,
|
|
177
|
+
amount: n.amount,
|
|
178
|
+
amountWei: n.amountWei,
|
|
179
|
+
assetId: n.assetId || '0',
|
|
180
|
+
commitment: n.commitment,
|
|
181
|
+
leafIndex: n.leafIndex,
|
|
182
|
+
spent: n.spent,
|
|
183
|
+
timestamp: n.timestamp,
|
|
184
|
+
...(n.spent ? { spentTxHash: n.spentTxHash, spentAt: n.spentAt } : {}),
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
outputSuccess({
|
|
188
|
+
count: safeNotes.length,
|
|
189
|
+
notes: safeNotes,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function handleImport(args) {
|
|
194
|
+
const backup = args[0];
|
|
195
|
+
if (!backup) {
|
|
196
|
+
outputError('Missing backup string. Usage: ceaser-mcp import <backup>');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const noteData = parseBackup(backup);
|
|
200
|
+
|
|
201
|
+
// Check for duplicates
|
|
202
|
+
const existing = listNotes(true);
|
|
203
|
+
const duplicate = existing.find((n) => n.commitment === noteData.commitment);
|
|
204
|
+
if (duplicate) {
|
|
205
|
+
outputError(`Note already exists with ID ${duplicate.id} (commitment: ${duplicate.commitment})`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const note = addNote({
|
|
209
|
+
secret: noteData.secret,
|
|
210
|
+
nullifier: noteData.nullifier,
|
|
211
|
+
commitment: noteData.commitment,
|
|
212
|
+
amount: noteData.amount,
|
|
213
|
+
amountWei: noteData.amountWei,
|
|
214
|
+
assetId: noteData.assetId,
|
|
215
|
+
leafIndex: noteData.leafIndex,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
outputSuccess({
|
|
219
|
+
noteId: note.id,
|
|
220
|
+
amount: noteData.amount,
|
|
221
|
+
amountWei: noteData.amountWei,
|
|
222
|
+
commitment: noteData.commitment,
|
|
223
|
+
leafIndex: noteData.leafIndex,
|
|
224
|
+
assetId: noteData.assetId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleHelp() {
|
|
229
|
+
const usage = `ceaser-mcp - Ceaser privacy protocol CLI & MCP server
|
|
230
|
+
|
|
231
|
+
USAGE:
|
|
232
|
+
ceaser-mcp Start MCP stdio server
|
|
233
|
+
ceaser-mcp shield <amount> Shield ETH (generate proof + unsigned tx)
|
|
234
|
+
ceaser-mcp unshield <noteId> <addr> Unshield ETH to address (gasless via x402)
|
|
235
|
+
ceaser-mcp notes [--all] List notes (--all includes spent)
|
|
236
|
+
ceaser-mcp import <backup> Import note from backup string
|
|
237
|
+
ceaser-mcp help Show this help
|
|
238
|
+
|
|
239
|
+
DENOMINATIONS:
|
|
240
|
+
${DENOMINATIONS.map((d) => d.value).join(', ')} ETH
|
|
241
|
+
|
|
242
|
+
EXAMPLES:
|
|
243
|
+
ceaser-mcp shield 0.001
|
|
244
|
+
ceaser-mcp notes
|
|
245
|
+
ceaser-mcp unshield 1737000000000-a1b2c3d4 0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18
|
|
246
|
+
ceaser-mcp import eyJzIjoiMTIzLi4uIn0=
|
|
247
|
+
|
|
248
|
+
ENVIRONMENT:
|
|
249
|
+
CEASER_API_URL Facilitator URL (default: https://ceaser.org)
|
|
250
|
+
CEASER_CONTRACT_ADDRESS zkWrapper address
|
|
251
|
+
CEASER_CHAIN_ID Chain ID (default: 8453)
|
|
252
|
+
CEASER_DATA_DIR Data directory (default: ~/.ceaser-mcp)
|
|
253
|
+
|
|
254
|
+
All CLI output is JSON (stdout=success, stderr=errors/progress).
|
|
255
|
+
Notes are stored at ~/.ceaser-mcp/notes.json.
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
process.stdout.write(usage);
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -- Dispatcher --
|
|
263
|
+
|
|
264
|
+
const COMMANDS = {
|
|
265
|
+
shield: handleShield,
|
|
266
|
+
unshield: handleUnshield,
|
|
267
|
+
notes: handleNotes,
|
|
268
|
+
import: handleImport,
|
|
269
|
+
help: handleHelp,
|
|
270
|
+
'--help': handleHelp,
|
|
271
|
+
'-h': handleHelp,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export async function runCli(subcommand, args) {
|
|
275
|
+
const handler = COMMANDS[subcommand];
|
|
276
|
+
if (!handler) {
|
|
277
|
+
outputError(`Unknown command: ${subcommand}. Run: ceaser-mcp help`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await handler(args);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
// Don't leak internal details for non-API errors
|
|
284
|
+
const msg = err.message?.includes('API') ? err.message : `Command failed: ${err.message}`;
|
|
285
|
+
outputError(msg);
|
|
286
|
+
}
|
|
287
|
+
}
|
package/src/tools/unshield.js
CHANGED
|
@@ -20,7 +20,7 @@ import { log, logError } from '../lib/log.js';
|
|
|
20
20
|
*/
|
|
21
21
|
const MAX_TREE_BATCHES = 200; // 200 * 1000 = 200k leaves max
|
|
22
22
|
|
|
23
|
-
async function buildTreeFromIndexer() {
|
|
23
|
+
export async function buildTreeFromIndexer() {
|
|
24
24
|
const tree = new MerkleTree();
|
|
25
25
|
await tree.init();
|
|
26
26
|
|