@leashmarket/mcp 0.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.
@@ -0,0 +1,1001 @@
1
+ /**
2
+ * `LeashHost` implementation for the standalone STDIO MCP / CLI.
3
+ *
4
+ * Differs from the chat product in two important ways:
5
+ *
6
+ * 1. **Settlement happens in-process.** `pay` and `withdraw` actually
7
+ * sign + submit on Solana using the local executive keypair —
8
+ * no UI in the loop. The result blob carries a real `tx_signature`,
9
+ * not a "review-the-card" artifact. This is the killer demo path.
10
+ *
11
+ * 2. **No platform DB / Privy session.** Off-chain calls to the
12
+ * Leash API authenticate via a legacy `LEASH_API_KEY` bearer
13
+ * token until the X-Leash-Sig auth path lands in batch 4.
14
+ *
15
+ * The four host methods all `try/catch` aggressively and return a
16
+ * structured `{ status: 'ok' | 'error' }` blob — never throw — so
17
+ * the LLM never sees a tool exception, only a recoverable JSON
18
+ * response with a `message` it can surface.
19
+ */
20
+ import { LEASH_EXPLORER_DEFAULT, TOKEN_2022_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ID, deriveAgentTreasuryAddress, deriveAgentTreasuryAta, leashReceiptUrl, listSplBalances, parseLeashHeaders, tokenProgramForMint, } from '@leashmarket/core';
21
+ import { fetchDiscover, fetchPaySkillsProvider, fetchReputation, isLikelyBase58Address, jsonResult, lookupTokenBySymbolSafe, noAgentResult, probePaymentLink, } from '@leashmarket/mcp-core';
22
+ import { SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID as UMI_TOKEN_2022_PROGRAM_ID, getSpendDelegation, revokeSpendDelegation, setSpendDelegation, withdrawTreasury, withdrawTreasurySol, } from '@leashmarket/registry-utils';
23
+ import { createBuyer } from '@leashmarket/buyer-kit';
24
+ import { loadSigner } from './signer.js';
25
+ const LAMPORTS_PER_SOL = 1000000000n;
26
+ /**
27
+ * Default spend rules baked into every standalone-MCP buyer-kit
28
+ * call. Conservative — the user can raise these later by setting
29
+ * env vars (LEASH_PER_CALL_USDC, LEASH_PER_DAY_USDC) or editing
30
+ * `~/.config/leash/agent.json`. The on-chain SPL `Approve`
31
+ * delegation is the real ceiling; these values are belt + braces.
32
+ */
33
+ function defaultRules() {
34
+ const perCall = process.env.LEASH_PER_CALL_USDC?.trim() || '1';
35
+ const perDay = process.env.LEASH_PER_DAY_USDC?.trim() || '10';
36
+ return {
37
+ v: '0.1',
38
+ budget: { perCall, daily: perDay, currency: 'USDC' },
39
+ hosts: {},
40
+ triggers: [],
41
+ };
42
+ }
43
+ function tokenNetwork(network) {
44
+ return network === 'solana-mainnet' ? 'mainnet' : 'devnet';
45
+ }
46
+ /** Network slug -> buyer-kit + receipt cluster slug. */
47
+ function buyerKitNetwork(network) {
48
+ return network;
49
+ }
50
+ /**
51
+ * Build the standalone `LeashHost`. Captures the `LeashAgentConfig`
52
+ * + `LeashSigner` once so per-call methods can stay tight.
53
+ */
54
+ export function createStdioHost(config) {
55
+ const signer = loadSigner(config.executiveSecretBase58);
56
+ return new StdioHost(config, signer);
57
+ }
58
+ class StdioHost {
59
+ agentMint;
60
+ ownerWallet;
61
+ network;
62
+ rpcUrl;
63
+ apiBaseUrl;
64
+ explorerBaseUrl;
65
+ config;
66
+ signer;
67
+ constructor(config, signer) {
68
+ this.config = config;
69
+ this.signer = signer;
70
+ this.agentMint = config.agentMint;
71
+ this.ownerWallet = signer.pubkey;
72
+ this.network = config.network;
73
+ this.rpcUrl = config.rpcUrl;
74
+ this.apiBaseUrl = config.apiBaseUrl;
75
+ this.explorerBaseUrl = config.explorerBaseUrl ?? LEASH_EXPLORER_DEFAULT;
76
+ }
77
+ async checkTreasuryBalance(args) {
78
+ if (!this.agentMint)
79
+ return noAgentResult('treasury_balance');
80
+ try {
81
+ const treasury = await deriveAgentTreasuryAddress(this.agentMint);
82
+ const result = await listSplBalances({
83
+ owner: String(treasury),
84
+ rpcUrl: this.rpcUrl,
85
+ network: tokenNetwork(this.network),
86
+ pinKnownStables: true,
87
+ });
88
+ const filtered = args.symbol
89
+ ? result.tokens.filter((t) => (t.symbol ?? '').toLowerCase() === args.symbol.toLowerCase())
90
+ : result.tokens;
91
+ return jsonResult({
92
+ kind: 'treasury_balance',
93
+ status: 'ok',
94
+ treasury: String(treasury),
95
+ network: this.network,
96
+ sol: result.sol,
97
+ tokens: filtered.map((t) => ({
98
+ symbol: t.symbol,
99
+ name: t.name,
100
+ ui: t.ui,
101
+ amount: t.amount,
102
+ decimals: t.decimals,
103
+ mint: t.mint,
104
+ program: t.program,
105
+ })),
106
+ });
107
+ }
108
+ catch (e) {
109
+ return jsonResult({
110
+ kind: 'treasury_balance',
111
+ status: 'error',
112
+ message: e instanceof Error ? e.message : 'unknown',
113
+ });
114
+ }
115
+ }
116
+ async pay(args) {
117
+ if (!this.agentMint)
118
+ return noAgentResult('payment_receipt');
119
+ try {
120
+ // Probe the seller's paywall first so we can pick the right
121
+ // SPL mint for the buyer-kit's `sourceTokenAccount` (the
122
+ // treasury's ATA for the demanded asset). The probe is cheap
123
+ // (one HTTP GET) and lets us surface a clean error if the URL
124
+ // isn't actually an x402 link.
125
+ const preview = await probePaymentLink(args.url);
126
+ const tokenProgramKind = tokenProgramForMint(preview.asset);
127
+ const sourceAta = await deriveAgentTreasuryAta({
128
+ asset: this.agentMint,
129
+ mint: preview.asset,
130
+ ...(tokenProgramKind === 'spl-token-2022'
131
+ ? { tokenProgram: TOKEN_2022_PROGRAM_ADDRESS }
132
+ : {}),
133
+ });
134
+ const kitSigner = await this.signer.getKitSigner();
135
+ const buyer = createBuyer({
136
+ agent: this.agentMint,
137
+ signer: kitSigner,
138
+ networks: [buyerKitNetwork(this.network)],
139
+ rpcUrl: this.rpcUrl,
140
+ sourceTokenAccount: String(sourceAta.ata),
141
+ rules: defaultRules(),
142
+ });
143
+ const { response, receipt, failureReason } = await buyer.fetch(args.url, {
144
+ method: 'GET',
145
+ });
146
+ const bodyText = await response
147
+ .clone()
148
+ .text()
149
+ .catch(() => '');
150
+ if (receipt.tx_sig && response.ok) {
151
+ // Prefer the seller-stamped `X-Leash-*` headers over the
152
+ // buyer-kit's locally-computed receipt. The buyer-side hash is
153
+ // computed against the buyer's view of the request (its own
154
+ // `nonce` / `ts`) so it diverges from the canonical seller-side
155
+ // earn receipt that `apps/api`'s paywall publishes — and the
156
+ // explorer only indexes the seller-side hash. Falling back to
157
+ // the local hash keeps legacy paywalls (no header stamping)
158
+ // working. Same precedence as the chat product applies in
159
+ // `apps/agents/components/chat/pay-request-artifact.tsx`.
160
+ const stamped = parseLeashHeaders(response);
161
+ const txSignature = stamped.txSig ?? receipt.tx_sig;
162
+ const receiptHash = stamped.receiptHash ?? receipt.receipt_hash ?? null;
163
+ return jsonResult({
164
+ kind: 'payment_receipt',
165
+ status: 'ok',
166
+ url: args.url,
167
+ agent_mint: this.agentMint,
168
+ network: this.network,
169
+ paid_amount_atomic: receipt.price?.amount ?? null,
170
+ currency: receipt.price?.currency ?? null,
171
+ tx_signature: txSignature,
172
+ response_status: response.status,
173
+ response_body: bodyText.slice(0, 4000),
174
+ receipt_hash: receiptHash,
175
+ receipt_url: leashReceiptUrl(receiptHash, { baseUrl: this.explorerBaseUrl }),
176
+ explorer_url: explorerTxUrl(txSignature, this.network),
177
+ });
178
+ }
179
+ return jsonResult({
180
+ kind: 'payment_receipt',
181
+ status: 'error',
182
+ url: args.url,
183
+ agent_mint: this.agentMint,
184
+ message: failureReason ?? `seller returned HTTP ${response.status}`,
185
+ response_status: response.status,
186
+ response_body: bodyText.slice(0, 1000),
187
+ quoted_amount_atomic: receipt.price?.amount ?? null,
188
+ });
189
+ }
190
+ catch (err) {
191
+ return jsonResult({
192
+ kind: 'payment_receipt',
193
+ status: 'error',
194
+ url: args.url,
195
+ message: err instanceof Error ? err.message : 'unknown error',
196
+ });
197
+ }
198
+ }
199
+ async withdraw(args) {
200
+ if (!this.agentMint)
201
+ return noAgentResult('withdraw_receipt');
202
+ if (!isLikelyBase58Address(args.destination)) {
203
+ return jsonResult({
204
+ kind: 'withdraw_receipt',
205
+ status: 'error',
206
+ message: 'Destination does not look like a Solana wallet address (base58, 32–44 chars).',
207
+ });
208
+ }
209
+ try {
210
+ const umi = this.signer.getUmi(this.rpcUrl);
211
+ if (args.token === 'SOL') {
212
+ const lamports = BigInt(Math.floor(args.amount * Number(LAMPORTS_PER_SOL)));
213
+ if (lamports <= 0n) {
214
+ return jsonResult({
215
+ kind: 'withdraw_receipt',
216
+ status: 'error',
217
+ message: 'Amount rounds to zero lamports — request a larger amount.',
218
+ });
219
+ }
220
+ const result = await withdrawTreasurySol(umi, {
221
+ agentAsset: this.agentMint,
222
+ destination: args.destination,
223
+ lamports,
224
+ });
225
+ return jsonResult({
226
+ kind: 'withdraw_receipt',
227
+ status: 'ok',
228
+ agent_mint: this.agentMint,
229
+ token: 'SOL',
230
+ decimals: 9,
231
+ amount: String(args.amount),
232
+ amount_atomic: lamports.toString(),
233
+ destination: args.destination,
234
+ treasury: result.treasury,
235
+ tx_signature: result.signature,
236
+ network: this.network,
237
+ explorer_url: explorerTxUrl(result.signature, this.network),
238
+ });
239
+ }
240
+ const meta = lookupTokenBySymbolSafe(args.token, tokenNetwork(this.network));
241
+ if (!meta) {
242
+ return jsonResult({
243
+ kind: 'withdraw_receipt',
244
+ status: 'error',
245
+ message: `Token ${args.token} is not catalogued on ${this.network}. Try USDC, USDG, or USDT.`,
246
+ });
247
+ }
248
+ const atomic = BigInt(Math.floor(args.amount * 10 ** meta.decimals));
249
+ if (atomic <= 0n) {
250
+ return jsonResult({
251
+ kind: 'withdraw_receipt',
252
+ status: 'error',
253
+ message: 'Amount rounds to zero atomic units — request a larger amount.',
254
+ });
255
+ }
256
+ const result = await withdrawTreasury(umi, {
257
+ agentAsset: this.agentMint,
258
+ mint: meta.mint,
259
+ destination: args.destination,
260
+ amount: atomic,
261
+ decimals: meta.decimals,
262
+ ...(meta.program === 'spl-token-2022' ? { tokenProgram: UMI_TOKEN_2022_PROGRAM_ID } : {}),
263
+ });
264
+ return jsonResult({
265
+ kind: 'withdraw_receipt',
266
+ status: 'ok',
267
+ agent_mint: this.agentMint,
268
+ token: meta.symbol,
269
+ mint: meta.mint,
270
+ token_program: meta.program === 'spl-token-2022' ? TOKEN_2022_PROGRAM_ID : null,
271
+ decimals: meta.decimals,
272
+ amount: String(args.amount),
273
+ amount_atomic: atomic.toString(),
274
+ destination: args.destination,
275
+ treasury: result.treasury,
276
+ tx_signature: result.signature,
277
+ network: this.network,
278
+ explorer_url: explorerTxUrl(result.signature, this.network),
279
+ });
280
+ }
281
+ catch (err) {
282
+ return jsonResult({
283
+ kind: 'withdraw_receipt',
284
+ status: 'error',
285
+ message: err instanceof Error ? err.message : 'unknown error',
286
+ });
287
+ }
288
+ }
289
+ async createPaymentLink(args) {
290
+ if (!this.agentMint)
291
+ return noAgentResult('payment_link');
292
+ // Until X-Leash-Sig auth ships in batch 4, the standalone MCP
293
+ // requires a legacy API key. If the user hasn't set one, return a
294
+ // clean error the LLM can surface verbatim.
295
+ if (!this.config.apiKey) {
296
+ return jsonResult({
297
+ kind: 'payment_link',
298
+ status: 'error',
299
+ message: 'Creating payment links from the standalone MCP currently requires LEASH_API_KEY in the environment. (X-Leash-Sig auth ships in the next release.)',
300
+ });
301
+ }
302
+ try {
303
+ const body = {
304
+ label: args.label,
305
+ description: args.description,
306
+ owner_agent: this.agentMint,
307
+ method: 'GET',
308
+ price: `${args.amount} ${args.currency}`,
309
+ currency: args.currency,
310
+ response: {
311
+ status: 200,
312
+ mimeType: 'application/json',
313
+ body: { ok: true, label: args.label },
314
+ },
315
+ };
316
+ const res = await fetch(`${this.apiBaseUrl}/v1/payment-links`, {
317
+ method: 'POST',
318
+ headers: {
319
+ authorization: `Bearer ${this.config.apiKey}`,
320
+ 'content-type': 'application/json',
321
+ },
322
+ body: JSON.stringify(body),
323
+ });
324
+ const text = await res.text();
325
+ if (!res.ok) {
326
+ return jsonResult({
327
+ kind: 'payment_link',
328
+ status: 'error',
329
+ message: `Leash API ${res.status}: ${text.slice(0, 300)}`,
330
+ });
331
+ }
332
+ const json = JSON.parse(text);
333
+ return jsonResult({
334
+ kind: 'payment_link',
335
+ status: 'ok',
336
+ id: json.id,
337
+ url: json.share_url,
338
+ price: `${args.amount} ${args.currency}`,
339
+ currency: args.currency,
340
+ label: args.label,
341
+ network: json.network,
342
+ owner_agent: json.owner_agent,
343
+ });
344
+ }
345
+ catch (err) {
346
+ return jsonResult({
347
+ kind: 'payment_link',
348
+ status: 'error',
349
+ message: err instanceof Error ? err.message : 'unknown error',
350
+ });
351
+ }
352
+ }
353
+ async registerAgent(_args) {
354
+ return jsonResult({
355
+ kind: 'register_agent',
356
+ status: 'already_registered',
357
+ agent_mint: this.agentMint,
358
+ executive_pubkey: this.ownerWallet,
359
+ network: this.network,
360
+ message: `Agent ${this.agentMint} is already registered on this host. Use \`leash_get_identity\` to inspect it, or rotate to a fresh agent by deleting \`~/.config/leash/agent.json\` and re-running.`,
361
+ });
362
+ }
363
+ async getIdentity(_args) {
364
+ if (!this.agentMint)
365
+ return noAgentResult('identity');
366
+ try {
367
+ const treasury = await deriveAgentTreasuryAddress(this.agentMint);
368
+ return jsonResult({
369
+ kind: 'identity',
370
+ status: 'ok',
371
+ agent_mint: this.agentMint,
372
+ treasury_address: String(treasury),
373
+ executive_pubkey: this.ownerWallet,
374
+ network: this.network,
375
+ api_base_url: this.apiBaseUrl,
376
+ rpc_url: this.rpcUrl,
377
+ explorer_url: explorerAccountUrl(this.agentMint, this.network),
378
+ });
379
+ }
380
+ catch (err) {
381
+ return jsonResult({
382
+ kind: 'identity',
383
+ status: 'error',
384
+ message: err instanceof Error ? err.message : 'unknown error',
385
+ });
386
+ }
387
+ }
388
+ async receipts(args) {
389
+ if (!this.agentMint)
390
+ return noAgentResult('receipts');
391
+ if (!this.config.apiKey) {
392
+ return jsonResult({
393
+ kind: 'receipts',
394
+ status: 'error',
395
+ message: 'Listing receipts from the standalone MCP currently requires LEASH_API_KEY in the environment. (X-Leash-Sig auth ships in the next release alongside discovery + reputation.)',
396
+ });
397
+ }
398
+ try {
399
+ const url = new URL(`${this.apiBaseUrl}/v1/receipts/${this.agentMint}`);
400
+ if (args.limit)
401
+ url.searchParams.set('limit', String(args.limit));
402
+ if (args.direction === 'outgoing')
403
+ url.searchParams.set('kind', 'spend');
404
+ else if (args.direction === 'incoming')
405
+ url.searchParams.set('kind', 'earn');
406
+ const res = await fetch(url, {
407
+ headers: { authorization: `Bearer ${this.config.apiKey}` },
408
+ });
409
+ const text = await res.text();
410
+ if (!res.ok) {
411
+ return jsonResult({
412
+ kind: 'receipts',
413
+ status: 'error',
414
+ message: `Leash API ${res.status}: ${text.slice(0, 300)}`,
415
+ });
416
+ }
417
+ const json = JSON.parse(text);
418
+ return jsonResult({
419
+ kind: 'receipts',
420
+ status: 'ok',
421
+ agent_mint: this.agentMint,
422
+ network: this.network,
423
+ count: json.items.length,
424
+ next_cursor: json.next_cursor,
425
+ items: json.items.map((r) => ({
426
+ receipt_hash: r.receipt_hash,
427
+ direction: r.kind === 'spend' ? 'outgoing' : 'incoming',
428
+ decision: r.decision,
429
+ tx_signature: r.tx_sig,
430
+ url: r.raw?.request?.url ?? null,
431
+ amount: r.raw?.price?.amount ?? null,
432
+ currency: r.raw?.price?.currency ?? null,
433
+ timestamp: r.ingested_at,
434
+ explorer_url: r.tx_sig ? explorerTxUrl(r.tx_sig, this.network) : null,
435
+ })),
436
+ });
437
+ }
438
+ catch (err) {
439
+ return jsonResult({
440
+ kind: 'receipts',
441
+ status: 'error',
442
+ message: err instanceof Error ? err.message : 'unknown error',
443
+ });
444
+ }
445
+ }
446
+ async discover(args) {
447
+ return fetchDiscover({
448
+ apiBaseUrl: this.apiBaseUrl,
449
+ network: this.network,
450
+ query: args,
451
+ });
452
+ }
453
+ async reputation(args) {
454
+ return fetchReputation({
455
+ apiBaseUrl: this.apiBaseUrl,
456
+ network: this.network,
457
+ query: args,
458
+ });
459
+ }
460
+ async paySkillsProvider(args) {
461
+ return fetchPaySkillsProvider({
462
+ apiBaseUrl: this.apiBaseUrl,
463
+ network: this.network,
464
+ query: args,
465
+ });
466
+ }
467
+ /**
468
+ * Owner-driven update of the SPL `Approve` delegation that lets the
469
+ * executive spend the requested stable from the agent treasury PDA.
470
+ * Mode + amount semantics:
471
+ * - `unlimited` (default) → `u64::MAX` (the protocol default).
472
+ * - `revoke` → drop the delegation entirely.
473
+ * - `amount` + `amount: N` → cap at `N * 10**decimals`.
474
+ */
475
+ async setSpendLimit(args) {
476
+ if (!this.agentMint)
477
+ return noAgentResult('spend_limit');
478
+ const symbol = (args.symbol ?? 'USDC');
479
+ const meta = lookupTokenBySymbolSafe(symbol, tokenNetwork(this.network));
480
+ if (!meta) {
481
+ return jsonResult({
482
+ kind: 'spend_limit',
483
+ status: 'error',
484
+ message: `${symbol} is not configured for ${this.network}.`,
485
+ });
486
+ }
487
+ const mode = args.mode ?? 'unlimited';
488
+ if (mode === 'amount' && (args.amount === undefined || !(args.amount > 0))) {
489
+ return jsonResult({
490
+ kind: 'spend_limit',
491
+ status: 'error',
492
+ message: '`mode: "amount"` requires `amount` (a positive decimal number).',
493
+ });
494
+ }
495
+ try {
496
+ const umi = this.signer.getUmi(this.rpcUrl);
497
+ const tokenProgram = meta.program === 'spl-token-2022' ? UMI_TOKEN_2022_PROGRAM_ID : SPL_TOKEN_PROGRAM_ID;
498
+ if (mode === 'revoke') {
499
+ const result = await revokeSpendDelegation(umi, {
500
+ agentAsset: this.agentMint,
501
+ mint: meta.mint,
502
+ tokenProgram,
503
+ });
504
+ return jsonResult({
505
+ kind: 'spend_limit',
506
+ status: 'ok',
507
+ mode: 'revoke',
508
+ symbol,
509
+ mint: meta.mint,
510
+ treasury: result.treasury,
511
+ source_token_account: result.sourceTokenAccount,
512
+ delegated_amount_atomic: '0',
513
+ delegated_amount: '0',
514
+ tx_signature: result.signature,
515
+ network: this.network,
516
+ explorer_url: explorerTxUrl(result.signature, this.network),
517
+ });
518
+ }
519
+ const cap = mode === 'unlimited'
520
+ ? 2n ** 64n - 1n
521
+ : decimalToAtomic(args.amount, meta.decimals);
522
+ if (cap <= 0n) {
523
+ return jsonResult({
524
+ kind: 'spend_limit',
525
+ status: 'error',
526
+ message: 'Resolved cap is zero — pass a larger `amount` or use `mode: "unlimited"`.',
527
+ });
528
+ }
529
+ const result = await setSpendDelegation(umi, {
530
+ agentAsset: this.agentMint,
531
+ mint: meta.mint,
532
+ executive: this.ownerWallet ?? '',
533
+ amount: cap,
534
+ tokenProgram,
535
+ });
536
+ return jsonResult({
537
+ kind: 'spend_limit',
538
+ status: 'ok',
539
+ mode,
540
+ symbol,
541
+ mint: meta.mint,
542
+ delegate: result.delegate,
543
+ treasury: result.treasury,
544
+ source_token_account: result.sourceTokenAccount,
545
+ delegated_amount_atomic: result.delegatedAmount.toString(),
546
+ delegated_amount: mode === 'unlimited' ? 'unlimited' : atomicToDecimal(cap, meta.decimals),
547
+ tx_signature: result.signature,
548
+ network: this.network,
549
+ explorer_url: explorerTxUrl(result.signature, this.network),
550
+ });
551
+ }
552
+ catch (err) {
553
+ return jsonResult({
554
+ kind: 'spend_limit',
555
+ status: 'error',
556
+ message: err instanceof Error ? err.message : 'unknown error',
557
+ });
558
+ }
559
+ }
560
+ /**
561
+ * Look up a single ReceiptV1 by its `receipt_hash` via the Leash
562
+ * API's by-hash endpoint. Returns the canonical seller-side blob
563
+ * (the same JSON the explorer renders) plus a few convenience
564
+ * fields the LLM can quote inline.
565
+ */
566
+ async getReceipt(args) {
567
+ if (!this.config.apiKey) {
568
+ return jsonResult({
569
+ kind: 'receipt',
570
+ status: 'error',
571
+ message: 'Looking up receipts by hash currently requires LEASH_API_KEY in the environment. (X-Leash-Sig auth ships in the next release alongside discovery + reputation.)',
572
+ });
573
+ }
574
+ const hash = (args.receipt_hash ?? '').trim();
575
+ if (!hash) {
576
+ return jsonResult({
577
+ kind: 'receipt',
578
+ status: 'error',
579
+ message: '`receipt_hash` is required (the 64-hex-char value the explorer renders at /receipt/{hash}).',
580
+ });
581
+ }
582
+ try {
583
+ const url = `${this.apiBaseUrl}/v1/receipts/by-hash/${encodeURIComponent(hash)}`;
584
+ const res = await fetch(url, {
585
+ headers: { authorization: `Bearer ${this.config.apiKey}` },
586
+ });
587
+ const text = await res.text();
588
+ if (res.status === 404) {
589
+ return jsonResult({
590
+ kind: 'receipt',
591
+ status: 'not_found',
592
+ receipt_hash: hash,
593
+ network: this.network,
594
+ message: `No receipt with hash ${hash} on ${this.network} (cross-network reads are impossible by design \u2014 if this hash came from the sibling cluster, switch LEASH_NETWORK and retry).`,
595
+ });
596
+ }
597
+ if (!res.ok) {
598
+ return jsonResult({
599
+ kind: 'receipt',
600
+ status: 'error',
601
+ message: `Leash API ${res.status}: ${text.slice(0, 300)}`,
602
+ });
603
+ }
604
+ const row = JSON.parse(text);
605
+ return jsonResult({
606
+ kind: 'receipt',
607
+ status: 'ok',
608
+ receipt_hash: row.receipt_hash,
609
+ agent: row.agent,
610
+ direction: row.kind === 'spend' ? 'outgoing' : 'incoming',
611
+ decision: row.decision,
612
+ network: row.network,
613
+ tx_signature: row.tx_sig,
614
+ ingested_at: row.ingested_at,
615
+ explorer_url: leashReceiptUrl(row.receipt_hash, { baseUrl: this.explorerBaseUrl }),
616
+ tx_explorer_url: row.tx_sig ? explorerTxUrl(row.tx_sig, row.network) : null,
617
+ receipt: row.raw,
618
+ });
619
+ }
620
+ catch (err) {
621
+ return jsonResult({
622
+ kind: 'receipt',
623
+ status: 'error',
624
+ message: err instanceof Error ? err.message : 'unknown error',
625
+ });
626
+ }
627
+ }
628
+ /**
629
+ * Paginate `/v1/receipts/{agent}` and trim to the rolling
630
+ * `now - days` window, returning the receipts plus running totals.
631
+ * USD totals sum stables (USDC/USDG/USDT) at 1:1.
632
+ */
633
+ async transactionHistory(args) {
634
+ if (!this.agentMint)
635
+ return noAgentResult('transaction_history');
636
+ if (!this.config.apiKey) {
637
+ return jsonResult({
638
+ kind: 'transaction_history',
639
+ status: 'error',
640
+ message: 'Listing receipts from the standalone MCP currently requires LEASH_API_KEY in the environment.',
641
+ });
642
+ }
643
+ const days = clampInt(args.days ?? 7, 1, 90);
644
+ const limit = clampInt(args.limit ?? 200, 1, 1000);
645
+ const direction = args.direction ?? 'both';
646
+ const cutoffMs = Date.now() - days * 86_400_000;
647
+ try {
648
+ const rows = await fetchReceiptWindow({
649
+ apiBaseUrl: this.apiBaseUrl,
650
+ apiKey: this.config.apiKey,
651
+ agent: this.agentMint,
652
+ direction,
653
+ limit,
654
+ cutoffMs,
655
+ });
656
+ const totals = aggregateReceipts(rows.items);
657
+ return jsonResult({
658
+ kind: 'transaction_history',
659
+ status: 'ok',
660
+ agent_mint: this.agentMint,
661
+ network: this.network,
662
+ range: {
663
+ from: new Date(cutoffMs).toISOString(),
664
+ to: new Date().toISOString(),
665
+ days,
666
+ },
667
+ direction,
668
+ count: rows.items.length,
669
+ truncated: rows.truncated,
670
+ total_sent_usd: totals.totalSentUsd,
671
+ total_received_usd: totals.totalReceivedUsd,
672
+ net_usd: totals.netUsd,
673
+ sent_count: totals.sentCount,
674
+ received_count: totals.receivedCount,
675
+ non_usd_count: totals.nonUsdCount,
676
+ items: rows.items.map((r) => ({
677
+ receipt_hash: r.receipt_hash,
678
+ direction: r.kind === 'spend' ? 'outgoing' : 'incoming',
679
+ decision: r.decision,
680
+ tx_signature: r.tx_sig,
681
+ url: r.raw?.request?.url ?? null,
682
+ method: r.raw?.request?.method ?? null,
683
+ amount: r.raw?.price?.amount ?? null,
684
+ currency: r.raw?.price?.currency ?? null,
685
+ timestamp: r.ingested_at,
686
+ explorer_url: leashReceiptUrl(r.receipt_hash, { baseUrl: this.explorerBaseUrl }),
687
+ tx_explorer_url: r.tx_sig ? explorerTxUrl(r.tx_sig, this.network) : null,
688
+ })),
689
+ });
690
+ }
691
+ catch (err) {
692
+ return jsonResult({
693
+ kind: 'transaction_history',
694
+ status: 'error',
695
+ message: err instanceof Error ? err.message : 'unknown error',
696
+ });
697
+ }
698
+ }
699
+ /**
700
+ * Bin the same receipts as `transactionHistory` by UTC ingest date
701
+ * and return per-day buckets plus grand totals. Days with zero
702
+ * activity are filled with zeros so the timeline is continuous.
703
+ */
704
+ async dailyTransactions(args) {
705
+ if (!this.agentMint)
706
+ return noAgentResult('daily_transactions');
707
+ if (!this.config.apiKey) {
708
+ return jsonResult({
709
+ kind: 'daily_transactions',
710
+ status: 'error',
711
+ message: 'Daily aggregates from the standalone MCP currently require LEASH_API_KEY in the environment.',
712
+ });
713
+ }
714
+ const days = clampInt(args.days ?? 7, 1, 90);
715
+ const cutoffMs = Date.now() - days * 86_400_000;
716
+ try {
717
+ const rows = await fetchReceiptWindow({
718
+ apiBaseUrl: this.apiBaseUrl,
719
+ apiKey: this.config.apiKey,
720
+ agent: this.agentMint,
721
+ direction: 'both',
722
+ limit: 1000,
723
+ cutoffMs,
724
+ });
725
+ const buckets = bucketReceiptsByDay(rows.items, days);
726
+ const totals = aggregateReceipts(rows.items);
727
+ return jsonResult({
728
+ kind: 'daily_transactions',
729
+ status: 'ok',
730
+ agent_mint: this.agentMint,
731
+ network: this.network,
732
+ range: {
733
+ from: new Date(cutoffMs).toISOString(),
734
+ to: new Date().toISOString(),
735
+ days,
736
+ },
737
+ daily: buckets,
738
+ totals: {
739
+ sent_count: totals.sentCount,
740
+ sent_usd: totals.totalSentUsd,
741
+ received_count: totals.receivedCount,
742
+ received_usd: totals.totalReceivedUsd,
743
+ net_usd: totals.netUsd,
744
+ non_usd_count: totals.nonUsdCount,
745
+ },
746
+ truncated: rows.truncated,
747
+ });
748
+ }
749
+ catch (err) {
750
+ return jsonResult({
751
+ kind: 'daily_transactions',
752
+ status: 'error',
753
+ message: err instanceof Error ? err.message : 'unknown error',
754
+ });
755
+ }
756
+ }
757
+ /**
758
+ * Read the current SPL delegation + treasury balance for `symbol`.
759
+ * Pure RPC — no signing.
760
+ */
761
+ async getSpendLimit(args) {
762
+ if (!this.agentMint)
763
+ return noAgentResult('spend_limit');
764
+ const symbol = (args.symbol ?? 'USDC');
765
+ const meta = lookupTokenBySymbolSafe(symbol, tokenNetwork(this.network));
766
+ if (!meta) {
767
+ return jsonResult({
768
+ kind: 'spend_limit',
769
+ status: 'error',
770
+ message: `${symbol} is not configured for ${this.network}.`,
771
+ });
772
+ }
773
+ try {
774
+ const umi = this.signer.getUmi(this.rpcUrl);
775
+ const tokenProgram = meta.program === 'spl-token-2022' ? UMI_TOKEN_2022_PROGRAM_ID : SPL_TOKEN_PROGRAM_ID;
776
+ const status = await getSpendDelegation(umi, {
777
+ agentAsset: this.agentMint,
778
+ mint: meta.mint,
779
+ tokenProgram,
780
+ });
781
+ const isUnlimited = status.delegatedAmount === 2n ** 64n - 1n;
782
+ return jsonResult({
783
+ kind: 'spend_limit',
784
+ status: 'ok',
785
+ symbol,
786
+ mint: meta.mint,
787
+ treasury: status.treasury,
788
+ source_token_account: status.sourceTokenAccount,
789
+ source_exists: status.sourceExists,
790
+ delegate: status.delegate,
791
+ executive_pubkey: this.ownerWallet,
792
+ delegate_matches_executive: status.delegate === this.ownerWallet,
793
+ delegated_amount_atomic: status.delegatedAmount.toString(),
794
+ delegated_amount: isUnlimited
795
+ ? 'unlimited'
796
+ : atomicToDecimal(status.delegatedAmount, meta.decimals),
797
+ balance_atomic: status.balance.toString(),
798
+ balance: atomicToDecimal(status.balance, meta.decimals),
799
+ decimals: meta.decimals,
800
+ network: this.network,
801
+ });
802
+ }
803
+ catch (err) {
804
+ return jsonResult({
805
+ kind: 'spend_limit',
806
+ status: 'error',
807
+ message: err instanceof Error ? err.message : 'unknown error',
808
+ });
809
+ }
810
+ }
811
+ }
812
+ /**
813
+ * Convert a human decimal (e.g. `100`, `1.5`) to atomic units using
814
+ * the mint's decimals. Floors to avoid silently rounding up.
815
+ */
816
+ function decimalToAtomic(amount, decimals) {
817
+ if (!Number.isFinite(amount) || amount <= 0)
818
+ return 0n;
819
+ // Multiply via string to avoid float precision drift on small
820
+ // amounts (e.g. 0.000001 USDC).
821
+ const [whole, frac = ''] = amount.toString().split('.');
822
+ const fracPadded = (frac + '0'.repeat(decimals)).slice(0, decimals);
823
+ const combined = `${whole}${fracPadded}`.replace(/^0+(?=\d)/, '');
824
+ try {
825
+ return BigInt(combined);
826
+ }
827
+ catch {
828
+ return 0n;
829
+ }
830
+ }
831
+ /** Inverse of {@link decimalToAtomic}. Trims trailing zeros. */
832
+ function atomicToDecimal(amount, decimals) {
833
+ if (decimals === 0)
834
+ return amount.toString();
835
+ const s = amount.toString().padStart(decimals + 1, '0');
836
+ const whole = s.slice(0, s.length - decimals);
837
+ const frac = s.slice(s.length - decimals).replace(/0+$/, '');
838
+ return frac.length > 0 ? `${whole}.${frac}` : whole;
839
+ }
840
+ function explorerAccountUrl(pubkey, network) {
841
+ const cluster = network === 'solana-mainnet' ? '' : '?cluster=devnet';
842
+ return `https://solscan.io/account/${pubkey}${cluster}`;
843
+ }
844
+ function explorerTxUrl(signature, network) {
845
+ const cluster = network === 'solana-mainnet' ? '' : '?cluster=devnet';
846
+ return `https://solscan.io/tx/${signature}${cluster}`;
847
+ }
848
+ /** Clamp an integer into `[min, max]`, falling back to `min` on NaN. */
849
+ function clampInt(n, min, max) {
850
+ if (!Number.isFinite(n))
851
+ return min;
852
+ return Math.max(min, Math.min(max, Math.floor(n)));
853
+ }
854
+ /**
855
+ * Walk the paginated `/v1/receipts/{agent}` feed newest-first until
856
+ * we either hit `cutoffMs` (i.e. the user's window boundary), exhaust
857
+ * the feed, or hit `limit` rows. Returns the in-window subset plus a
858
+ * `truncated` flag the caller can surface when more receipts exist
859
+ * but would have blown the cap.
860
+ */
861
+ async function fetchReceiptWindow(args) {
862
+ const items = [];
863
+ let cursor = null;
864
+ let truncated = false;
865
+ // Cap the underlying paginations to prevent runaway loops on
866
+ // extremely active agents \u2014 200 rows/page * 10 pages = 2000 rows
867
+ // is well above any sane day-window response.
868
+ const maxPages = 10;
869
+ for (let page = 0; page < maxPages; page++) {
870
+ const url = new URL(`${args.apiBaseUrl}/v1/receipts/${args.agent}`);
871
+ url.searchParams.set('limit', '200');
872
+ if (args.direction === 'outgoing')
873
+ url.searchParams.set('kind', 'spend');
874
+ else if (args.direction === 'incoming')
875
+ url.searchParams.set('kind', 'earn');
876
+ if (cursor)
877
+ url.searchParams.set('cursor', cursor);
878
+ const res = await fetch(url, { headers: { authorization: `Bearer ${args.apiKey}` } });
879
+ const text = await res.text();
880
+ if (!res.ok) {
881
+ throw new Error(`Leash API ${res.status}: ${text.slice(0, 300)}`);
882
+ }
883
+ const json = JSON.parse(text);
884
+ let stop = false;
885
+ for (const r of json.items) {
886
+ const ingestedMs = Date.parse(r.ingested_at);
887
+ if (Number.isFinite(ingestedMs) && ingestedMs < args.cutoffMs) {
888
+ stop = true;
889
+ break;
890
+ }
891
+ items.push(r);
892
+ if (items.length >= args.limit) {
893
+ truncated = true;
894
+ stop = true;
895
+ break;
896
+ }
897
+ }
898
+ if (stop || !json.next_cursor)
899
+ break;
900
+ cursor = json.next_cursor;
901
+ }
902
+ return { items, truncated };
903
+ }
904
+ /** USD-symbol whitelist used by the per-day + transaction-history aggregators. */
905
+ const USD_STABLES = new Set(['USDC', 'USDG', 'USDT']);
906
+ /** Sum sent/received decimals across a list of receipts. */
907
+ function aggregateReceipts(items) {
908
+ let sentCount = 0;
909
+ let receivedCount = 0;
910
+ let nonUsdCount = 0;
911
+ // Use a string-based decimal sum to avoid float drift on long
912
+ // running totals. We accumulate in a Decimal128-style helper.
913
+ let sentSum = 0;
914
+ let receivedSum = 0;
915
+ for (const r of items) {
916
+ const amt = parseFloat(r.raw?.price?.amount ?? '');
917
+ const cur = (r.raw?.price?.currency ?? '').toUpperCase();
918
+ if (r.kind === 'spend')
919
+ sentCount++;
920
+ else if (r.kind === 'earn')
921
+ receivedCount++;
922
+ if (!Number.isFinite(amt) || !cur)
923
+ continue;
924
+ if (!USD_STABLES.has(cur)) {
925
+ nonUsdCount++;
926
+ continue;
927
+ }
928
+ if (r.kind === 'spend')
929
+ sentSum += amt;
930
+ else if (r.kind === 'earn')
931
+ receivedSum += amt;
932
+ }
933
+ const round = (n) => Math.round(n * 1_000_000) / 1_000_000;
934
+ return {
935
+ sentCount,
936
+ receivedCount,
937
+ nonUsdCount,
938
+ totalSentUsd: round(sentSum).toString(),
939
+ totalReceivedUsd: round(receivedSum).toString(),
940
+ netUsd: round(receivedSum - sentSum).toString(),
941
+ };
942
+ }
943
+ /**
944
+ * Bucket receipts into per-day rows (UTC `YYYY-MM-DD`). Days with no
945
+ * activity are emitted with zeros so the timeline is continuous.
946
+ * Sorted newest-first.
947
+ */
948
+ function bucketReceiptsByDay(items, days) {
949
+ const map = new Map();
950
+ // Seed with empty buckets for every day in the window so the LLM
951
+ // sees a continuous range even when the agent had no activity.
952
+ const today = utcDay(new Date());
953
+ for (let i = 0; i < days; i++) {
954
+ const d = new Date(today.getTime() - i * 86_400_000);
955
+ map.set(formatUtcDate(d), { sentCount: 0, sentSum: 0, receivedCount: 0, receivedSum: 0 });
956
+ }
957
+ for (const r of items) {
958
+ const ingested = new Date(r.ingested_at);
959
+ if (Number.isNaN(ingested.getTime()))
960
+ continue;
961
+ const key = formatUtcDate(ingested);
962
+ let bucket = map.get(key);
963
+ if (!bucket) {
964
+ bucket = { sentCount: 0, sentSum: 0, receivedCount: 0, receivedSum: 0 };
965
+ map.set(key, bucket);
966
+ }
967
+ if (r.kind === 'spend')
968
+ bucket.sentCount++;
969
+ else if (r.kind === 'earn')
970
+ bucket.receivedCount++;
971
+ const amt = parseFloat(r.raw?.price?.amount ?? '');
972
+ const cur = (r.raw?.price?.currency ?? '').toUpperCase();
973
+ if (!Number.isFinite(amt) || !USD_STABLES.has(cur))
974
+ continue;
975
+ if (r.kind === 'spend')
976
+ bucket.sentSum += amt;
977
+ else if (r.kind === 'earn')
978
+ bucket.receivedSum += amt;
979
+ }
980
+ const round = (n) => Math.round(n * 1_000_000) / 1_000_000;
981
+ return [...map.entries()]
982
+ .sort((a, b) => (a[0] < b[0] ? 1 : -1))
983
+ .map(([date, b]) => ({
984
+ date,
985
+ sent_count: b.sentCount,
986
+ sent_usd: round(b.sentSum).toString(),
987
+ received_count: b.receivedCount,
988
+ received_usd: round(b.receivedSum).toString(),
989
+ net_usd: round(b.receivedSum - b.sentSum).toString(),
990
+ }));
991
+ }
992
+ function utcDay(d) {
993
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
994
+ }
995
+ function formatUtcDate(d) {
996
+ const y = d.getUTCFullYear();
997
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
998
+ const day = String(d.getUTCDate()).padStart(2, '0');
999
+ return `${y}-${m}-${day}`;
1000
+ }
1001
+ //# sourceMappingURL=host-stdio.js.map