@@ -1418,16 +1418,19 @@ def _build_0x78_envelope(
14181418 currency : str = "0x20c0000000000000000000000000000000000000" ,
14191419 recipient : str = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" ,
14201420 amount : int = 1000000 ,
1421+ memo : str | None = None ,
14211422 ) -> str :
14221423 """Build a 0x78 fee payer envelope."""
14231424 from pytempo import Call , TempoTransaction
14241425
14251426 from mpp .methods .tempo .fee_payer_envelope import encode_fee_payer_envelope
14261427
1427- selector = "a9059cbb"
1428+ selector = TRANSFER_WITH_MEMO_SELECTOR if memo is not None else TRANSFER_SELECTOR
14281429 to_padded = recipient [2 :].lower ().zfill (64 )
14291430 amount_padded = hex (amount )[2 :].zfill (64 )
14301431 transfer_data = f"0x{ selector } { to_padded } { amount_padded } "
1432+ if memo is not None :
1433+ transfer_data += memo [2 :] if memo .startswith ("0x" ) else memo
14311434
14321435 tx = TempoTransaction .create (
14331436 chain_id = 42431 ,
@@ -1449,16 +1452,19 @@ def _build_0x76_tx(
14491452 currency : str = "0x20c0000000000000000000000000000000000000" ,
14501453 recipient : str = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" ,
14511454 amount : int = 1000000 ,
1455+ memo : str | None = None ,
14521456 ) -> str :
14531457 """Build a standard 0x76 transaction."""
14541458 import attrs
14551459 from pytempo import Call , TempoTransaction
14561460 from pytempo .models import Signature
14571461
1458- selector = "a9059cbb"
1462+ selector = TRANSFER_WITH_MEMO_SELECTOR if memo is not None else TRANSFER_SELECTOR
14591463 to_padded = recipient [2 :].lower ().zfill (64 )
14601464 amount_padded = hex (amount )[2 :].zfill (64 )
14611465 transfer_data = f"0x{ selector } { to_padded } { amount_padded } "
1466+ if memo is not None :
1467+ transfer_data += memo [2 :] if memo .startswith ("0x" ) else memo
14621468
14631469 tx = TempoTransaction .create (
14641470 chain_id = 42431 ,
@@ -1486,6 +1492,19 @@ def test_accepts_0x78_with_matching_call(self) -> None:
14861492 # Should not raise
14871493 intent ._validate_transaction_payload (sig , request )
14881494
1495+ def test_accepts_0x78_with_transfer_with_memo_when_no_server_memo (self ) -> None :
1496+ """Should accept 0x78 envelopes with client attribution memos."""
1497+ intent = ChargeIntent (rpc_url = "https://rpc.test" )
1498+ request = ChargeRequest (
1499+ amount = "1000000" ,
1500+ currency = "0x20c0000000000000000000000000000000000000" ,
1501+ recipient = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" ,
1502+ )
1503+ memo = encode_attribution (challenge_id = "challenge-123" , server_id = "api.example.com" )
1504+ sig = self ._build_0x78_envelope (memo = memo )
1505+
1506+ intent ._validate_transaction_payload (sig , request )
1507+
14891508 def test_rejects_0x78_with_wrong_amount (self ) -> None :
14901509 """Should reject a 0x78 envelope with mismatched amount."""
14911510 intent = ChargeIntent (rpc_url = "https://rpc.test" )
@@ -2092,15 +2111,21 @@ def test_memo_normalization_no_0x_prefix(self) -> None:
20922111 )
20932112 assert _match_transfer_calldata (calldata , request ) is True
20942113
2095- def test_no_memo_accepts_only_transfer_selector (self ) -> None :
2096- """When no memo, only plain transfer selector should be accepted."""
2114+ def test_no_memo_accepts_plain_transfer_and_transfer_with_memo (self ) -> None :
2115+ """When no memo, plain transfer and transferWithMemo should be accepted."""
20972116 request = self ._make_request (memo = None )
20982117 calldata_plain = self ._build_calldata (TRANSFER_SELECTOR , self .RECIPIENT , self .AMOUNT )
20992118 calldata_memo = self ._build_calldata (
2100- TRANSFER_WITH_MEMO_SELECTOR , self .RECIPIENT , self .AMOUNT
2119+ TRANSFER_WITH_MEMO_SELECTOR , self .RECIPIENT , self .AMOUNT , self . MEMO
21012120 )
21022121 assert _match_transfer_calldata (calldata_plain , request ) is True
2103- assert _match_transfer_calldata (calldata_memo , request ) is False
2122+ assert _match_transfer_calldata (calldata_memo , request ) is True
2123+
2124+ def test_no_memo_rejects_short_transfer_with_memo_calldata (self ) -> None :
2125+ """When no memo, truncated transferWithMemo calldata should still be rejected."""
2126+ request = self ._make_request (memo = None )
2127+ calldata = self ._build_calldata (TRANSFER_WITH_MEMO_SELECTOR , self .RECIPIENT , self .AMOUNT )
2128+ assert _match_transfer_calldata (calldata , request ) is False
21042129
21052130 def test_short_calldata_rejected (self ) -> None :
21062131 """Calldata shorter than 136 chars should always be rejected."""
@@ -2335,6 +2360,25 @@ def test_valid_tx_with_matching_call_passes(self) -> None:
23352360
23362361 intent ._validate_transaction_payload (sig , request )
23372362
2363+ def test_valid_tx_with_transfer_with_memo_passes_when_no_server_memo (self ) -> None :
2364+ """Transaction credentials should allow client attribution memos."""
2365+ intent = ChargeIntent (rpc_url = "https://rpc.test" )
2366+ request = self ._make_request ()
2367+ memo = encode_attribution (challenge_id = "challenge-123" , server_id = "api.example.com" )
2368+
2369+ selector = bytes .fromhex (TRANSFER_WITH_MEMO_SELECTOR )
2370+ to_padded = bytes .fromhex (self .RECIPIENT [2 :].lower ().zfill (64 ))
2371+ amount_padded = bytes .fromhex (hex (1000000 )[2 :].zfill (64 ))
2372+ call_data = selector + to_padded + amount_padded + bytes .fromhex (memo [2 :])
2373+
2374+ currency_bytes = bytes .fromhex (self .CURRENCY [2 :])
2375+ call = [currency_bytes , b"" , call_data ]
2376+ decoded = [b"\x01 " , b"\x01 " , b"\x01 " , b"\x01 " , [call ], b"" , b"" , b"\x00 " , b"" , b"" , b"" ]
2377+ payload = b"\x76 " + rlp .encode (decoded ) # type: ignore[operator]
2378+ sig = "0x" + payload .hex ()
2379+
2380+ intent ._validate_transaction_payload (sig , request )
2381+
23382382 def test_no_matching_call_raises (self ) -> None :
23392383 """Transaction with no matching call should raise VerificationError."""
23402384 intent = ChargeIntent (rpc_url = "https://rpc.test" )
@@ -2774,8 +2818,18 @@ def test_memo_accepts_correct_selector(self) -> None:
27742818 is True
27752819 )
27762820
2777- def test_no_memo_rejects_transfer_with_memo_selector (self ) -> None :
2778- """When no memo expected, transferWithMemo calldata must be rejected."""
2821+ def test_no_memo_accepts_transfer_with_memo_selector (self ) -> None :
2822+ """When no memo expected, transferWithMemo calldata should be accepted."""
2823+ calldata = self ._build_calldata (
2824+ TRANSFER_WITH_MEMO_SELECTOR ,
2825+ self .RECIPIENT ,
2826+ self .AMOUNT ,
2827+ "ab" * 32 ,
2828+ )
2829+ assert _match_single_transfer_calldata (calldata , self .RECIPIENT , self .AMOUNT , None ) is True
2830+
2831+ def test_no_memo_rejects_short_transfer_with_memo_calldata (self ) -> None :
2832+ """When no memo expected, truncated transferWithMemo calldata should be rejected."""
27792833 calldata = self ._build_calldata (TRANSFER_WITH_MEMO_SELECTOR , self .RECIPIENT , self .AMOUNT )
27802834 assert _match_single_transfer_calldata (calldata , self .RECIPIENT , self .AMOUNT , None ) is False
27812835
@@ -2785,7 +2839,7 @@ def test_no_memo_accepts_plain_transfer(self) -> None:
27852839
27862840
27872841class TestSplitLogMemoStrictness :
2788- """Tests that memo-less split logs reject transferWithMemo events ."""
2842+ """Tests that memo-less split logs can still preserve attribution memos ."""
27892843
27902844 CURRENCY = "0x20c0000000000000000000000000000000000000"
27912845 RECIPIENT = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00"
@@ -2830,8 +2884,8 @@ def test_single_transfer_accepts_transfer_with_memo_log(self) -> None:
28302884 }
28312885 assert intent ._verify_transfer_logs (receipt , request )
28322886
2833- def test_multi_split_rejects_transfer_with_memo_log_for_memoless (self ) -> None :
2834- """Memo-less split legs must reject TransferWithMemo logs."""
2887+ def test_multi_split_accepts_transfer_with_memo_log_for_memoless (self ) -> None :
2888+ """Memo-less split legs should accept TransferWithMemo logs."""
28352889 intent = ChargeIntent (rpc_url = "https://rpc.test" )
28362890 request = ChargeRequest (
28372891 amount = str (self .AMOUNT ),
@@ -2845,7 +2899,7 @@ def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None:
28452899 "logs" : [
28462900 # primary as Transfer (correct)
28472901 self ._make_log (TRANSFER_TOPIC , self .RECIPIENT , 700000 ),
2848- # split as TransferWithMemo (should be rejected )
2902+ # split as TransferWithMemo (accepted; challenge binding is checked later )
28492903 self ._make_log (
28502904 TRANSFER_WITH_MEMO_TOPIC ,
28512905 self .SPLIT_RECIPIENT ,
@@ -2854,7 +2908,34 @@ def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None:
28542908 ),
28552909 ],
28562910 }
2857- assert not intent ._verify_transfer_logs (receipt , request )
2911+ matched_logs = intent ._verify_transfer_logs (receipt , request )
2912+ assert [matched_log .kind for matched_log in matched_logs ] == ["transfer" , "memo" ]
2913+
2914+ def test_multi_split_prefers_transfer_with_memo_log_for_memoless (self ) -> None :
2915+ """Memo-less split legs should prefer memo logs over plain transfers when both exist."""
2916+ intent = ChargeIntent (rpc_url = "https://rpc.test" )
2917+ request = ChargeRequest (
2918+ amount = str (self .AMOUNT ),
2919+ currency = self .CURRENCY ,
2920+ recipient = self .RECIPIENT ,
2921+ methodDetails = MethodDetails (
2922+ splits = [Split (amount = "300000" , recipient = self .SPLIT_RECIPIENT )]
2923+ ),
2924+ )
2925+ receipt = {
2926+ "logs" : [
2927+ self ._make_log (TRANSFER_TOPIC , self .RECIPIENT , 700000 ),
2928+ self ._make_log (TRANSFER_TOPIC , self .SPLIT_RECIPIENT , 300000 ),
2929+ self ._make_log (
2930+ TRANSFER_WITH_MEMO_TOPIC ,
2931+ self .SPLIT_RECIPIENT ,
2932+ 300000 ,
2933+ memo = "0x" + "ee" * 32 ,
2934+ ),
2935+ ],
2936+ }
2937+ matched_logs = intent ._verify_transfer_logs (receipt , request )
2938+ assert [matched_log .kind for matched_log in matched_logs ] == ["transfer" , "memo" ]
28582939
28592940
28602941class TestSplitsFeePayerRejection :
0 commit comments