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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uvd-x402-sdk",
3
- "version": "2.78.0",
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
- /** Pull `reason` out of a JSON error body, if there is one and it is a string. */
192
- function reasonFrom(body: string): string | undefined {
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
- const parsed = JSON.parse(body) as { reason?: unknown };
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 undefined;
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 reason = reasonFrom(body);
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 retryable = status === 429 || status === 502 || status === 503 || status === 504;
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
- const safeToReplay = status === 429 || (status === 503 && isReplayableLeaseReason(reason));
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 }