@azeth/mcp-server 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +48 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/tools/account.d.ts +4 -0
  8. package/dist/tools/account.d.ts.map +1 -0
  9. package/dist/tools/account.js +640 -0
  10. package/dist/tools/account.js.map +1 -0
  11. package/dist/tools/agreements.d.ts +4 -0
  12. package/dist/tools/agreements.d.ts.map +1 -0
  13. package/dist/tools/agreements.js +865 -0
  14. package/dist/tools/agreements.js.map +1 -0
  15. package/dist/tools/guardian-approval.d.ts +4 -0
  16. package/dist/tools/guardian-approval.d.ts.map +1 -0
  17. package/dist/tools/guardian-approval.js +319 -0
  18. package/dist/tools/guardian-approval.js.map +1 -0
  19. package/dist/tools/guardian.d.ts +4 -0
  20. package/dist/tools/guardian.d.ts.map +1 -0
  21. package/dist/tools/guardian.js +267 -0
  22. package/dist/tools/guardian.js.map +1 -0
  23. package/dist/tools/messaging.d.ts +4 -0
  24. package/dist/tools/messaging.d.ts.map +1 -0
  25. package/dist/tools/messaging.js +353 -0
  26. package/dist/tools/messaging.js.map +1 -0
  27. package/dist/tools/payments.d.ts +14 -0
  28. package/dist/tools/payments.d.ts.map +1 -0
  29. package/dist/tools/payments.js +723 -0
  30. package/dist/tools/payments.js.map +1 -0
  31. package/dist/tools/registry.d.ts +4 -0
  32. package/dist/tools/registry.d.ts.map +1 -0
  33. package/dist/tools/registry.js +608 -0
  34. package/dist/tools/registry.js.map +1 -0
  35. package/dist/tools/reputation.d.ts +4 -0
  36. package/dist/tools/reputation.d.ts.map +1 -0
  37. package/dist/tools/reputation.js +433 -0
  38. package/dist/tools/reputation.js.map +1 -0
  39. package/dist/tools/transfer.d.ts +4 -0
  40. package/dist/tools/transfer.d.ts.map +1 -0
  41. package/dist/tools/transfer.js +181 -0
  42. package/dist/tools/transfer.js.map +1 -0
  43. package/dist/utils/client.d.ts +25 -0
  44. package/dist/utils/client.d.ts.map +1 -0
  45. package/dist/utils/client.js +100 -0
  46. package/dist/utils/client.js.map +1 -0
  47. package/dist/utils/error-selectors.d.ts +23 -0
  48. package/dist/utils/error-selectors.d.ts.map +1 -0
  49. package/dist/utils/error-selectors.js +159 -0
  50. package/dist/utils/error-selectors.js.map +1 -0
  51. package/dist/utils/rate-limit.d.ts +17 -0
  52. package/dist/utils/rate-limit.d.ts.map +1 -0
  53. package/dist/utils/rate-limit.js +75 -0
  54. package/dist/utils/rate-limit.js.map +1 -0
  55. package/dist/utils/resolve.d.ts +38 -0
  56. package/dist/utils/resolve.d.ts.map +1 -0
  57. package/dist/utils/resolve.js +308 -0
  58. package/dist/utils/resolve.js.map +1 -0
  59. package/dist/utils/response.d.ts +42 -0
  60. package/dist/utils/response.d.ts.map +1 -0
  61. package/dist/utils/response.js +257 -0
  62. package/dist/utils/response.js.map +1 -0
  63. package/package.json +62 -0
@@ -0,0 +1,865 @@
1
+ import { z } from 'zod';
2
+ import { formatUnits, decodeEventLog } from 'viem';
3
+ import { TOKENS } from '@azeth/common';
4
+ import { PaymentAgreementModuleAbi } from '@azeth/common/abis';
5
+ import { createClient, resolveChain } from '../utils/client.js';
6
+ import { resolveAddress, resolveSmartAccount } from '../utils/resolve.js';
7
+ import { success, error, handleError, guardianRequiredError } from '../utils/response.js';
8
+ // ──────────────────────────────────────────────
9
+ // Shared formatting utilities
10
+ // ──────────────────────────────────────────────
11
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
12
+ /** Resolve a token address to a human-readable symbol */
13
+ function resolveTokenSymbol(tokenAddress, chain) {
14
+ if (tokenAddress === ZERO_ADDRESS)
15
+ return 'ETH';
16
+ const tokens = TOKENS[chain];
17
+ const lower = tokenAddress.toLowerCase();
18
+ if (lower === tokens.USDC.toLowerCase())
19
+ return 'USDC';
20
+ if (lower === tokens.WETH.toLowerCase())
21
+ return 'WETH';
22
+ return tokenAddress.slice(0, 6) + '...' + tokenAddress.slice(-4);
23
+ }
24
+ /** Get the number of decimals for a token */
25
+ function tokenDecimals(tokenAddress, chain) {
26
+ if (tokenAddress === ZERO_ADDRESS)
27
+ return 18;
28
+ const tokens = TOKENS[chain];
29
+ const lower = tokenAddress.toLowerCase();
30
+ if (lower === tokens.USDC.toLowerCase())
31
+ return 6;
32
+ return 18; // default to 18 for WETH and unknown tokens
33
+ }
34
+ /** Format an interval in seconds to a human-readable string */
35
+ function formatInterval(secs) {
36
+ if (secs >= 86400 && secs % 86400 === 0) {
37
+ const days = secs / 86400;
38
+ return days === 1 ? 'daily' : `every ${days} days`;
39
+ }
40
+ if (secs >= 3600 && secs % 3600 === 0) {
41
+ const hours = secs / 3600;
42
+ return hours === 1 ? 'hourly' : `every ${hours} hours`;
43
+ }
44
+ if (secs >= 60 && secs % 60 === 0) {
45
+ const mins = secs / 60;
46
+ return mins === 1 ? 'every minute' : `every ${mins} minutes`;
47
+ }
48
+ return `every ${secs} seconds`;
49
+ }
50
+ /** Format a countdown in seconds to human-readable string */
51
+ function formatCountdown(seconds) {
52
+ if (seconds <= 0)
53
+ return 'now (overdue)';
54
+ if (seconds < 60)
55
+ return `${seconds} seconds`;
56
+ if (seconds < 3600) {
57
+ const mins = Math.floor(seconds / 60);
58
+ const secs = seconds % 60;
59
+ return secs > 0 ? `${mins} minute${mins > 1 ? 's' : ''} ${secs} seconds` : `${mins} minute${mins > 1 ? 's' : ''}`;
60
+ }
61
+ const hours = Math.floor(seconds / 3600);
62
+ const mins = Math.floor((seconds % 3600) / 60);
63
+ return mins > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ${mins} minute${mins > 1 ? 's' : ''}` : `${hours} hour${hours > 1 ? 's' : ''}`;
64
+ }
65
+ /** Format overdue duration to human-readable string */
66
+ function formatOverdue(seconds) {
67
+ if (seconds < 1)
68
+ return 'just now';
69
+ return formatCountdown(seconds);
70
+ }
71
+ /** Derive agreement status from on-chain data */
72
+ function deriveStatus(agreement, now) {
73
+ if (!agreement.active) {
74
+ if (agreement.maxExecutions !== 0n && agreement.executionCount >= agreement.maxExecutions) {
75
+ return 'completed';
76
+ }
77
+ return 'cancelled';
78
+ }
79
+ if (agreement.endTime !== 0n && agreement.endTime <= now) {
80
+ return 'expired';
81
+ }
82
+ return 'active';
83
+ }
84
+ /** Convert a token amount to a formatted USD string for known stablecoins.
85
+ * Returns null for non-stablecoin tokens (ETH/WETH would need an oracle). */
86
+ function tokenAmountToUSD(amount, tokenAddress, chain) {
87
+ const tokens = TOKENS[chain];
88
+ if (tokenAddress.toLowerCase() === tokens.USDC.toLowerCase()) {
89
+ // USDC is 6 decimals; format as human-readable dollar string
90
+ const usdStr = formatUnits(amount, 6);
91
+ return `$${usdStr}`;
92
+ }
93
+ return null; // ETH/WETH needs oracle — omit rather than show incorrect value
94
+ }
95
+ /** Attempt reverse-lookup of a payee address to a name via trust registry */
96
+ async function lookupPayeeName(client, payeeAddress) {
97
+ // Best-effort name lookup; failures are silently ignored
98
+ try {
99
+ const chain = resolveChain(process.env['AZETH_CHAIN']);
100
+ const { AZETH_CONTRACTS, ERC8004_REGISTRY } = await import('@azeth/common');
101
+ const { TrustRegistryModuleAbi } = await import('@azeth/common/abis');
102
+ const trustRegistryAddr = AZETH_CONTRACTS[chain].trustRegistryModule;
103
+ const identityRegistryAddr = ERC8004_REGISTRY[chain];
104
+ const tokenId = await client.publicClient.readContract({
105
+ address: trustRegistryAddr,
106
+ abi: TrustRegistryModuleAbi,
107
+ functionName: 'getTokenId',
108
+ args: [payeeAddress],
109
+ });
110
+ if (tokenId === 0n)
111
+ return null;
112
+ const uri = await client.publicClient.readContract({
113
+ address: identityRegistryAddr,
114
+ abi: [{
115
+ type: 'function',
116
+ name: 'tokenURI',
117
+ inputs: [{ name: 'tokenId', type: 'uint256', internalType: 'uint256' }],
118
+ outputs: [{ name: '', type: 'string', internalType: 'string' }],
119
+ stateMutability: 'view',
120
+ }],
121
+ functionName: 'tokenURI',
122
+ args: [tokenId],
123
+ });
124
+ if (!uri.startsWith('data:application/json,'))
125
+ return null;
126
+ const jsonStr = decodeURIComponent(uri.slice('data:application/json,'.length));
127
+ const metadata = JSON.parse(jsonStr);
128
+ return metadata.name ?? null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ /** Register payment agreement management MCP tools */
135
+ export function registerAgreementTools(server) {
136
+ // ──────────────────────────────────────────────
137
+ // azeth_execute_agreement
138
+ // ──────────────────────────────────────────────
139
+ server.registerTool('azeth_execute_agreement', {
140
+ description: [
141
+ 'Execute a due payment from an on-chain agreement. Anyone can call this — the payer, payee, or a third-party keeper.',
142
+ '',
143
+ 'Use this when: You are a service provider collecting a recurring payment owed to you,',
144
+ 'a payer triggering your own agreement manually, or a keeper bot executing due agreements.',
145
+ '',
146
+ 'Keeper support: When the "account" is a foreign address (not owned by your private key),',
147
+ 'execution routes through your own account or EOA automatically. No special configuration needed.',
148
+ '',
149
+ 'The contract validates all conditions on-chain: interval elapsed, active, within caps and limits.',
150
+ 'Pro-rata accrual means the payout scales with elapsed time (capped at 3x the interval).',
151
+ '',
152
+ 'Returns: Transaction hash, amount paid, execution count, and next execution time.',
153
+ 'If the agreement soft-fails (insufficient balance, guardian limit), it returns the failure reason without reverting.',
154
+ ].join('\n'),
155
+ inputSchema: z.object({
156
+ chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
157
+ account: z.string().describe('The payer smart account whose agreement to execute: Ethereum address, participant name, "me", or "#N".'),
158
+ agreementId: z.coerce.number().int().min(0).describe('The agreement ID to execute (from azeth_create_payment_agreement or azeth_list_agreements).'),
159
+ }),
160
+ }, async (args) => {
161
+ let client;
162
+ try {
163
+ client = await createClient(args.chain);
164
+ const chain = resolveChain(args.chain);
165
+ // Resolve the payer account
166
+ let accountResolved;
167
+ try {
168
+ accountResolved = await resolveAddress(args.account, client);
169
+ }
170
+ catch (resolveErr) {
171
+ return handleError(resolveErr);
172
+ }
173
+ const account = accountResolved.address;
174
+ const agreementId = BigInt(args.agreementId);
175
+ // Pre-flight check: call canExecutePayment before submitting a transaction
176
+ let canExec;
177
+ try {
178
+ canExec = await client.canExecutePayment(agreementId, account);
179
+ }
180
+ catch {
181
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} not found for account ${account}.`, 'Check the agreement ID with azeth_list_agreements.');
182
+ }
183
+ if (!canExec.executable) {
184
+ // Map reason strings to appropriate error codes and messages
185
+ const reason = canExec.reason.toLowerCase();
186
+ if (reason.includes('not initialized') || reason.includes('not found') || reason.includes('agreement not exists')) {
187
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} not found for account ${account}.`, 'Check the agreement ID with azeth_list_agreements.');
188
+ }
189
+ if (reason.includes('not active')) {
190
+ // Get agreement to provide more context
191
+ try {
192
+ const agreement = await client.getAgreement(agreementId, account);
193
+ const now = BigInt(Math.floor(Date.now() / 1000));
194
+ const status = deriveStatus(agreement, now);
195
+ const decimals = tokenDecimals(agreement.token, chain);
196
+ const totalPaid = formatUnits(agreement.totalPaid, decimals);
197
+ const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
198
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} is ${status}. Total paid: ${totalPaid} ${tokenSymbol}.`);
199
+ }
200
+ catch {
201
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} is not active.`);
202
+ }
203
+ }
204
+ if (reason.includes('interval not elapsed')) {
205
+ try {
206
+ const nextTime = await client.getNextExecutionTime(agreementId, account);
207
+ const nextDate = new Date(Number(nextTime) * 1000).toISOString();
208
+ const now = Math.floor(Date.now() / 1000);
209
+ const countdown = formatCountdown(Number(nextTime) - now);
210
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} is not due yet. Next execution: ${nextDate} (${countdown}).`);
211
+ }
212
+ catch {
213
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} is not due yet.`);
214
+ }
215
+ }
216
+ if (reason.includes('max executions')) {
217
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} has reached maximum executions.`);
218
+ }
219
+ if (reason.includes('total cap')) {
220
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} has reached its total payment cap.`);
221
+ }
222
+ if (reason.includes('token not whitelisted')) {
223
+ return error('GUARDIAN_REJECTED', `Agreement #${args.agreementId} cannot execute: token not whitelisted by guardian.`, 'Add the token to the guardian whitelist.');
224
+ }
225
+ if (reason.includes('exceeds max tx') || reason.includes('max tx amount')) {
226
+ return error('GUARDIAN_REJECTED', `Agreement #${args.agreementId} cannot execute: payment exceeds per-transaction limit.`, 'Increase the guardian per-tx limit or reduce the agreement amount.');
227
+ }
228
+ if (reason.includes('daily spend') || reason.includes('daily limit')) {
229
+ return error('GUARDIAN_REJECTED', `Agreement #${args.agreementId} cannot execute: daily spend limit exceeded.`, 'Wait until tomorrow or increase the daily limit via guardian.');
230
+ }
231
+ if (reason.includes('insufficient balance') || reason.includes('balance')) {
232
+ return error('INSUFFICIENT_BALANCE', `Agreement #${args.agreementId} cannot execute: insufficient balance.`, 'Fund the payer account before retrying.');
233
+ }
234
+ // Generic fallback
235
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} cannot execute: ${canExec.reason}.`);
236
+ }
237
+ // ── Balance pre-check (non-fatal: proceed if check fails) ──
238
+ const accountAddr = account;
239
+ const agreementIdNum = agreementId;
240
+ try {
241
+ const agreement = await client.getAgreement(agreementIdNum, accountAddr);
242
+ if (agreement) {
243
+ const token = agreement.token;
244
+ const amount = agreement.amount;
245
+ if (token && amount) {
246
+ const ETH_ZERO = '0x0000000000000000000000000000000000000000';
247
+ let balance;
248
+ if (token === ETH_ZERO) {
249
+ balance = await client.publicClient.getBalance({ address: accountAddr });
250
+ }
251
+ else {
252
+ const erc20Abi = [{
253
+ type: 'function',
254
+ name: 'balanceOf',
255
+ inputs: [{ name: 'account', type: 'address' }],
256
+ outputs: [{ name: '', type: 'uint256' }],
257
+ stateMutability: 'view',
258
+ }];
259
+ balance = await client.publicClient.readContract({
260
+ address: token,
261
+ abi: erc20Abi,
262
+ functionName: 'balanceOf',
263
+ args: [accountAddr],
264
+ });
265
+ }
266
+ if (balance < amount) {
267
+ const decimals = tokenDecimals(token, chain);
268
+ return error('INSUFFICIENT_BALANCE', `Account ${accountAddr} has insufficient balance to execute agreement #${args.agreementId}. ` +
269
+ `Balance: ${formatUnits(balance, decimals)}, minimum needed: ${formatUnits(amount, decimals)}`, 'Deposit more funds into the smart account with azeth_deposit.');
270
+ }
271
+ }
272
+ }
273
+ }
274
+ catch {
275
+ // Non-fatal: if balance check fails, proceed and let the contract validate
276
+ }
277
+ // Capture pre-execution totalPaid as fallback for delta calculation
278
+ let preExecutionTotal = 0n;
279
+ try {
280
+ const preExecAgreement = await client.getAgreement(agreementId, account);
281
+ preExecutionTotal = preExecAgreement.totalPaid;
282
+ }
283
+ catch {
284
+ // Non-fatal: delta fallback won't be available
285
+ }
286
+ // Execute the agreement
287
+ const txHash = await client.executeAgreement(agreementId, account);
288
+ // Primary: parse PaymentExecuted event from receipt for exact amount paid
289
+ let executionAmount = 0n;
290
+ try {
291
+ const receipt = await client.publicClient.waitForTransactionReceipt({ hash: txHash, timeout: 120_000 });
292
+ for (const log of receipt.logs) {
293
+ try {
294
+ const decoded = decodeEventLog({
295
+ abi: PaymentAgreementModuleAbi,
296
+ data: log.data,
297
+ topics: log.topics,
298
+ });
299
+ if (decoded.eventName === 'PaymentExecuted') {
300
+ const eventArgs = decoded.args;
301
+ if (eventArgs.agreementId === agreementId) {
302
+ executionAmount = eventArgs.amount;
303
+ break;
304
+ }
305
+ }
306
+ }
307
+ catch {
308
+ // Not a PaymentExecuted event from this ABI — skip
309
+ }
310
+ }
311
+ }
312
+ catch {
313
+ // Receipt fetch failed — fall back to delta approach below
314
+ }
315
+ // Enrich response with post-execution state
316
+ const agreement = await client.getAgreement(agreementId, account);
317
+ const decimals = tokenDecimals(agreement.token, chain);
318
+ const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
319
+ const now = BigInt(Math.floor(Date.now() / 1000));
320
+ const status = deriveStatus(agreement, now);
321
+ // Fallback: if event parsing didn't yield an amount, use delta approach
322
+ if (executionAmount === 0n) {
323
+ executionAmount = agreement.totalPaid - preExecutionTotal;
324
+ }
325
+ let nextExecutionTime;
326
+ let nextExecutionIn;
327
+ if (status !== 'active') {
328
+ nextExecutionTime = 'completed';
329
+ nextExecutionIn = 'N/A (completed)';
330
+ }
331
+ else {
332
+ try {
333
+ const nextTime = await client.getNextExecutionTime(agreementId, account);
334
+ nextExecutionTime = new Date(Number(nextTime) * 1000).toISOString();
335
+ const nowSecs = Math.floor(Date.now() / 1000);
336
+ nextExecutionIn = formatCountdown(Number(nextTime) - nowSecs);
337
+ }
338
+ catch {
339
+ nextExecutionTime = 'unknown';
340
+ nextExecutionIn = 'unknown';
341
+ }
342
+ }
343
+ // Attempt payee name resolution
344
+ const payeeName = await lookupPayeeName(client, agreement.payee);
345
+ // USD conversion for stablecoins
346
+ const amountPaidUSD = tokenAmountToUSD(executionAmount, agreement.token, chain);
347
+ const totalPaidUSD = tokenAmountToUSD(agreement.totalPaid, agreement.token, chain);
348
+ return success({
349
+ account,
350
+ agreementId: args.agreementId.toString(),
351
+ payee: agreement.payee,
352
+ ...(payeeName ? { payeeName } : {}),
353
+ token: agreement.token,
354
+ tokenSymbol,
355
+ amountPaid: formatUnits(executionAmount, decimals),
356
+ ...(amountPaidUSD ? { amountPaidUSD } : {}),
357
+ executionCount: agreement.executionCount.toString(),
358
+ maxExecutions: agreement.maxExecutions === 0n ? 'unlimited' : agreement.maxExecutions.toString(),
359
+ totalPaid: formatUnits(agreement.totalPaid, decimals),
360
+ ...(totalPaidUSD ? { totalPaidUSD } : {}),
361
+ totalCap: agreement.totalCap === 0n ? 'unlimited' : formatUnits(agreement.totalCap, decimals),
362
+ remainingBudget: agreement.totalCap === 0n
363
+ ? 'unlimited'
364
+ : formatUnits(agreement.totalCap - agreement.totalPaid, decimals),
365
+ nextExecutionTime,
366
+ nextExecutionIn,
367
+ active: agreement.active,
368
+ }, { txHash });
369
+ }
370
+ catch (err) {
371
+ if (err instanceof Error && /AA24/.test(err.message)) {
372
+ return guardianRequiredError('Agreement execution exceeds your standard spending limit.', { operation: 'execute_agreement' });
373
+ }
374
+ return handleError(err);
375
+ }
376
+ finally {
377
+ try {
378
+ await client?.destroy();
379
+ }
380
+ catch (e) {
381
+ process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
382
+ }
383
+ }
384
+ });
385
+ // ──────────────────────────────────────────────
386
+ // azeth_cancel_agreement
387
+ // ──────────────────────────────────────────────
388
+ server.registerTool('azeth_cancel_agreement', {
389
+ description: [
390
+ 'Cancel an active payment agreement. Only the payer (agreement creator) can cancel.',
391
+ '',
392
+ 'Use this when: You want to stop a recurring payment subscription or data feed.',
393
+ 'Cancellation is immediate — no timelock, no penalty. Already-paid amounts are not refunded.',
394
+ '',
395
+ 'Returns: Transaction hash and final agreement state (total paid, execution count).',
396
+ ].join('\n'),
397
+ inputSchema: z.object({
398
+ chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
399
+ agreementId: z.coerce.number().int().min(0).describe('The agreement ID to cancel.'),
400
+ smartAccount: z.string().optional().describe('YOUR smart account that owns the agreement: address or "#N". Only your own accounts can be cancelled. Defaults to first smart account.'),
401
+ }),
402
+ }, async (args) => {
403
+ let client;
404
+ try {
405
+ client = await createClient(args.chain);
406
+ const chain = resolveChain(args.chain);
407
+ // Resolve to the caller's OWN smart account (not arbitrary addresses).
408
+ // Only the payer can cancel their own agreements — resolveSmartAccount
409
+ // restricts resolution to accounts owned by the caller's private key.
410
+ let account;
411
+ if (args.smartAccount) {
412
+ try {
413
+ const resolved = await resolveSmartAccount(args.smartAccount, client);
414
+ if (!resolved) {
415
+ account = await client.resolveSmartAccount();
416
+ }
417
+ else {
418
+ account = resolved;
419
+ }
420
+ }
421
+ catch (resolveErr) {
422
+ return handleError(resolveErr);
423
+ }
424
+ }
425
+ else {
426
+ account = await client.resolveSmartAccount();
427
+ }
428
+ const agreementId = BigInt(args.agreementId);
429
+ // Pre-flight: check agreement exists and is active
430
+ let agreement;
431
+ try {
432
+ agreement = await client.getAgreement(agreementId, account);
433
+ }
434
+ catch {
435
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} not found for account ${account}.`, 'Check the agreement ID with azeth_list_agreements.');
436
+ }
437
+ // Zero payee means no agreement exists at this ID for this account
438
+ if (agreement.payee === '0x0000000000000000000000000000000000000000') {
439
+ return error('AGREEMENT_NOT_FOUND', `Agreement #${args.agreementId} not found for your account ${account}.`, 'The agreement may belong to a different account. Use azeth_list_agreements to see your agreements.');
440
+ }
441
+ const now = BigInt(Math.floor(Date.now() / 1000));
442
+ const status = deriveStatus(agreement, now);
443
+ if (status !== 'active') {
444
+ const decimals = tokenDecimals(agreement.token, chain);
445
+ const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
446
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} is already ${status}. Total paid: ${formatUnits(agreement.totalPaid, decimals)} ${tokenSymbol}.`);
447
+ }
448
+ // Cancel
449
+ const txHash = await client.cancelAgreement(agreementId, account);
450
+ // Get final state
451
+ const finalAgreement = await client.getAgreement(agreementId, account);
452
+ const decimals = tokenDecimals(finalAgreement.token, chain);
453
+ const tokenSymbol = resolveTokenSymbol(finalAgreement.token, chain);
454
+ return success({
455
+ agreementId: args.agreementId.toString(),
456
+ status: 'cancelled',
457
+ payee: finalAgreement.payee,
458
+ token: finalAgreement.token,
459
+ tokenSymbol,
460
+ totalPaid: formatUnits(finalAgreement.totalPaid, decimals),
461
+ executionCount: finalAgreement.executionCount.toString(),
462
+ maxExecutions: finalAgreement.maxExecutions === 0n ? 'unlimited' : finalAgreement.maxExecutions.toString(),
463
+ }, { txHash });
464
+ }
465
+ catch (err) {
466
+ if (err instanceof Error && /AA24/.test(err.message)) {
467
+ return guardianRequiredError('Agreement cancellation requires guardian co-signature.', { operation: 'cancel_agreement' });
468
+ }
469
+ return handleError(err);
470
+ }
471
+ finally {
472
+ try {
473
+ await client?.destroy();
474
+ }
475
+ catch (e) {
476
+ process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
477
+ }
478
+ }
479
+ });
480
+ // ──────────────────────────────────────────────
481
+ // azeth_get_agreement
482
+ // ──────────────────────────────────────────────
483
+ server.registerTool('azeth_get_agreement', {
484
+ description: [
485
+ 'View full details of a payment agreement including status, payment history, and next execution time.',
486
+ '',
487
+ 'Use this when: You want to inspect an agreement before executing or cancelling it,',
488
+ 'verify terms after creation, or check how much has been paid so far.',
489
+ '',
490
+ 'Returns: Complete agreement details with human-readable amounts, status, and timing.',
491
+ '',
492
+ 'Note: This is a read-only on-chain query. No gas or private key required for the query itself,',
493
+ 'but account resolution may need your key if using "me" or "#N".',
494
+ ].join('\n'),
495
+ inputSchema: z.object({
496
+ chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
497
+ account: z.string().describe('The payer smart account: Ethereum address, participant name, "me", or "#N".'),
498
+ agreementId: z.coerce.number().int().min(0).describe('The agreement ID to query.'),
499
+ }),
500
+ }, async (args) => {
501
+ let client;
502
+ try {
503
+ client = await createClient(args.chain);
504
+ const chain = resolveChain(args.chain);
505
+ let accountResolved;
506
+ try {
507
+ accountResolved = await resolveAddress(args.account, client);
508
+ }
509
+ catch (resolveErr) {
510
+ return handleError(resolveErr);
511
+ }
512
+ const account = accountResolved.address;
513
+ const agreementId = BigInt(args.agreementId);
514
+ // Single RPC call: agreement + executability + isDue + nextExecutionTime + count
515
+ let data;
516
+ try {
517
+ data = await client.getAgreementData(agreementId, account);
518
+ }
519
+ catch {
520
+ return error('INVALID_INPUT', `Agreement #${args.agreementId} not found for account ${account}.`, 'Check the agreement ID with azeth_list_agreements.');
521
+ }
522
+ const { agreement, executable, reason, isDue: contractIsDue, nextExecutionTime: nextExecTime } = data;
523
+ const decimals = tokenDecimals(agreement.token, chain);
524
+ const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
525
+ const now = BigInt(Math.floor(Date.now() / 1000));
526
+ const status = deriveStatus(agreement, now);
527
+ const intervalSecs = Number(agreement.interval);
528
+ // Timing
529
+ const lastExecutedAt = agreement.lastExecuted === 0n
530
+ ? null
531
+ : new Date(Number(agreement.lastExecuted) * 1000).toISOString();
532
+ let nextExecutionTime;
533
+ let nextExecutionIn;
534
+ let isDue = contractIsDue;
535
+ let canExecute = executable;
536
+ let canExecuteReason;
537
+ if (status !== 'active') {
538
+ nextExecutionTime = 'N/A';
539
+ nextExecutionIn = `N/A (${status})`;
540
+ canExecute = false;
541
+ isDue = false;
542
+ }
543
+ else {
544
+ nextExecutionTime = new Date(Number(nextExecTime) * 1000).toISOString();
545
+ const nowSecs = Math.floor(Date.now() / 1000);
546
+ const diff = Number(nextExecTime) - nowSecs;
547
+ if (diff <= 0) {
548
+ nextExecutionIn = `now (overdue by ${formatOverdue(-diff)})`;
549
+ isDue = true;
550
+ }
551
+ else {
552
+ nextExecutionIn = formatCountdown(diff);
553
+ }
554
+ if (!executable && reason) {
555
+ canExecuteReason = reason;
556
+ }
557
+ }
558
+ // Payee name resolution
559
+ const payeeName = await lookupPayeeName(client, agreement.payee);
560
+ return success({
561
+ agreementId: args.agreementId.toString(),
562
+ account,
563
+ payee: agreement.payee,
564
+ ...(payeeName ? { payeeName } : {}),
565
+ token: agreement.token,
566
+ tokenSymbol,
567
+ status,
568
+ // Payment terms
569
+ amountPerInterval: formatUnits(agreement.amount, decimals),
570
+ intervalSeconds: intervalSecs,
571
+ intervalHuman: formatInterval(intervalSecs),
572
+ // Execution state
573
+ executionCount: agreement.executionCount.toString(),
574
+ maxExecutions: agreement.maxExecutions === 0n ? 'unlimited' : agreement.maxExecutions.toString(),
575
+ totalPaid: formatUnits(agreement.totalPaid, decimals),
576
+ totalCap: agreement.totalCap === 0n ? 'unlimited' : formatUnits(agreement.totalCap, decimals),
577
+ remainingBudget: agreement.totalCap === 0n
578
+ ? 'unlimited'
579
+ : formatUnits(agreement.totalCap - agreement.totalPaid, decimals),
580
+ // Timing
581
+ lastExecutedAt,
582
+ nextExecutionTime,
583
+ nextExecutionIn,
584
+ expiresAt: agreement.endTime === 0n ? 'never' : new Date(Number(agreement.endTime) * 1000).toISOString(),
585
+ // Checks
586
+ isDue,
587
+ canExecute,
588
+ ...(canExecuteReason ? { canExecuteReason } : {}),
589
+ });
590
+ }
591
+ catch (err) {
592
+ return handleError(err);
593
+ }
594
+ finally {
595
+ try {
596
+ await client?.destroy();
597
+ }
598
+ catch (e) {
599
+ process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
600
+ }
601
+ }
602
+ });
603
+ // ──────────────────────────────────────────────
604
+ // azeth_list_agreements
605
+ // ──────────────────────────────────────────────
606
+ server.registerTool('azeth_list_agreements', {
607
+ description: [
608
+ 'List all payment agreements for a smart account with summary status.',
609
+ '',
610
+ 'Use this when: You need to find an agreement ID, see all active subscriptions,',
611
+ 'check which agreements are due for execution, or get an overview of payment commitments.',
612
+ '',
613
+ 'Returns: Array of agreement summaries sorted by ID (newest first), with status and timing.',
614
+ '',
615
+ 'Note: This is a read-only on-chain query. Iterates through all agreements for the account.',
616
+ 'For accounts with many agreements, this may take a few seconds.',
617
+ ].join('\n'),
618
+ inputSchema: z.object({
619
+ chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
620
+ account: z.string().optional().describe('Smart account to query: address, name, "me", or "#N". Defaults to "me".'),
621
+ status: z.enum(['all', 'active', 'completed', 'cancelled', 'due']).optional().default('all')
622
+ .describe('Filter by status. "due" shows only agreements ready for execution right now.'),
623
+ }),
624
+ }, async (args) => {
625
+ let client;
626
+ try {
627
+ client = await createClient(args.chain);
628
+ const chain = resolveChain(args.chain);
629
+ // Resolve account (default to "me")
630
+ let account;
631
+ try {
632
+ const accountInput = args.account ?? 'me';
633
+ const resolved = await resolveAddress(accountInput, client);
634
+ account = resolved.address;
635
+ }
636
+ catch (resolveErr) {
637
+ return handleError(resolveErr);
638
+ }
639
+ // Get count from first getAgreementData call (avoids separate getAgreementCount RPC)
640
+ let count;
641
+ try {
642
+ const firstData = await client.getAgreementData(0n, account);
643
+ count = firstData.count;
644
+ }
645
+ catch {
646
+ return success({
647
+ account,
648
+ totalAgreements: 0,
649
+ showing: 0,
650
+ filter: args.status ?? 'all',
651
+ agreements: [],
652
+ });
653
+ }
654
+ if (count === 0n) {
655
+ return success({
656
+ account,
657
+ totalAgreements: 0,
658
+ showing: 0,
659
+ filter: args.status ?? 'all',
660
+ agreements: [],
661
+ });
662
+ }
663
+ const now = BigInt(Math.floor(Date.now() / 1000));
664
+ const statusFilter = args.status ?? 'all';
665
+ const agreements = [];
666
+ // Iterate from newest to oldest — 1 RPC per agreement via getAgreementData
667
+ for (let i = Number(count) - 1; i >= 0; i--) {
668
+ let data;
669
+ try {
670
+ data = await client.getAgreementData(BigInt(i), account);
671
+ }
672
+ catch {
673
+ continue;
674
+ }
675
+ const { agreement, executable, isDue: contractIsDue, nextExecutionTime: nextExecTime } = data;
676
+ const decimals = tokenDecimals(agreement.token, chain);
677
+ const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
678
+ const status = deriveStatus(agreement, now);
679
+ // Status filter
680
+ if (statusFilter === 'due') {
681
+ if (status !== 'active' || !contractIsDue)
682
+ continue;
683
+ }
684
+ else if (statusFilter !== 'all' && status !== statusFilter) {
685
+ continue;
686
+ }
687
+ // Compute timing for active agreements
688
+ let isDue = contractIsDue;
689
+ let nextExecutionIn;
690
+ if (status === 'active') {
691
+ const nowSecs = Math.floor(Date.now() / 1000);
692
+ const diff = Number(nextExecTime) - nowSecs;
693
+ if (diff <= 0) {
694
+ isDue = true;
695
+ nextExecutionIn = `now (overdue by ${formatOverdue(-diff)})`;
696
+ }
697
+ else {
698
+ nextExecutionIn = formatCountdown(diff);
699
+ }
700
+ }
701
+ // Payee name (best-effort)
702
+ const payeeName = await lookupPayeeName(client, agreement.payee);
703
+ agreements.push({
704
+ agreementId: i.toString(),
705
+ payee: agreement.payee,
706
+ ...(payeeName ? { payeeName } : {}),
707
+ tokenSymbol,
708
+ amountPerInterval: formatUnits(agreement.amount, decimals),
709
+ intervalHuman: formatInterval(Number(agreement.interval)),
710
+ status,
711
+ executionCount: agreement.executionCount.toString(),
712
+ maxExecutions: agreement.maxExecutions === 0n ? 'unlimited' : agreement.maxExecutions.toString(),
713
+ totalPaid: formatUnits(agreement.totalPaid, decimals),
714
+ ...(isDue !== undefined ? { isDue } : {}),
715
+ ...(nextExecutionIn ? { nextExecutionIn } : {}),
716
+ });
717
+ }
718
+ return success({
719
+ account,
720
+ totalAgreements: Number(count),
721
+ showing: agreements.length,
722
+ filter: statusFilter,
723
+ agreements,
724
+ });
725
+ }
726
+ catch (err) {
727
+ return handleError(err);
728
+ }
729
+ finally {
730
+ try {
731
+ await client?.destroy();
732
+ }
733
+ catch (e) {
734
+ process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
735
+ }
736
+ }
737
+ });
738
+ // ──────────────────────────────────────────────
739
+ // azeth_get_due_agreements
740
+ // ──────────────────────────────────────────────
741
+ server.registerTool('azeth_get_due_agreements', {
742
+ description: [
743
+ 'Find all payment agreements that are due for execution across one or more accounts.',
744
+ '',
745
+ 'Use this when: You are a keeper bot looking for agreements to execute,',
746
+ 'or a service provider checking which of your customers\' payments are collectible.',
747
+ '',
748
+ 'Returns: Array of due agreements with payer account, agreement ID, and expected payout.',
749
+ 'Each entry can be passed directly to azeth_execute_agreement.',
750
+ '',
751
+ 'Note: This scans all agreements for the specified accounts. For large-scale keeper operations,',
752
+ 'consider filtering by specific accounts rather than scanning all.',
753
+ ].join('\n'),
754
+ inputSchema: z.object({
755
+ chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'),
756
+ accounts: z.array(z.string()).min(1).max(20).optional()
757
+ .describe('Accounts to scan: addresses, names, "me", or "#N". Defaults to ["me"].'),
758
+ }),
759
+ }, async (args) => {
760
+ let client;
761
+ try {
762
+ client = await createClient(args.chain);
763
+ const chain = resolveChain(args.chain);
764
+ // Resolve all accounts
765
+ const accountInputs = args.accounts ?? ['me'];
766
+ const resolvedAccounts = [];
767
+ for (const input of accountInputs) {
768
+ try {
769
+ const resolved = await resolveAddress(input, client);
770
+ resolvedAccounts.push({
771
+ address: resolved.address,
772
+ name: resolved.name ?? resolved.resolvedFrom,
773
+ });
774
+ }
775
+ catch (resolveErr) {
776
+ return handleError(resolveErr);
777
+ }
778
+ }
779
+ let scannedAgreements = 0;
780
+ const dueAgreements = [];
781
+ for (const acct of resolvedAccounts) {
782
+ // Get count from first getAgreementData call (avoids separate getAgreementCount RPC)
783
+ let count;
784
+ try {
785
+ const firstData = await client.getAgreementData(0n, acct.address);
786
+ count = firstData.count;
787
+ }
788
+ catch {
789
+ continue;
790
+ }
791
+ for (let i = 0; i < Number(count); i++) {
792
+ scannedAgreements++;
793
+ // Single RPC: agreement + executability + isDue + nextExecutionTime
794
+ let data;
795
+ try {
796
+ data = await client.getAgreementData(BigInt(i), acct.address);
797
+ }
798
+ catch {
799
+ continue;
800
+ }
801
+ const { agreement, executable, isDue, nextExecutionTime: nextExecTime } = data;
802
+ if (!agreement.active)
803
+ continue;
804
+ if (!executable || !isDue)
805
+ continue;
806
+ const decimals = tokenDecimals(agreement.token, chain);
807
+ const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
808
+ // Estimate payout (pro-rata based on elapsed time)
809
+ const nowSecs = BigInt(Math.floor(Date.now() / 1000));
810
+ const elapsed = agreement.lastExecuted === 0n
811
+ ? agreement.interval // first execution: assume full interval
812
+ : nowSecs - agreement.lastExecuted;
813
+ const estimatedPayout = elapsed > 0n
814
+ ? (agreement.amount * elapsed) / agreement.interval
815
+ : agreement.amount;
816
+ // Cap at 3x interval (max accrual multiplier)
817
+ const cappedPayout = estimatedPayout > agreement.amount * 3n
818
+ ? agreement.amount * 3n
819
+ : estimatedPayout;
820
+ // Calculate overdue from nextExecutionTime (already available, no extra RPC)
821
+ let overdueBy;
822
+ const diff = Math.floor(Date.now() / 1000) - Number(nextExecTime);
823
+ if (diff > 0) {
824
+ overdueBy = formatOverdue(diff);
825
+ }
826
+ // Payee name
827
+ const payeeName = await lookupPayeeName(client, agreement.payee);
828
+ dueAgreements.push({
829
+ account: acct.address,
830
+ ...(acct.name ? { accountName: acct.name } : {}),
831
+ agreementId: i.toString(),
832
+ payee: agreement.payee,
833
+ ...(payeeName ? { payeeName } : {}),
834
+ tokenSymbol,
835
+ estimatedPayout: formatUnits(cappedPayout, decimals),
836
+ ...(overdueBy ? { overdueBy } : {}),
837
+ });
838
+ }
839
+ }
840
+ // Sort by estimated payout descending (highest value first for keeper prioritization)
841
+ dueAgreements.sort((a, b) => {
842
+ const payoutA = parseFloat(a.estimatedPayout);
843
+ const payoutB = parseFloat(b.estimatedPayout);
844
+ return payoutB - payoutA;
845
+ });
846
+ return success({
847
+ scannedAccounts: resolvedAccounts.length,
848
+ scannedAgreements,
849
+ dueAgreements,
850
+ });
851
+ }
852
+ catch (err) {
853
+ return handleError(err);
854
+ }
855
+ finally {
856
+ try {
857
+ await client?.destroy();
858
+ }
859
+ catch (e) {
860
+ process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`);
861
+ }
862
+ }
863
+ });
864
+ }
865
+ //# sourceMappingURL=agreements.js.map