77
88import asyncio
99import time
10+ from dataclasses import dataclass
1011from datetime import UTC , datetime
11- from typing import TYPE_CHECKING , Any
12+ from typing import TYPE_CHECKING , Any , Literal
1213
1314import attrs
1415
4445ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
4546
4647
48+ @dataclass (frozen = True , slots = True )
49+ class MatchedTransferLog :
50+ kind : Literal ["memo" , "transfer" ]
51+ memo : str | None = None
52+
53+
4754def _rpc_error_msg (result : dict ) -> str :
4855 """Extract error message from a JSON-RPC error response."""
4956 error_obj = result ["error" ]
@@ -230,21 +237,23 @@ async def verify(
230237 raise VerificationError (f"Invalid credential type: { payload_data ['type' ]} " )
231238
232239 if isinstance (payload , HashCredentialPayload ):
233- return await self ._verify_hash (payload , req )
240+ return await self ._verify_hash (
241+ payload ,
242+ req ,
243+ challenge_id = credential .challenge .id ,
244+ realm = credential .challenge .realm ,
245+ )
234246 else :
235247 return await self ._verify_transaction (payload , req )
236248
237249 async def _verify_hash (
238250 self ,
239251 payload : HashCredentialPayload ,
240252 request : ChargeRequest ,
253+ challenge_id : str ,
254+ realm : str ,
241255 ) -> Receipt :
242256 """Verify a credential with a transaction hash."""
243- if self ._store is not None :
244- store_key = f"mpp:charge:{ payload .hash .lower ()} "
245- if not await self ._store .put_if_absent (store_key , payload .hash ):
246- raise VerificationError ("Transaction hash already used" )
247-
248257 client = await self ._get_client ()
249258
250259 rpc_url = self ._get_rpc_url ()
@@ -270,19 +279,56 @@ async def _verify_hash(
270279 if receipt_data .get ("status" ) != "0x1" :
271280 raise VerificationError ("Transaction reverted" )
272281
273- if not self ._verify_transfer_logs (receipt_data , request ):
282+ matched_logs = self ._verify_transfer_logs (receipt_data , request )
283+ if not matched_logs :
274284 raise VerificationError (
275285 "Transaction must contain a Transfer log matching request parameters"
276286 )
277287
288+ # Only verify challenge binding when using auto-generated attribution memos.
289+ # Explicit memos (set by the server) are strictly matched by _verify_transfer_logs
290+ # but are NOT challenge-bound. Callers that set explicit memos are responsible
291+ # for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
292+ if request .methodDetails .memo is None :
293+ self ._assert_challenge_bound_memo (
294+ matched_logs ,
295+ challenge_id = challenge_id ,
296+ realm = realm ,
297+ )
298+
299+ if self ._store is not None :
300+ store_key = f"mpp:charge:{ payload .hash .lower ()} "
301+ if not await self ._store .put_if_absent (store_key , payload .hash ):
302+ raise VerificationError ("Transaction hash already used" )
303+
278304 return Receipt .success (payload .hash )
279305
306+ def _assert_challenge_bound_memo (
307+ self ,
308+ matched_logs : list [MatchedTransferLog ],
309+ challenge_id : str ,
310+ realm : str ,
311+ ) -> None :
312+ from mpp .methods .tempo ._attribution import verify_challenge_binding , verify_server
313+
314+ bound = any (
315+ matched_log .kind == "memo"
316+ and matched_log .memo is not None
317+ and verify_server (matched_log .memo , realm )
318+ and verify_challenge_binding (matched_log .memo , challenge_id )
319+ for matched_log in matched_logs
320+ )
321+ if not bound :
322+ raise VerificationError (
323+ "Payment verification failed: memo is not bound to this challenge."
324+ )
325+
280326 def _verify_transfer_logs (
281327 self ,
282328 receipt : dict [str , Any ],
283329 request : ChargeRequest ,
284330 expected_sender : str | None = None ,
285- ) -> bool :
331+ ) -> list [ MatchedTransferLog ] :
286332 """Check if receipt contains matching Transfer or TransferWithMemo logs.
287333
288334 Args:
@@ -292,10 +338,13 @@ def _verify_transfer_logs(
292338 Transfer log matches this address (for payer identity verification).
293339
294340 Returns:
295- True if a matching Transfer/TransferWithMemo log is found,
296- False otherwise.
341+ Matched logs in priority order, with memo logs before plain
342+ transfers so downstream verification can inspect the memo that
343+ actually satisfied the payment.
297344 """
298345 expected_memo = request .methodDetails .memo
346+ memo_matches : list [MatchedTransferLog ] = []
347+ transfer_matches : list [MatchedTransferLog ] = []
299348
300349 for log in receipt .get ("logs" , []):
301350 if log .get ("address" , "" ).lower () != request .currency .lower ():
@@ -315,9 +364,7 @@ def _verify_transfer_logs(
315364 if expected_sender and from_address .lower () != expected_sender .lower ():
316365 continue
317366
318- if expected_memo :
319- if event_topic != TRANSFER_WITH_MEMO_TOPIC :
320- continue
367+ if event_topic == TRANSFER_WITH_MEMO_TOPIC :
321368 # TransferWithMemo has 3 indexed params (from, to, memo)
322369 # so memo is in topics[3] and only amount is in data
323370 if len (topics ) < 4 :
@@ -326,22 +373,26 @@ def _verify_transfer_logs(
326373 if len (data ) < 66 :
327374 continue
328375 amount = int (data [2 :66 ], 16 )
329- memo = topics [3 ]
330- memo_clean = expected_memo .lower ()
331- if not memo_clean .startswith ("0x" ):
332- memo_clean = "0x" + memo_clean
333- if amount == int (request .amount ) and memo .lower () == memo_clean :
334- return True
335- else :
336- if event_topic != TRANSFER_TOPIC :
376+ if amount != int (request .amount ):
337377 continue
378+ memo = topics [3 ]
379+ if expected_memo :
380+ memo_clean = expected_memo .lower ()
381+ if not memo_clean .startswith ("0x" ):
382+ memo_clean = "0x" + memo_clean
383+ if memo .lower () != memo_clean :
384+ continue
385+ memo_matches .append (MatchedTransferLog (kind = "memo" , memo = memo ))
386+ continue
387+
388+ if event_topic == TRANSFER_TOPIC and expected_memo is None :
338389 data = log .get ("data" , "0x" )
339390 if len (data ) >= 66 :
340391 amount = int (data , 16 )
341392 if amount == int (request .amount ):
342- return True
393+ transfer_matches . append ( MatchedTransferLog ( kind = "transfer" ))
343394
344- return False
395+ return memo_matches + transfer_matches
345396
346397 async def _verify_transaction (
347398 self ,
@@ -449,6 +500,11 @@ async def _verify_transaction(
449500 "Transaction must contain a Transfer log matching request parameters"
450501 )
451502
503+ if self ._store is not None :
504+ store_key = f"mpp:charge:{ tx_hash .lower ()} "
505+ if not await self ._store .put_if_absent (store_key , tx_hash ):
506+ raise VerificationError ("Transaction hash already used" )
507+
452508 return Receipt .success (tx_hash )
453509
454510 def _cosign_as_fee_payer (
0 commit comments