@@ -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
@@ -211,13 +215,14 @@ def parse_402(status, headers, body):
211215 "amount" : str (amount ) if amount is not None else None ,
212216 "payTo" : a .get ("payTo" ),
213217 "asset" : a .get ("asset" ),
214- "maxTimeoutSeconds" : a .get ("maxTimeoutSeconds" , 60 ),
218+ "maxTimeoutSeconds" : a .get ("maxTimeoutSeconds" , 3600 ),
215219 "extra" : a .get ("extra" , {}),
216220 "resource" : a .get ("resource" ),
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,18 @@ 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: the spec allows "0", and the official Python
319+ # client uses it, but the TypeScript reference client backdates 10
320+ # minutes because some facilitators reject "0" and it absorbs clock
321+ # skew. Follow the safer value.
322+ "validAfter" : str (now - 600 ),
323+ "validBefore" : str (now + int (option ["maxTimeoutSeconds" ])),
303324 "nonce" : nonce ,
304325 }
305326 typed_data = {
@@ -331,6 +352,37 @@ def build_typed_data(option, chain_id, from_addr):
331352 return typed_data , authorization
332353
333354
355+ def build_payment (version , option , resource_info , signature , authorization , url ):
356+ """Assemble the PaymentPayload for the chosen option.
357+
358+ The envelope differs by version (x402 spec section 5.2): v2 nests the chosen
359+ requirements under `accepted` and forwards the `resource`, while v1 puts
360+ `scheme`/`network` at the top level. A facilitator rejects a v2 payload that
361+ is missing `accepted`.
362+ """
363+ if version == 2 :
364+ return {
365+ "x402Version" : 2 ,
366+ "resource" : resource_info or {"url" : url },
367+ "accepted" : {
368+ "scheme" : option ["scheme" ],
369+ "network" : option ["network" ],
370+ "amount" : option ["amount" ],
371+ "asset" : option ["asset" ],
372+ "payTo" : option ["payTo" ],
373+ "maxTimeoutSeconds" : option ["maxTimeoutSeconds" ],
374+ "extra" : option .get ("extra" , {}),
375+ },
376+ "payload" : {"signature" : signature , "authorization" : authorization },
377+ }
378+ return {
379+ "x402Version" : 1 ,
380+ "scheme" : option ["scheme" ],
381+ "network" : option ["network" ],
382+ "payload" : {"signature" : signature , "authorization" : authorization },
383+ }
384+
385+
334386def validate (option , chain_id ):
335387 """Check the offer is structurally sound before signing.
336388
@@ -351,23 +403,38 @@ def validate(option, chain_id):
351403 raise CeremonyError ("402 option missing EIP-712 domain name/version in 'extra'" )
352404
353405
354- def settlement (headers , version ):
355- """Decode the facilitator's settlement receipt, if the server sent one."""
406+ def settlement (headers , version , body ):
407+ """Decode the facilitator's settlement receipt.
408+
409+ The spec puts it in the X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header, but
410+ some servers return it only in the response body, so fall back to the body
411+ when the header is absent.
412+ """
356413 name = "X-PAYMENT-RESPONSE" if version == 1 else "PAYMENT-RESPONSE"
357414 raw = headers .get (name )
358- if not raw :
415+ if raw :
416+ try :
417+ return json .loads (base64 .b64decode (raw ))
418+ except (ValueError , UnicodeDecodeError ):
419+ pass
420+ try :
421+ data = json .loads (body .decode ("utf-8" ))
422+ except (ValueError , UnicodeDecodeError ):
359423 return None
360- return json .loads (base64 .b64decode (raw ))
424+ if isinstance (data , dict ) and (data .get ("transaction" ) or data .get ("txHash" )):
425+ return data
426+ return None
361427
362428
363429def cmd_inspect (url , method , data , content_type ):
364430 """Print the 402 payment requirement(s) without signing or spending."""
365431 body , headers = request_parts (data , content_type )
366432 status , rheaders , rbody = http (url , method , headers , body )
367- version , options = parse_402 (status , rheaders , rbody )
433+ version , options , resource_info = parse_402 (status , rheaders , rbody )
368434 print (json .dumps ({
369435 "status" : "payment_required" ,
370436 "x402Version" : version ,
437+ "resource" : resource_info ,
371438 "options" : [describe (o ) for o in options ],
372439 }, indent = 2 ))
373440
@@ -380,7 +447,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
380447 body , headers = request_parts (data , content_type )
381448 # Fetch fresh so the short 402 window is never stale.
382449 status , rheaders , rbody = http (url , method , headers , body )
383- version , options = parse_402 (status , rheaders , rbody )
450+ version , options , resource_info = parse_402 (status , rheaders , rbody )
384451 option = select (options , want_asset , want_network )
385452 chain_id = option ["chainId" ]
386453 validate (option , chain_id )
@@ -393,9 +460,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
393460 option ["payTo" ], url )
394461 signature = sign_typed_data (chain_id , typed_data , intent )
395462
396- payment = {"x402Version" : version , "scheme" : option ["scheme" ],
397- "network" : option ["network" ],
398- "payload" : {"signature" : signature , "authorization" : authorization }}
463+ payment = build_payment (version , option , resource_info , signature , authorization , url )
399464 b64 = base64 .b64encode (json .dumps (payment ).encode ()).decode ()
400465 header = "X-PAYMENT" if version == 1 else "PAYMENT-SIGNATURE"
401466
@@ -405,7 +470,7 @@ def cmd_pay(url, method, data, content_type, confirm, want_asset, want_network):
405470 # One attempt only: do not retry a payment.
406471 raise CeremonyError ("payment not accepted (HTTP %s): %s" % (status , rbody .decode ("utf-8" , "replace" )))
407472
408- settle = settlement (rheaders , version )
473+ settle = settlement (rheaders , version , rbody )
409474 try :
410475 resource = json .loads (rbody .decode ("utf-8" ))
411476 except ValueError :
0 commit comments