@guiie/buda-mcp 1.5.1 → 1.5.2

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 (103) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/PUBLISH_CHECKLIST.md +39 -32
  3. package/dist/audit.d.ts +21 -0
  4. package/dist/audit.d.ts.map +1 -0
  5. package/dist/audit.js +14 -0
  6. package/dist/http.js +25 -3
  7. package/dist/tools/account.js +1 -1
  8. package/dist/tools/arbitrage.js +1 -1
  9. package/dist/tools/balance.js +1 -1
  10. package/dist/tools/balances.js +1 -1
  11. package/dist/tools/banks.js +1 -1
  12. package/dist/tools/batch_orders.d.ts +1 -1
  13. package/dist/tools/batch_orders.d.ts.map +1 -1
  14. package/dist/tools/batch_orders.js +12 -2
  15. package/dist/tools/cancel_all_orders.d.ts +1 -1
  16. package/dist/tools/cancel_all_orders.d.ts.map +1 -1
  17. package/dist/tools/cancel_all_orders.js +10 -13
  18. package/dist/tools/cancel_order.d.ts +1 -1
  19. package/dist/tools/cancel_order.d.ts.map +1 -1
  20. package/dist/tools/cancel_order.js +9 -9
  21. package/dist/tools/cancel_order_by_client_id.d.ts +1 -1
  22. package/dist/tools/cancel_order_by_client_id.d.ts.map +1 -1
  23. package/dist/tools/cancel_order_by_client_id.js +9 -9
  24. package/dist/tools/compare_markets.d.ts +9 -0
  25. package/dist/tools/compare_markets.d.ts.map +1 -1
  26. package/dist/tools/compare_markets.js +63 -53
  27. package/dist/tools/dead_mans_switch.d.ts +1 -1
  28. package/dist/tools/dead_mans_switch.d.ts.map +1 -1
  29. package/dist/tools/dead_mans_switch.js +35 -3
  30. package/dist/tools/deposits.js +2 -2
  31. package/dist/tools/fees.js +1 -1
  32. package/dist/tools/lightning.d.ts +1 -1
  33. package/dist/tools/lightning.d.ts.map +1 -1
  34. package/dist/tools/lightning.js +11 -9
  35. package/dist/tools/market_sentiment.js +1 -1
  36. package/dist/tools/market_summary.js +1 -1
  37. package/dist/tools/markets.js +1 -1
  38. package/dist/tools/order_lookup.js +2 -2
  39. package/dist/tools/orderbook.js +1 -1
  40. package/dist/tools/orders.js +1 -1
  41. package/dist/tools/place_order.d.ts +1 -1
  42. package/dist/tools/place_order.d.ts.map +1 -1
  43. package/dist/tools/place_order.js +22 -4
  44. package/dist/tools/price_history.js +1 -1
  45. package/dist/tools/quotation.js +1 -1
  46. package/dist/tools/receive_addresses.d.ts +1 -1
  47. package/dist/tools/receive_addresses.d.ts.map +1 -1
  48. package/dist/tools/receive_addresses.js +11 -11
  49. package/dist/tools/remittance_recipients.js +2 -2
  50. package/dist/tools/remittances.d.ts +2 -2
  51. package/dist/tools/remittances.d.ts.map +1 -1
  52. package/dist/tools/remittances.js +19 -20
  53. package/dist/tools/simulate_order.js +1 -1
  54. package/dist/tools/spread.js +1 -1
  55. package/dist/tools/technical_indicators.js +1 -1
  56. package/dist/tools/ticker.js +1 -1
  57. package/dist/tools/trades.js +1 -1
  58. package/dist/tools/volume.js +1 -1
  59. package/dist/tools/withdrawals.d.ts +1 -1
  60. package/dist/tools/withdrawals.d.ts.map +1 -1
  61. package/dist/tools/withdrawals.js +10 -10
  62. package/dist/utils.d.ts +10 -0
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +25 -0
  65. package/package.json +1 -1
  66. package/server.json +2 -2
  67. package/src/audit.ts +24 -0
  68. package/src/http.ts +27 -3
  69. package/src/tools/account.ts +1 -1
  70. package/src/tools/arbitrage.ts +1 -1
  71. package/src/tools/balance.ts +1 -1
  72. package/src/tools/balances.ts +1 -1
  73. package/src/tools/banks.ts +1 -1
  74. package/src/tools/batch_orders.ts +12 -1
  75. package/src/tools/cancel_all_orders.ts +10 -12
  76. package/src/tools/cancel_order.ts +9 -8
  77. package/src/tools/cancel_order_by_client_id.ts +9 -8
  78. package/src/tools/compare_markets.ts +78 -61
  79. package/src/tools/dead_mans_switch.ts +37 -2
  80. package/src/tools/deposits.ts +2 -2
  81. package/src/tools/fees.ts +1 -1
  82. package/src/tools/lightning.ts +12 -9
  83. package/src/tools/market_sentiment.ts +1 -1
  84. package/src/tools/market_summary.ts +1 -1
  85. package/src/tools/markets.ts +1 -1
  86. package/src/tools/order_lookup.ts +2 -2
  87. package/src/tools/orderbook.ts +1 -1
  88. package/src/tools/orders.ts +1 -1
  89. package/src/tools/place_order.ts +24 -5
  90. package/src/tools/price_history.ts +1 -1
  91. package/src/tools/quotation.ts +1 -1
  92. package/src/tools/receive_addresses.ts +11 -10
  93. package/src/tools/remittance_recipients.ts +2 -2
  94. package/src/tools/remittances.ts +19 -18
  95. package/src/tools/simulate_order.ts +1 -1
  96. package/src/tools/spread.ts +1 -1
  97. package/src/tools/technical_indicators.ts +1 -1
  98. package/src/tools/ticker.ts +1 -1
  99. package/src/tools/trades.ts +1 -1
  100. package/src/tools/volume.ts +1 -1
  101. package/src/tools/withdrawals.ts +10 -9
  102. package/src/utils.ts +33 -0
  103. package/test/unit.ts +362 -4
@@ -247,7 +247,7 @@ export async function handleTechnicalIndicators(
247
247
  } catch (err) {
248
248
  const msg =
249
249
  err instanceof BudaApiError
250
- ? { error: err.message, code: err.status, path: err.path }
250
+ ? { error: err.message, code: err.status }
251
251
  : { error: String(err), code: "UNKNOWN" };
252
252
  return {
253
253
  content: [{ type: "text", text: JSON.stringify(msg) }],
@@ -77,7 +77,7 @@ export function register(server: McpServer, client: BudaClient, cache: MemoryCac
77
77
  } catch (err) {
78
78
  const msg =
79
79
  err instanceof BudaApiError
80
- ? { error: err.message, code: err.status, path: err.path }
80
+ ? { error: err.message, code: err.status }
81
81
  : { error: String(err), code: "UNKNOWN" };
82
82
  return {
83
83
  content: [{ type: "text", text: JSON.stringify(msg) }],
@@ -94,7 +94,7 @@ export function register(server: McpServer, client: BudaClient, _cache: MemoryCa
94
94
  } catch (err) {
95
95
  const msg =
96
96
  err instanceof BudaApiError
97
- ? { error: err.message, code: err.status, path: err.path }
97
+ ? { error: err.message, code: err.status }
98
98
  : { error: String(err), code: "UNKNOWN" };
99
99
  return {
100
100
  content: [{ type: "text", text: JSON.stringify(msg) }],
@@ -71,7 +71,7 @@ export function register(server: McpServer, client: BudaClient, _cache: MemoryCa
71
71
  } catch (err) {
72
72
  const msg =
73
73
  err instanceof BudaApiError
74
- ? { error: err.message, code: err.status, path: err.path }
74
+ ? { error: err.message, code: err.status }
75
75
  : { error: String(err), code: "UNKNOWN" };
76
76
  return {
77
77
  content: [{ type: "text", text: JSON.stringify(msg) }],
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { BudaClient, BudaApiError } from "../client.js";
4
4
  import { validateCurrency, validateCryptoAddress } from "../validation.js";
5
5
  import { flattenAmount } from "../utils.js";
6
+ import { logAudit } from "../audit.js";
6
7
  import type { WithdrawalsResponse, SingleWithdrawalResponse, Withdrawal } from "../types.js";
7
8
 
8
9
  export const getWithdrawalHistoryToolSchema = {
@@ -106,7 +107,7 @@ export async function handleGetWithdrawalHistory(
106
107
  } catch (err) {
107
108
  const msg =
108
109
  err instanceof BudaApiError
109
- ? { error: err.message, code: err.status, path: err.path }
110
+ ? { error: err.message, code: err.status }
110
111
  : { error: String(err), code: "UNKNOWN" };
111
112
  return {
112
113
  content: [{ type: "text", text: JSON.stringify(msg) }],
@@ -152,6 +153,7 @@ type CreateWithdrawalArgs = {
152
153
  export async function handleCreateWithdrawal(
153
154
  args: CreateWithdrawalArgs,
154
155
  client: BudaClient,
156
+ transport: "http" | "stdio" = "stdio",
155
157
  ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
156
158
  const { currency, amount, address, network, bank_account_id, confirmation_token } = args;
157
159
 
@@ -238,18 +240,17 @@ export async function handleCreateWithdrawal(
238
240
  payload,
239
241
  );
240
242
 
241
- return {
242
- content: [{ type: "text", text: JSON.stringify(normalizeWithdrawal(data.withdrawal), null, 2) }],
243
- };
243
+ const result = { content: [{ type: "text" as const, text: JSON.stringify(normalizeWithdrawal(data.withdrawal), null, 2) }] };
244
+ logAudit({ ts: new Date().toISOString(), tool: "create_withdrawal", transport, args_summary: { currency, amount, type: hasAddress ? "crypto" : "fiat" }, success: true });
245
+ return result;
244
246
  } catch (err) {
245
247
  const msg =
246
248
  err instanceof BudaApiError
247
- ? { error: err.message, code: err.status, path: err.path }
249
+ ? { error: err.message, code: err.status }
248
250
  : { error: String(err), code: "UNKNOWN" };
249
- return {
250
- content: [{ type: "text", text: JSON.stringify(msg) }],
251
- isError: true,
252
- };
251
+ const result = { content: [{ type: "text" as const, text: JSON.stringify(msg) }], isError: true as const };
252
+ logAudit({ ts: new Date().toISOString(), tool: "create_withdrawal", transport, args_summary: { currency, amount, type: hasAddress ? "crypto" : "fiat" }, success: false, error_code: msg.code });
253
+ return result;
253
254
  }
254
255
  }
255
256
 
package/src/utils.ts CHANGED
@@ -1,5 +1,38 @@
1
+ import { timingSafeEqual } from "crypto";
1
2
  import type { Amount, OhlcvCandle } from "./types.js";
2
3
 
4
+ /**
5
+ * Constant-time string comparison to prevent timing attacks on bearer tokens.
6
+ */
7
+ export function safeTokenEqual(a: string, b: string): boolean {
8
+ const aBuf = Buffer.from(a);
9
+ const bBuf = Buffer.from(b);
10
+ if (aBuf.length !== bBuf.length) return false;
11
+ return timingSafeEqual(aBuf, bBuf);
12
+ }
13
+
14
+ /**
15
+ * Parses a raw string (from an environment variable) as an integer within [min, max].
16
+ * Returns the fallback when raw is undefined.
17
+ * Throws a descriptive Error if the value is non-numeric or out of range.
18
+ */
19
+ export function parseEnvInt(
20
+ raw: string | undefined,
21
+ fallback: number,
22
+ min: number,
23
+ max: number,
24
+ name: string,
25
+ ): number {
26
+ if (raw === undefined) return fallback;
27
+ const n = parseInt(raw, 10);
28
+ if (isNaN(n) || n < min || n > max) {
29
+ throw new Error(
30
+ `[buda-mcp] Invalid ${name} "${raw}". Must be an integer between ${min} and ${max}.`,
31
+ );
32
+ }
33
+ return n;
34
+ }
35
+
3
36
  /**
4
37
  * Flattens a Buda API Amount tuple [value_string, currency] into a typed object.
5
38
  * All numeric strings are cast to float via parseFloat.
package/test/unit.ts CHANGED
@@ -9,7 +9,8 @@ import { MemoryCache } from "../src/cache.js";
9
9
  import { validateMarketId, validateCryptoAddress } from "../src/validation.js";
10
10
  import { handlePlaceOrder } from "../src/tools/place_order.js";
11
11
  import { handleCancelOrder } from "../src/tools/cancel_order.js";
12
- import { flattenAmount, getLiquidityRating, aggregateTradesToCandles } from "../src/utils.js";
12
+ import { flattenAmount, getLiquidityRating, aggregateTradesToCandles, safeTokenEqual, parseEnvInt } from "../src/utils.js";
13
+ import { logAudit } from "../src/audit.js";
13
14
  import { handleArbitrageOpportunities } from "../src/tools/arbitrage.js";
14
15
  import { handleMarketSummary } from "../src/tools/market_summary.js";
15
16
  import { handleSimulateOrder } from "../src/tools/simulate_order.js";
@@ -35,6 +36,7 @@ import { handlePlaceBatchOrders } from "../src/tools/batch_orders.js";
35
36
  import { handleCreateWithdrawal } from "../src/tools/withdrawals.js";
36
37
  import { handleCreateFiatDeposit } from "../src/tools/deposits.js";
37
38
  import { handleLightningWithdrawal, handleCreateLightningInvoice } from "../src/tools/lightning.js";
39
+ import { handleCompareMarkets } from "../src/tools/compare_markets.js";
38
40
 
39
41
  // ----------------------------------------------------------------
40
42
  // Minimal test harness
@@ -2934,7 +2936,7 @@ await test("handleLightningWithdrawal: missing/wrong token returns CONFIRMATION_
2934
2936
  try {
2935
2937
  const client = {} as BudaClient;
2936
2938
  const result = await handleLightningWithdrawal(
2937
- { invoice: "lnbc1000u1ptest...", confirmation_token: "NOPE" },
2939
+ { invoice: "lnbc1000u1p" + "a".repeat(40), confirmation_token: "NOPE" },
2938
2940
  client,
2939
2941
  );
2940
2942
  assert(result.isError === true, "should be error");
@@ -2966,7 +2968,7 @@ await test("handleLightningWithdrawal: happy path returns flat withdrawal", asyn
2966
2968
  try {
2967
2969
  const client = new BudaClient("https://www.buda.com/api/v2");
2968
2970
  const result = await handleLightningWithdrawal(
2969
- { invoice: "lnbc1000u1ptest...", confirmation_token: "CONFIRM" },
2971
+ { invoice: "lnbc1000u1p" + "a".repeat(40), confirmation_token: "CONFIRM" },
2970
2972
  client,
2971
2973
  );
2972
2974
  assert(!result.isError, "should not be error");
@@ -2992,7 +2994,7 @@ await test("handleLightningWithdrawal: API error passthrough", async () => {
2992
2994
  try {
2993
2995
  const client = new BudaClient("https://www.buda.com/api/v2");
2994
2996
  const result = await handleLightningWithdrawal(
2995
- { invoice: "lnbc1000u1ptest...", confirmation_token: "CONFIRM" },
2997
+ { invoice: "lnbc1000u1p" + "a".repeat(40), confirmation_token: "CONFIRM" },
2996
2998
  client,
2997
2999
  );
2998
3000
  assert(result.isError === true, "should be error");
@@ -3307,6 +3309,362 @@ await test("handlePlaceBatchOrders: market orders contribute 0 to notional (cap
3307
3309
  }
3308
3310
  });
3309
3311
 
3312
+ // ----------------------------------------------------------------
3313
+ // Security — audit logging (logAudit)
3314
+ // ----------------------------------------------------------------
3315
+
3316
+ section("logAudit — structured audit logging");
3317
+
3318
+ await test("logAudit: writes valid JSON with required fields to stderr", () => {
3319
+ const lines: string[] = [];
3320
+ const savedWrite = process.stderr.write.bind(process.stderr);
3321
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
3322
+ lines.push(s);
3323
+ return true;
3324
+ };
3325
+ try {
3326
+ logAudit({ ts: "2024-01-01T00:00:00.000Z", tool: "place_order", transport: "stdio", args_summary: { market_id: "BTC-CLP" }, success: true });
3327
+ } finally {
3328
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = savedWrite;
3329
+ }
3330
+ assert(lines.length === 1, "should write exactly one line");
3331
+ const parsed = JSON.parse(lines[0].trim()) as Record<string, unknown>;
3332
+ assert(parsed.audit === true, "must have audit:true marker");
3333
+ assertEqual(parsed.tool as string, "place_order", "tool field must be present");
3334
+ assertEqual(parsed.transport as string, "stdio", "transport field must be present");
3335
+ assert(typeof parsed.ts === "string", "ts field must be a string");
3336
+ assert(typeof parsed.success === "boolean", "success field must be a boolean");
3337
+ });
3338
+
3339
+ await test("logAudit: output does NOT contain confirmation_token", () => {
3340
+ const lines: string[] = [];
3341
+ const savedWrite = process.stderr.write.bind(process.stderr);
3342
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
3343
+ lines.push(s);
3344
+ return true;
3345
+ };
3346
+ try {
3347
+ logAudit({ ts: new Date().toISOString(), tool: "place_order", transport: "stdio", args_summary: { market_id: "BTC-CLP", type: "Bid" }, success: false, error_code: "CONFIRMATION_REQUIRED" });
3348
+ } finally {
3349
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = savedWrite;
3350
+ }
3351
+ const raw = lines[0] ?? "";
3352
+ assert(!raw.includes("confirmation_token"), "audit log must NOT contain confirmation_token");
3353
+ assert(!raw.includes("invoice"), "audit log must NOT contain invoice field");
3354
+ });
3355
+
3356
+ await test("logAudit: error_code is included when success is false", () => {
3357
+ const lines: string[] = [];
3358
+ const savedWrite = process.stderr.write.bind(process.stderr);
3359
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
3360
+ lines.push(s);
3361
+ return true;
3362
+ };
3363
+ try {
3364
+ logAudit({ ts: new Date().toISOString(), tool: "cancel_order", transport: "http", args_summary: {}, success: false, error_code: 422 });
3365
+ } finally {
3366
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = savedWrite;
3367
+ }
3368
+ const parsed = JSON.parse(lines[0].trim()) as Record<string, unknown>;
3369
+ assertEqual(parsed.error_code as number, 422, "error_code must be 422");
3370
+ assertEqual(parsed.success as boolean, false, "success must be false");
3371
+ });
3372
+
3373
+ await test("handlePlaceOrder with CONFIRM → logAudit called with success:true", async () => {
3374
+ const savedFetch = globalThis.fetch;
3375
+ const auditLines: string[] = [];
3376
+ const savedWrite = process.stderr.write.bind(process.stderr);
3377
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
3378
+ if (s.includes('"audit":true')) auditLines.push(s);
3379
+ return true;
3380
+ };
3381
+ globalThis.fetch = async () => new Response(
3382
+ JSON.stringify({ order: { id: 1, state: "pending", market_id: "btc-clp", type: "Bid", price_type: "limit", amount: ["0.001", "BTC"], original_amount: ["0.001", "BTC"], traded_amount: ["0", "BTC"], total_exchanged: ["0", "CLP"], paid_fee: ["0", "CLP"], limit: ["80000000", "CLP"] } }),
3383
+ { status: 200 },
3384
+ );
3385
+ try {
3386
+ const client = new BudaClient(undefined, "key", "secret");
3387
+ const result = await handlePlaceOrder(
3388
+ { market_id: "BTC-CLP", type: "Bid", price_type: "limit", amount: 0.001, limit_price: 80_000_000, confirmation_token: "CONFIRM" },
3389
+ client,
3390
+ );
3391
+ assert(!result.isError, "should succeed");
3392
+ assert(auditLines.length > 0, "logAudit must have been called");
3393
+ const parsed = JSON.parse(auditLines[0].trim()) as Record<string, unknown>;
3394
+ assertEqual(parsed.tool as string, "place_order", "tool should be place_order");
3395
+ assert(parsed.success === true, "success should be true");
3396
+ } finally {
3397
+ globalThis.fetch = savedFetch;
3398
+ (process.stderr as unknown as { write: (s: string) => boolean }).write = savedWrite;
3399
+ }
3400
+ });
3401
+
3402
+ // ----------------------------------------------------------------
3403
+ // Security — error response does not expose API path
3404
+ // ----------------------------------------------------------------
3405
+
3406
+ section("Error responses — path field redacted");
3407
+
3408
+ await test("handlePlaceOrder with invalid market: error response has no 'path' field", async () => {
3409
+ const client = new BudaClient(undefined, "key", "secret");
3410
+ const result = await handlePlaceOrder(
3411
+ { market_id: "INVALID!!!", type: "Bid", price_type: "limit", amount: 1, limit_price: 1, confirmation_token: "CONFIRM" },
3412
+ client,
3413
+ );
3414
+ assert(result.isError === true, "should be error");
3415
+ const parsed = JSON.parse(result.content[0].text) as Record<string, unknown>;
3416
+ assert(!("path" in parsed), "error response must NOT contain 'path' field");
3417
+ });
3418
+
3419
+ await test("handleCancelAllOrders API error: response has no 'path' field", async () => {
3420
+ const savedFetch = globalThis.fetch;
3421
+ globalThis.fetch = async () => new Response(JSON.stringify({ message: "Not found" }), { status: 404 });
3422
+ try {
3423
+ const client = new BudaClient(undefined, "key", "secret");
3424
+ const result = await handleCancelAllOrders(
3425
+ { market_id: "BTC-CLP", confirmation_token: "CONFIRM" },
3426
+ client,
3427
+ );
3428
+ assert(result.isError === true, "should be error");
3429
+ const parsed = JSON.parse(result.content[0].text) as Record<string, unknown>;
3430
+ assert(!("path" in parsed), "error response must NOT contain 'path' field");
3431
+ } finally {
3432
+ globalThis.fetch = savedFetch;
3433
+ }
3434
+ });
3435
+
3436
+ // ----------------------------------------------------------------
3437
+ // Security — validateCurrency in compare_markets
3438
+ // ----------------------------------------------------------------
3439
+
3440
+ section("handleCompareMarkets — validateCurrency guard");
3441
+
3442
+ await test("handleCompareMarkets: base_currency with spaces → INVALID_CURRENCY (no API call)", async () => {
3443
+ let fetchCalled = false;
3444
+ const savedFetch = globalThis.fetch;
3445
+ globalThis.fetch = async () => { fetchCalled = true; return new Response("{}", { status: 200 }); };
3446
+ try {
3447
+ const client = new BudaClient(undefined, "key", "secret");
3448
+ const cache = new MemoryCache();
3449
+ const result = await handleCompareMarkets({ base_currency: "B T C" }, client, cache);
3450
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3451
+ assertEqual(parsed.code, "INVALID_CURRENCY", "spaces in currency should return INVALID_CURRENCY");
3452
+ assert(result.isError === true, "isError must be true");
3453
+ assert(!fetchCalled, "fetch must NOT be called for invalid currency");
3454
+ } finally {
3455
+ globalThis.fetch = savedFetch;
3456
+ }
3457
+ });
3458
+
3459
+ await test("handleCompareMarkets: base_currency exceeding max length → INVALID_CURRENCY", async () => {
3460
+ const client = new BudaClient(undefined, "key", "secret");
3461
+ const cache = new MemoryCache();
3462
+ const result = await handleCompareMarkets({ base_currency: "A".repeat(20) }, client, cache);
3463
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3464
+ assertEqual(parsed.code, "INVALID_CURRENCY", "overlong currency should return INVALID_CURRENCY");
3465
+ });
3466
+
3467
+ await test("handleCompareMarkets: empty string → INVALID_CURRENCY", async () => {
3468
+ const client = new BudaClient(undefined, "key", "secret");
3469
+ const cache = new MemoryCache();
3470
+ const result = await handleCompareMarkets({ base_currency: "" }, client, cache);
3471
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3472
+ assertEqual(parsed.code, "INVALID_CURRENCY", "empty string should return INVALID_CURRENCY");
3473
+ });
3474
+
3475
+ await test("handleCompareMarkets: valid currency passes validation (reaches API/cache)", async () => {
3476
+ const savedFetch = globalThis.fetch;
3477
+ globalThis.fetch = async () => new Response(
3478
+ JSON.stringify({ tickers: [] }),
3479
+ { status: 200 },
3480
+ );
3481
+ try {
3482
+ const client = new BudaClient(undefined, "key", "secret");
3483
+ const cache = new MemoryCache();
3484
+ const result = await handleCompareMarkets({ base_currency: "BTC" }, client, cache);
3485
+ const parsed = JSON.parse(result.content[0].text) as { code?: string };
3486
+ assert(parsed.code !== "INVALID_CURRENCY", "valid currency must not return INVALID_CURRENCY");
3487
+ } finally {
3488
+ globalThis.fetch = savedFetch;
3489
+ }
3490
+ });
3491
+
3492
+ // ----------------------------------------------------------------
3493
+ // Security — BOLT-11 regex improvement
3494
+ // ----------------------------------------------------------------
3495
+
3496
+ section("handleLightningWithdrawal — improved BOLT-11 regex");
3497
+
3498
+ await test("BOLT-11 regex: invoice without bech32 separator '1' → INVALID_INVOICE", async () => {
3499
+ const client = new BudaClient(undefined, "key", "secret");
3500
+ // "lnbc" + 46 chars: passes Zod min(50) but has no '1' separator → should fail new regex
3501
+ const noSeparator = "lnbc" + "a".repeat(46);
3502
+ const result = await handleLightningWithdrawal(
3503
+ { invoice: noSeparator, confirmation_token: "CONFIRM" },
3504
+ client,
3505
+ );
3506
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3507
+ assertEqual(parsed.code, "INVALID_INVOICE", "invoice without bech32 separator must fail");
3508
+ });
3509
+
3510
+ await test("BOLT-11 regex: invoice with insufficient data after separator → INVALID_INVOICE", async () => {
3511
+ const client = new BudaClient(undefined, "key", "secret");
3512
+ // "lnbc1" + 5 chars (< 20 required after separator) padded to 50 total
3513
+ const tooShortData = "lnbc1" + "a".repeat(5) + "x".repeat(40);
3514
+ // This has '1' at position 5, then only 5 chars of [a-z0-9] before non-bech32 chars
3515
+ // Actually we need to construct this carefully:
3516
+ // New regex: /^ln(bc|tb|bcrt)\d*[munp]?1[a-z0-9]{20,}$/i
3517
+ // "lnbc1aaaaa" has only 5 chars after 1 - fails {20,}
3518
+ const shortAfterSep = "lnbc1" + "a".repeat(5) + " ".repeat(45); // has spaces → fails [a-z0-9] and $
3519
+ const result = await handleLightningWithdrawal(
3520
+ { invoice: shortAfterSep, confirmation_token: "CONFIRM" },
3521
+ client,
3522
+ );
3523
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3524
+ assertEqual(parsed.code, "INVALID_INVOICE", "invoice with insufficient bech32 data must fail");
3525
+ });
3526
+
3527
+ await test("BOLT-11 regex: valid mainnet invoice structure passes (existing test)", async () => {
3528
+ const savedFetch = globalThis.fetch;
3529
+ globalThis.fetch = async () => new Response(
3530
+ JSON.stringify({ message: "invalid invoice" }), { status: 422 },
3531
+ );
3532
+ try {
3533
+ const client = new BudaClient(undefined, "key", "secret");
3534
+ // Same invoice from existing test — must still pass the format guard
3535
+ const validInvoice = "lntb1230n1pj8ygappp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq";
3536
+ const result = await handleLightningWithdrawal(
3537
+ { invoice: validInvoice, confirmation_token: "CONFIRM" },
3538
+ client,
3539
+ );
3540
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3541
+ assert(parsed.code !== "INVALID_INVOICE", "well-formed testnet invoice must pass format guard");
3542
+ } finally {
3543
+ globalThis.fetch = savedFetch;
3544
+ }
3545
+ });
3546
+
3547
+ // ----------------------------------------------------------------
3548
+ // Security — DMS renew/disarm blocked on HTTP transport
3549
+ // ----------------------------------------------------------------
3550
+
3551
+ section("DMS renew_cancel_timer / disarm_cancel_timer — HTTP transport guard");
3552
+
3553
+ await test("renew_cancel_timer: transport='http' returns TRANSPORT_NOT_SUPPORTED", () => {
3554
+ const renewTransportGuard = (transport: "stdio" | "http") => {
3555
+ if (transport === "http") {
3556
+ return Promise.resolve({
3557
+ content: [{ type: "text" as const, text: JSON.stringify({ code: "TRANSPORT_NOT_SUPPORTED" }) }],
3558
+ isError: true,
3559
+ });
3560
+ }
3561
+ return Promise.resolve(handleRenewCancelTimer({ market_id: "BTC-CLP" }, new BudaClient(undefined, "k", "s")));
3562
+ };
3563
+ const result = renewTransportGuard("http");
3564
+ return result.then((r) => {
3565
+ const parsed = JSON.parse(r.content[0].text) as { code: string };
3566
+ assertEqual(parsed.code, "TRANSPORT_NOT_SUPPORTED", "renew on HTTP must return TRANSPORT_NOT_SUPPORTED");
3567
+ assert(r.isError === true, "isError must be true");
3568
+ });
3569
+ });
3570
+
3571
+ await test("disarm_cancel_timer: transport='http' returns TRANSPORT_NOT_SUPPORTED", () => {
3572
+ const disarmTransportGuard = (transport: "stdio" | "http") => {
3573
+ if (transport === "http") {
3574
+ return Promise.resolve({
3575
+ content: [{ type: "text" as const, text: JSON.stringify({ code: "TRANSPORT_NOT_SUPPORTED" }) }],
3576
+ isError: true,
3577
+ });
3578
+ }
3579
+ return Promise.resolve(handleDisarmCancelTimer({ market_id: "BTC-CLP" }));
3580
+ };
3581
+ const result = disarmTransportGuard("http");
3582
+ return result.then((r) => {
3583
+ const parsed = JSON.parse(r.content[0].text) as { code: string };
3584
+ assertEqual(parsed.code, "TRANSPORT_NOT_SUPPORTED", "disarm on HTTP must return TRANSPORT_NOT_SUPPORTED");
3585
+ assert(r.isError === true, "isError must be true");
3586
+ });
3587
+ });
3588
+
3589
+ await test("renew_cancel_timer: transport='stdio' without active timer → NO_ACTIVE_TIMER (handler unchanged)", () => {
3590
+ const client = new BudaClient(undefined, "key", "secret");
3591
+ const result = handleRenewCancelTimer({ market_id: "ETH-CLP" }, client);
3592
+ const parsed = JSON.parse(result.content[0].text) as { code: string };
3593
+ assertEqual(parsed.code, "NO_ACTIVE_TIMER", "no timer should return NO_ACTIVE_TIMER");
3594
+ });
3595
+
3596
+ await test("disarm_cancel_timer: transport='stdio' without active timer → disarmed: false (handler unchanged)", () => {
3597
+ const result = handleDisarmCancelTimer({ market_id: "ETH-CLP" });
3598
+ const parsed = JSON.parse(result.content[0].text) as { disarmed: boolean };
3599
+ assert(parsed.disarmed === false, "disarm with no timer should return disarmed: false");
3600
+ });
3601
+
3602
+ // ----------------------------------------------------------------
3603
+ // Security — safeTokenEqual (constant-time bearer comparison)
3604
+ // ----------------------------------------------------------------
3605
+
3606
+ section("safeTokenEqual — constant-time bearer token comparison");
3607
+
3608
+ await test("safeTokenEqual: identical strings → true", () => {
3609
+ assert(safeTokenEqual("Bearer abc123", "Bearer abc123"), "identical strings must be equal");
3610
+ });
3611
+
3612
+ await test("safeTokenEqual: different same-length strings → false", () => {
3613
+ assert(!safeTokenEqual("Bearer abc", "Bearer xyz"), "different same-length strings must differ");
3614
+ });
3615
+
3616
+ await test("safeTokenEqual: different-length strings → false (no crash)", () => {
3617
+ assert(!safeTokenEqual("short", "longerstring"), "different-length strings must differ");
3618
+ assert(!safeTokenEqual("longerstring", "short"), "reversed different-length must differ");
3619
+ });
3620
+
3621
+ await test("safeTokenEqual: empty strings → true", () => {
3622
+ assert(safeTokenEqual("", ""), "two empty strings are equal");
3623
+ });
3624
+
3625
+ await test("safeTokenEqual: one empty string → false", () => {
3626
+ assert(!safeTokenEqual("", "token"), "empty vs non-empty must differ");
3627
+ assert(!safeTokenEqual("token", ""), "non-empty vs empty must differ");
3628
+ });
3629
+
3630
+ // ----------------------------------------------------------------
3631
+ // Security — parseEnvInt (config validation helper)
3632
+ // ----------------------------------------------------------------
3633
+
3634
+ section("parseEnvInt — environment variable integer validation");
3635
+
3636
+ await test("parseEnvInt: undefined raw → returns fallback", () => {
3637
+ assertEqual(parseEnvInt(undefined, 3000, 1, 65535, "PORT"), 3000, "should return fallback");
3638
+ });
3639
+
3640
+ await test("parseEnvInt: valid string within range → parsed value", () => {
3641
+ assertEqual(parseEnvInt("8080", 3000, 1, 65535, "PORT"), 8080, "should parse 8080");
3642
+ assertEqual(parseEnvInt("120", 120, 1, 10_000, "MCP_RATE_LIMIT"), 120, "should parse 120");
3643
+ });
3644
+
3645
+ await test("parseEnvInt: NaN string → throws", () => {
3646
+ let threw = false;
3647
+ try { parseEnvInt("abc", 3000, 1, 65535, "PORT"); } catch { threw = true; }
3648
+ assert(threw, "non-numeric string should throw");
3649
+ });
3650
+
3651
+ await test("parseEnvInt: value below min → throws", () => {
3652
+ let threw = false;
3653
+ try { parseEnvInt("0", 3000, 1, 65535, "PORT"); } catch { threw = true; }
3654
+ assert(threw, "value 0 below min 1 should throw");
3655
+ });
3656
+
3657
+ await test("parseEnvInt: value above max → throws", () => {
3658
+ let threw = false;
3659
+ try { parseEnvInt("70000", 3000, 1, 65535, "PORT"); } catch { threw = true; }
3660
+ assert(threw, "value 70000 above max 65535 should throw");
3661
+ });
3662
+
3663
+ await test("parseEnvInt: boundary values are accepted", () => {
3664
+ assertEqual(parseEnvInt("1", 3000, 1, 65535, "PORT"), 1, "min boundary should be accepted");
3665
+ assertEqual(parseEnvInt("65535", 3000, 1, 65535, "PORT"), 65535, "max boundary should be accepted");
3666
+ });
3667
+
3310
3668
  // ----------------------------------------------------------------
3311
3669
  // Summary
3312
3670
  // ----------------------------------------------------------------