@haven_ai/sdk 0.1.25-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);
@@ -2862,6 +2902,45 @@ var HavenClient = class {
2862
2902
  );
2863
2903
  }
2864
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
+ }
2865
2944
  throwIfNonSignableAuthorizationState(label, raw) {
2866
2945
  if (raw.status === "pending_signature") return;
2867
2946
  this.throwPaymentStateError(label, raw);
@@ -4082,6 +4161,7 @@ exports.SWEEP_BASE_SEPOLIA_USDC_ADDRESS = SWEEP_BASE_SEPOLIA_USDC_ADDRESS;
4082
4161
  exports.SWEEP_BASE_USDC_ADDRESS = SWEEP_BASE_USDC_ADDRESS;
4083
4162
  exports.SignerRefusalCode = SignerRefusalCode;
4084
4163
  exports.TRANSFER_WITH_AUTHORIZATION_TYPES = TRANSFER_WITH_AUTHORIZATION_TYPES;
4164
+ exports.X402AlreadySettledError = X402AlreadySettledError;
4085
4165
  exports.X402PaymentHeaderValidationError = X402PaymentHeaderValidationError;
4086
4166
  exports.X402UnexpectedStatusError = X402UnexpectedStatusError;
4087
4167
  exports.X402_MAX_AUTHORIZATION_WINDOW_SECONDS = X402_MAX_AUTHORIZATION_WINDOW_SECONDS;