@haven_ai/sdk 0.1.24-alpha.0 → 0.1.26-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/README.md CHANGED
@@ -122,6 +122,66 @@ if (apiResponse.status === 402) {
122
122
  }
123
123
  ```
124
124
 
125
+ ### Idempotency: what the key guarantees, and what it costs
126
+
127
+ **Omit `idempotencyKey` and the SDK synthesises one** from the merchant's
128
+ resource URL and description, the payee, asset, amount, network, and a
129
+ **5-minute time bucket**. Every one of those inputs except the bucket
130
+ describes *the product*, so the guarantee is:
131
+
132
+ > Repeated calls for the same product, at the same price, within the same
133
+ > 5-minute bucket are **one payment**.
134
+
135
+ That is what makes a retried HTTP request safe by default — a dropped
136
+ connection or a re-run tool call cannot pay twice.
137
+
138
+ **The cost is the flip side of the same rule.** The SDK cannot tell a retry
139
+ from a *deliberate* second purchase of the same item: both re-fetch the 402
140
+ and produce identical key material. So a genuine second purchase inside the
141
+ window collapses onto the first payment. When that happens the SDK does not
142
+ hand you a fresh authorization — the funds are already spent, and any
143
+ authorization it minted would be unfundable. It throws
144
+ `X402AlreadySettledError`, carrying the original receipt:
145
+
146
+ ```typescript
147
+ import { X402AlreadySettledError } from '@haven_ai/sdk'
148
+
149
+ try {
150
+ await haven.fetch('https://paid-api.example.com/data')
151
+ } catch (err) {
152
+ if (err instanceof X402AlreadySettledError) {
153
+ // The FIRST payment's receipt — real, settled funds.
154
+ console.log(err.receipt.paymentId, err.receipt.txHash)
155
+ // 'settled' — the delegate was checked on-chain and cannot fund again.
156
+ // 'unverifiable' — no chainRpcs entry for the chain, so it was not checked.
157
+ console.log(err.basis)
158
+ }
159
+ }
160
+ ```
161
+
162
+ **To buy the same item twice, pass distinct keys** — that is the supported
163
+ way to say "this is a new purchase, not a retry":
164
+
165
+ ```typescript
166
+ await haven.fetch(url, init, { idempotencyKey: `vpn-renewal:${orderId}` })
167
+ ```
168
+
169
+ Configure `chainRpcs` for your chain. Without it the SDK cannot check the
170
+ delegate's balance, so an accidental key collision refuses on the weaker
171
+ `unverifiable` basis rather than risk issuing an authorization it cannot vouch
172
+ for.
173
+
174
+ **Resuming is not affected by that.** When you are following the documented
175
+ approval flow — re-calling after a queued payment is approved, or calling
176
+ `resumeAuthorizedX402({ paymentId })` — you named the payment, so an
177
+ unverifiable balance lets the resume proceed as before. Only a balance
178
+ verified *absent* refuses there. The stricter default applies solely to the
179
+ case where two purchases collided on a key you did not choose.
180
+
181
+ This applies to the **EIP-3009 funding-leg** scheme, which routes money
182
+ through the delegate EOA. **erc7710 direct settlement is unaffected**: it has
183
+ no funding leg and no delegate balance to exhaust.
184
+
125
185
  For agents that need to inspect the price before paying, use the quote-first
126
186
  path. `quoteX402()` probes the merchant and parses the HTTP 402 response, but it
127
187
  does not create a Haven payment, approval request, signature, or on-chain
@@ -445,6 +505,12 @@ try {
445
505
  }
446
506
  ```
447
507
 
508
+ `X402AlreadySettledError` extends `HavenApiError` (status 409) and is the one
509
+ error above that is **not** a failure to pay — it reports that the payment it
510
+ describes *succeeded*, earlier. Handle it before the generic `HavenApiError`
511
+ branch, and treat `err.receipt` as proof of purchase rather than retrying. See
512
+ [Idempotency](#idempotency-what-the-key-guarantees-and-what-it-costs).
513
+
448
514
  ## License
449
515
 
450
516
  MIT
package/dist/index.cjs CHANGED
@@ -277,6 +277,17 @@ var X402UnexpectedStatusError = class extends HavenApiError {
277
277
  this.name = "X402UnexpectedStatusError";
278
278
  }
279
279
  };
280
+ var X402AlreadySettledError = class extends HavenApiError {
281
+ constructor(message, receipt, basis) {
282
+ super(message, 409, void 0, receipt.paymentId);
283
+ this.receipt = receipt;
284
+ this.basis = basis;
285
+ this.name = "X402AlreadySettledError";
286
+ }
287
+ receipt;
288
+ basis;
289
+ x402ErrorCode = "already_settled";
290
+ };
280
291
  var HavenPaymentStateError = class extends HavenApiError {
281
292
  constructor(message, statusCode, state, body) {
282
293
  super(message, statusCode, body, state.paymentId);
@@ -953,8 +964,8 @@ function createJsonRpcProvider(url) {
953
964
  function createWallet(privateKey, provider) {
954
965
  return new ethers.ethers.Wallet(privateKey, provider);
955
966
  }
956
- function createErc20Contract(address, abi, signer) {
957
- return new ethers.ethers.Contract(address, abi, signer);
967
+ function createErc20Contract(address, abi, runner) {
968
+ return new ethers.ethers.Contract(address, abi, runner);
958
969
  }
959
970
 
960
971
  // src/client.ts
@@ -1934,7 +1945,6 @@ var HavenClient = class {
1934
1945
  }
1935
1946
  }
1936
1947
  async authorizeStandardX402(paymentRequired, option, idempotencyKey) {
1937
- const paymentHeader = await this.createStandardX402Header(paymentRequired, option);
1938
1948
  const raw = await this.post("/x402", {
1939
1949
  url: paymentRequired.resource.url,
1940
1950
  payTo: this.delegateAddress,
@@ -1949,12 +1959,30 @@ var HavenClient = class {
1949
1959
  // declaration keeps both writers of the 3009 shape loud-by-default.
1950
1960
  settlementScheme: "eip3009"
1951
1961
  });
1962
+ const state = this.paymentStateFromRaw("x402 payment", raw);
1963
+ const executedReplay = raw.success && raw.tx_hash ? "idempotency-collision" : state?.nextAction === AgentPaymentNextAction.RetryOriginalX402Request ? "approval-resume" : null;
1964
+ if (executedReplay) {
1965
+ const canFund = await this.delegateCanFund(
1966
+ raw.chain_id ?? state?.chainId ?? chainIdFromNetwork(option.network),
1967
+ option.asset,
1968
+ x402AuthorizationAmount(option)
1969
+ );
1970
+ const refuse = executedReplay === "idempotency-collision" ? canFund !== true : canFund === false;
1971
+ if (refuse) {
1972
+ const settledReceipt = state && executedReplay === "approval-resume" ? this.mapX402ReceiptFromStatus(paymentRequired, option, void 0, state) : this.mapX402ReceiptFromAuthorization(paymentRequired, option, void 0, raw);
1973
+ throw new X402AlreadySettledError(
1974
+ canFund === false ? "This x402 payment already settled \u2014 the delegate no longer holds the funds to authorize it again. To buy the same item a second time, pass a distinct `idempotencyKey`; the synthesised key intentionally collapses repeat calls for the same product within a 5-minute window so a retried request cannot pay twice." : "This x402 payment already settled, and whether the delegate can still fund a new authorization could not be verified (no `chainRpcs` entry for this chain). Refusing rather than issue an authorization that may be unfundable. To buy the same item a second time, pass a distinct `idempotencyKey`; to finish an interrupted payment, resume it by `paymentId`.",
1975
+ settledReceipt,
1976
+ canFund === false ? "settled" : "unverifiable"
1977
+ );
1978
+ }
1979
+ }
1980
+ const paymentHeader = await this.createStandardX402Header(paymentRequired, option);
1952
1981
  if (raw.success && raw.tx_hash) {
1953
1982
  const receipt2 = this.mapX402ReceiptFromAuthorization(paymentRequired, option, paymentHeader, raw);
1954
1983
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt2);
1955
1984
  return receipt2;
1956
1985
  }
1957
- const state = this.paymentStateFromRaw("x402 payment", raw);
1958
1986
  if (state?.nextAction === AgentPaymentNextAction.RetryOriginalX402Request) {
1959
1987
  const receipt2 = this.mapX402ReceiptFromStatus(paymentRequired, option, paymentHeader, state);
1960
1988
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt2);
@@ -2135,6 +2163,18 @@ var HavenClient = class {
2135
2163
  if (cached && cached.expiresAt > Date.now()) return cached.receipt;
2136
2164
  const status = await this.getPaymentStatus(input.paymentId);
2137
2165
  this.assertCanResumeX402(status, input.paymentRequired, option);
2166
+ const canFund = await this.delegateCanFund(
2167
+ status.chainId ?? chainIdFromNetwork(option.network),
2168
+ option.asset,
2169
+ x402AuthorizationAmount(option)
2170
+ );
2171
+ if (canFund === false) {
2172
+ throw new X402AlreadySettledError(
2173
+ `x402 payment ${status.paymentId} has already settled \u2014 the delegate no longer holds the funds to authorize it again, so there is nothing left to resume.`,
2174
+ this.mapX402ReceiptFromStatus(input.paymentRequired, option, void 0, status),
2175
+ "settled"
2176
+ );
2177
+ }
2138
2178
  const paymentHeader = await this.createStandardX402Header(input.paymentRequired, option);
2139
2179
  const receipt = this.mapX402ReceiptFromStatus(input.paymentRequired, option, paymentHeader, status);
2140
2180
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt);
@@ -2465,8 +2505,18 @@ var HavenClient = class {
2465
2505
  * balance — otherwise it rejects with "Payment verification failed". The
2466
2506
  * SDK's local path already does this (see authorizeStandardX402); the hosted
2467
2507
  * split flow regressed when the 5→3 collapse removed the incidental
2468
- * inter-call latency that used to mask it. No-op when the funding tx hash or
2469
- * a chain RPC (chainRpcs[chainId]) is unavailable.
2508
+ * inter-call latency that used to mask it.
2509
+ *
2510
+ * **NOT a no-op when the funding tx hash is absent** (#1508). The WAIT is
2511
+ * skipped without a hash or a chain RPC, but the `GET /payments/:id` read
2512
+ * below runs UNCONDITIONALLY — it is how the fallback hash and the chainId
2513
+ * are obtained. That distinction is load-bearing: this method must never be
2514
+ * called on a scheme with no funding leg, because the read itself fails once
2515
+ * the intent reaches a status the backend maps to a non-2xx (`submitted` is a
2516
+ * 409), turning a settled payment into a reported error. The previous wording
2517
+ * here said "No-op when the funding tx hash ... is unavailable", and the
2518
+ * hosted erc7710 path was written against that promise — see
2519
+ * `deliverMerchantPayment`'s `noFundingLeg` option.
2470
2520
  */
2471
2521
  async ensureFundingConfirmed(paymentId, fundingTxHash) {
2472
2522
  const status = await this.getPaymentStatus(paymentId);
@@ -2475,8 +2525,10 @@ var HavenClient = class {
2475
2525
  async completeX402MerchantCall(input) {
2476
2526
  const evidenceContext = await this.resolveX402MerchantCompletionContext({
2477
2527
  paymentId: input.paymentId,
2478
- url: input.url
2528
+ url: input.url,
2529
+ noFundingLeg: input.noFundingLeg === true
2479
2530
  });
2531
+ const fundingTxHash = evidenceContext.txHash;
2480
2532
  const shouldHandshakeMcp = isMcpUrl(input.url) || input.mcpTransport?.handshakeRequired === true;
2481
2533
  const x402Wallet = shouldHandshakeMcp ? await this.resolveX402WalletForMerchantCall() : this.x402PayerAddress();
2482
2534
  let mcpSessionId;
@@ -2500,33 +2552,37 @@ var HavenClient = class {
2500
2552
  body = text;
2501
2553
  }
2502
2554
  if (!surfaced.ok) {
2503
- await this.recordMerchantRetryRejected({
2504
- rail: "x402",
2505
- paymentId: evidenceContext.paymentId,
2506
- txHash: evidenceContext.txHash,
2507
- resourceUrl: evidenceContext.resourceUrl,
2508
- merchant: {
2509
- merchant_status: surfaced.status,
2510
- merchant_status_text: surfaced.statusText,
2511
- merchant_headers: Object.fromEntries(surfaced.headers.entries()),
2512
- merchant_body: text
2513
- },
2514
- details: {
2515
- merchant_to: evidenceContext.merchantAddress
2516
- }
2517
- });
2555
+ if (!input.noFundingLeg && fundingTxHash) {
2556
+ await this.recordMerchantRetryRejected({
2557
+ rail: "x402",
2558
+ paymentId: evidenceContext.paymentId,
2559
+ txHash: fundingTxHash,
2560
+ resourceUrl: evidenceContext.resourceUrl,
2561
+ merchant: {
2562
+ merchant_status: surfaced.status,
2563
+ merchant_status_text: surfaced.statusText,
2564
+ merchant_headers: Object.fromEntries(surfaced.headers.entries()),
2565
+ merchant_body: text
2566
+ },
2567
+ details: {
2568
+ merchant_to: evidenceContext.merchantAddress
2569
+ }
2570
+ });
2571
+ }
2518
2572
  } else {
2519
- await this.reportMachinePaymentEvidence({
2520
- paymentId: evidenceContext.paymentId,
2521
- rail: "x402",
2522
- txHash: evidenceContext.txHash,
2523
- resourceUrl: evidenceContext.resourceUrl,
2524
- merchantStatus: surfaced.status,
2525
- paymentProofHeaderName: "X-PAYMENT",
2526
- paymentProofHeader: input.paymentHeader,
2527
- protocolReceiptHeaderName: protocolReceiptHeader ? "PAYMENT-RESPONSE" : void 0,
2528
- protocolReceiptHeader
2529
- });
2573
+ if (!input.noFundingLeg && fundingTxHash) {
2574
+ await this.reportMachinePaymentEvidence({
2575
+ paymentId: evidenceContext.paymentId,
2576
+ rail: "x402",
2577
+ txHash: fundingTxHash,
2578
+ resourceUrl: evidenceContext.resourceUrl,
2579
+ merchantStatus: surfaced.status,
2580
+ paymentProofHeaderName: "X-PAYMENT",
2581
+ paymentProofHeader: input.paymentHeader,
2582
+ protocolReceiptHeaderName: protocolReceiptHeader ? "PAYMENT-RESPONSE" : void 0,
2583
+ protocolReceiptHeader
2584
+ });
2585
+ }
2530
2586
  await this.reportMerchantReceipt(evidenceContext.paymentId, surfaced);
2531
2587
  }
2532
2588
  return {
@@ -2572,11 +2628,11 @@ var HavenClient = class {
2572
2628
  status
2573
2629
  );
2574
2630
  }
2575
- const readyForMerchantCompletion = status.nextAction === AgentPaymentNextAction.RetryOriginalX402Request || status.kind === "payment_intent" && status.status === "confirmed" && status.phase === AgentPaymentPhase.PaymentConfirmed && status.nextAction === AgentPaymentNextAction.None;
2631
+ const readyForMerchantCompletion = input.noFundingLeg ? status.kind === "payment_intent" && status.status === "submitted" : status.nextAction === AgentPaymentNextAction.RetryOriginalX402Request || status.kind === "payment_intent" && status.status === "confirmed" && status.phase === AgentPaymentPhase.PaymentConfirmed && status.nextAction === AgentPaymentNextAction.None;
2576
2632
  if (!readyForMerchantCompletion) {
2577
2633
  throw new HavenPaymentStateError(status.message, PAYMENT_STATE_STATUS_CODES[status.status] ?? 409, status);
2578
2634
  }
2579
- if (!status.txHash) {
2635
+ if (!input.noFundingLeg && !status.txHash) {
2580
2636
  throw new HavenApiError(
2581
2637
  `x402 payment ${status.paymentId} is ready for merchant completion but has no Haven transaction hash.`,
2582
2638
  502,
@@ -2846,6 +2902,45 @@ var HavenClient = class {
2846
2902
  );
2847
2903
  }
2848
2904
  }
2905
+ /**
2906
+ * Can the delegate EOA still fund an authorization for `amountAtomic`?
2907
+ *
2908
+ * #1521: the only question that separates a legitimate resume (funding
2909
+ * confirmed, merchant never paid — the delegate still holds the money) from
2910
+ * a replayed settled payment (funding confirmed, merchant paid, delegate
2911
+ * spent). The intent's own `status: 'confirmed'` is identical in both.
2912
+ *
2913
+ * The balance is asked of the CHAIN rather than of Haven's bookkeeping on
2914
+ * purpose: the merchant-settlement evidence record is written by this SDK
2915
+ * *after* the merchant call, so a client that dies between the two leaves
2916
+ * the backend believing the merchant was never paid — the exact case the
2917
+ * discriminator has to get right. The chain cannot be behind in that way.
2918
+ *
2919
+ * Returns `null` — never a guess — when `chainRpcs` has no entry for the
2920
+ * chain or the read fails. Callers must treat that as "unverifiable", not
2921
+ * as "funded".
2922
+ */
2923
+ async delegateCanFund(chainId, tokenAddress, amountAtomic, timeoutMs = 1e4) {
2924
+ if (!chainId || !this.delegateAddress) return null;
2925
+ const rpcUrl = this.chainRpcs[chainId];
2926
+ if (!rpcUrl) return null;
2927
+ try {
2928
+ const provider = createJsonRpcProvider(rpcUrl);
2929
+ const token = createErc20Contract(
2930
+ tokenAddress,
2931
+ ["function balanceOf(address) view returns (uint256)"],
2932
+ provider
2933
+ );
2934
+ const balance = await Promise.race([
2935
+ token.balanceOf(this.delegateAddress),
2936
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs).unref?.())
2937
+ ]);
2938
+ if (balance === null) return null;
2939
+ return balance >= BigInt(amountAtomic);
2940
+ } catch {
2941
+ return null;
2942
+ }
2943
+ }
2849
2944
  throwIfNonSignableAuthorizationState(label, raw) {
2850
2945
  if (raw.status === "pending_signature") return;
2851
2946
  this.throwPaymentStateError(label, raw);
@@ -4066,6 +4161,7 @@ exports.SWEEP_BASE_SEPOLIA_USDC_ADDRESS = SWEEP_BASE_SEPOLIA_USDC_ADDRESS;
4066
4161
  exports.SWEEP_BASE_USDC_ADDRESS = SWEEP_BASE_USDC_ADDRESS;
4067
4162
  exports.SignerRefusalCode = SignerRefusalCode;
4068
4163
  exports.TRANSFER_WITH_AUTHORIZATION_TYPES = TRANSFER_WITH_AUTHORIZATION_TYPES;
4164
+ exports.X402AlreadySettledError = X402AlreadySettledError;
4069
4165
  exports.X402PaymentHeaderValidationError = X402PaymentHeaderValidationError;
4070
4166
  exports.X402UnexpectedStatusError = X402UnexpectedStatusError;
4071
4167
  exports.X402_MAX_AUTHORIZATION_WINDOW_SECONDS = X402_MAX_AUTHORIZATION_WINDOW_SECONDS;