@haven_ai/sdk 0.1.25-alpha.0 → 0.1.27-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
@@ -1503,7 +1514,8 @@ var HavenClient = class {
1503
1514
  isResetPending: a.onchain.isResetPending
1504
1515
  };
1505
1516
  });
1506
- return { ...agent, readiness: deriveReadiness(agent.status, allowances), allowances };
1517
+ const readiness = deriveReadiness(agent.status, allowances);
1518
+ return { ...agent, readiness, spend_authority_readiness: readiness, allowances };
1507
1519
  }
1508
1520
  /**
1509
1521
  * Sweep stranded USDC and ETH from the delegate EOA back to the originating Safe.
@@ -1934,7 +1946,6 @@ var HavenClient = class {
1934
1946
  }
1935
1947
  }
1936
1948
  async authorizeStandardX402(paymentRequired, option, idempotencyKey) {
1937
- const paymentHeader = await this.createStandardX402Header(paymentRequired, option);
1938
1949
  const raw = await this.post("/x402", {
1939
1950
  url: paymentRequired.resource.url,
1940
1951
  payTo: this.delegateAddress,
@@ -1949,12 +1960,30 @@ var HavenClient = class {
1949
1960
  // declaration keeps both writers of the 3009 shape loud-by-default.
1950
1961
  settlementScheme: "eip3009"
1951
1962
  });
1963
+ const state = this.paymentStateFromRaw("x402 payment", raw);
1964
+ const executedReplay = raw.success && raw.tx_hash ? "idempotency-collision" : state?.nextAction === AgentPaymentNextAction.RetryOriginalX402Request ? "approval-resume" : null;
1965
+ if (executedReplay) {
1966
+ const canFund = await this.delegateCanFund(
1967
+ raw.chain_id ?? state?.chainId ?? chainIdFromNetwork(option.network),
1968
+ option.asset,
1969
+ x402AuthorizationAmount(option)
1970
+ );
1971
+ const refuse = executedReplay === "idempotency-collision" ? canFund !== true : canFund === false;
1972
+ if (refuse) {
1973
+ const settledReceipt = state && executedReplay === "approval-resume" ? this.mapX402ReceiptFromStatus(paymentRequired, option, void 0, state) : this.mapX402ReceiptFromAuthorization(paymentRequired, option, void 0, raw);
1974
+ throw new X402AlreadySettledError(
1975
+ 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`.",
1976
+ settledReceipt,
1977
+ canFund === false ? "settled" : "unverifiable"
1978
+ );
1979
+ }
1980
+ }
1981
+ const paymentHeader = await this.createStandardX402Header(paymentRequired, option);
1952
1982
  if (raw.success && raw.tx_hash) {
1953
1983
  const receipt2 = this.mapX402ReceiptFromAuthorization(paymentRequired, option, paymentHeader, raw);
1954
1984
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt2);
1955
1985
  return receipt2;
1956
1986
  }
1957
- const state = this.paymentStateFromRaw("x402 payment", raw);
1958
1987
  if (state?.nextAction === AgentPaymentNextAction.RetryOriginalX402Request) {
1959
1988
  const receipt2 = this.mapX402ReceiptFromStatus(paymentRequired, option, paymentHeader, state);
1960
1989
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt2);
@@ -2066,7 +2095,10 @@ var HavenClient = class {
2066
2095
  // redeemable ONLY by them. `null` here means the merchant advertised none
2067
2096
  // (or an empty array, which the backend 400s on), so the field is OMITTED
2068
2097
  // rather than sent empty. See x402FacilitatorAddresses.
2069
- ...selection.facilitatorAddresses ? { facilitatorAddresses: selection.facilitatorAddresses } : {}
2098
+ ...selection.facilitatorAddresses ? { facilitatorAddresses: selection.facilitatorAddresses } : {},
2099
+ // #1307/#1547: persisted so the settle leg can rehydrate the merchant
2100
+ // call by payment_id on this scheme too, not only on the 3009 bridge.
2101
+ ...options.mcpCallContext ? { mcpCallContext: options.mcpCallContext } : {}
2070
2102
  });
2071
2103
  if (!raw.payment_id) {
2072
2104
  throw new HavenApiError("No payment_id returned from x402/authorize", 500, raw);
@@ -2135,6 +2167,18 @@ var HavenClient = class {
2135
2167
  if (cached && cached.expiresAt > Date.now()) return cached.receipt;
2136
2168
  const status = await this.getPaymentStatus(input.paymentId);
2137
2169
  this.assertCanResumeX402(status, input.paymentRequired, option);
2170
+ const canFund = await this.delegateCanFund(
2171
+ status.chainId ?? chainIdFromNetwork(option.network),
2172
+ option.asset,
2173
+ x402AuthorizationAmount(option)
2174
+ );
2175
+ if (canFund === false) {
2176
+ throw new X402AlreadySettledError(
2177
+ `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.`,
2178
+ this.mapX402ReceiptFromStatus(input.paymentRequired, option, void 0, status),
2179
+ "settled"
2180
+ );
2181
+ }
2138
2182
  const paymentHeader = await this.createStandardX402Header(input.paymentRequired, option);
2139
2183
  const receipt = this.mapX402ReceiptFromStatus(input.paymentRequired, option, paymentHeader, status);
2140
2184
  this.cacheX402Receipt(idempotencyKey, paymentHeader, receipt);
@@ -2862,6 +2906,45 @@ var HavenClient = class {
2862
2906
  );
2863
2907
  }
2864
2908
  }
2909
+ /**
2910
+ * Can the delegate EOA still fund an authorization for `amountAtomic`?
2911
+ *
2912
+ * #1521: the only question that separates a legitimate resume (funding
2913
+ * confirmed, merchant never paid — the delegate still holds the money) from
2914
+ * a replayed settled payment (funding confirmed, merchant paid, delegate
2915
+ * spent). The intent's own `status: 'confirmed'` is identical in both.
2916
+ *
2917
+ * The balance is asked of the CHAIN rather than of Haven's bookkeeping on
2918
+ * purpose: the merchant-settlement evidence record is written by this SDK
2919
+ * *after* the merchant call, so a client that dies between the two leaves
2920
+ * the backend believing the merchant was never paid — the exact case the
2921
+ * discriminator has to get right. The chain cannot be behind in that way.
2922
+ *
2923
+ * Returns `null` — never a guess — when `chainRpcs` has no entry for the
2924
+ * chain or the read fails. Callers must treat that as "unverifiable", not
2925
+ * as "funded".
2926
+ */
2927
+ async delegateCanFund(chainId, tokenAddress, amountAtomic, timeoutMs = 1e4) {
2928
+ if (!chainId || !this.delegateAddress) return null;
2929
+ const rpcUrl = this.chainRpcs[chainId];
2930
+ if (!rpcUrl) return null;
2931
+ try {
2932
+ const provider = createJsonRpcProvider(rpcUrl);
2933
+ const token = createErc20Contract(
2934
+ tokenAddress,
2935
+ ["function balanceOf(address) view returns (uint256)"],
2936
+ provider
2937
+ );
2938
+ const balance = await Promise.race([
2939
+ token.balanceOf(this.delegateAddress),
2940
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs).unref?.())
2941
+ ]);
2942
+ if (balance === null) return null;
2943
+ return balance >= BigInt(amountAtomic);
2944
+ } catch {
2945
+ return null;
2946
+ }
2947
+ }
2865
2948
  throwIfNonSignableAuthorizationState(label, raw) {
2866
2949
  if (raw.status === "pending_signature") return;
2867
2950
  this.throwPaymentStateError(label, raw);
@@ -3497,9 +3580,9 @@ var toolDescriptions = {
3497
3580
  nextActionGuidance: ""
3498
3581
  },
3499
3582
  getAgent: {
3500
- summary: "Return the authenticated agent identity AND its live spend authority in one call: Haven wallet, delegate, chain, raw status, a readiness signal, and per-token remaining allowance (atomic + human-readable). The recommended first call in a new session to confirm who you are and whether you can pay right now.",
3583
+ summary: "Return the authenticated agent identity AND its live spend authority in one call: Haven wallet, delegate, chain, raw status, spend_authority_readiness, and per-token remaining allowance (atomic + human-readable). The recommended first call in a new session to confirm who you are and whether Haven will let you spend right now.",
3501
3584
  selectionGuidance: "Use this as the one-shot orientation/bootstrap at the start of a session, or whenever you need to confirm identity together with whether the agent can spend right now. For a detailed per-token breakdown (configured vs spent vs reset window) use haven_get_allowances.",
3502
- behavior: 'Reads identity plus the live spend-authority snapshot in one shot \u2014 the on-chain AllowanceModule on the legacy rail, the active budget delegation on the delegation rail. readiness is "ready" when at least one token has remaining spend authority, "needs_approval" when the agent is active but has none, and "revoked" when the credential is not active. What an over-budget payment does differs by rail: on the legacy AllowanceModule rail it is queued for the wallet owner to approve in Haven; on the delegation rail there is no approval queue \u2014 an over-budget redemption reverts on-chain, so ask the owner to grant or raise the budget in Haven rather than waiting for an approval. allowances[] carries remainingAtomic and remainingDisplay per token. Identity fields (id, name, status, safeAddress, delegateAddress, chainId) are unchanged from before.',
3585
+ behavior: 'Reads identity plus the live spend-authority snapshot in one shot \u2014 the on-chain AllowanceModule on the legacy rail, the active budget delegation on the delegation rail. spend_authority_readiness (readiness is a deprecated alias, same value) is "ready" when at least one token has remaining spend authority, "needs_approval" when the agent is active but has none, and "revoked" when the credential is not active. It covers hosted identity + on-chain spend authority ONLY \u2014 the hosted server cannot see the LOCAL signer, so "ready" does not mean the signer can start; verify the signer with a signer tool call or connect --doctor. What an over-budget payment does differs by rail: on the legacy AllowanceModule rail it is queued for the wallet owner to approve in Haven; on the delegation rail there is no approval queue \u2014 an over-budget redemption reverts on-chain, so ask the owner to grant or raise the budget in Haven rather than waiting for an approval. allowances[] carries remainingAtomic and remainingDisplay per token. Identity fields (id, name, status, safeAddress, delegateAddress, chainId) are unchanged from before.',
3503
3586
  nextActionGuidance: ""
3504
3587
  },
3505
3588
  getAllowances: {
@@ -3530,7 +3613,7 @@ var toolDescriptions = {
3530
3613
  summary: "Step 1 of a purchase: discover payable services from Haven's curated merchant catalog \u2014 names, prices, and which pay tool to use next.",
3531
3614
  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.",
3532
3615
  behavior: "Use each entry's suggested_tool field first \u2014 it names the exact next call. Read-only lookup against Haven's curated catalog; entries are periodically re-verified against the live merchant and degraded entries are flagged. Use category for a case-insensitive category filter (for example, VPN or vpn), or search for a product name, category, or description term. Returns name, description, price, rail, resource URL, tool_name, tool_arguments, and suggested_tool. The catalog price (price_display/price_atomic, marked price_is_indicative) is a last-verified hint, NOT authoritative \u2014 the real price comes from the merchant's live 402 at pay time. Never creates a payment, signature, or approval.",
3533
- nextActionGuidance: `Pick an entry and pay it with the tool named in suggested_tool, passing the entry's resource_url, tool_name, and tool_arguments for MCP merchants. Confirm the price from the live pay-tool result (not the catalog), and pass the user's cap as max_amount_human in whole tokens ("no more than 1 USDC" \u2192 max_amount_human: "1") \u2014 never convert it to atomic units by hand (#1351).`
3616
+ nextActionGuidance: `Pick an entry and pay it with the tool named in suggested_tool, passing the entry's resource_url, tool_name, and tool_arguments for MCP merchants. Confirm the price from the live pay-tool result (not the catalog), and pass the user's cap as max_amount_human in whole tokens ("no more than 1 USDC" \u2192 max_amount_human: "1") \u2014 never convert it to atomic units by hand.`
3534
3617
  },
3535
3618
  sweep_delegate: {
3536
3619
  summary: "Sweep stranded USDC and/or ETH from the delegate wallet back to the originating Safe.",
@@ -3775,9 +3858,13 @@ user's approval in Haven.
3775
3858
 
3776
3859
  Hosted tools run in the \`mcp__haven__\` namespace. Local signing tools run in
3777
3860
  the \`mcp__haven-signer__\` namespace and keep the delegate key on this machine.
3778
- Tool results carry the exact next step (\`next_action\`, \`next_tool\`,
3779
- \`next_arguments\`) \u2014 follow those fields first; the prose below is fallback
3780
- and orientation, not the source of truth.
3861
+ That namespacing is Claude-family; other runtimes name the servers by their
3862
+ own config keys (Codex: \`haven\`, \`haven_signer\`). Tool results carry the
3863
+ exact next step (\`next_action\`, \`next_tool\`, \`next_arguments\`, plus the
3864
+ runtime-neutral \`next_tool_server\` + \`next_tool_name\` \u2014 the bare tool name
3865
+ on that logical server, whatever your runtime calls it).
3866
+ Follow those fields first; the prose below is fallback and orientation, not
3867
+ the source of truth.
3781
3868
 
3782
3869
  ## When to use this skill
3783
3870
 
@@ -3803,8 +3890,10 @@ Before any payment, confirm the *live remaining* budget with the tools \u2014
3803
3890
  spending:
3804
3891
 
3805
3892
  - \`mcp__haven__haven_get_agent\` \u2014 the recommended first call: identity
3806
- (wallet, network) plus a readiness signal (\`ready\` / \`needs_approval\` /
3807
- \`revoked\`) and live remaining per-token allowance, in one shot.
3893
+ (wallet, network) plus \`spend_authority_readiness\` (\`ready\` / \`needs_approval\` /
3894
+ \`revoked\`) and live remaining per-token allowance, in one shot. That signal
3895
+ covers hosted identity and on-chain spend authority only \u2014 it cannot see the
3896
+ local signer; the signer is verified by calling any signer tool.
3808
3897
  - \`mcp__haven__haven_get_allowances\` \u2014 detailed per-token breakdown
3809
3898
  (configured, spent, reset window) when you need more than the summary.
3810
3899
 
@@ -4082,6 +4171,7 @@ exports.SWEEP_BASE_SEPOLIA_USDC_ADDRESS = SWEEP_BASE_SEPOLIA_USDC_ADDRESS;
4082
4171
  exports.SWEEP_BASE_USDC_ADDRESS = SWEEP_BASE_USDC_ADDRESS;
4083
4172
  exports.SignerRefusalCode = SignerRefusalCode;
4084
4173
  exports.TRANSFER_WITH_AUTHORIZATION_TYPES = TRANSFER_WITH_AUTHORIZATION_TYPES;
4174
+ exports.X402AlreadySettledError = X402AlreadySettledError;
4085
4175
  exports.X402PaymentHeaderValidationError = X402PaymentHeaderValidationError;
4086
4176
  exports.X402UnexpectedStatusError = X402UnexpectedStatusError;
4087
4177
  exports.X402_MAX_AUTHORIZATION_WINDOW_SECONDS = X402_MAX_AUTHORIZATION_WINDOW_SECONDS;