@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 +66 -0
- package/dist/index.cjs +84 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -1
- package/dist/index.d.ts +56 -1
- package/dist/index.js +84 -5
- 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);
|
|
@@ -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;
|