uvd-x402-sdk 2.86.0 → 2.87.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.86.0",
3
+ "version": "2.87.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",
@@ -79,7 +79,13 @@ import {
79
79
  OPERATOR_FEE_BPS,
80
80
  } from '../escrow-preauth';
81
81
  import { buildLifecycleAuth } from '../lifecycle-auth';
82
- import type { LifecycleSigner } from '../lifecycle-auth';
82
+ import type {
83
+ LifecycleAction,
84
+ LifecycleAuth,
85
+ LifecyclePaymentInfo,
86
+ LifecycleSigner,
87
+ } from '../lifecycle-auth';
88
+ import { X402Error } from '../types';
83
89
  import { getChainByName, isUsdPegged, usdConversionError } from '../chains';
84
90
  import {
85
91
  DEFAULT_RETRY_AFTER_SECONDS,
@@ -5770,9 +5776,65 @@ export interface LifecycleAuthOptions {
5770
5776
  lifecycleSigner?: LifecycleSigner;
5771
5777
  /**
5772
5778
  * Unix seconds. Default: `now + 600`, which leaves 300 s of headroom under
5773
- * the facilitator's 900 s ceiling.
5779
+ * the facilitator's 900 s ceiling. Only read alongside `lifecycleSigner` —
5780
+ * a `lifecycleAuth` already carries the deadline it was signed with.
5774
5781
  */
5775
5782
  lifecycleDeadline?: number;
5783
+ /**
5784
+ * An order that was ALREADY SIGNED somewhere else — the publisher's browser
5785
+ * — and that this backend only transports.
5786
+ *
5787
+ * The signing side builds the document with `buildLifecycleTypedData`, the
5788
+ * browser signs it, and `lifecycleAuthFromSignature` turns the signature
5789
+ * back into this block. Nothing is re-derived here: the `paymentInfo`, the
5790
+ * `payer` and the `amount` this call sends must be the ones the order
5791
+ * committed to, or the facilitator reads `bad_signature`.
5792
+ *
5793
+ * MUTUALLY EXCLUSIVE with `lifecycleSigner`. Passing both is not a
5794
+ * preference to resolve — it means the caller believes two different orders
5795
+ * are about to travel, and only one can. It throws.
5796
+ */
5797
+ lifecycleAuth?: LifecycleAuth;
5798
+ }
5799
+
5800
+ /**
5801
+ * The order that rides on a lifecycle request, from whichever of the two
5802
+ * shapes the caller chose.
5803
+ *
5804
+ * Kept out of both methods so the exclusivity rule is written ONCE: duplicated,
5805
+ * the day one branch grows a condition the other silently keeps the old one.
5806
+ */
5807
+ async function resolveLifecycleAuth(
5808
+ action: LifecycleAction,
5809
+ options: LifecycleAuthOptions | undefined,
5810
+ order: {
5811
+ paymentInfo: LifecyclePaymentInfo;
5812
+ payer: string;
5813
+ amount: string;
5814
+ chainId: number;
5815
+ }
5816
+ ): Promise<LifecycleAuth | undefined> {
5817
+ if (options?.lifecycleSigner && options?.lifecycleAuth) {
5818
+ throw new X402Error(
5819
+ 'lifecycleSigner and lifecycleAuth are mutually exclusive: the first signs an order ' +
5820
+ 'here, the second was already signed elsewhere. Passing both means two different ' +
5821
+ 'orders exist and only one can travel.',
5822
+ 'INVALID_CONFIG'
5823
+ );
5824
+ }
5825
+ // Transported as-is. Re-signing or "normalizing" it would change the bytes
5826
+ // the browser committed to.
5827
+ if (options?.lifecycleAuth) return options.lifecycleAuth;
5828
+ if (!options?.lifecycleSigner) return undefined;
5829
+ return buildLifecycleAuth({
5830
+ action,
5831
+ paymentInfo: order.paymentInfo,
5832
+ payer: order.payer,
5833
+ amount: order.amount,
5834
+ chainId: order.chainId,
5835
+ wallet: options.lifecycleSigner,
5836
+ deadline: options.lifecycleDeadline,
5837
+ });
5776
5838
  }
5777
5839
 
5778
5840
  /**
@@ -6469,6 +6531,13 @@ export class AdvancedEscrowClient {
6469
6531
  * lifecycleSigner: payerWallet, // EnvKeyAdapter, OWS, wagmiLifecycleSigner(...)
6470
6532
  * });
6471
6533
  * ```
6534
+ *
6535
+ * @example Transporting an order the publisher signed in their browser
6536
+ * ```typescript
6537
+ * // The browser sent back a signature over a document THIS backend built.
6538
+ * const lifecycleAuth = lifecycleAuthFromSignature(typedData, signature, payer);
6539
+ * await client.releaseViaFacilitator(pi, amount, { lifecycleAuth });
6540
+ * ```
6472
6541
  */
6473
6542
  async releaseViaFacilitator(
6474
6543
  paymentInfo: AdvancedPaymentInfo,
@@ -6493,20 +6562,16 @@ export class AdvancedEscrowClient {
6493
6562
  };
6494
6563
  const releaseAmount = amount || paymentInfo.maxAmount;
6495
6564
 
6496
- // The signed lifecycle order, when a signer is supplied. Without one the
6497
- // payload goes out byte-for-byte as it did before — the facilitator's
6498
- // default mode does not look at the field at all.
6499
- const lifecycleAuth = options?.lifecycleSigner
6500
- ? await buildLifecycleAuth({
6501
- action: 'release',
6502
- paymentInfo: wirePaymentInfo,
6503
- payer: this.payerAddress,
6504
- amount: releaseAmount,
6505
- chainId: this.chainId,
6506
- wallet: options.lifecycleSigner,
6507
- deadline: options.lifecycleDeadline,
6508
- })
6509
- : undefined;
6565
+ // The signed lifecycle order: signed here from a `lifecycleSigner`, or
6566
+ // handed over already signed as `lifecycleAuth`. With NEITHER the payload
6567
+ // goes out byte-for-byte as it did before — the facilitator's default
6568
+ // mode does not look at the field at all.
6569
+ const lifecycleAuth = await resolveLifecycleAuth('release', options, {
6570
+ paymentInfo: wirePaymentInfo,
6571
+ payer: this.payerAddress,
6572
+ amount: releaseAmount,
6573
+ chainId: this.chainId,
6574
+ });
6510
6575
 
6511
6576
  const payload = {
6512
6577
  x402Version: 2,
@@ -6660,17 +6725,12 @@ export class AdvancedEscrowClient {
6660
6725
  // Same optional order as `releaseViaFacilitator`, with the caveat that
6661
6726
  // the accepted signers differ: the receiver, the operator owner, or the
6662
6727
  // payer once `authorizationExpiry` has passed.
6663
- const lifecycleAuth = options?.lifecycleSigner
6664
- ? await buildLifecycleAuth({
6665
- action: 'refundInEscrow',
6666
- paymentInfo: wirePaymentInfo,
6667
- payer: this.payerAddress,
6668
- amount: refundAmount,
6669
- chainId: this.chainId,
6670
- wallet: options.lifecycleSigner,
6671
- deadline: options.lifecycleDeadline,
6672
- })
6673
- : undefined;
6728
+ const lifecycleAuth = await resolveLifecycleAuth('refundInEscrow', options, {
6729
+ paymentInfo: wirePaymentInfo,
6730
+ payer: this.payerAddress,
6731
+ amount: refundAmount,
6732
+ chainId: this.chainId,
6733
+ });
6674
6734
 
6675
6735
  const payload = {
6676
6736
  x402Version: 2,
package/src/index.ts CHANGED
@@ -359,6 +359,7 @@ export type {
359
359
  export {
360
360
  buildLifecycleAuth,
361
361
  buildLifecycleTypedData,
362
+ lifecycleAuthFromSignature,
362
363
  wagmiLifecycleSigner,
363
364
  LIFECYCLE_ACTIONS,
364
365
  LIFECYCLE_DOMAIN_NAME,
@@ -372,6 +373,7 @@ export type {
372
373
  LifecycleAuth,
373
374
  LifecyclePaymentInfo,
374
375
  LifecycleSigner,
376
+ LifecycleTypedData,
375
377
  LifecycleTypedDataParams,
376
378
  BuildLifecycleAuthParams,
377
379
  WagmiLifecycleWalletClient,
@@ -18,6 +18,19 @@
18
18
  * KMS. In H2A/H2H the PAYER signs in their own browser with the same wallet
19
19
  * they paid with, and the marketplace merely transports the block.
20
20
  *
21
+ * >>> TWO SHAPES, ONE ORDER <<<
22
+ * `buildLifecycleAuth` does the whole ceremony in one call: it mints the
23
+ * nonce and the deadline, signs through the injected adapter, and returns the
24
+ * block. That is the server and the `wagmi` paths.
25
+ *
26
+ * A publisher's browser cannot use it. There the backend assembles the
27
+ * document — nonce and deadline included — the browser signs whatever it was
28
+ * handed, and gives back a signature and nothing else. That is
29
+ * {@link buildLifecycleTypedData} on one side and
30
+ * {@link lifecycleAuthFromSignature} on the other, and it produces the SAME
31
+ * bytes as the one-call path for the same nonce and deadline, pinned in
32
+ * `src/lifecycle-auth.test.ts`.
33
+ *
21
34
  * Source of truth for the format is the facilitator, `x402-rs`
22
35
  * `src/payment_operator/lifecycle_auth.rs` (PR #21). The Python twin is
23
36
  * `uvd_x402_sdk.escrow_signing.build_lifecycle_auth` (0.78.0); the two must
@@ -191,31 +204,44 @@ export interface LifecycleTypedDataParams {
191
204
  amount: string | number | bigint;
192
205
  /** EVM chain id of the payment network. */
193
206
  chainId: number;
194
- /** Unix seconds after which the order is dead. */
195
- deadline: number;
196
- /** 32 bytes, hex. */
197
- nonce: string;
198
- }
199
-
200
- export interface BuildLifecycleAuthParams
201
- extends Omit<LifecycleTypedDataParams, 'deadline' | 'nonce'> {
202
207
  /**
203
- * The signer. Must be the payer, the receiver or the operator owner
204
- * depending on the action — see the table in the module docblock.
208
+ * Unix seconds after which the order is dead. Default: `now + 600`, which
209
+ * leaves 300 s of headroom under the facilitator's 900 s ceiling.
205
210
  */
206
- wallet: LifecycleSigner;
207
- /** Unix seconds. Default: `now + 600`. */
208
211
  deadline?: number;
209
212
  /**
210
- * 32 bytes in hex. Default: a fresh random one. Distinct PER ORDER — the
211
- * facilitator consumes it on acceptance (a stream's partial settles emit
212
- * one per delta).
213
+ * 32 bytes, hex. Default: a fresh random one. Distinct PER ORDER — the
214
+ * facilitator consumes it on acceptance.
213
215
  */
214
216
  nonce?: string;
215
- /** Unix seconds, for tests. Default: the clock. */
217
+ /** Unix seconds, for the `deadline` default and for tests. Default: the clock. */
216
218
  now?: number;
217
219
  }
218
220
 
221
+ /**
222
+ * The EIP-712 document itself: what {@link buildLifecycleTypedData} returns and
223
+ * what {@link lifecycleAuthFromSignature} reads back.
224
+ *
225
+ * Named because it is a WIRE type in the split flow: a backend builds it,
226
+ * ships it to a browser as JSON, and the browser hands it to `signTypedData`.
227
+ * `types` carries no `EIP712Domain` entry — ethers and viem both derive that
228
+ * one, and including it makes ethers throw `ambiguous primary types`.
229
+ */
230
+ export interface LifecycleTypedData {
231
+ domain: { name: string; version: string; chainId: number };
232
+ types: Record<string, Array<{ name: string; type: string }>>;
233
+ primaryType: 'LifecycleOrder';
234
+ message: Record<string, unknown>;
235
+ }
236
+
237
+ export interface BuildLifecycleAuthParams extends LifecycleTypedDataParams {
238
+ /**
239
+ * The signer. Must be the payer, the receiver or the operator owner
240
+ * depending on the action — see the table in the module docblock.
241
+ */
242
+ wallet: LifecycleSigner;
243
+ }
244
+
219
245
  // ============================================================================
220
246
  // COERCIONS — each one is a rejection the caller cannot see
221
247
  // ============================================================================
@@ -280,6 +306,28 @@ function randomNonce(): string {
280
306
  return ethers.hexlify(ethers.randomBytes(32));
281
307
  }
282
308
 
309
+ /**
310
+ * The two values that must be IDENTICAL in the signed document and in the
311
+ * `lifecycleAuth` block, resolved in ONE place so the two entry points cannot
312
+ * drift. {@link buildLifecycleAuth} needs them before signing; the split
313
+ * browser flow reads them back out of the document it built.
314
+ */
315
+ function resolveOrderTiming(params: { deadline?: number; nonce?: string; now?: number }): {
316
+ nowSec: number;
317
+ deadline: number;
318
+ nonce: string;
319
+ } {
320
+ const nowSec = params.now === undefined ? Math.floor(Date.now() / 1000) : Math.floor(params.now);
321
+ return {
322
+ nowSec,
323
+ deadline:
324
+ params.deadline === undefined
325
+ ? nowSec + LIFECYCLE_DEFAULT_DEADLINE_SECS
326
+ : Math.floor(params.deadline),
327
+ nonce: params.nonce === undefined ? randomNonce() : params.nonce,
328
+ };
329
+ }
330
+
283
331
  function toUintString(value: unknown, field: string): string {
284
332
  let n: bigint;
285
333
  try {
@@ -301,21 +349,40 @@ function toUintString(value: unknown, field: string): string {
301
349
  * The exact EIP-712 document a lifecycle order signs.
302
350
  *
303
351
  * Kept separate from {@link buildLifecycleAuth} because it is the useful
304
- * seam: it is what the Python twin mirrors, and what a test can poke field
305
- * by field to confirm the signature stops verifying.
352
+ * seam: it is what the Python twin mirrors, what a test can poke field by
353
+ * field to confirm the signature stops verifying, and — the reason it is
354
+ * PUBLIC — what a backend hands to a browser that signs.
355
+ *
356
+ * {@link buildLifecycleAuth} cannot serve that browser: it takes an injected
357
+ * `WalletAdapter` and mints its own nonce and deadline, while a publisher
358
+ * signs a document the backend already assembled and hands back only a
359
+ * signature. Build it here, ship it, and reassemble the wire block with
360
+ * {@link lifecycleAuthFromSignature} — which reads `deadline` and `nonce`
361
+ * back out of this same document, so the two cannot disagree.
362
+ *
363
+ * @example The split flow, backend half
364
+ * ```typescript
365
+ * const typedData = buildLifecycleTypedData({
366
+ * action: 'release',
367
+ * paymentInfo: pi, // the SAME object that will be sent
368
+ * payer, // payload.payer
369
+ * amount, // the SAME as payload.amount
370
+ * chainId: 8453,
371
+ * }); // deadline -> now + 600, nonce -> 32 fresh bytes
372
+ * // -> res.json({ typedData }) ... the browser signs it and posts back a signature
373
+ * const lifecycleAuth = lifecycleAuthFromSignature(typedData, signature, payer);
374
+ * ```
306
375
  *
307
376
  * @throws {X402Error} `INVALID_CONFIG` on an unknown action or a paymentInfo
308
- * missing a field; `INVALID_AMOUNT` on a negative amount. Nothing is
309
- * defaulted: an invented field is a signature over a different struct than
310
- * the one that is sent, i.e. a rejection the caller cannot diagnose.
377
+ * missing a field; `INVALID_AMOUNT` on a negative amount. Nothing IN THE
378
+ * SIGNED STRUCT is defaulted: an invented field is a signature over a
379
+ * different struct than the one that is sent, i.e. a rejection the caller
380
+ * cannot diagnose. `deadline` and `nonce` are the exception because they
381
+ * exist nowhere else — they are born here and read back from the document.
311
382
  */
312
- export function buildLifecycleTypedData(params: LifecycleTypedDataParams): {
313
- domain: { name: string; version: string; chainId: number };
314
- types: Record<string, Array<{ name: string; type: string }>>;
315
- primaryType: 'LifecycleOrder';
316
- message: Record<string, unknown>;
317
- } {
318
- const { action, paymentInfo, payer, amount, chainId, deadline, nonce } = params;
383
+ export function buildLifecycleTypedData(params: LifecycleTypedDataParams): LifecycleTypedData {
384
+ const { action, paymentInfo, payer, amount, chainId } = params;
385
+ const { deadline, nonce } = resolveOrderTiming(params);
319
386
 
320
387
  if (!LIFECYCLE_ACTIONS.includes(action)) {
321
388
  throw new X402Error(
@@ -402,11 +469,9 @@ export function buildLifecycleTypedData(params: LifecycleTypedDataParams): {
402
469
  export async function buildLifecycleAuth(
403
470
  params: BuildLifecycleAuthParams
404
471
  ): Promise<LifecycleAuth> {
405
- const { wallet, deadline, nonce, now, ...rest } = params;
472
+ const { wallet, ...rest } = params;
406
473
 
407
- const nowSec = now === undefined ? Math.floor(Date.now() / 1000) : Math.floor(now);
408
- const dl = deadline === undefined ? nowSec + LIFECYCLE_DEFAULT_DEADLINE_SECS : Math.floor(deadline);
409
- const nonceHex = nonce === undefined ? randomNonce() : nonce;
474
+ const { nowSec, deadline: dl, nonce: nonceHex } = resolveOrderTiming(params);
410
475
 
411
476
  if (dl < nowSec) {
412
477
  throw new X402Error(
@@ -434,6 +499,118 @@ export async function buildLifecycleAuth(
434
499
  };
435
500
  }
436
501
 
502
+ /**
503
+ * Reassemble `payload.lifecycleAuth` from a document that was signed
504
+ * ELSEWHERE — the other half of the split flow.
505
+ *
506
+ * `buildLifecycleAuth` owns the whole ceremony: it mints the nonce and the
507
+ * deadline, signs, and returns the block. A publisher's browser owns none of
508
+ * that. It receives a document the backend already assembled and gives back
509
+ * one string. This is the seam where that string becomes a wire block.
510
+ *
511
+ * `deadline` and `nonce` are NOT parameters: they are read out of
512
+ * `typedData.message`, which is the document that was actually hashed. Taking
513
+ * them from the caller would let the block claim a nonce the signature never
514
+ * committed to — a `bad_signature` the caller cannot see, because both halves
515
+ * look right on their own.
516
+ *
517
+ * The signature is NOT recovered here. This SDK's payers include ERC-7702
518
+ * delegated accounts and contract wallets that validate through ERC-1271
519
+ * (`src/erc7702.ts:8`), whose signatures do not ecrecover to their address;
520
+ * `ethers.verifyTypedData` would reject the good ones. Recovery — and the
521
+ * role check that goes with it — is the facilitator's, against the chain.
522
+ *
523
+ * @param typedData - what {@link buildLifecycleTypedData} returned, unmodified.
524
+ * @param signature - the 0x-hex the wallet gave back (`signTypedData`).
525
+ * @param signer - the address that signed, claimed in the block and checked
526
+ * against the recovered one by the facilitator.
527
+ *
528
+ * @throws {X402Error} `INVALID_CONFIG` if the document is not a
529
+ * `LifecycleOrder` for this domain, if the signature is not hex, or if the
530
+ * signer is not an address.
531
+ *
532
+ * @example The browser half
533
+ * ```typescript
534
+ * const signature = await walletClient.signTypedData({
535
+ * domain: typedData.domain,
536
+ * types: typedData.types,
537
+ * primaryType: typedData.primaryType,
538
+ * message: typedData.message,
539
+ * });
540
+ * const lifecycleAuth = lifecycleAuthFromSignature(typedData, signature, address);
541
+ * ```
542
+ */
543
+ export function lifecycleAuthFromSignature(
544
+ typedData: LifecycleTypedData,
545
+ signature: string,
546
+ signer: string
547
+ ): LifecycleAuth {
548
+ const td = (typedData ?? {}) as Partial<LifecycleTypedData>;
549
+ const message = (td.message ?? {}) as Record<string, unknown>;
550
+
551
+ // A payment's typed data would sail through the shape checks below and
552
+ // produce a block over the wrong struct entirely, so the document is
553
+ // identified before anything is read out of it.
554
+ if (td.primaryType !== 'LifecycleOrder') {
555
+ throw new X402Error(
556
+ `typedData.primaryType must be LifecycleOrder, got ${String(td.primaryType)}: ` +
557
+ 'pass the document buildLifecycleTypedData returned, unmodified',
558
+ 'INVALID_CONFIG'
559
+ );
560
+ }
561
+ const domain = (td.domain ?? {}) as Record<string, unknown>;
562
+ if (
563
+ domain.name !== LIFECYCLE_DOMAIN_NAME ||
564
+ String(domain.version) !== LIFECYCLE_DOMAIN_VERSION
565
+ ) {
566
+ throw new X402Error(
567
+ `typedData.domain must be ${LIFECYCLE_DOMAIN_NAME} v${LIFECYCLE_DOMAIN_VERSION}, got ` +
568
+ `${String(domain.name)} v${String(domain.version)}`,
569
+ 'INVALID_CONFIG'
570
+ );
571
+ }
572
+ if (message.deadline === undefined || message.nonce === undefined) {
573
+ throw new X402Error(
574
+ 'typedData.message carries no deadline/nonce: the wire block reads them from the ' +
575
+ 'document that was signed, it does not invent them',
576
+ 'INVALID_CONFIG'
577
+ );
578
+ }
579
+
580
+ // uint256 in the signature, a JSON number on the wire. The facilitator's
581
+ // block is typed `u64`, so a deadline that lost precision here is an
582
+ // `expired` on an order whose signature is perfectly good.
583
+ const deadlineBig = BigInt(toUintString(message.deadline, 'typedData.message.deadline'));
584
+ if (deadlineBig > BigInt(Number.MAX_SAFE_INTEGER)) {
585
+ throw new X402Error(
586
+ `typedData.message.deadline ${deadlineBig} does not fit a JSON number`,
587
+ 'INVALID_CONFIG'
588
+ );
589
+ }
590
+
591
+ if (typeof signature !== 'string' || !/^0x[0-9a-fA-F]+$/.test(signature) ||
592
+ signature.length % 2 !== 0) {
593
+ throw new X402Error(
594
+ `signature must be 0x-prefixed hex, got ${String(signature)}`,
595
+ 'INVALID_CONFIG'
596
+ );
597
+ }
598
+
599
+ let checksummed: string;
600
+ try {
601
+ checksummed = ethers.getAddress(signer);
602
+ } catch {
603
+ throw new X402Error(`signer is not an EVM address: ${String(signer)}`, 'INVALID_CONFIG');
604
+ }
605
+
606
+ return {
607
+ signer: checksummed,
608
+ deadline: Number(deadlineBig),
609
+ nonce: nonceToBytes32(message.nonce),
610
+ signature,
611
+ };
612
+ }
613
+
437
614
  // ============================================================================
438
615
  // BROWSER SIGNERS
439
616
  // ============================================================================