@haven_ai/sdk 0.1.9 → 0.1.11-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -37,7 +37,14 @@ var AgentPaymentPhase = {
37
37
  * is no approval that would fix this — the originating Safe needs more
38
38
  * funds or the agent's per-token allowance needs to be raised first.
39
39
  */
40
- InsufficientFunds: "insufficient_funds"
40
+ InsufficientFunds: "insufficient_funds",
41
+ /**
42
+ * Haven's funding leg (Safe → delegate) confirmed on-chain, but the
43
+ * merchant rejected the x402 retry. The delegate wallet may hold stranded
44
+ * USDC that was never settled to the merchant. The agent should stop, tell
45
+ * the user, and wait for the sweep flow to reclaim the funds.
46
+ */
47
+ FundedButUnsettled: "funded_but_unsettled"
41
48
  };
42
49
  var AgentPaymentNextAction = {
43
50
  /** Sign with the delegate key and submit the payment to Haven. */
@@ -61,7 +68,13 @@ var AgentPaymentNextAction = {
61
68
  * the agent's per-token allowance needs to be raised before the payment
62
69
  * can succeed. A user approval will not fix this state on its own.
63
70
  */
64
- FundSafeOrRaiseAllowance: "fund_safe_or_raise_allowance"
71
+ FundSafeOrRaiseAllowance: "fund_safe_or_raise_allowance",
72
+ /**
73
+ * The delegate wallet may hold funds that were sent from the Safe but never
74
+ * settled to the merchant. The wallet owner should initiate a sweep to
75
+ * return those funds to the originating Safe.
76
+ */
77
+ SweepStrandedFunds: "sweep_stranded_funds"
65
78
  };
66
79
  var AgentPaymentRail = {
67
80
  /** Standard Haven payment from the user's Safe through an approved delegate allowance. */
@@ -93,7 +106,8 @@ var AgentPaymentPhaseDescriptions = {
93
106
  [AgentPaymentPhase.Rejected]: "The wallet owner rejected the request; the agent should stop and tell the user.",
94
107
  [AgentPaymentPhase.Expired]: "The payment or approval request expired before completion.",
95
108
  [AgentPaymentPhase.Failed]: "Haven could not complete the payment; the agent should stop and surface the failure.",
96
- [AgentPaymentPhase.InsufficientFunds]: "Pre-flight check determined the delegate balance plus the remaining on-chain allowance cannot cover the requested amount, so no payment was created. The originating Safe must be funded or the agent allowance raised before retrying."
109
+ [AgentPaymentPhase.InsufficientFunds]: "Pre-flight check determined the delegate balance plus the remaining on-chain allowance cannot cover the requested amount, so no payment was created. The originating Safe must be funded or the agent allowance raised before retrying.",
110
+ [AgentPaymentPhase.FundedButUnsettled]: "Haven's funding leg confirmed on-chain but the merchant rejected the x402 retry. The delegate wallet may hold stranded funds. The agent should stop and wait for the wallet owner to sweep the stranded funds back to the Safe."
97
111
  };
98
112
  var AgentPaymentNextActionDescriptions = {
99
113
  [AgentPaymentNextAction.SignAndSubmitPayment]: "Sign with the delegate key and submit the payment to Haven.",
@@ -104,7 +118,8 @@ var AgentPaymentNextActionDescriptions = {
104
118
  [AgentPaymentNextAction.RetryOriginalX402Request]: "Resume this payment id and retry the original x402 request with the merchant payment header.",
105
119
  [AgentPaymentNextAction.StopAndTellUser]: "Stop retrying this payment and tell the user what happened.",
106
120
  [AgentPaymentNextAction.RequestAgainIfUserStillWantsIt]: "Ask again only if the user still wants the payment after expiry.",
107
- [AgentPaymentNextAction.FundSafeOrRaiseAllowance]: "Stop and tell the user that the originating Safe needs to be funded or the agent allowance raised before the payment can succeed."
121
+ [AgentPaymentNextAction.FundSafeOrRaiseAllowance]: "Stop and tell the user that the originating Safe needs to be funded or the agent allowance raised before the payment can succeed.",
122
+ [AgentPaymentNextAction.SweepStrandedFunds]: "Tell the user that funds may be stranded in the delegate wallet and prompt them to initiate a sweep in Haven to return them to the originating Safe."
108
123
  };
109
124
  var AgentPaymentRailDescriptions = {
110
125
  [AgentPaymentRail.Direct]: "Standard Haven payment from the user-controlled Safe through an approved delegate allowance.",
@@ -218,16 +233,52 @@ function verifySignature(hash, signature, expectedAddress) {
218
233
  return false;
219
234
  }
220
235
  }
221
- var BASE_USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
222
- var X402_IDEMPOTENCY_BUCKET_MS = 3e5;
223
- var DECIMAL_ATOMIC_AMOUNT_RE = /^[0-9]+$/;
236
+
237
+ // src/base64.ts
238
+ function normalizeBase64(value) {
239
+ const standard = value.replace(/-/g, "+").replace(/_/g, "/");
240
+ const remainder = standard.length % 4;
241
+ return remainder === 0 ? standard : standard + "=".repeat(4 - remainder);
242
+ }
243
+ function encodeBase64Utf8(value) {
244
+ if (typeof Buffer !== "undefined") {
245
+ return Buffer.from(value, "utf8").toString("base64");
246
+ }
247
+ const bytes = new TextEncoder().encode(value);
248
+ let binary = "";
249
+ for (let i = 0; i < bytes.length; i++) {
250
+ binary += String.fromCharCode(bytes[i]);
251
+ }
252
+ return btoa(binary);
253
+ }
254
+ function decodeBase64Utf8(value) {
255
+ const normalized = normalizeBase64(value);
256
+ if (typeof Buffer !== "undefined") {
257
+ return Buffer.from(normalized, "base64").toString("utf8");
258
+ }
259
+ const binary = atob(normalized);
260
+ const bytes = new Uint8Array(binary.length);
261
+ for (let i = 0; i < binary.length; i++) {
262
+ bytes[i] = binary.charCodeAt(i);
263
+ }
264
+ return new TextDecoder().decode(bytes);
265
+ }
266
+ function encodeBase64Json(value) {
267
+ return encodeBase64Utf8(JSON.stringify(value));
268
+ }
224
269
  function decodeBase64Json(value, label) {
225
270
  try {
226
- return JSON.parse(atob(value));
227
- } catch {
228
- throw new Error(`Failed to decode ${label}`);
271
+ return JSON.parse(decodeBase64Utf8(value));
272
+ } catch (err) {
273
+ if (label) throw new Error(`Failed to decode ${label}`);
274
+ throw err;
229
275
  }
230
276
  }
277
+
278
+ // src/x402.ts
279
+ var BASE_USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
280
+ var X402_IDEMPOTENCY_BUCKET_MS = 3e5;
281
+ var DECIMAL_ATOMIC_AMOUNT_RE = /^[0-9]+$/;
231
282
  function isPositiveDecimalAtomicAmount(value) {
232
283
  return DECIMAL_ATOMIC_AMOUNT_RE.test(value) && BigInt(value) > 0n;
233
284
  }
@@ -435,7 +486,7 @@ function encodePaymentProof(receipt) {
435
486
  chainId: receipt.chainId
436
487
  }
437
488
  };
438
- return btoa(JSON.stringify(payload));
489
+ return encodeBase64Json(payload);
439
490
  }
440
491
  function resolveTokenFromAddress(address, network) {
441
492
  const lower = address.toLowerCase();
@@ -453,13 +504,6 @@ function stableStringify(value) {
453
504
  const object = value;
454
505
  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
455
506
  }
456
- function decodeBase64Json2(value, label) {
457
- try {
458
- return JSON.parse(atob(value));
459
- } catch {
460
- throw new Error(`Failed to decode ${label}`);
461
- }
462
- }
463
507
  function normalizeChallenge(value) {
464
508
  const candidate = value;
465
509
  if (!candidate || typeof candidate !== "object" || candidate.rail !== "mpp_demo" || typeof candidate.version !== "string" || typeof candidate.challengeId !== "string" || typeof candidate.resource !== "string" || typeof candidate.description !== "string" || // TODO: relax these checks when non-demo machine payment rails are added.
@@ -486,7 +530,7 @@ function parseMachinePaymentChallenge(response) {
486
530
  throw new Error("No MACHINE-PAYMENT-CHALLENGE header found in 402 response.");
487
531
  }
488
532
  const parsed = normalizeChallenge(
489
- decodeBase64Json2(header, "MACHINE-PAYMENT-CHALLENGE header")
533
+ decodeBase64Json(header, "MACHINE-PAYMENT-CHALLENGE header")
490
534
  );
491
535
  if (!parsed) throw new Error("Invalid machine payment challenge");
492
536
  return parsed;
@@ -517,7 +561,7 @@ function buildMachinePaymentIdempotencyKey(challenge) {
517
561
  return `${challenge.rail}:${crypto.createHash("sha256").update(material).digest("hex").slice(0, 16)}`;
518
562
  }
519
563
  function encodeMachinePaymentProof(receipt) {
520
- return btoa(JSON.stringify({
564
+ return encodeBase64Json({
521
565
  rail: receipt.rail,
522
566
  challengeId: receipt.challengeId,
523
567
  paymentId: receipt.paymentId,
@@ -525,7 +569,16 @@ function encodeMachinePaymentProof(receipt) {
525
569
  settledVia: "haven",
526
570
  payer: receipt.payer,
527
571
  chainId: receipt.chainId
528
- }));
572
+ });
573
+ }
574
+ function createJsonRpcProvider(url) {
575
+ return new ethers.ethers.JsonRpcProvider(url);
576
+ }
577
+ function createWallet(privateKey, provider) {
578
+ return new ethers.ethers.Wallet(privateKey, provider);
579
+ }
580
+ function createErc20Contract(address, abi, signer) {
581
+ return new ethers.ethers.Contract(address, abi, signer);
529
582
  }
530
583
 
531
584
  // src/client.ts
@@ -534,6 +587,9 @@ var CHAIN_EXPLORER_TX = {
534
587
  100: "https://gnosisscan.io/tx",
535
588
  8453: "https://basescan.org/tx"
536
589
  };
590
+ var CHAIN_USDC = {
591
+ 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
592
+ };
537
593
  function buildExplorerUrl(chainId, txHash) {
538
594
  const base = CHAIN_EXPLORER_TX[chainId ?? 8453] ?? CHAIN_EXPLORER_TX[8453];
539
595
  return `${base}/${txHash}`;
@@ -544,6 +600,16 @@ function explorerUrlOrEmpty(chainId, txHash) {
544
600
  var DEFAULT_REQUEST_TIMEOUT = 3e4;
545
601
  var DEFAULT_CONFIRMATION_TIMEOUT = 9e4;
546
602
  var DEFAULT_POLLING_INTERVAL = 3e3;
603
+ function formatAtomicAmount(atomic, decimals) {
604
+ const s = atomic.toString().padStart(decimals + 1, "0");
605
+ const intPart = s.slice(0, s.length - decimals) || "0";
606
+ const fracPart = s.slice(s.length - decimals).replace(/0+$/, "") || "0";
607
+ return `${intPart}.${fracPart}`;
608
+ }
609
+ var MCP_PROTOCOL_VERSION = "2025-06-18";
610
+ var MCP_ACCEPT = "application/json, text/event-stream";
611
+ var MCP_CLIENT_INFO = { name: "haven-sdk", version: "1" };
612
+ var MERCHANT_BODY_SNIPPET_LIMIT = 1e3;
547
613
  var PAYMENT_STATE_STATUS_CODES = {
548
614
  pending: 202,
549
615
  pending_approval: 202,
@@ -631,6 +697,47 @@ function parseMerchantSettlement(header) {
631
697
  const tx = typeof parsed?.transaction === "string" ? parsed.transaction : typeof parsed?.txHash === "string" ? parsed.txHash : typeof parsed?.tx_hash === "string" ? parsed.tx_hash : null;
632
698
  return { settlementTxHash: tx };
633
699
  }
700
+ function isMcpUrl(url) {
701
+ try {
702
+ return new URL(url).pathname.replace(/\/+$/, "").endsWith("/mcp");
703
+ } catch {
704
+ return /\/mcp(?:[/?#]|$)/.test(url);
705
+ }
706
+ }
707
+ async function responseHasBazaarExtension(response) {
708
+ try {
709
+ const body = await response.clone().json();
710
+ return body?.extensions?.bazaar != null;
711
+ } catch {
712
+ return false;
713
+ }
714
+ }
715
+ function parseSseJsonRpcMessages(text) {
716
+ const messages = [];
717
+ let dataLines = [];
718
+ const flush = () => {
719
+ if (dataLines.length === 0) return;
720
+ try {
721
+ messages.push(JSON.parse(dataLines.join("\n")));
722
+ } catch {
723
+ }
724
+ dataLines = [];
725
+ };
726
+ for (const line of text.split(/\r?\n/)) {
727
+ if (line === "") {
728
+ flush();
729
+ continue;
730
+ }
731
+ if (line.startsWith("data:")) {
732
+ dataLines.push(line.slice(5).replace(/^ /, ""));
733
+ }
734
+ }
735
+ flush();
736
+ return messages;
737
+ }
738
+ function selectJsonRpcResult(messages) {
739
+ return messages.find((m) => "result" in m || "error" in m) ?? messages[messages.length - 1];
740
+ }
634
741
  var HavenClient = class {
635
742
  apiKey;
636
743
  delegateKey;
@@ -639,6 +746,7 @@ var HavenClient = class {
639
746
  requestTimeout;
640
747
  confirmationTimeout;
641
748
  pollingInterval;
749
+ chainRpcs;
642
750
  inFlightX402 = /* @__PURE__ */ new Map();
643
751
  x402ReceiptCache = /* @__PURE__ */ new Map();
644
752
  inFlightMachinePayments = /* @__PURE__ */ new Map();
@@ -655,6 +763,8 @@ var HavenClient = class {
655
763
  * the same time — see their own headers without stepping on each other.
656
764
  */
657
765
  requestContext = new async_hooks.AsyncLocalStorage();
766
+ /** Monotonic JSON-RPC id source for the MCP `initialize` handshake. */
767
+ mcpRequestId = 0;
658
768
  /** Delegate address derived from the private key (if provided) */
659
769
  delegateAddress;
660
770
  constructor(config) {
@@ -665,6 +775,7 @@ var HavenClient = class {
665
775
  this.requestTimeout = config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
666
776
  this.confirmationTimeout = config.confirmationTimeout ?? DEFAULT_CONFIRMATION_TIMEOUT;
667
777
  this.pollingInterval = config.pollingInterval ?? DEFAULT_POLLING_INTERVAL;
778
+ this.chainRpcs = config.chainRpcs ?? {};
668
779
  this.defaultHeaders = { ...config.defaultHeaders ?? {} };
669
780
  if (this.delegateKey) {
670
781
  this.delegateAddress = addressFromKey(this.delegateKey);
@@ -860,6 +971,78 @@ var HavenClient = class {
860
971
  chainId: raw.chain_id
861
972
  };
862
973
  }
974
+ /**
975
+ * Sweep stranded USDC and ETH from the delegate EOA back to the originating Safe.
976
+ *
977
+ * The delegate key held by this client signs and submits the transfer transactions
978
+ * directly — Haven's backend never handles the key or constructs signed txs
979
+ * (CASP/MiCA Red Line #2). Funds always go to the Safe linked to this agent.
980
+ *
981
+ * Requires `chainRpcs` to be set for the agent's chain in `HavenClientConfig`.
982
+ */
983
+ async sweepDelegate() {
984
+ if (!this.delegateKey) {
985
+ throw new HavenSigningError("delegateKey is required for sweepDelegate.");
986
+ }
987
+ const agent = await this.getAgent();
988
+ const { safeAddress, delegateAddress, chainId } = agent;
989
+ if (!delegateAddress) {
990
+ throw new HavenApiError("Agent has no delegate address.", 422);
991
+ }
992
+ const rpcUrl = this.chainRpcs[chainId];
993
+ if (!rpcUrl) {
994
+ throw new HavenApiError(
995
+ `chainRpcs[${chainId}] must be configured to sweep the delegate wallet.`,
996
+ 422
997
+ );
998
+ }
999
+ const provider = createJsonRpcProvider(rpcUrl);
1000
+ const wallet = createWallet(this.delegateKey, provider);
1001
+ const ERC20_TRANSFER_ABI = ["function balanceOf(address) view returns (uint256)", "function transfer(address to, uint256 amount) returns (bool)"];
1002
+ const transfers = [];
1003
+ const usdcAddress = CHAIN_USDC[chainId];
1004
+ if (usdcAddress) {
1005
+ const usdcContract = createErc20Contract(usdcAddress, ERC20_TRANSFER_ABI, wallet);
1006
+ const usdcBalance = await usdcContract.balanceOf(delegateAddress);
1007
+ if (usdcBalance > 0n) {
1008
+ const tx = await usdcContract.transfer(safeAddress, usdcBalance);
1009
+ const receipt = await tx.wait(1);
1010
+ const txHash = receipt?.hash ?? tx.hash;
1011
+ transfers.push({
1012
+ asset: "USDC",
1013
+ amount: formatAtomicAmount(usdcBalance, 6),
1014
+ amountAtomic: usdcBalance.toString(),
1015
+ txHash,
1016
+ explorerUrl: buildExplorerUrl(chainId, txHash)
1017
+ });
1018
+ }
1019
+ }
1020
+ const ethBalance = await provider.getBalance(delegateAddress);
1021
+ if (ethBalance > 0n) {
1022
+ const gasPrice = (await provider.getFeeData()).gasPrice ?? 1000000n;
1023
+ const gasLimit = 21000n;
1024
+ const gasCost = gasPrice * gasLimit;
1025
+ const ethToSend = ethBalance > gasCost ? ethBalance - gasCost : 0n;
1026
+ if (ethToSend > 0n) {
1027
+ const tx = await wallet.sendTransaction({ to: safeAddress, value: ethToSend });
1028
+ const receipt = await tx.wait(1);
1029
+ const txHash = receipt?.hash ?? tx.hash;
1030
+ transfers.push({
1031
+ asset: "ETH",
1032
+ amount: formatAtomicAmount(ethToSend, 18),
1033
+ amountAtomic: ethToSend.toString(),
1034
+ txHash,
1035
+ explorerUrl: buildExplorerUrl(chainId, txHash)
1036
+ });
1037
+ }
1038
+ }
1039
+ return {
1040
+ fromAddress: delegateAddress,
1041
+ toAddress: safeAddress,
1042
+ chainId,
1043
+ transfers
1044
+ };
1045
+ }
863
1046
  /**
864
1047
  * Get configured and on-chain allowances for the authenticated agent.
865
1048
  */
@@ -889,6 +1072,36 @@ var HavenClient = class {
889
1072
  }))
890
1073
  };
891
1074
  }
1075
+ /**
1076
+ * Discover payable services from Haven's curated merchant catalog.
1077
+ *
1078
+ * Read-only: returns catalog entries (price, rail, protocol) so an agent
1079
+ * can choose a service and pay it with the regular payment tools in the
1080
+ * same session. Never creates payments or signatures.
1081
+ */
1082
+ async discoverTools(options = {}) {
1083
+ const params = new URLSearchParams();
1084
+ if (options.category) params.set("category", options.category);
1085
+ if (options.rail) params.set("rail", options.rail);
1086
+ const query = params.size > 0 ? `?${params.toString()}` : "";
1087
+ const raw = await this.get(`/catalog${query}`);
1088
+ return raw.entries.map((entry) => ({
1089
+ id: entry.id,
1090
+ name: entry.name,
1091
+ description: entry.description,
1092
+ category: entry.category,
1093
+ resourceUrl: entry.resource_url,
1094
+ rail: entry.rail,
1095
+ protocol: entry.protocol,
1096
+ toolName: entry.tool_name,
1097
+ priceDisplay: entry.price_display,
1098
+ priceAtomic: entry.price_atomic,
1099
+ asset: entry.asset,
1100
+ network: entry.network,
1101
+ status: entry.status,
1102
+ verifiedAt: entry.verified_at
1103
+ }));
1104
+ }
892
1105
  /**
893
1106
  * List recent machine-payment receipts/evidence for bookkeeping.
894
1107
  */
@@ -1046,6 +1259,10 @@ var HavenClient = class {
1046
1259
  if (execResult.status !== "confirmed") {
1047
1260
  this.throwPaymentStateError("x402 payment", execResult);
1048
1261
  }
1262
+ await this.waitForFundingTx(
1263
+ execResult.tx_hash,
1264
+ execResult.chain_id ?? chainIdFromNetwork(option.network)
1265
+ );
1049
1266
  const receipt = this.mapX402ReceiptFromAuthorization(paymentRequired, option, paymentHeader, raw, execResult);
1050
1267
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt);
1051
1268
  return receipt;
@@ -1112,16 +1329,33 @@ var HavenClient = class {
1112
1329
  * const data = await response.json()
1113
1330
  * ```
1114
1331
  *
1332
+ * **MCP-over-x402 auto-handshake (issue #315):** when the endpoint is
1333
+ * MCP-shaped — the URL path ends in `/mcp`, or the 402 body carries a
1334
+ * Coinbase Bazaar `extensions.bazaar` block — the SDK runs the MCP
1335
+ * `initialize` handshake, threads the resulting `mcp-session-id`,
1336
+ * `Accept: application/json, text/event-stream`, and `x402-wallet` headers
1337
+ * through every request, and collapses SSE responses to the JSON-RPC
1338
+ * `result`. The caller just passes `(url, { body })` and never sees the
1339
+ * protocol plumbing. A non-MCP server (handshake error / no session id)
1340
+ * falls back to standard x402 behaviour.
1341
+ *
1115
1342
  * Requires `delegateKey` to be set in the client config.
1116
1343
  */
1117
1344
  async fetch(url, init, options = {}) {
1118
- const initialInit = this.withX402Wallet(init, this.x402PayerAddress());
1119
- const response = await globalThis.fetch(url, initialInit);
1120
- if (response.status !== 402) return response;
1345
+ let mcpSessionId;
1346
+ if (isMcpUrl(url)) {
1347
+ mcpSessionId = await this.mcpInitialize(url, init);
1348
+ }
1349
+ let requestInit = this.withX402Wallet(init, this.x402PayerAddress());
1350
+ if (mcpSessionId) requestInit = this.withMcpHeaders(requestInit, mcpSessionId);
1351
+ const response = await globalThis.fetch(url, requestInit);
1352
+ if (response.status !== 402) {
1353
+ return mcpSessionId ? this.surfaceMcpResult(response) : response;
1354
+ }
1121
1355
  const machineChallengeHeader = response.headers.get("MACHINE-PAYMENT-CHALLENGE");
1122
1356
  if (machineChallengeHeader) {
1123
1357
  const challenge = await parseMachinePaymentChallengeResponse(response);
1124
- return this.fetchWithMachinePayment(url, initialInit, challenge);
1358
+ return this.fetchWithMachinePayment(url, requestInit, challenge);
1125
1359
  }
1126
1360
  let paymentRequired;
1127
1361
  try {
@@ -1133,9 +1367,13 @@ var HavenClient = class {
1133
1367
  } catch {
1134
1368
  return response;
1135
1369
  }
1136
- return this.fetchWithMachinePayment(url, initialInit, challenge);
1370
+ return this.fetchWithMachinePayment(url, requestInit, challenge);
1137
1371
  }
1138
- const request = this.snapshotX402Request(url, initialInit);
1372
+ if (!mcpSessionId && await responseHasBazaarExtension(response)) {
1373
+ mcpSessionId = await this.mcpInitialize(url, init);
1374
+ if (mcpSessionId) requestInit = this.withMcpHeaders(requestInit, mcpSessionId);
1375
+ }
1376
+ const request = this.snapshotX402Request(url, requestInit);
1139
1377
  const option = selectStandardPaymentOption(paymentRequired.accepts);
1140
1378
  const idempotencyKey = options.idempotencyKey ?? (option ? buildX402IdempotencyKey(paymentRequired, option) : void 0);
1141
1379
  let receipt;
@@ -1153,7 +1391,122 @@ var HavenClient = class {
1153
1391
  }
1154
1392
  throw err;
1155
1393
  }
1156
- return this.retryX402Request(url, initialInit, paymentRequired, receipt);
1394
+ const retryResponse = await this.retryX402Request(url, requestInit, paymentRequired, receipt);
1395
+ return mcpSessionId ? this.surfaceMcpResult(retryResponse) : retryResponse;
1396
+ }
1397
+ // ── MCP-over-x402 transport helpers (issue #315) ─────────────────
1398
+ /**
1399
+ * Run the MCP `initialize` handshake against a Streamable-HTTP endpoint and
1400
+ * return the `mcp-session-id` the server assigns.
1401
+ *
1402
+ * Returns `undefined` whenever the endpoint is not actually an MCP server —
1403
+ * a transport/HTTP error, a missing session id, or a JSON-RPC error in the
1404
+ * handshake response — so the caller can fall back to plain x402.
1405
+ */
1406
+ async mcpInitialize(url, init) {
1407
+ try {
1408
+ const headers = new Headers(init?.headers);
1409
+ headers.set("Content-Type", "application/json");
1410
+ headers.set("Accept", MCP_ACCEPT);
1411
+ const wallet = this.x402PayerAddress();
1412
+ if (wallet && !headers.has("x402-wallet")) headers.set("x402-wallet", wallet);
1413
+ const response = await globalThis.fetch(url, {
1414
+ method: "POST",
1415
+ headers,
1416
+ body: JSON.stringify({
1417
+ jsonrpc: "2.0",
1418
+ id: ++this.mcpRequestId,
1419
+ method: "initialize",
1420
+ params: {
1421
+ protocolVersion: MCP_PROTOCOL_VERSION,
1422
+ capabilities: {},
1423
+ clientInfo: MCP_CLIENT_INFO
1424
+ }
1425
+ })
1426
+ });
1427
+ if (!response.ok) return void 0;
1428
+ const sessionId = response.headers.get("mcp-session-id");
1429
+ if (!sessionId) return void 0;
1430
+ const message = await this.readMcpMessage(response);
1431
+ if (message && "error" in message) return void 0;
1432
+ await this.mcpNotifyInitialized(url, init, sessionId);
1433
+ return sessionId;
1434
+ } catch {
1435
+ return void 0;
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Send the MCP `notifications/initialized` notification that completes the
1440
+ * lifecycle handshake. Best-effort: the session is already established, so a
1441
+ * failed notification must not abort the payment.
1442
+ */
1443
+ async mcpNotifyInitialized(url, init, sessionId) {
1444
+ try {
1445
+ const headers = new Headers(init?.headers);
1446
+ headers.set("Content-Type", "application/json");
1447
+ headers.set("Accept", MCP_ACCEPT);
1448
+ headers.set("mcp-session-id", sessionId);
1449
+ const wallet = this.x402PayerAddress();
1450
+ if (wallet && !headers.has("x402-wallet")) headers.set("x402-wallet", wallet);
1451
+ await globalThis.fetch(url, {
1452
+ method: "POST",
1453
+ headers,
1454
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })
1455
+ });
1456
+ } catch {
1457
+ }
1458
+ }
1459
+ /** Read a single JSON-RPC message from an MCP response (JSON or SSE body). */
1460
+ async readMcpMessage(response) {
1461
+ let text;
1462
+ try {
1463
+ text = await response.clone().text();
1464
+ } catch {
1465
+ return void 0;
1466
+ }
1467
+ if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) {
1468
+ return selectJsonRpcResult(parseSseJsonRpcMessages(text));
1469
+ }
1470
+ try {
1471
+ return JSON.parse(text);
1472
+ } catch {
1473
+ return void 0;
1474
+ }
1475
+ }
1476
+ /** Add the MCP transport headers (session id + SSE Accept) to a request. */
1477
+ withMcpHeaders(init, sessionId) {
1478
+ const headers = new Headers(init?.headers);
1479
+ headers.set("mcp-session-id", sessionId);
1480
+ headers.set("Accept", MCP_ACCEPT);
1481
+ return { ...init, headers };
1482
+ }
1483
+ /**
1484
+ * Collapse an MCP SSE response into a plain JSON response carrying the
1485
+ * JSON-RPC `result`, so callers of `fetch()` never see raw SSE framing.
1486
+ * Non-SSE responses pass through untouched.
1487
+ */
1488
+ async surfaceMcpResult(response) {
1489
+ if (!(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
1490
+ return response;
1491
+ }
1492
+ let text;
1493
+ try {
1494
+ text = await response.clone().text();
1495
+ } catch {
1496
+ return response;
1497
+ }
1498
+ const message = selectJsonRpcResult(parseSseJsonRpcMessages(text));
1499
+ if (!message) return response;
1500
+ const body = "result" in message ? message.result : message;
1501
+ const headers = new Headers(response.headers);
1502
+ headers.set("content-type", "application/json");
1503
+ headers.delete("content-length");
1504
+ headers.delete("mcp-session-id");
1505
+ return new Response(JSON.stringify(body), {
1506
+ status: response.status,
1507
+ statusText: response.statusText,
1508
+ headers
1509
+ });
1157
1510
  }
1158
1511
  /**
1159
1512
  * Probe a paid MPP endpoint or inspect an existing challenge without creating
@@ -1212,12 +1565,13 @@ var HavenClient = class {
1212
1565
  headers: retryHeaders
1213
1566
  });
1214
1567
  if (!retryResponse.ok) {
1568
+ const merchant = await captureMerchantResponse(retryResponse);
1215
1569
  await this.recordMerchantRetryRejected({
1216
1570
  rail: "x402",
1217
1571
  paymentId: receipt.paymentId,
1218
1572
  txHash: receipt.txHash,
1219
1573
  resourceUrl: receipt.resourceUrl,
1220
- retryResponse,
1574
+ merchant,
1221
1575
  details: {
1222
1576
  merchant_to: receipt.merchantTo,
1223
1577
  delegate_to: receipt.to
@@ -1225,14 +1579,15 @@ var HavenClient = class {
1225
1579
  });
1226
1580
  throw new HavenApiError(
1227
1581
  "x402 retry failed after Haven funded the delegate wallet; reconciliation may be required.",
1228
- retryResponse.status,
1582
+ merchant.merchant_status,
1229
1583
  {
1230
1584
  marker: "x402_retry_rejected_after_funding",
1231
1585
  payment_id: receipt.paymentId,
1232
1586
  tx_hash: receipt.txHash,
1233
1587
  resource_url: receipt.resourceUrl,
1234
1588
  merchant_to: receipt.merchantTo,
1235
- delegate_to: receipt.to
1589
+ delegate_to: receipt.to,
1590
+ ...merchant
1236
1591
  }
1237
1592
  );
1238
1593
  }
@@ -1365,25 +1720,27 @@ var HavenClient = class {
1365
1720
  headers: retryHeaders
1366
1721
  });
1367
1722
  if (!retryResponse.ok) {
1723
+ const merchant = await captureMerchantResponse(retryResponse);
1368
1724
  await this.recordMerchantRetryRejected({
1369
1725
  rail: receipt.rail,
1370
1726
  paymentId: receipt.paymentId,
1371
1727
  txHash: receipt.txHash,
1372
1728
  resourceUrl: receipt.resourceUrl,
1373
- retryResponse,
1729
+ merchant,
1374
1730
  details: {
1375
1731
  challenge_id: receipt.challengeId
1376
1732
  }
1377
1733
  });
1378
1734
  throw new HavenApiError(
1379
1735
  "Machine payment retry failed after Haven sent the payment.",
1380
- retryResponse.status,
1736
+ merchant.merchant_status,
1381
1737
  {
1382
1738
  marker: "machine_payment_retry_rejected_after_payment",
1383
1739
  payment_id: receipt.paymentId,
1384
1740
  tx_hash: receipt.txHash,
1385
1741
  resource_url: receipt.resourceUrl,
1386
- rail: receipt.rail
1742
+ rail: receipt.rail,
1743
+ ...merchant
1387
1744
  }
1388
1745
  );
1389
1746
  }
@@ -1619,12 +1976,12 @@ var HavenClient = class {
1619
1976
  requirements
1620
1977
  );
1621
1978
  if (paymentRequired.x402Version < 2) return header;
1622
- const payment = decodeBase64Json3(header);
1623
- return btoa(JSON.stringify({
1979
+ const payment = decodeBase64Json(header);
1980
+ return encodeBase64Json({
1624
1981
  x402Version: paymentRequired.x402Version,
1625
1982
  accepted: option,
1626
1983
  payload: payment.payload
1627
- }));
1984
+ });
1628
1985
  }
1629
1986
  cacheX402Receipt(idempotencyKey, paymentHeader, receipt) {
1630
1987
  const expiresAt = getPaymentHeaderValidBefore(paymentHeader);
@@ -1686,11 +2043,11 @@ var HavenClient = class {
1686
2043
  rail: input.rail,
1687
2044
  eventType: "merchant_retry_rejected_after_payment",
1688
2045
  txHash: input.txHash,
1689
- reason: `Merchant returned HTTP ${input.retryResponse.status} after Haven payment confirmation`,
2046
+ reason: `Merchant returned HTTP ${input.merchant.merchant_status} after Haven payment confirmation`,
1690
2047
  details: {
1691
2048
  resource_url: input.resourceUrl,
1692
- retry_status: input.retryResponse.status,
1693
- retry_body: await responseSnippet(input.retryResponse),
2049
+ retry_status: input.merchant.merchant_status,
2050
+ retry_body: input.merchant.merchant_body.slice(0, MERCHANT_BODY_SNIPPET_LIMIT) || null,
1694
2051
  ...input.details
1695
2052
  }
1696
2053
  });
@@ -1716,6 +2073,29 @@ var HavenClient = class {
1716
2073
  } catch {
1717
2074
  }
1718
2075
  }
2076
+ /**
2077
+ * Wait for a funding tx to be mined with ≥1 confirmation before the
2078
+ * merchant retry, eliminating the race where the merchant's
2079
+ * `balanceOf(delegate)` runs before the funding block propagates.
2080
+ *
2081
+ * Skipped when `chainRpcs` does not include the chain; in that case Haven's
2082
+ * backend has already confirmed on-chain submission and callers accept the
2083
+ * small propagation window as a trade-off for not configuring an RPC URL.
2084
+ */
2085
+ async waitForFundingTx(txHash, chainId, timeoutMs = 3e4) {
2086
+ if (!txHash || !chainId) return;
2087
+ const rpcUrl = this.chainRpcs[chainId];
2088
+ if (!rpcUrl) return;
2089
+ const provider = createJsonRpcProvider(rpcUrl);
2090
+ const onChainReceipt = await provider.waitForTransaction(txHash, 1, timeoutMs);
2091
+ if (!onChainReceipt || onChainReceipt.status !== 1) {
2092
+ throw new HavenApiError(
2093
+ "Funding tx did not confirm on-chain within the timeout window.",
2094
+ 500,
2095
+ { txHash, chainId }
2096
+ );
2097
+ }
2098
+ }
1719
2099
  throwIfNonSignableAuthorizationState(label, raw) {
1720
2100
  if (raw.status === "pending_signature") return;
1721
2101
  this.throwPaymentStateError(label, raw);
@@ -2301,7 +2681,7 @@ function sleep(ms) {
2301
2681
  }
2302
2682
  function getPaymentHeaderValidBefore(paymentHeader) {
2303
2683
  try {
2304
- const payment = decodeBase64Json3(
2684
+ const payment = decodeBase64Json(
2305
2685
  paymentHeader
2306
2686
  );
2307
2687
  const payload = payment.payload;
@@ -2311,12 +2691,9 @@ function getPaymentHeaderValidBefore(paymentHeader) {
2311
2691
  }
2312
2692
  return 0;
2313
2693
  }
2314
- function decodeBase64Json3(value) {
2315
- return JSON.parse(atob(value));
2316
- }
2317
2694
  function parseProtocolReceiptHeader(value) {
2318
2695
  try {
2319
- return JSON.parse(atob(value));
2696
+ return decodeBase64Json(value);
2320
2697
  } catch {
2321
2698
  try {
2322
2699
  return JSON.parse(value);
@@ -2325,13 +2702,14 @@ function parseProtocolReceiptHeader(value) {
2325
2702
  }
2326
2703
  }
2327
2704
  }
2328
- async function responseSnippet(response) {
2329
- try {
2330
- const text = await response.clone().text();
2331
- return text.slice(0, 1e3) || null;
2332
- } catch {
2333
- return null;
2334
- }
2705
+ async function captureMerchantResponse(response) {
2706
+ const merchant_body = await response.text().catch(() => "");
2707
+ return {
2708
+ merchant_status: response.status,
2709
+ merchant_status_text: response.statusText,
2710
+ merchant_headers: Object.fromEntries(response.headers.entries()),
2711
+ merchant_body
2712
+ };
2335
2713
  }
2336
2714
 
2337
2715
  // src/tool-descriptions.ts
@@ -2403,6 +2781,30 @@ var toolDescriptions = {
2403
2781
  selectionGuidance: "Use this for transaction history, receipts, payment evidence, or bookkeeping; use the allowance tool instead for remaining allowance, budget, spend-limit, or what-can-I-spend questions.",
2404
2782
  behavior: "Returns the agent's recent machine-payment receipts ordered by recency. Proof header values are not returned.",
2405
2783
  nextActionGuidance: ""
2784
+ },
2785
+ payMcpTool: {
2786
+ summary: "Call a named tool on an MCP merchant that requires an x402 payment, handling the full initialize \u2192 pay \u2192 retry round trip.",
2787
+ selectionGuidance: "Use this when the agent wants to call a specific tool on an MCP merchant (e.g. Soundside, Coinbase Bazaar) and payment is required. Prefer this over haven_pay_x402 when you know the merchant_url and tool_name \u2014 it builds the JSON-RPC envelope internally. Use haven_pay_x402 for arbitrary HTTP resources. Do NOT use for read-only allowance or budget questions \u2014 use haven_get_allowances.",
2788
+ behavior: "Builds the JSON-RPC tools/call envelope, runs the MCP Streamable-HTTP initialize handshake automatically (if the endpoint is MCP-shaped), pays any HTTP 402 x402 challenge through Haven's AllowanceModule path, and retries the request. Returns the JSON-RPC result (the actual merchant output) on success. Amounts within the on-chain allowance execute automatically; over-allowance transfers are queued as pending_approval.",
2789
+ nextActionGuidance: "If pending_approval is returned, preserve payment_id and resume_state and wait for the wallet owner to approve in Haven. Use haven_resume_x402_payment once nextAction=retry_original_x402_request."
2790
+ },
2791
+ discoverTools: {
2792
+ summary: "Discover payable services from Haven's curated merchant catalog \u2014 names, prices, and which pay tool to use.",
2793
+ selectionGuidance: "Use this when the user asks what the agent can buy, pay for, or which paid services exist \u2014 or when you need a resource URL for a service the user described. Do NOT use for balance, budget, or spend-limit questions \u2014 use haven_get_allowances. Do NOT use to pay \u2014 each returned entry names the pay tool to use next.",
2794
+ behavior: "Read-only lookup against Haven's curated catalog. Entries are periodically re-verified against the live merchant; degraded entries are flagged. Returns name, description, price, rail, resource URL, and a suggested_tool field naming the exact Haven pay tool for that entry. Never creates a payment, signature, or approval.",
2795
+ nextActionGuidance: "Pick an entry, confirm the price with the user if it is non-trivial, and pay it with the tool named in suggested_tool, passing the entry's resource_url (and tool_name for MCP merchants)."
2796
+ },
2797
+ sweep_delegate: {
2798
+ summary: "Sweep stranded USDC and/or ETH from the delegate wallet back to the originating Safe.",
2799
+ selectionGuidance: "Use this when the user instructs you to recover stranded funds on the delegate wallet, or when a payment status returns nextAction=sweep_stranded_funds. Do NOT use for normal payments \u2014 use haven_pay_x402 or haven_pay_mpp_challenge. Do NOT use to read balances only \u2014 use haven_get_allowances.",
2800
+ behavior: "Reads the delegate EOA's on-chain USDC and ETH balances. For each non-zero balance, signs and submits a transfer from the delegate EOA to the originating Safe (hardcoded destination). The delegate key signs locally \u2014 Haven never sees it and the backend never constructs signed transactions (CASP/MiCA Red Line #2). Returns tx hashes and recovered amounts. Returns an empty transfers list when nothing is stranded.",
2801
+ nextActionGuidance: "If transfers is non-empty, confirm the amounts with the user. No further action required \u2014 funds are on their way back to the Safe."
2802
+ },
2803
+ send: {
2804
+ summary: "Send ETH or USDC directly from the agent's Haven wallet to a recipient address.",
2805
+ selectionGuidance: "Use this for plain transfers \u2014 refunding a user, paying a freelancer, topping up a co-agent's wallet, or moving funds between addresses. Do NOT use for x402 paid endpoints (use haven_pay_x402 instead) or MPP merchant payments (use haven_pay_mpp_challenge). Do NOT use for read-only allowance, budget, or what-can-I-spend questions \u2014 use haven_get_allowances.",
2806
+ behavior: "Sends the requested amount through the Safe AllowanceModule. Amounts within the remaining on-chain allowance for the asset execute automatically; amounts that exceed the allowance are queued as pending_approval for the wallet owner to approve in Haven. The agent's signing key signs the AllowanceModule transfer hash; Haven never receives the key.",
2807
+ nextActionGuidance: "If pending_approval is returned, preserve the payment_id and wait for the wallet owner to approve in Haven. Poll haven_get_payment_status until nextAction=none."
2406
2808
  }
2407
2809
  };
2408
2810
 
@@ -2532,6 +2934,12 @@ var GET_ALLOWANCES_DESCRIPTION = composeDescription(toolDescriptions.getAllowanc
2532
2934
  var AUTHORIZE_X402_DESCRIPTION = composeDescription(toolDescriptions.payX402) + " In this SDK tool set, the allowance lookup tool is get_allowances. When a paid API returns x402 payment requirements, use this tool to sign with the agent-owned delegate key and request a policy-limited Safe AllowanceModule top-up when needed. Haven relays signed transactions only; the agent key authorizes payment and on-chain limits enforce spend. If this returns pending_approval, tell the user it is waiting in Haven, preserve the original merchant/MCP session and x402 details, call get_payment_status later, and use resume_x402_payment only when next_action is retry_original_x402_request. Do not start a new merchant session or loop retries while approval is pending. Use the returned payment_header as the X-PAYMENT header on the retry request when doing a manual HTTP retry.";
2533
2935
  var RESUME_X402_DESCRIPTION = toolDescriptions.resumeX402.summary + " Use this only after get_payment_status returns next_action=retry_original_x402_request. It checks the approved payment, validates the original x402 details, and returns a merchant X-PAYMENT header without creating a new approval request or merchant session.";
2534
2936
  var AUTHORIZE_MACHINE_PAYMENT_DESCRIPTION = composeDescription(toolDescriptions.payMpp) + " In this SDK tool set, the allowance lookup tool is get_allowances. Currently scoped to the internal MPP demo rail. The agent signs the payment, Haven relays it within the on-chain allowance, and the tool returns a proof header for the retry request.";
2937
+ var SWEEP_DELEGATE_DESCRIPTION = composeDescription(toolDescriptions.sweep_delegate);
2938
+ var sweepDelegateSchema = {
2939
+ type: "object",
2940
+ properties: {},
2941
+ required: []
2942
+ };
2535
2943
  function claudeTools() {
2536
2944
  return [
2537
2945
  {
@@ -2563,6 +2971,11 @@ function claudeTools() {
2563
2971
  name: "authorize_machine_payment",
2564
2972
  description: AUTHORIZE_MACHINE_PAYMENT_DESCRIPTION,
2565
2973
  input_schema: authorizeMachinePaymentSchema
2974
+ },
2975
+ {
2976
+ name: "haven_sweep_delegate",
2977
+ description: SWEEP_DELEGATE_DESCRIPTION,
2978
+ input_schema: sweepDelegateSchema
2566
2979
  }
2567
2980
  ];
2568
2981
  }
@@ -2615,6 +3028,14 @@ function openaiTools() {
2615
3028
  description: AUTHORIZE_MACHINE_PAYMENT_DESCRIPTION,
2616
3029
  parameters: authorizeMachinePaymentSchema
2617
3030
  }
3031
+ },
3032
+ {
3033
+ type: "function",
3034
+ function: {
3035
+ name: "haven_sweep_delegate",
3036
+ description: SWEEP_DELEGATE_DESCRIPTION,
3037
+ parameters: sweepDelegateSchema
3038
+ }
2618
3039
  }
2619
3040
  ];
2620
3041
  }
@@ -2647,6 +3068,10 @@ exports.addressFromKey = addressFromKey;
2647
3068
  exports.buildMachinePaymentIdempotencyKey = buildMachinePaymentIdempotencyKey;
2648
3069
  exports.buildX402ExpectedMessage = buildX402ExpectedMessage;
2649
3070
  exports.composeDescription = composeDescription;
3071
+ exports.decodeBase64Json = decodeBase64Json;
3072
+ exports.decodeBase64Utf8 = decodeBase64Utf8;
3073
+ exports.encodeBase64Json = encodeBase64Json;
3074
+ exports.encodeBase64Utf8 = encodeBase64Utf8;
2650
3075
  exports.encodeMachinePaymentProof = encodeMachinePaymentProof;
2651
3076
  exports.encodePaymentProof = encodePaymentProof;
2652
3077
  exports.havenTools = havenTools;