@@ -172,7 +172,11 @@ def asset_meta(chain_id, asset):
172172
173173
174174def parse_402 (status , headers , body ):
175- """Return (version, [normalized_option, ...]). Raises if not a usable 402."""
175+ """Return (version, [option, ...], resource_info). Raises if not a usable 402.
176+
177+ resource_info is the v2 top-level ResourceInfo object (v2 forwards it into
178+ the payment payload); it is None for v1, which carries resource per option.
179+ """
176180 if status != 402 :
177181 raise CeremonyError ("expected HTTP 402, got %s" % status )
178182
@@ -217,7 +221,8 @@ def parse_402(status, headers, body):
217221 })
218222 if not options :
219223 raise CeremonyError ("402 had no payment options" )
220- return version , options
224+ resource_info = data .get ("resource" ) if isinstance (data , dict ) else None
225+ return version , options , resource_info
221226
222227
223228def describe (option ):
@@ -228,7 +233,12 @@ def describe(option):
228233 except CeremonyError :
229234 chain_id = None
230235 out ["chainId" ] = chain_id
231- out ["eligible" ] = chain_id is not None and option .get ("scheme" ) == "exact"
236+ # extra.assetTransferMethod is absent on v1 and on eip3009 v2 offers; only an
237+ # explicit "permit2" is ineligible, since this script signs EIP-3009 only.
238+ transfer = (option .get ("extra" ) or {}).get ("assetTransferMethod" )
239+ out ["assetTransferMethod" ] = transfer or "eip3009"
240+ out ["eligible" ] = (chain_id is not None and option .get ("scheme" ) == "exact"
241+ and out ["assetTransferMethod" ] == "eip3009" )
232242 meta = asset_meta (chain_id , option .get ("asset" ))
233243 if meta :
234244 out ["symbol" ] = meta ["symbol" ]
@@ -257,8 +267,15 @@ def select(options, want_asset=None, want_network=None):
257267 continue
258268 eligible .append (d )
259269 if not eligible :
270+ exact_on_chain = [d for d in described
271+ if d .get ("chainId" ) is not None and d .get ("scheme" ) == "exact" ]
272+ if exact_on_chain and all (d ["assetTransferMethod" ] == "permit2" for d in exact_on_chain ):
273+ raise CeremonyError (
274+ "the only payable options use the permit2 asset transfer method, which this "
275+ "script does not support (it signs EIP-3009 transferWithAuthorization only). "
276+ "Offered: %s" % json .dumps (described ))
260277 raise CeremonyError (
261- "no eligible option (need scheme 'exact' on a network mm supports). "
278+ "no eligible option (need scheme 'exact', EIP-3009, on a network mm supports). "
262279 "Offered: %s" % json .dumps (described ))
263280 if len (eligible ) > 1 :
264281 raise CeremonyError (
@@ -292,14 +309,16 @@ def build_typed_data(option, chain_id, from_addr):
292309 authorization may live is the wallet's policy, not a constant here. A random
293310 nonce keeps each authorization single-use.
294311 """
312+ now = int (time .time ())
295313 nonce = "0x" + secrets .token_bytes (32 ).hex ()
296- valid_before = str (int (time .time ()) + int (option ["maxTimeoutSeconds" ]))
297314 authorization = {
298315 "from" : from_addr ,
299316 "to" : option ["payTo" ],
300317 "value" : option ["amount" ],
301- "validAfter" : "0" ,
302- "validBefore" : valid_before ,
318+ # Backdate validAfter (matching the reference client); some facilitators
319+ # reject "0" as anomalous and it tolerates minor clock skew.
320+ "validAfter" : str (now - 600 ),
321+ "validBefore" : str (now + int (option ["maxTimeoutSeconds" ])),
303322 "nonce" : nonce ,
304323 }
305324 typed_data = {
@@ -331,6 +350,37 @@ def build_typed_data(option, chain_id, from_addr):
331350 return typed_data , authorization
332351
333352
353+ def build_payment (version , option , resource_info , signature , authorization , url ):
354+ """Assemble the PaymentPayload for the chosen option.
355+
356+ The envelope differs by version (x402 spec section 5.2): v2 nests the chosen
357+ requirements under `accepted` and forwards the `resource`, while v1 puts
358+ `scheme`/`network` at the top level. A facilitator rejects a v2 payload that
359+ is missing `accepted`.
360+ """
361+ if version == 2 :
362+ return {
363+ "x402Version" : 2 ,
364+ "resource" : resource_info or {"url" : url },
365+ "accepted" : {
366+ "scheme" : option ["scheme" ],
367+ "network" : option ["network" ],
368+ "amount" : option ["amount" ],
369+ "asset" : option ["asset" ],
370+ "payTo" : option ["payTo" ],
371+ "maxTimeoutSeconds" : option ["maxTimeoutSeconds" ],
372+ "extra" : option .get ("extra" , {}),
373+ },
374+ "payload" : {"signature" : signature , "authorization" : authorization },
375+ }
376+ return {
377+ "x402Version" : 1 ,
378+ "scheme" : option ["scheme" ],
379+ "network" : option ["network" ],
380+ "payload" : {"signature" : signature , "authorization" : authorization },
381+ }
382+
383+
334384def validate (option , chain_id ):
335385 """Check the offer is structurally sound before signing.
336386
@@ -351,23 +401,38 @@ def validate(option, chain_id):
351401 raise CeremonyError ("402 option missing EIP-712 domain name/version in 'extra'" )
352402
353403
354- def settlement (headers , version ):
355- """Decode the facilitator's settlement receipt, if the server sent one."""
404+ def settlement (headers , version , body ):
405+ """Decode the facilitator's settlement receipt.
406+
407+ The spec puts it in the X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header, but
408+ some servers return it only in the response body, so fall back to the body
409+ when the header is absent.
410+ """
356411 name = "X-PAYMENT-RESPONSE" if version == 1 else "PAYMENT-RESPONSE"
357412 raw = headers .get (name )
358- if not raw :
413+ if raw :
414+ try :
415+ return json .loads (base64 .b64decode (raw ))
416+ except (ValueError , UnicodeDecodeError ):
417+ pass
418+ try :
419+ data = json .loads (body .decode ("utf-8" ))
420+ except (ValueError , UnicodeDecodeError ):
359421 return None
360- return json .loads (base64 .b64decode (raw ))
422+ if isinstance (data , dict ) and (data .get ("transaction" ) or data .get ("txHash" )):
423+ return data
424+ return None
361425
362426
363427def cmd_inspect (url , method , data , content_type ):
364428 """Print the 402 payment requirement(s) without signing or spending."""
365429 body , headers = request_parts (data , content_type )
366430 status , rheaders , rbody = http (url , method , headers , body )
367- version , options = parse_402 (status , rheaders , rbody )
431+ version , options , resource_info = parse_402 (status , rheaders , rbody )
368432 print (json .dumps ({
369433 "status" : "payment_required" ,
370434 "x402Version" : version ,
435+ "resource" : resource_info ,
371436 "options" : [describe (o ) for o in options ],
372437 }, indent = 2 ))
373438
@@ -380,7 +445,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
380445 body , headers = request_parts (data , content_type )
381446 # Fetch fresh so the short 402 window is never stale.
382447 status , rheaders , rbody = http (url , method , headers , body )
383- version , options = parse_402 (status , rheaders , rbody )
448+ version , options , resource_info = parse_402 (status , rheaders , rbody )
384449 option = select (options , want_asset , want_network )
385450 chain_id = option ["chainId" ]
386451 validate (option , chain_id )
@@ -393,9 +458,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
393458 option ["payTo" ], url )
394459 signature = sign_typed_data (chain_id , typed_data , intent )
395460
396- payment = {"x402Version" : version , "scheme" : option ["scheme" ],
397- "network" : option ["network" ],
398- "payload" : {"signature" : signature , "authorization" : authorization }}
461+ payment = build_payment (version , option , resource_info , signature , authorization , url )
399462 b64 = base64 .b64encode (json .dumps (payment ).encode ()).decode ()
400463 header = "X-PAYMENT" if version == 1 else "PAYMENT-SIGNATURE"
401464
@@ -405,7 +468,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
405468 # One attempt only: do not retry a payment.
406469 raise CeremonyError ("payment not accepted (HTTP %s): %s" % (status , rbody .decode ("utf-8" , "replace" )))
407470
408- settle = settlement (rheaders , version )
471+ settle = settlement (rheaders , version , rbody )
409472 try :
410473 resource = json .loads (rbody .decode ("utf-8" ))
411474 except ValueError :
0 commit comments