@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 +66 -0
- package/dist/index.cjs +131 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -3
- package/dist/index.d.ts +78 -3
- package/dist/index.js +131 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
957
|
-
return new ethers.ethers.Contract(address, abi,
|
|
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.
|
|
2469
|
-
*
|
|
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
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
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
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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;
|