uvd-x402-sdk 2.78.0 → 2.80.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 +107 -0
- package/dist/backend/index.d.mts +179 -21
- package/dist/backend/index.d.ts +179 -21
- package/dist/backend/index.js +133 -31
- package/dist/backend/index.js.map +1 -1
- package/dist/backend/index.mjs +131 -32
- package/dist/backend/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +102 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +100 -23
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/backend/facilitator-error.ts +201 -11
- package/src/backend/index.ts +201 -44
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uvd-x402-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.80.0",
|
|
4
4
|
"description": "x402 Payment SDK - Gasless crypto payments across 25 blockchains via Ultravioleta facilitator. Supports EVM (including Scroll, SKALE Base, Robinhood Chain), Solana, Fogo, Stellar, NEAR, Algorand, Sui, and XRP Ledger. Features: ERC-8004 Trustless Agents, Escrow/Refunds, multi-stablecoin (USDC, EURC, AUSD, PYUSD, USDT, USDG).",
|
|
5
5
|
"author": "Ultravioleta DAO <ultravioletadao@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,8 +42,34 @@
|
|
|
42
42
|
* sequence that once minted five duplicate agents — reconcile with
|
|
43
43
|
* `GET /identity/{network}/owner/{recipient}` or `getRegisterStatus` instead.
|
|
44
44
|
*
|
|
45
|
+
* # The two `502`s of `/settle`, which mean opposite things
|
|
46
|
+
*
|
|
47
|
+
* | body `error` | `Retry-After` | did the money move? | retry? |
|
|
48
|
+
* |----------------------------|---------------|---------------------|-----------|
|
|
49
|
+
* | `upstream_rpc_unavailable` | `30` | no, never broadcast | **yes** |
|
|
50
|
+
* | `settlement_unconfirmed` | **absent** | **maybe — mined?** | **NEVER** |
|
|
51
|
+
*
|
|
52
|
+
* `settlement_unconfirmed` is answered after the transaction was broadcast and
|
|
53
|
+
* no receipt ever arrived, so it MAY be mined. Retrying re-signs a new
|
|
54
|
+
* authorization with a fresh nonce, which the chain accepts as a second,
|
|
55
|
+
* perfectly valid payment for the same purchase — the buyer pays twice, in
|
|
56
|
+
* exactly the case the facilitator emits it to prevent. The `transaction` hash
|
|
57
|
+
* and `paymentId` travel in the body so the caller can LOOK AT THE CHAIN; they
|
|
58
|
+
* are not an invitation to send it again.
|
|
59
|
+
*
|
|
60
|
+
* The status cannot tell the two apart, which is why this file used to get it
|
|
61
|
+
* wrong: every `502` was retryable, and until `settlement_unconfirmed` existed
|
|
62
|
+
* that was correct. **Branch on the body.**
|
|
63
|
+
*
|
|
64
|
+
* The general form of that rule — **a 5xx whose body carries a transaction hash
|
|
65
|
+
* was broadcast, whatever the error is called** — is adopted from the Python
|
|
66
|
+
* SDK, which has carried it as its anti-double-settle guard
|
|
67
|
+
* (`uvd_x402_sdk/client.py`, `_is_retryable_settle_error`) while this one had
|
|
68
|
+
* only the status to go on.
|
|
69
|
+
*
|
|
45
70
|
* Source of the shape: x402-rs `src/handlers.rs` `writer_lease_unavailable()`
|
|
46
|
-
* and `require_writer_lease()
|
|
71
|
+
* and `require_writer_lease()`; `SettlementUnconfirmedResponse` in
|
|
72
|
+
* `src/types.rs`, built in the `IntoResponse` of `FacilitatorLocalError`.
|
|
47
73
|
*/
|
|
48
74
|
|
|
49
75
|
/**
|
|
@@ -90,6 +116,14 @@ export const REPLAYABLE_LEASE_REASONS: readonly WriterLeaseReason[] = [
|
|
|
90
116
|
*/
|
|
91
117
|
export const AMBIGUOUS_LEASE_REASONS: readonly WriterLeaseReason[] = ['forward_failed'];
|
|
92
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The facilitator's `error` code for a settle it broadcast and could not confirm.
|
|
121
|
+
*
|
|
122
|
+
* The transaction may be mined. This is the one upstream failure that must
|
|
123
|
+
* never be retried; reconcile with `transaction` / `paymentId` instead.
|
|
124
|
+
*/
|
|
125
|
+
export const SETTLEMENT_UNCONFIRMED = 'settlement_unconfirmed';
|
|
126
|
+
|
|
93
127
|
/**
|
|
94
128
|
* Ceiling, in seconds, on how long an automatic retry will wait.
|
|
95
129
|
*
|
|
@@ -119,6 +153,24 @@ export interface FacilitatorErrorInfo {
|
|
|
119
153
|
status: number;
|
|
120
154
|
/** The facilitator's own `reason`, when the body carried one. */
|
|
121
155
|
reason?: string;
|
|
156
|
+
/**
|
|
157
|
+
* The facilitator's machine-readable `error` code, verbatim.
|
|
158
|
+
*
|
|
159
|
+
* Some codes carry a `(ref: <uuid>)` suffix, so compare with care —
|
|
160
|
+
* {@link SETTLEMENT_UNCONFIRMED} is emitted bare. Branch on this rather than
|
|
161
|
+
* on the status: `/settle` has two `502`s that mean opposite things.
|
|
162
|
+
*/
|
|
163
|
+
errorCode?: string;
|
|
164
|
+
/**
|
|
165
|
+
* The hash of a transaction that WAS broadcast, in that chain's own encoding.
|
|
166
|
+
*
|
|
167
|
+
* Present on {@link SETTLEMENT_UNCONFIRMED}. Not always `0x`-prefixed:
|
|
168
|
+
* Algorand prints base32 and Solana base58, and reformatting it makes it
|
|
169
|
+
* unpasteable in an explorer — which is the entire remedy on offer.
|
|
170
|
+
*/
|
|
171
|
+
transaction?: string;
|
|
172
|
+
/** `keccak256(caip2 ‖ txHash)`, the same id a successful settle would print. */
|
|
173
|
+
paymentId?: string;
|
|
122
174
|
/**
|
|
123
175
|
* Seconds to wait before retrying, already clamped to
|
|
124
176
|
* {@link MAX_RETRY_AFTER_SECONDS}. Absent when the answer is not retryable.
|
|
@@ -148,6 +200,15 @@ export interface FacilitatorFailureFields {
|
|
|
148
200
|
status?: number;
|
|
149
201
|
/** The facilitator's `reason` for the refusal (see {@link WriterLeaseReason}). */
|
|
150
202
|
reason?: string;
|
|
203
|
+
/** The facilitator's machine-readable `error` code (see {@link FacilitatorErrorInfo.errorCode}). */
|
|
204
|
+
errorCode?: string;
|
|
205
|
+
/**
|
|
206
|
+
* A transaction that WAS broadcast and could not be confirmed. Look it up on
|
|
207
|
+
* chain; do NOT send the payment again. See {@link SETTLEMENT_UNCONFIRMED}.
|
|
208
|
+
*/
|
|
209
|
+
transaction?: string;
|
|
210
|
+
/** The payment id for that transaction, identical to the one a settle prints. */
|
|
211
|
+
paymentId?: string;
|
|
151
212
|
/** True when the same request may be sent again without re-signing anything. */
|
|
152
213
|
retryable?: boolean;
|
|
153
214
|
/** Seconds to wait before retrying, clamped to {@link MAX_RETRY_AFTER_SECONDS}. */
|
|
@@ -188,17 +249,120 @@ export function parseRetryAfterSeconds(response: {
|
|
|
188
249
|
return Math.min(seconds, MAX_RETRY_AFTER_SECONDS);
|
|
189
250
|
}
|
|
190
251
|
|
|
191
|
-
/**
|
|
192
|
-
|
|
252
|
+
/** Everything this SDK reads out of a facilitator error body. */
|
|
253
|
+
export interface ParsedFacilitatorErrorBody {
|
|
254
|
+
/** The `error` code, verbatim. */
|
|
255
|
+
errorCode?: string;
|
|
256
|
+
/** The writer-lease `reason`. */
|
|
257
|
+
reason?: string;
|
|
258
|
+
/** A broadcast transaction hash, in its own chain's encoding. */
|
|
259
|
+
transaction?: string;
|
|
260
|
+
/** The payment id for that hash. */
|
|
261
|
+
paymentId?: string;
|
|
262
|
+
/**
|
|
263
|
+
* The facilitator's OWN retry verdict, when it stated one.
|
|
264
|
+
*
|
|
265
|
+
* `undefined` on every body that does not carry the field — which is most of
|
|
266
|
+
* them, so absence means "the facilitator did not say", never "no".
|
|
267
|
+
*/
|
|
268
|
+
retryable?: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A body field is only usable when it is a non-empty string. */
|
|
272
|
+
function stringField(source: Record<string, unknown>, key: string): string | undefined {
|
|
273
|
+
const value = source[key];
|
|
274
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Find a broadcast transaction hash anywhere the facilitator spells one.
|
|
279
|
+
*
|
|
280
|
+
* All five spellings, because the endpoint and the error path disagree:
|
|
281
|
+
* `{"transaction": "0x…"}`, `{"transaction": {"hash": "0x…"}}`, `txHash`,
|
|
282
|
+
* `tx_hash`, `transaction_hash`. `settle()` already read three of them on the
|
|
283
|
+
* SUCCESS path — after a consumer failed closed on an unreadable receipt and
|
|
284
|
+
* revoked access to a payment that had settled — and the error path reading
|
|
285
|
+
* fewer is the same defect with the stakes reversed.
|
|
286
|
+
*
|
|
287
|
+
* Ported from the Python SDK's `_extract_tx_hash_from_body`
|
|
288
|
+
* (`uvd_x402_sdk/client.py`), which had the wider read first.
|
|
289
|
+
*/
|
|
290
|
+
function transactionHashIn(source: Record<string, unknown>): string | undefined {
|
|
291
|
+
const tx = source.transaction;
|
|
292
|
+
if (tx && typeof tx === 'object') {
|
|
293
|
+
const hash = (tx as Record<string, unknown>).hash;
|
|
294
|
+
if (typeof hash === 'string' && hash !== '') return hash;
|
|
295
|
+
}
|
|
296
|
+
if (typeof tx === 'string' && tx !== '') return tx;
|
|
297
|
+
for (const key of ['txHash', 'tx_hash', 'transaction_hash']) {
|
|
298
|
+
const value = stringField(source, key);
|
|
299
|
+
if (value !== undefined) return value;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read a JSON error body, tolerating anything that is not one.
|
|
306
|
+
*
|
|
307
|
+
* Exported so {@link FacilitatorErrorInfo} and `Erc8004LookupError` read the
|
|
308
|
+
* SAME fields the same way. Two subtly different parses of the same body is how
|
|
309
|
+
* one code path stops honouring a `retryable: false` the other one honours.
|
|
310
|
+
*/
|
|
311
|
+
export function parseFacilitatorErrorBody(body: string): ParsedFacilitatorErrorBody {
|
|
312
|
+
let parsed: Record<string, unknown>;
|
|
193
313
|
try {
|
|
194
|
-
|
|
195
|
-
if (parsed && typeof parsed === 'object' && typeof parsed.reason === 'string') {
|
|
196
|
-
return parsed.reason;
|
|
197
|
-
}
|
|
314
|
+
parsed = JSON.parse(body) as Record<string, unknown>;
|
|
198
315
|
} catch {
|
|
199
316
|
/* an HTML 503 from a load balancer is still a 503 */
|
|
317
|
+
return {};
|
|
200
318
|
}
|
|
201
|
-
return
|
|
319
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
320
|
+
return {
|
|
321
|
+
errorCode: stringField(parsed, 'error'),
|
|
322
|
+
reason: stringField(parsed, 'reason'),
|
|
323
|
+
transaction: transactionHashIn(parsed),
|
|
324
|
+
paymentId: stringField(parsed, 'paymentId'),
|
|
325
|
+
retryable: typeof parsed.retryable === 'boolean' ? parsed.retryable : undefined,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The facilitator said, or showed, that this refusal must not be retried.
|
|
331
|
+
*
|
|
332
|
+
* THREE independent signals, because the cost of missing one is a second
|
|
333
|
+
* payment for the same purchase:
|
|
334
|
+
*
|
|
335
|
+
* 1. an explicit `retryable: false` — the facilitator answering the question;
|
|
336
|
+
* 2. the named code {@link SETTLEMENT_UNCONFIRMED};
|
|
337
|
+
* 3. **any 5xx body that carries a transaction hash at all.** A hash in a
|
|
338
|
+
* failure means the facilitator got as far as BROADCASTING before it failed,
|
|
339
|
+
* whatever it decided to call the error. This one is the general rule and it
|
|
340
|
+
* holds for codes that do not exist yet.
|
|
341
|
+
*
|
|
342
|
+
* Signal 3 is adopted from the Python SDK, whose `_is_retryable_settle_error`
|
|
343
|
+
* has carried it as the anti-double-settle guard while this SDK had only the
|
|
344
|
+
* status to go on (`uvd_x402_sdk/client.py`). It is deliberately kept ALONGSIDE
|
|
345
|
+
* the first two rather than replacing them: a facilitator that answers
|
|
346
|
+
* `retryable: false` with no hash must still be obeyed.
|
|
347
|
+
*/
|
|
348
|
+
function isDeclaredUnretryable(parsed: ParsedFacilitatorErrorBody): boolean {
|
|
349
|
+
return (
|
|
350
|
+
parsed.retryable === false ||
|
|
351
|
+
parsed.errorCode === SETTLEMENT_UNCONFIRMED ||
|
|
352
|
+
parsed.transaction !== undefined
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* This refusal reports a transaction that may already be mined.
|
|
358
|
+
*
|
|
359
|
+
* The caller's move is to look up `transaction` / `paymentId` on chain. Sending
|
|
360
|
+
* the payment again is the double-charge.
|
|
361
|
+
*/
|
|
362
|
+
export function isSettlementUnconfirmed(failure: {
|
|
363
|
+
errorCode?: string;
|
|
364
|
+
}): boolean {
|
|
365
|
+
return failure.errorCode === SETTLEMENT_UNCONFIRMED;
|
|
202
366
|
}
|
|
203
367
|
|
|
204
368
|
/**
|
|
@@ -219,10 +383,21 @@ export async function readFacilitatorError(response: {
|
|
|
219
383
|
body = '';
|
|
220
384
|
}
|
|
221
385
|
const status = response.status;
|
|
222
|
-
const
|
|
386
|
+
const parsed = parseFacilitatorErrorBody(body);
|
|
387
|
+
const reason = parsed.reason;
|
|
223
388
|
// 429 is admission control and 502/503/504 are "no verdict". None of them is
|
|
224
389
|
// a rejection of the payment, so none of them may be reported as one.
|
|
225
|
-
const
|
|
390
|
+
const transportRetryable =
|
|
391
|
+
status === 429 || status === 502 || status === 503 || status === 504;
|
|
392
|
+
// ...unless the facilitator SAID otherwise in the body. `settlement_unconfirmed`
|
|
393
|
+
// is a 502 whose transaction was already broadcast and may be mined: retrying
|
|
394
|
+
// signs a second authorization, with a fresh nonce, that the chain will
|
|
395
|
+
// happily accept as another payment for the same purchase.
|
|
396
|
+
//
|
|
397
|
+
// Only ever DOWNGRADES. A body claiming `retryable: true` on a 402 must not
|
|
398
|
+
// make this SDK resend a credential the facilitator genuinely refused, so the
|
|
399
|
+
// status stays the ceiling and the body is only allowed to lower it.
|
|
400
|
+
const retryable = transportRetryable && !isDeclaredUnretryable(parsed);
|
|
226
401
|
const retryAfterSeconds = retryable
|
|
227
402
|
? (parseRetryAfterSeconds(response) ?? DEFAULT_RETRY_AFTER_SECONDS)
|
|
228
403
|
: undefined;
|
|
@@ -234,12 +409,19 @@ export async function readFacilitatorError(response: {
|
|
|
234
409
|
// load balancer, a 502, a 504, `forward_failed` — is merely retryable: the
|
|
235
410
|
// caller may resend after reconciling, but this SDK will not do it for them,
|
|
236
411
|
// because "a proxy answered" is not evidence that nothing ran.
|
|
237
|
-
|
|
412
|
+
//
|
|
413
|
+
// Gated on `retryable` so the two can never disagree: a refusal this SDK will
|
|
414
|
+
// not even retry must never be described as safe to replay automatically.
|
|
415
|
+
const safeToReplay =
|
|
416
|
+
retryable && (status === 429 || (status === 503 && isReplayableLeaseReason(reason)));
|
|
238
417
|
|
|
239
418
|
return {
|
|
240
419
|
error: `Facilitator error: ${status} - ${body}`,
|
|
241
420
|
status,
|
|
242
421
|
reason,
|
|
422
|
+
errorCode: parsed.errorCode,
|
|
423
|
+
transaction: parsed.transaction,
|
|
424
|
+
paymentId: parsed.paymentId,
|
|
243
425
|
retryAfterSeconds,
|
|
244
426
|
retryable,
|
|
245
427
|
safeToReplay,
|
|
@@ -257,6 +439,11 @@ export function failureFields(info: FacilitatorErrorInfo): Required<
|
|
|
257
439
|
retryable: info.retryable,
|
|
258
440
|
safeToReplay: info.safeToReplay,
|
|
259
441
|
...(info.reason !== undefined ? { reason: info.reason } : {}),
|
|
442
|
+
...(info.errorCode !== undefined ? { errorCode: info.errorCode } : {}),
|
|
443
|
+
// Without these the caller is told "do not retry" and given nothing to do
|
|
444
|
+
// instead — an error that swallows the hash is the same defect one layer up.
|
|
445
|
+
...(info.transaction !== undefined ? { transaction: info.transaction } : {}),
|
|
446
|
+
...(info.paymentId !== undefined ? { paymentId: info.paymentId } : {}),
|
|
260
447
|
...(info.retryAfterSeconds !== undefined
|
|
261
448
|
? { retryAfterSeconds: info.retryAfterSeconds }
|
|
262
449
|
: {}),
|
|
@@ -276,6 +463,9 @@ export function carryFailureFields(
|
|
|
276
463
|
return {
|
|
277
464
|
...(source.status !== undefined ? { status: source.status } : {}),
|
|
278
465
|
...(source.reason !== undefined ? { reason: source.reason } : {}),
|
|
466
|
+
...(source.errorCode !== undefined ? { errorCode: source.errorCode } : {}),
|
|
467
|
+
...(source.transaction !== undefined ? { transaction: source.transaction } : {}),
|
|
468
|
+
...(source.paymentId !== undefined ? { paymentId: source.paymentId } : {}),
|
|
279
469
|
...(source.retryable !== undefined ? { retryable: source.retryable } : {}),
|
|
280
470
|
...(source.retryAfterSeconds !== undefined
|
|
281
471
|
? { retryAfterSeconds: source.retryAfterSeconds }
|