From 38bee1936eca24ab562fd96f36a6b0ddc2d9de1d Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 17:20:11 -0700 Subject: [PATCH 1/9] feat: add split payments support for Tempo charges Port of mpp-rs PR #180. Allows a single charge to be split across multiple recipients. Changes: - Split model and splits field on MethodDetails (schemas) - get_transfers() computes ordered transfer list (primary + splits) - Multi-transfer verification in receipt logs and transaction calldata - Order-insensitive matching with memo-specificity sorting - Client builds multiple Call objects when splits present - Server charge() accepts splits parameter - Scope binding: memo and splits compared in verify flow --- .changelog/brave-cats-split.md | 5 + src/mpp/methods/tempo/__init__.py | 3 +- src/mpp/methods/tempo/client.py | 32 ++- src/mpp/methods/tempo/intents.py | 340 ++++++++++++++++++++++++------ src/mpp/methods/tempo/schemas.py | 9 + src/mpp/server/mpp.py | 5 +- tests/test_tempo.py | 255 ++++++++++++++++++++++ 7 files changed, 574 insertions(+), 75 deletions(-) create mode 100644 .changelog/brave-cats-split.md diff --git a/.changelog/brave-cats-split.md b/.changelog/brave-cats-split.md new file mode 100644 index 0000000..d748d4d --- /dev/null +++ b/.changelog/brave-cats-split.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added split payments support for Tempo charges, allowing a single charge to be split across multiple recipients. Port of [mpp-rs PR #180](https://github.com/tempoxyz/mpp-rs/pull/180). diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 5c2f735..6ec6aa3 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -43,7 +43,8 @@ _LAZY_EXPORTS = { "mpp.methods.tempo.account": ("TempoAccount",), "mpp.methods.tempo.client": ("TempoMethod", "TransactionError", "tempo"), - "mpp.methods.tempo.intents": ("ChargeIntent",), + "mpp.methods.tempo.intents": ("ChargeIntent", "Transfer", "get_transfers"), + "mpp.methods.tempo.schemas": ("Split",), } diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 11f3029..764467e 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -125,6 +125,8 @@ async def create_credential(self, challenge: Challenge) -> Credential: client_id=self.client_id, ) + splits = method_details.get("splits") if isinstance(method_details, dict) else None + # Resolve RPC URL from challenge's chainId (like mppx), falling back # to the method-level rpc_url. rpc_url = self.rpc_url @@ -159,6 +161,7 @@ async def create_credential(self, challenge: Challenge) -> Credential: rpc_url=rpc_url, expected_chain_id=expected_chain_id, awaiting_fee_payer=use_fee_payer, + splits=splits, ) # When signing with an access key, the credential source is the @@ -181,6 +184,7 @@ async def _build_tempo_transfer( rpc_url: str | None = None, expected_chain_id: int | None = None, awaiting_fee_payer: bool = False, + splits: list[dict] | None = None, ) -> tuple[str, int]: """Build a client-signed Tempo transaction. @@ -215,10 +219,28 @@ async def _build_tempo_transfer( resolved_rpc = rpc_url or self.rpc_url - if memo: - transfer_data = self._encode_transfer_with_memo(recipient, int(amount), memo) + if splits: + from mpp.methods.tempo.intents import get_transfers + from mpp.methods.tempo.schemas import Split as SplitModel + + parsed_splits = [SplitModel(**s) for s in splits] + transfer_list = get_transfers(int(amount), recipient, memo, parsed_splits) + call_list = [] + for t in transfer_list: + if t.memo is not None: + td = self._encode_transfer_with_memo(t.recipient, t.amount, "0x" + t.memo.hex()) + else: + td = self._encode_transfer(t.recipient, t.amount) + call_list.append(Call.create(to=currency, value=0, data=td)) + calls_tuple = tuple(call_list) + gas_estimate_data = call_list[0].data.hex() if call_list else None else: - transfer_data = self._encode_transfer(recipient, int(amount)) + if memo: + transfer_data = self._encode_transfer_with_memo(recipient, int(amount), memo) + else: + transfer_data = self._encode_transfer(recipient, int(amount)) + calls_tuple = (Call.create(to=currency, value=0, data=transfer_data),) + gas_estimate_data = transfer_data # When using an access key, fetch nonce from the root account # (smart wallet), not the access key address. @@ -243,7 +265,7 @@ async def _build_tempo_transfer( gas_limit = DEFAULT_GAS_LIMIT try: - estimated = await estimate_gas(resolved_rpc, nonce_address, currency, transfer_data) + estimated = await estimate_gas(resolved_rpc, nonce_address, currency, gas_estimate_data) gas_limit = max(gas_limit, estimated + 5_000) except Exception: pass @@ -258,7 +280,7 @@ async def _build_tempo_transfer( fee_token=None if awaiting_fee_payer else currency, awaiting_fee_payer=awaiting_fee_payer, valid_before=valid_before, - calls=(Call.create(to=currency, value=0, data=transfer_data),), + calls=calls_tuple, ) if self.root_account: diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index a4721f2..dbd353d 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -20,6 +20,7 @@ ChargeRequest, CredentialPayload, HashCredentialPayload, + Split, TransactionCredentialPayload, ) from mpp.store import Store @@ -44,6 +45,115 @@ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +MAX_SPLITS = 10 + + +def _parse_memo_bytes(memo: str | None) -> bytes | None: + """Parse a hex memo string into 32 bytes, or None if invalid.""" + if memo is None: + return None + hex_str = memo[2:] if memo.startswith("0x") else memo + try: + b = bytes.fromhex(hex_str) + except ValueError: + return None + return b if len(b) == 32 else None + + +@dataclass +class Transfer: + """A single transfer in a charge (primary or split).""" + + amount: int + recipient: str + memo: bytes | None = None + + +def get_transfers( + total_amount: int, + primary_recipient: str, + primary_memo: str | None, + splits: list[Split] | None, +) -> list[Transfer]: + """Compute the ordered list of transfers for a charge. + + The primary transfer receives total_amount - sum(splits) and inherits + the top-level memo. Split transfers follow in declaration order. + """ + if not splits: + return [Transfer( + amount=total_amount, + recipient=primary_recipient, + memo=_parse_memo_bytes(primary_memo), + )] + + if len(splits) > MAX_SPLITS: + raise VerificationError(f"Too many splits: {len(splits)} (max {MAX_SPLITS})") + + split_sum = 0 + split_transfers: list[Transfer] = [] + + for s in splits: + amt = int(s.amount) + if amt <= 0: + raise VerificationError("Split amount must be greater than zero") + split_sum += amt + split_transfers.append(Transfer( + amount=amt, + recipient=s.recipient, + memo=_parse_memo_bytes(s.memo), + )) + + if split_sum >= total_amount: + raise VerificationError( + f"Sum of splits ({split_sum}) must be less than total amount ({total_amount})" + ) + + primary_amount = total_amount - split_sum + transfers = [Transfer( + amount=primary_amount, + recipient=primary_recipient, + memo=_parse_memo_bytes(primary_memo), + )] + transfers.extend(split_transfers) + return transfers + + +def _match_single_transfer_calldata( + call_data_hex: str, + recipient: str, + amount: int, + memo: bytes | None, +) -> bool: + """Check if ABI-encoded calldata matches a single expected transfer.""" + if len(call_data_hex) < 136: + return False + + selector = call_data_hex[:8].lower() + + if memo is not None: + if selector != TRANSFER_WITH_MEMO_SELECTOR: + return False + elif selector not in (TRANSFER_SELECTOR, TRANSFER_WITH_MEMO_SELECTOR): + return False + + decoded_to = "0x" + call_data_hex[32:72] + decoded_amount = int(call_data_hex[72:136], 16) + + if decoded_to.lower() != recipient.lower(): + return False + if decoded_amount != amount: + return False + + if memo is not None: + if len(call_data_hex) < 200: + return False + decoded_memo = bytes.fromhex(call_data_hex[136:200]) + if decoded_memo != memo: + return False + + return True + @dataclass(frozen=True, slots=True) class MatchedTransferLog: @@ -323,33 +433,19 @@ def _assert_challenge_bound_memo( "Payment verification failed: memo is not bound to this challenge." ) - def _verify_transfer_logs( + def _verify_single_transfer_log( self, receipt: dict[str, Any], - request: ChargeRequest, + currency: str, + recipient: str, + amount: int, + memo: bytes | None, expected_sender: str | None = None, - ) -> list[MatchedTransferLog]: - """Check if receipt contains matching Transfer or TransferWithMemo logs. - - Args: - receipt: Transaction receipt from RPC. - request: The charge request with expected amount/currency/recipient. - expected_sender: If provided, validates the 'from' address in the - Transfer log matches this address (for payer identity verification). - - Returns: - Matched logs in priority order, with memo logs before plain - transfers so downstream verification can inspect the memo that - actually satisfied the payment. - """ - expected_memo = request.methodDetails.memo - memo_matches: list[MatchedTransferLog] = [] - transfer_matches: list[MatchedTransferLog] = [] - + ) -> bool: + """Check if receipt contains a matching Transfer/TransferWithMemo log.""" for log in receipt.get("logs", []): - if log.get("address", "").lower() != request.currency.lower(): + if log.get("address", "").lower() != currency.lower(): continue - topics = log.get("topics", []) if len(topics) < 3: continue @@ -358,41 +454,112 @@ def _verify_transfer_logs( from_address = "0x" + topics[1][-40:] to_address = "0x" + topics[2][-40:] - if to_address.lower() != request.recipient.lower(): + if to_address.lower() != recipient.lower(): continue - if expected_sender and from_address.lower() != expected_sender.lower(): continue - if event_topic == TRANSFER_WITH_MEMO_TOPIC: - # TransferWithMemo has 3 indexed params (from, to, memo) - # so memo is in topics[3] and only amount is in data + if memo is not None: + if event_topic != TRANSFER_WITH_MEMO_TOPIC: + continue if len(topics) < 4: continue data = log.get("data", "0x") if len(data) < 66: continue - amount = int(data[2:66], 16) - if amount != int(request.amount): + log_amount = int(data[2:66], 16) + memo_topic = topics[3] + expected_memo_hex = "0x" + memo.hex() + if log_amount == amount and memo_topic.lower() == expected_memo_hex.lower(): + return True + else: + if event_topic not in (TRANSFER_TOPIC, TRANSFER_WITH_MEMO_TOPIC): continue - memo = topics[3] - if expected_memo: - memo_clean = expected_memo.lower() - if not memo_clean.startswith("0x"): - memo_clean = "0x" + memo_clean - if memo.lower() != memo_clean: - continue - memo_matches.append(MatchedTransferLog(kind="memo", memo=memo)) - continue - - if event_topic == TRANSFER_TOPIC and expected_memo is None: data = log.get("data", "0x") if len(data) >= 66: - amount = int(data, 16) - if amount == int(request.amount): - transfer_matches.append(MatchedTransferLog(kind="transfer")) + log_amount = int(data[2:66], 16) if event_topic == TRANSFER_WITH_MEMO_TOPIC else int(data, 16) + if log_amount == amount: + return True + + return False + + def _verify_transfer_logs( + self, + receipt: dict[str, Any], + request: ChargeRequest, + expected_sender: str | None = None, + ) -> bool: + """Check if receipt contains matching Transfer or TransferWithMemo logs.""" + expected = get_transfers( + int(request.amount), + request.recipient, + request.methodDetails.memo, + request.methodDetails.splits, + ) + + if len(expected) == 1: + t = expected[0] + return self._verify_single_transfer_log( + receipt, request.currency, t.recipient, t.amount, t.memo, + expected_sender, + ) + + # Multi-transfer: order-insensitive matching + sorted_expected = sorted(expected, key=lambda t: (0 if t.memo else 1)) + logs = receipt.get("logs", []) + used_logs: set[int] = set() + + for transfer in sorted_expected: + found = False + for log_idx, log in enumerate(logs): + if log_idx in used_logs: + continue + if log.get("address", "").lower() != request.currency.lower(): + continue + + topics = log.get("topics", []) + if len(topics) < 3: + continue + + event_topic = topics[0] + from_address = "0x" + topics[1][-40:] + to_address = "0x" + topics[2][-40:] + + if to_address.lower() != transfer.recipient.lower(): + continue + if expected_sender and from_address.lower() != expected_sender.lower(): + continue - return memo_matches + transfer_matches + if transfer.memo is not None: + if event_topic != TRANSFER_WITH_MEMO_TOPIC: + continue + if len(topics) < 4: + continue + data = log.get("data", "0x") + if len(data) < 66: + continue + amount = int(data[2:66], 16) + memo_topic = topics[3] + expected_memo_hex = "0x" + transfer.memo.hex() + if amount == transfer.amount and memo_topic.lower() == expected_memo_hex.lower(): + used_logs.add(log_idx) + found = True + break + else: + if event_topic not in (TRANSFER_TOPIC, TRANSFER_WITH_MEMO_TOPIC): + continue + data = log.get("data", "0x") + if len(data) >= 66: + amount = int(data[2:66], 16) if event_topic == TRANSFER_WITH_MEMO_TOPIC else int(data, 16) + if amount == transfer.amount: + used_logs.add(log_idx) + found = True + break + + if not found: + return False + + return True async def _verify_transaction( self, @@ -597,16 +764,35 @@ def _int(b: bytes) -> int: return "0x" + cosigned.encode().hex() def _validate_calls(self, calls: tuple, request: ChargeRequest) -> None: - """Validate that at least one call matches the expected transfer.""" - for call in calls: - call_to = "0x" + bytes(call.to).hex() - if call_to.lower() != request.currency.lower(): - continue - if call.value: - continue - if _match_transfer_calldata(call.data.hex(), request): - return - raise VerificationError("Invalid transaction: no matching payment call found") + """Validate that calls match all expected transfers.""" + expected = get_transfers( + int(request.amount), + request.recipient, + request.methodDetails.memo, + request.methodDetails.splits, + ) + + sorted_expected = sorted(expected, key=lambda t: (0 if t.memo else 1)) + used_calls: set[int] = set() + + for transfer in sorted_expected: + found = False + for call_idx, call in enumerate(calls): + if call_idx in used_calls: + continue + call_to = "0x" + bytes(call.to).hex() + if call_to.lower() != request.currency.lower(): + continue + if call.value: + continue + if _match_single_transfer_calldata( + call.data.hex(), transfer.recipient, transfer.amount, transfer.memo + ): + used_calls.add(call_idx) + found = True + break + if not found: + raise VerificationError("Invalid transaction: no matching payment call found") def _validate_transaction_payload(self, signature: str, request: ChargeRequest) -> None: """Best-effort pre-broadcast check. Silently skips if decoding fails.""" @@ -631,18 +817,36 @@ def _validate_transaction_payload(self, signature: str, request: ChargeRequest) if not calls_data: raise VerificationError("Transaction contains no calls") - for call_item in calls_data: - if not isinstance(call_item, (list, tuple)) or len(call_item) < 3: - continue - call_to_bytes, call_data_bytes = call_item[0], call_item[2] - if not call_to_bytes or not call_data_bytes: - continue - to_hex = call_to_bytes.hex() if isinstance(call_to_bytes, bytes) else str(call_to_bytes) - if ("0x" + to_hex).lower() != request.currency.lower(): - continue - raw = call_data_bytes - data_hex = raw.hex() if isinstance(raw, bytes) else str(raw) - if _match_transfer_calldata(data_hex, request): - return + expected = get_transfers( + int(request.amount), + request.recipient, + request.methodDetails.memo, + request.methodDetails.splits, + ) - raise VerificationError("Invalid transaction: no matching payment call found") + sorted_expected = sorted(expected, key=lambda t: (0 if t.memo else 1)) + used_calls: set[int] = set() + + for transfer in sorted_expected: + found = False + for call_idx, call_item in enumerate(calls_data): + if call_idx in used_calls: + continue + if not isinstance(call_item, (list, tuple)) or len(call_item) < 3: + continue + call_to_bytes, call_data_bytes = call_item[0], call_item[2] + if not call_to_bytes or not call_data_bytes: + continue + to_hex = call_to_bytes.hex() if isinstance(call_to_bytes, bytes) else str(call_to_bytes) + if ("0x" + to_hex).lower() != request.currency.lower(): + continue + raw = call_data_bytes + data_hex = raw.hex() if isinstance(raw, bytes) else str(raw) + if _match_single_transfer_calldata( + data_hex, transfer.recipient, transfer.amount, transfer.memo + ): + used_calls.add(call_idx) + found = True + break + if not found: + raise VerificationError("Invalid transaction: no matching payment call found") diff --git a/src/mpp/methods/tempo/schemas.py b/src/mpp/methods/tempo/schemas.py index 470970d..676bdbb 100644 --- a/src/mpp/methods/tempo/schemas.py +++ b/src/mpp/methods/tempo/schemas.py @@ -7,6 +7,14 @@ from pydantic import BaseModel, Field, field_validator +class Split(BaseModel): + """A single split in a split payment.""" + + amount: str + recipient: str + memo: str | None = None + + class MethodDetails(BaseModel): """Method-specific details for Tempo charge requests.""" @@ -14,6 +22,7 @@ class MethodDetails(BaseModel): feePayer: bool = False feePayerUrl: str | None = None memo: str | None = None + splits: list[Split] | None = None @field_validator("memo", mode="before") @classmethod diff --git a/src/mpp/server/mpp.py b/src/mpp/server/mpp.py index a4915b8..5534998 100644 --- a/src/mpp/server/mpp.py +++ b/src/mpp/server/mpp.py @@ -124,6 +124,7 @@ async def charge( expires: str | None = None, description: str | None = None, memo: str | None = None, + splits: list[dict[str, str]] | None = None, fee_payer: bool = False, chain_id: int | None = None, extra: dict[str, str] | None = None, @@ -177,12 +178,14 @@ async def charge( if resolved_chain_id is None: resolved_chain_id = getattr(self.method, "chain_id", None) - if memo or fee_payer or resolved_chain_id is not None: + if memo or splits or fee_payer or resolved_chain_id is not None: method_details: dict[str, Any] = {} if resolved_chain_id is not None: method_details["chainId"] = resolved_chain_id if memo: method_details["memo"] = memo + if splits: + method_details["splits"] = splits if fee_payer: method_details["feePayer"] = True request["methodDetails"] = method_details diff --git a/tests/test_tempo.py b/tests/test_tempo.py index a91b60e..e52b7a8 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -28,13 +28,16 @@ TRANSFER_WITH_MEMO_SELECTOR, TRANSFER_WITH_MEMO_TOPIC, ChargeIntent, + Transfer, _match_transfer_calldata, _rpc_error_msg, + get_transfers, ) from mpp.methods.tempo.schemas import ( ChargeRequest, HashCredentialPayload, MethodDetails, + Split, TransactionCredentialPayload, ) from mpp.server.intent import VerificationError @@ -2476,3 +2479,255 @@ def test_chain_rpc_urls_is_immutable(self) -> None: """CHAIN_RPC_URLS should reject mutation.""" with pytest.raises(TypeError): CHAIN_RPC_URLS[9999] = "https://evil.rpc" # type: ignore[index] + + +class TestGetTransfers: + """Tests for get_transfers() split computation.""" + + def test_no_splits_returns_single_transfer(self) -> None: + transfers = get_transfers(1_000_000, "0x01", None, None) + assert len(transfers) == 1 + assert transfers[0].amount == 1_000_000 + assert transfers[0].recipient == "0x01" + assert transfers[0].memo is None + + def test_empty_splits_returns_single_transfer(self) -> None: + transfers = get_transfers(1_000_000, "0x01", None, []) + assert len(transfers) == 1 + + def test_single_split(self) -> None: + splits = [Split(amount="300000", recipient="0x1111111111111111111111111111111111111111")] + transfers = get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + assert len(transfers) == 2 + assert transfers[0].amount == 700_000 # primary gets remainder + assert transfers[1].amount == 300_000 + + def test_primary_inherits_memo(self) -> None: + memo = "0x" + "ab" * 32 + splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111")] + transfers = get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", memo, splits) + assert transfers[0].memo is not None + assert transfers[1].memo is None + + def test_split_with_memo(self) -> None: + split_memo = "0x" + "cd" * 32 + splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111", memo=split_memo)] + transfers = get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + assert transfers[1].memo is not None + assert transfers[1].memo[0] == 0xCD + + def test_multiple_splits_preserve_order(self) -> None: + splits = [ + Split(amount="100000", recipient="0x1111111111111111111111111111111111111111"), + Split(amount="200000", recipient="0x2222222222222222222222222222222222222222"), + Split(amount="50000", recipient="0x3333333333333333333333333333333333333333"), + ] + transfers = get_transfers(1_000_000, "0x4444444444444444444444444444444444444444", None, splits) + assert len(transfers) == 4 + assert transfers[0].amount == 650_000 # primary + assert transfers[1].amount == 100_000 + assert transfers[2].amount == 200_000 + assert transfers[3].amount == 50_000 + + def test_rejects_sum_equals_total(self) -> None: + splits = [Split(amount="1000000", recipient="0x1111111111111111111111111111111111111111")] + with pytest.raises(VerificationError, match="must be less than"): + get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + + def test_rejects_sum_exceeds_total(self) -> None: + splits = [Split(amount="1500000", recipient="0x1111111111111111111111111111111111111111")] + with pytest.raises(VerificationError): + get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + + def test_rejects_zero_split_amount(self) -> None: + splits = [Split(amount="0", recipient="0x1111111111111111111111111111111111111111")] + with pytest.raises(VerificationError, match="greater than zero"): + get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + + def test_rejects_too_many_splits(self) -> None: + splits = [ + Split(amount="1000", recipient=f"0x{hex(i + 2)[2:].zfill(40)}") + for i in range(11) + ] + with pytest.raises(VerificationError, match="Too many splits"): + get_transfers(1_000_000, "0x0000000000000000000000000000000000000001", None, splits) + + def test_max_splits_allowed(self) -> None: + splits = [ + Split(amount="1000", recipient=f"0x{hex(i + 2)[2:].zfill(40)}") + for i in range(10) + ] + transfers = get_transfers(1_000_000, "0x0000000000000000000000000000000000000001", None, splits) + assert len(transfers) == 11 + assert transfers[0].amount == 990_000 + + +class TestVerifyTransferLogsWithSplits: + """Tests for _verify_transfer_logs with split payments.""" + + CURRENCY = "0x20c0000000000000000000000000000000000000" + RECIPIENT = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" + SPLIT_RECIPIENT = "0x1111111111111111111111111111111111111111" + AMOUNT = 1000000 + SENDER = "0x" + "ab" * 20 + + def _make_transfer_log(self, recipient: str, amount: int, memo: str | None = None) -> dict: + to_padded = "0x" + "0" * 24 + recipient[2:].lower() + from_padded = "0x" + "0" * 24 + self.SENDER[2:].lower() + if memo: + return { + "address": self.CURRENCY, + "topics": [TRANSFER_WITH_MEMO_TOPIC, from_padded, to_padded, memo], + "data": "0x" + hex(amount)[2:].zfill(64), + } + return { + "address": self.CURRENCY, + "topics": [TRANSFER_TOPIC, from_padded, to_padded], + "data": "0x" + hex(amount)[2:].zfill(64), + } + + def test_split_logs_accepted(self) -> None: + """Receipt with matching split logs should be accepted.""" + intent = ChargeIntent(rpc_url="https://rpc.test") + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails( + splits=[Split(amount="300000", recipient=self.SPLIT_RECIPIENT)] + ), + ) + receipt = { + "status": "0x1", + "logs": [ + self._make_transfer_log(self.RECIPIENT, 700000), # primary + self._make_transfer_log(self.SPLIT_RECIPIENT, 300000), # split + ], + } + assert intent._verify_transfer_logs(receipt, request) is True + + def test_split_logs_wrong_amount_rejected(self) -> None: + """Receipt with wrong split amount should be rejected.""" + intent = ChargeIntent(rpc_url="https://rpc.test") + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails( + splits=[Split(amount="300000", recipient=self.SPLIT_RECIPIENT)] + ), + ) + receipt = { + "status": "0x1", + "logs": [ + self._make_transfer_log(self.RECIPIENT, 700000), + self._make_transfer_log(self.SPLIT_RECIPIENT, 200000), # wrong + ], + } + assert intent._verify_transfer_logs(receipt, request) is False + + def test_split_logs_missing_split_rejected(self) -> None: + """Receipt missing a split log should be rejected.""" + intent = ChargeIntent(rpc_url="https://rpc.test") + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails( + splits=[Split(amount="300000", recipient=self.SPLIT_RECIPIENT)] + ), + ) + receipt = { + "status": "0x1", + "logs": [self._make_transfer_log(self.RECIPIENT, 700000)], + } + assert intent._verify_transfer_logs(receipt, request) is False + + def test_split_with_memo_accepted(self) -> None: + """Split with memo should match TransferWithMemo log.""" + split_memo = "0x" + "dd" * 32 + intent = ChargeIntent(rpc_url="https://rpc.test") + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails( + splits=[Split(amount="300000", recipient=self.SPLIT_RECIPIENT, memo=split_memo)] + ), + ) + receipt = { + "status": "0x1", + "logs": [ + self._make_transfer_log(self.RECIPIENT, 700000), + self._make_transfer_log(self.SPLIT_RECIPIENT, 300000, memo=split_memo), + ], + } + assert intent._verify_transfer_logs(receipt, request) is True + + def test_split_order_insensitive(self) -> None: + """Logs in different order from splits should still match.""" + intent = ChargeIntent(rpc_url="https://rpc.test") + split2 = "0x2222222222222222222222222222222222222222" + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails( + splits=[ + Split(amount="200000", recipient=self.SPLIT_RECIPIENT), + Split(amount="100000", recipient=split2), + ] + ), + ) + receipt = { + "status": "0x1", + "logs": [ + self._make_transfer_log(split2, 100000), # split2 first + self._make_transfer_log(self.RECIPIENT, 700000), + self._make_transfer_log(self.SPLIT_RECIPIENT, 200000), + ], + } + assert intent._verify_transfer_logs(receipt, request) is True + + +class TestSplitSchemas: + """Tests for Split schema.""" + + def test_split_model(self) -> None: + s = Split(amount="300000", recipient="0x1111111111111111111111111111111111111111") + assert s.amount == "300000" + assert s.memo is None + + def test_split_with_memo(self) -> None: + s = Split(amount="300000", recipient="0x1111", memo="0x" + "ab" * 32) + assert s.memo is not None + + def test_method_details_with_splits(self) -> None: + md = MethodDetails( + splits=[Split(amount="300000", recipient="0x1111111111111111111111111111111111111111")] + ) + assert md.splits is not None + assert len(md.splits) == 1 + + def test_method_details_splits_serialization(self) -> None: + md = MethodDetails( + splits=[Split(amount="300000", recipient="0x1111111111111111111111111111111111111111")] + ) + data = md.model_dump() + assert "splits" in data + assert data["splits"][0]["amount"] == "300000" + + def test_charge_request_with_splits(self) -> None: + req = ChargeRequest( + amount="1000000", + currency="0x20c0000000000000000000000000000000000000", + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + methodDetails=MethodDetails( + splits=[ + Split(amount="300000", recipient="0x1111111111111111111111111111111111111111"), + Split(amount="200000", recipient="0x2222222222222222222222222222222222222222"), + ] + ), + ) + assert req.methodDetails.splits is not None + assert len(req.methodDetails.splits) == 2 From 0bc53595f684934bd672396fbba5e97c6f45ceca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 00:20:41 +0000 Subject: [PATCH 2/9] chore: add changelog --- .changelog/shy-frogs-walk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/shy-frogs-walk.md diff --git a/.changelog/shy-frogs-walk.md b/.changelog/shy-frogs-walk.md new file mode 100644 index 0000000..d748d4d --- /dev/null +++ b/.changelog/shy-frogs-walk.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added split payments support for Tempo charges, allowing a single charge to be split across multiple recipients. Port of [mpp-rs PR #180](https://github.com/tempoxyz/mpp-rs/pull/180). From f2d4252ca53d05fdd05e8b6408a5a3786acf06db Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:00:00 -0700 Subject: [PATCH 3/9] fix: address cyclops review findings for split payments - _parse_memo_bytes: raise VerificationError on invalid explicit memos instead of silently downgrading to None (fail-closed) - Memo-less transfers strictly require TRANSFER_SELECTOR only, rejecting transferWithMemo to prevent cross-intent double spending - Gas estimation sums estimates across all calls in split batches instead of using only the first call - Reject splits + fee_payer=True until a split-aware sponsor is available --- src/mpp/methods/tempo/client.py | 15 ++++++++++++--- src/mpp/methods/tempo/intents.py | 20 +++++++++++++------- src/mpp/server/mpp.py | 3 +++ tests/test_tempo.py | 6 +++--- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 764467e..3a90537 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -233,7 +233,6 @@ async def _build_tempo_transfer( td = self._encode_transfer(t.recipient, t.amount) call_list.append(Call.create(to=currency, value=0, data=td)) calls_tuple = tuple(call_list) - gas_estimate_data = call_list[0].data.hex() if call_list else None else: if memo: transfer_data = self._encode_transfer_with_memo(recipient, int(amount), memo) @@ -265,8 +264,18 @@ async def _build_tempo_transfer( gas_limit = DEFAULT_GAS_LIMIT try: - estimated = await estimate_gas(resolved_rpc, nonce_address, currency, gas_estimate_data) - gas_limit = max(gas_limit, estimated + 5_000) + if splits: + total_estimated = 0 + for c in calls_tuple: + total_estimated += await estimate_gas( + resolved_rpc, nonce_address, currency, c.data.hex() + ) + gas_limit = max(gas_limit, total_estimated + 5_000 * len(calls_tuple)) + else: + estimated = await estimate_gas( + resolved_rpc, nonce_address, currency, gas_estimate_data + ) + gas_limit = max(gas_limit, estimated + 5_000) except Exception: pass diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index dbd353d..99a7b91 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -49,15 +49,21 @@ def _parse_memo_bytes(memo: str | None) -> bytes | None: - """Parse a hex memo string into 32 bytes, or None if invalid.""" + """Parse a hex memo string into 32 bytes. + + Returns None when no memo is supplied. Raises VerificationError when a memo + is explicitly provided but cannot be decoded as exactly 32 bytes of hex. + """ if memo is None: return None hex_str = memo[2:] if memo.startswith("0x") else memo try: b = bytes.fromhex(hex_str) except ValueError: - return None - return b if len(b) == 32 else None + raise VerificationError(f"Invalid memo hex: {memo}") + if len(b) != 32: + raise VerificationError(f"Memo must be exactly 32 bytes, got {len(b)}") + return b @dataclass @@ -134,7 +140,7 @@ def _match_single_transfer_calldata( if memo is not None: if selector != TRANSFER_WITH_MEMO_SELECTOR: return False - elif selector not in (TRANSFER_SELECTOR, TRANSFER_WITH_MEMO_SELECTOR): + elif selector != TRANSFER_SELECTOR: return False decoded_to = "0x" + call_data_hex[32:72] @@ -182,7 +188,7 @@ def _match_transfer_calldata(call_data_hex: str, request: ChargeRequest) -> bool if expected_memo: if selector != TRANSFER_WITH_MEMO_SELECTOR: return False - elif selector not in (TRANSFER_SELECTOR, TRANSFER_WITH_MEMO_SELECTOR): + elif selector != TRANSFER_SELECTOR: return False decoded_to = "0x" + call_data_hex[32:72] @@ -546,11 +552,11 @@ def _verify_transfer_logs( found = True break else: - if event_topic not in (TRANSFER_TOPIC, TRANSFER_WITH_MEMO_TOPIC): + if event_topic != TRANSFER_TOPIC: continue data = log.get("data", "0x") if len(data) >= 66: - amount = int(data[2:66], 16) if event_topic == TRANSFER_WITH_MEMO_TOPIC else int(data, 16) + amount = int(data, 16) if amount == transfer.amount: used_logs.add(log_idx) found = True diff --git a/src/mpp/server/mpp.py b/src/mpp/server/mpp.py index 5534998..1a3f7e7 100644 --- a/src/mpp/server/mpp.py +++ b/src/mpp/server/mpp.py @@ -178,6 +178,9 @@ async def charge( if resolved_chain_id is None: resolved_chain_id = getattr(self.method, "chain_id", None) + if splits and fee_payer: + raise ValueError("splits and fee_payer cannot be used together") + if memo or splits or fee_payer or resolved_chain_id is not None: method_details: dict[str, Any] = {} if resolved_chain_id is not None: diff --git a/tests/test_tempo.py b/tests/test_tempo.py index e52b7a8..ee1c39c 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -2178,15 +2178,15 @@ def test_memo_normalization_no_0x_prefix(self) -> None: ) assert _match_transfer_calldata(calldata, request) is True - def test_no_memo_accepts_either_selector(self) -> None: - """When no memo, both transfer and transferWithMemo selectors should be accepted.""" + def test_no_memo_accepts_only_transfer_selector(self) -> None: + """When no memo, only plain transfer selector should be accepted.""" request = self._make_request(memo=None) calldata_plain = self._build_calldata(TRANSFER_SELECTOR, self.RECIPIENT, self.AMOUNT) calldata_memo = self._build_calldata( TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT ) assert _match_transfer_calldata(calldata_plain, request) is True - assert _match_transfer_calldata(calldata_memo, request) is True + assert _match_transfer_calldata(calldata_memo, request) is False def test_short_calldata_rejected(self) -> None: """Calldata shorter than 136 chars should always be rejected.""" From 029051b0e0de6d97af078958c9ff1373e4ce642c Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:13:24 -0700 Subject: [PATCH 4/9] test: add coverage for cyclops fix behaviors - _parse_memo_bytes: valid input, invalid hex, wrong length, empty - _match_single_transfer_calldata: memo strictness, no-memo rejection - Log verification: memo-less single/multi rejects TransferWithMemo - splits + fee_payer: raises ValueError - get_transfers: invalid/short memos on primary and split --- tests/test_tempo.py | 177 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/test_tempo.py b/tests/test_tempo.py index ee1c39c..650c069 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -29,7 +29,9 @@ TRANSFER_WITH_MEMO_TOPIC, ChargeIntent, Transfer, + _match_single_transfer_calldata, _match_transfer_calldata, + _parse_memo_bytes, _rpc_error_msg, get_transfers, ) @@ -2731,3 +2733,178 @@ def test_charge_request_with_splits(self) -> None: ) assert req.methodDetails.splits is not None assert len(req.methodDetails.splits) == 2 + + +class TestParseMemoBytes: + """Tests for _parse_memo_bytes fail-closed behavior.""" + + def test_none_returns_none(self) -> None: + assert _parse_memo_bytes(None) is None + + def test_valid_32_byte_hex(self) -> None: + memo = "0x" + "ab" * 32 + result = _parse_memo_bytes(memo) + assert result is not None + assert len(result) == 32 + assert result[0] == 0xAB + + def test_valid_without_0x_prefix(self) -> None: + memo = "cd" * 32 + result = _parse_memo_bytes(memo) + assert result is not None + assert len(result) == 32 + + def test_invalid_hex_raises(self) -> None: + with pytest.raises(VerificationError, match="Invalid memo hex"): + _parse_memo_bytes("0xnothex") + + def test_short_memo_raises(self) -> None: + with pytest.raises(VerificationError, match="exactly 32 bytes"): + _parse_memo_bytes("0x" + "ab" * 16) + + def test_long_memo_raises(self) -> None: + with pytest.raises(VerificationError, match="exactly 32 bytes"): + _parse_memo_bytes("0x" + "ab" * 33) + + def test_empty_hex_raises(self) -> None: + with pytest.raises(VerificationError, match="exactly 32 bytes"): + _parse_memo_bytes("0x") + + +class TestMatchSingleTransferCalldata: + """Tests for _match_single_transfer_calldata memo strictness.""" + + RECIPIENT = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" + AMOUNT = 1000000 + MEMO = bytes.fromhex("ab" * 32) + + def _build_calldata(self, selector: str, recipient: str, amount: int, memo_hex: str = "") -> str: + to_padded = recipient[2:].lower().zfill(64) + amount_padded = hex(amount)[2:].zfill(64) + return f"{selector}{to_padded}{amount_padded}{memo_hex}" + + def test_memo_requires_transfer_with_memo_selector(self) -> None: + calldata = self._build_calldata(TRANSFER_SELECTOR, self.RECIPIENT, self.AMOUNT, "ab" * 32) + assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, self.MEMO) is False + + def test_memo_accepts_correct_selector(self) -> None: + calldata = self._build_calldata(TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT, "ab" * 32) + assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, self.MEMO) is True + + def test_no_memo_rejects_transfer_with_memo_selector(self) -> None: + """When no memo expected, transferWithMemo calldata must be rejected.""" + calldata = self._build_calldata(TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT) + assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, None) is False + + def test_no_memo_accepts_plain_transfer(self) -> None: + calldata = self._build_calldata(TRANSFER_SELECTOR, self.RECIPIENT, self.AMOUNT) + assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, None) is True + + +class TestSplitLogMemoStrictness: + """Tests that memo-less split logs reject transferWithMemo events.""" + + CURRENCY = "0x20c0000000000000000000000000000000000000" + RECIPIENT = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00" + SPLIT_RECIPIENT = "0x1111111111111111111111111111111111111111" + AMOUNT = 1000000 + SENDER = "0x" + "ab" * 20 + + def _make_log(self, topic: str, recipient: str, amount: int, memo: str | None = None) -> dict: + to_padded = "0x" + "0" * 24 + recipient[2:].lower() + from_padded = "0x" + "0" * 24 + self.SENDER[2:].lower() + topics = [topic, from_padded, to_padded] + if memo: + topics.append(memo) + return { + "address": self.CURRENCY, + "topics": topics, + "data": "0x" + hex(amount)[2:].zfill(64), + } + + def test_single_transfer_rejects_transfer_with_memo_log(self) -> None: + """A memo-less single transfer must reject TransferWithMemo logs.""" + intent = ChargeIntent(rpc_url="https://rpc.test") + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails(), + ) + receipt = { + "logs": [self._make_log( + TRANSFER_WITH_MEMO_TOPIC, self.RECIPIENT, self.AMOUNT, + memo="0x" + "ff" * 32, + )], + } + assert intent._verify_transfer_logs(receipt, request) is False + + def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None: + """Memo-less split legs must reject TransferWithMemo logs.""" + intent = ChargeIntent(rpc_url="https://rpc.test") + request = ChargeRequest( + amount=str(self.AMOUNT), + currency=self.CURRENCY, + recipient=self.RECIPIENT, + methodDetails=MethodDetails( + splits=[Split(amount="300000", recipient=self.SPLIT_RECIPIENT)] + ), + ) + receipt = { + "logs": [ + # primary as Transfer (correct) + self._make_log(TRANSFER_TOPIC, self.RECIPIENT, 700000), + # split as TransferWithMemo (should be rejected) + self._make_log( + TRANSFER_WITH_MEMO_TOPIC, self.SPLIT_RECIPIENT, 300000, + memo="0x" + "ff" * 32, + ), + ], + } + assert intent._verify_transfer_logs(receipt, request) is False + + +class TestSplitsFeePayerRejection: + """Test that splits + fee_payer raises.""" + + @pytest.mark.anyio + async def test_splits_with_fee_payer_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from mpp.server import Mpp + from mpp.methods.tempo import tempo + + monkeypatch.setenv("MPP_SECRET_KEY", "test-secret-key") + server = Mpp.create( + method=tempo( + intents={"charge": ChargeIntent(rpc_url="https://rpc.test")}, + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + ), + ) + with pytest.raises(ValueError, match="splits and fee_payer cannot be used together"): + await server.charge( + authorization=None, + amount="1.00", + splits=[{"amount": "300000", "recipient": "0x1111111111111111111111111111111111111111"}], + fee_payer=True, + ) + + +class TestGetTransfersInvalidMemo: + """Tests that get_transfers rejects invalid memos (fail-closed).""" + + def test_invalid_primary_memo_raises(self) -> None: + with pytest.raises(VerificationError, match="Invalid memo hex"): + get_transfers(1_000_000, "0x01", "not-hex", None) + + def test_short_primary_memo_raises(self) -> None: + with pytest.raises(VerificationError, match="exactly 32 bytes"): + get_transfers(1_000_000, "0x01", "0x" + "ab" * 10, None) + + def test_invalid_split_memo_raises(self) -> None: + splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111", memo="badhex")] + with pytest.raises(VerificationError, match="Invalid memo hex"): + get_transfers(1_000_000, "0x01", None, splits) + + def test_short_split_memo_raises(self) -> None: + splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111", memo="0x" + "ab" * 5)] + with pytest.raises(VerificationError, match="exactly 32 bytes"): + get_transfers(1_000_000, "0x01", None, splits) From ecca9ffec21dc22a5972bd4e5a334ca84a6b36f7 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:38:38 -0700 Subject: [PATCH 5/9] fix: resolve split-payments lint failures --- src/mpp/methods/tempo/intents.py | 61 ++++++++----- tests/test_tempo.py | 147 +++++++++++++++++++++++++------ 2 files changed, 158 insertions(+), 50 deletions(-) diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index 99a7b91..2183b40 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -59,8 +59,8 @@ def _parse_memo_bytes(memo: str | None) -> bytes | None: hex_str = memo[2:] if memo.startswith("0x") else memo try: b = bytes.fromhex(hex_str) - except ValueError: - raise VerificationError(f"Invalid memo hex: {memo}") + except ValueError as err: + raise VerificationError(f"Invalid memo hex: {memo}") from err if len(b) != 32: raise VerificationError(f"Memo must be exactly 32 bytes, got {len(b)}") return b @@ -87,11 +87,13 @@ def get_transfers( the top-level memo. Split transfers follow in declaration order. """ if not splits: - return [Transfer( - amount=total_amount, - recipient=primary_recipient, - memo=_parse_memo_bytes(primary_memo), - )] + return [ + Transfer( + amount=total_amount, + recipient=primary_recipient, + memo=_parse_memo_bytes(primary_memo), + ) + ] if len(splits) > MAX_SPLITS: raise VerificationError(f"Too many splits: {len(splits)} (max {MAX_SPLITS})") @@ -104,11 +106,13 @@ def get_transfers( if amt <= 0: raise VerificationError("Split amount must be greater than zero") split_sum += amt - split_transfers.append(Transfer( - amount=amt, - recipient=s.recipient, - memo=_parse_memo_bytes(s.memo), - )) + split_transfers.append( + Transfer( + amount=amt, + recipient=s.recipient, + memo=_parse_memo_bytes(s.memo), + ) + ) if split_sum >= total_amount: raise VerificationError( @@ -116,11 +120,13 @@ def get_transfers( ) primary_amount = total_amount - split_sum - transfers = [Transfer( - amount=primary_amount, - recipient=primary_recipient, - memo=_parse_memo_bytes(primary_memo), - )] + transfers = [ + Transfer( + amount=primary_amount, + recipient=primary_recipient, + memo=_parse_memo_bytes(primary_memo), + ) + ] transfers.extend(split_transfers) return transfers @@ -506,12 +512,16 @@ def _verify_transfer_logs( if len(expected) == 1: t = expected[0] return self._verify_single_transfer_log( - receipt, request.currency, t.recipient, t.amount, t.memo, + receipt, + request.currency, + t.recipient, + t.amount, + t.memo, expected_sender, ) # Multi-transfer: order-insensitive matching - sorted_expected = sorted(expected, key=lambda t: (0 if t.memo else 1)) + sorted_expected = sorted(expected, key=lambda t: 0 if t.memo else 1) logs = receipt.get("logs", []) used_logs: set[int] = set() @@ -547,7 +557,10 @@ def _verify_transfer_logs( amount = int(data[2:66], 16) memo_topic = topics[3] expected_memo_hex = "0x" + transfer.memo.hex() - if amount == transfer.amount and memo_topic.lower() == expected_memo_hex.lower(): + if ( + amount == transfer.amount + and memo_topic.lower() == expected_memo_hex.lower() + ): used_logs.add(log_idx) found = True break @@ -778,7 +791,7 @@ def _validate_calls(self, calls: tuple, request: ChargeRequest) -> None: request.methodDetails.splits, ) - sorted_expected = sorted(expected, key=lambda t: (0 if t.memo else 1)) + sorted_expected = sorted(expected, key=lambda t: 0 if t.memo else 1) used_calls: set[int] = set() for transfer in sorted_expected: @@ -830,7 +843,7 @@ def _validate_transaction_payload(self, signature: str, request: ChargeRequest) request.methodDetails.splits, ) - sorted_expected = sorted(expected, key=lambda t: (0 if t.memo else 1)) + sorted_expected = sorted(expected, key=lambda t: 0 if t.memo else 1) used_calls: set[int] = set() for transfer in sorted_expected: @@ -843,7 +856,9 @@ def _validate_transaction_payload(self, signature: str, request: ChargeRequest) call_to_bytes, call_data_bytes = call_item[0], call_item[2] if not call_to_bytes or not call_data_bytes: continue - to_hex = call_to_bytes.hex() if isinstance(call_to_bytes, bytes) else str(call_to_bytes) + to_hex = ( + call_to_bytes.hex() if isinstance(call_to_bytes, bytes) else str(call_to_bytes) + ) if ("0x" + to_hex).lower() != request.currency.lower(): continue raw = call_data_bytes diff --git a/tests/test_tempo.py b/tests/test_tempo.py index 650c069..cefd74e 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -28,7 +28,6 @@ TRANSFER_WITH_MEMO_SELECTOR, TRANSFER_WITH_MEMO_TOPIC, ChargeIntent, - Transfer, _match_single_transfer_calldata, _match_transfer_calldata, _parse_memo_bytes, @@ -2498,23 +2497,54 @@ def test_empty_splits_returns_single_transfer(self) -> None: assert len(transfers) == 1 def test_single_split(self) -> None: - splits = [Split(amount="300000", recipient="0x1111111111111111111111111111111111111111")] - transfers = get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + splits = [ + Split( + amount="300000", + recipient="0x1111111111111111111111111111111111111111", + ) + ] + transfers = get_transfers( + 1_000_000, + "0x2222222222222222222222222222222222222222", + None, + splits, + ) assert len(transfers) == 2 assert transfers[0].amount == 700_000 # primary gets remainder assert transfers[1].amount == 300_000 def test_primary_inherits_memo(self) -> None: memo = "0x" + "ab" * 32 - splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111")] - transfers = get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", memo, splits) + splits = [ + Split( + amount="100000", + recipient="0x1111111111111111111111111111111111111111", + ) + ] + transfers = get_transfers( + 1_000_000, + "0x2222222222222222222222222222222222222222", + memo, + splits, + ) assert transfers[0].memo is not None assert transfers[1].memo is None def test_split_with_memo(self) -> None: split_memo = "0x" + "cd" * 32 - splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111", memo=split_memo)] - transfers = get_transfers(1_000_000, "0x2222222222222222222222222222222222222222", None, splits) + splits = [ + Split( + amount="100000", + recipient="0x1111111111111111111111111111111111111111", + memo=split_memo, + ) + ] + transfers = get_transfers( + 1_000_000, + "0x2222222222222222222222222222222222222222", + None, + splits, + ) assert transfers[1].memo is not None assert transfers[1].memo[0] == 0xCD @@ -2524,7 +2554,12 @@ def test_multiple_splits_preserve_order(self) -> None: Split(amount="200000", recipient="0x2222222222222222222222222222222222222222"), Split(amount="50000", recipient="0x3333333333333333333333333333333333333333"), ] - transfers = get_transfers(1_000_000, "0x4444444444444444444444444444444444444444", None, splits) + transfers = get_transfers( + 1_000_000, + "0x4444444444444444444444444444444444444444", + None, + splits, + ) assert len(transfers) == 4 assert transfers[0].amount == 650_000 # primary assert transfers[1].amount == 100_000 @@ -2548,18 +2583,21 @@ def test_rejects_zero_split_amount(self) -> None: def test_rejects_too_many_splits(self) -> None: splits = [ - Split(amount="1000", recipient=f"0x{hex(i + 2)[2:].zfill(40)}") - for i in range(11) + Split(amount="1000", recipient=f"0x{hex(i + 2)[2:].zfill(40)}") for i in range(11) ] with pytest.raises(VerificationError, match="Too many splits"): get_transfers(1_000_000, "0x0000000000000000000000000000000000000001", None, splits) def test_max_splits_allowed(self) -> None: splits = [ - Split(amount="1000", recipient=f"0x{hex(i + 2)[2:].zfill(40)}") - for i in range(10) + Split(amount="1000", recipient=f"0x{hex(i + 2)[2:].zfill(40)}") for i in range(10) ] - transfers = get_transfers(1_000_000, "0x0000000000000000000000000000000000000001", None, splits) + transfers = get_transfers( + 1_000_000, + "0x0000000000000000000000000000000000000001", + None, + splits, + ) assert len(transfers) == 11 assert transfers[0].amount == 990_000 @@ -2778,18 +2816,50 @@ class TestMatchSingleTransferCalldata: AMOUNT = 1000000 MEMO = bytes.fromhex("ab" * 32) - def _build_calldata(self, selector: str, recipient: str, amount: int, memo_hex: str = "") -> str: + def _build_calldata( + self, + selector: str, + recipient: str, + amount: int, + memo_hex: str = "", + ) -> str: to_padded = recipient[2:].lower().zfill(64) amount_padded = hex(amount)[2:].zfill(64) return f"{selector}{to_padded}{amount_padded}{memo_hex}" def test_memo_requires_transfer_with_memo_selector(self) -> None: - calldata = self._build_calldata(TRANSFER_SELECTOR, self.RECIPIENT, self.AMOUNT, "ab" * 32) - assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, self.MEMO) is False + calldata = self._build_calldata( + TRANSFER_SELECTOR, + self.RECIPIENT, + self.AMOUNT, + "ab" * 32, + ) + assert ( + _match_single_transfer_calldata( + calldata, + self.RECIPIENT, + self.AMOUNT, + self.MEMO, + ) + is False + ) def test_memo_accepts_correct_selector(self) -> None: - calldata = self._build_calldata(TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT, "ab" * 32) - assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, self.MEMO) is True + calldata = self._build_calldata( + TRANSFER_WITH_MEMO_SELECTOR, + self.RECIPIENT, + self.AMOUNT, + "ab" * 32, + ) + assert ( + _match_single_transfer_calldata( + calldata, + self.RECIPIENT, + self.AMOUNT, + self.MEMO, + ) + is True + ) def test_no_memo_rejects_transfer_with_memo_selector(self) -> None: """When no memo expected, transferWithMemo calldata must be rejected.""" @@ -2832,10 +2902,14 @@ def test_single_transfer_rejects_transfer_with_memo_log(self) -> None: methodDetails=MethodDetails(), ) receipt = { - "logs": [self._make_log( - TRANSFER_WITH_MEMO_TOPIC, self.RECIPIENT, self.AMOUNT, - memo="0x" + "ff" * 32, - )], + "logs": [ + self._make_log( + TRANSFER_WITH_MEMO_TOPIC, + self.RECIPIENT, + self.AMOUNT, + memo="0x" + "ff" * 32, + ) + ], } assert intent._verify_transfer_logs(receipt, request) is False @@ -2856,7 +2930,9 @@ def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None: self._make_log(TRANSFER_TOPIC, self.RECIPIENT, 700000), # split as TransferWithMemo (should be rejected) self._make_log( - TRANSFER_WITH_MEMO_TOPIC, self.SPLIT_RECIPIENT, 300000, + TRANSFER_WITH_MEMO_TOPIC, + self.SPLIT_RECIPIENT, + 300000, memo="0x" + "ff" * 32, ), ], @@ -2869,8 +2945,8 @@ class TestSplitsFeePayerRejection: @pytest.mark.anyio async def test_splits_with_fee_payer_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: - from mpp.server import Mpp from mpp.methods.tempo import tempo + from mpp.server import Mpp monkeypatch.setenv("MPP_SECRET_KEY", "test-secret-key") server = Mpp.create( @@ -2883,7 +2959,12 @@ async def test_splits_with_fee_payer_raises(self, monkeypatch: pytest.MonkeyPatc await server.charge( authorization=None, amount="1.00", - splits=[{"amount": "300000", "recipient": "0x1111111111111111111111111111111111111111"}], + splits=[ + { + "amount": "300000", + "recipient": "0x1111111111111111111111111111111111111111", + } + ], fee_payer=True, ) @@ -2900,11 +2981,23 @@ def test_short_primary_memo_raises(self) -> None: get_transfers(1_000_000, "0x01", "0x" + "ab" * 10, None) def test_invalid_split_memo_raises(self) -> None: - splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111", memo="badhex")] + splits = [ + Split( + amount="100000", + recipient="0x1111111111111111111111111111111111111111", + memo="badhex", + ) + ] with pytest.raises(VerificationError, match="Invalid memo hex"): get_transfers(1_000_000, "0x01", None, splits) def test_short_split_memo_raises(self) -> None: - splits = [Split(amount="100000", recipient="0x1111111111111111111111111111111111111111", memo="0x" + "ab" * 5)] + splits = [ + Split( + amount="100000", + recipient="0x1111111111111111111111111111111111111111", + memo="0x" + "ab" * 5, + ) + ] with pytest.raises(VerificationError, match="exactly 32 bytes"): get_transfers(1_000_000, "0x01", None, splits) From e67a227786c7c521eab0a754e38e1985cc442a8e Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:41:06 -0700 Subject: [PATCH 6/9] fix: avoid unbound gas estimate data --- src/mpp/methods/tempo/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 3a90537..d9a74ec 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -219,6 +219,8 @@ async def _build_tempo_transfer( resolved_rpc = rpc_url or self.rpc_url + gas_estimate_data: str | None = None + if splits: from mpp.methods.tempo.intents import get_transfers from mpp.methods.tempo.schemas import Split as SplitModel @@ -271,7 +273,7 @@ async def _build_tempo_transfer( resolved_rpc, nonce_address, currency, c.data.hex() ) gas_limit = max(gas_limit, total_estimated + 5_000 * len(calls_tuple)) - else: + elif gas_estimate_data is not None: estimated = await estimate_gas( resolved_rpc, nonce_address, currency, gas_estimate_data ) From 421e7948f48b0dd6d57b961c3da09dd78f4622f8 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 6 Apr 2026 18:46:52 -0700 Subject: [PATCH 7/9] fix: return MatchedTransferLog lists from verify methods for memo binding --- src/mpp/methods/tempo/__init__.pyi | 6 ++ src/mpp/methods/tempo/intents.py | 93 ++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/mpp/methods/tempo/__init__.pyi b/src/mpp/methods/tempo/__init__.pyi index 8cc6db1..0d0cd24 100644 --- a/src/mpp/methods/tempo/__init__.pyi +++ b/src/mpp/methods/tempo/__init__.pyi @@ -10,6 +10,9 @@ from mpp.methods.tempo.client import TempoMethod as _TempoMethod from mpp.methods.tempo.client import TransactionError as _TransactionError from mpp.methods.tempo.client import tempo as _tempo from mpp.methods.tempo.intents import ChargeIntent as _ChargeIntent +from mpp.methods.tempo.intents import Transfer as _Transfer +from mpp.methods.tempo.intents import get_transfers as _get_transfers +from mpp.methods.tempo.schemas import Split as _Split CHAIN_ID = _CHAIN_ID ESCROW_CONTRACTS = _ESCROW_CONTRACTS @@ -23,3 +26,6 @@ TempoMethod = _TempoMethod TransactionError = _TransactionError tempo = _tempo ChargeIntent = _ChargeIntent +Transfer = _Transfer +get_transfers = _get_transfers +Split = _Split diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index 2183b40..d25b61d 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -453,8 +453,15 @@ def _verify_single_transfer_log( amount: int, memo: bytes | None, expected_sender: str | None = None, - ) -> bool: - """Check if receipt contains a matching Transfer/TransferWithMemo log.""" + ) -> list[MatchedTransferLog]: + """Check if receipt contains matching Transfer/TransferWithMemo logs. + + Returns matched logs in priority order, with memo logs before plain + transfers so downstream verification can inspect the memo. + """ + memo_matches: list[MatchedTransferLog] = [] + transfer_matches: list[MatchedTransferLog] = [] + for log in receipt.get("logs", []): if log.get("address", "").lower() != currency.lower(): continue @@ -471,37 +478,46 @@ def _verify_single_transfer_log( if expected_sender and from_address.lower() != expected_sender.lower(): continue - if memo is not None: - if event_topic != TRANSFER_WITH_MEMO_TOPIC: - continue + if event_topic == TRANSFER_WITH_MEMO_TOPIC: if len(topics) < 4: continue data = log.get("data", "0x") if len(data) < 66: continue log_amount = int(data[2:66], 16) - memo_topic = topics[3] - expected_memo_hex = "0x" + memo.hex() - if log_amount == amount and memo_topic.lower() == expected_memo_hex.lower(): - return True - else: - if event_topic not in (TRANSFER_TOPIC, TRANSFER_WITH_MEMO_TOPIC): + if log_amount != amount: + continue + log_memo = topics[3] + if memo is not None: + expected_memo_hex = "0x" + memo.hex() + if log_memo.lower() != expected_memo_hex.lower(): + continue + memo_matches.append( + MatchedTransferLog(kind="memo", memo=log_memo) + ) + elif event_topic == TRANSFER_TOPIC: + if memo is not None: continue data = log.get("data", "0x") if len(data) >= 66: - log_amount = int(data[2:66], 16) if event_topic == TRANSFER_WITH_MEMO_TOPIC else int(data, 16) + log_amount = int(data, 16) if log_amount == amount: - return True + transfer_matches.append( + MatchedTransferLog(kind="transfer") + ) - return False + return memo_matches + transfer_matches def _verify_transfer_logs( self, receipt: dict[str, Any], request: ChargeRequest, expected_sender: str | None = None, - ) -> bool: - """Check if receipt contains matching Transfer or TransferWithMemo logs.""" + ) -> list[MatchedTransferLog]: + """Check if receipt contains matching Transfer or TransferWithMemo logs. + + Returns matched logs. Empty list means no match. + """ expected = get_transfers( int(request.amount), request.recipient, @@ -521,16 +537,22 @@ def _verify_transfer_logs( ) # Multi-transfer: order-insensitive matching - sorted_expected = sorted(expected, key=lambda t: 0 if t.memo else 1) + sorted_expected = sorted( + expected, key=lambda t: 0 if t.memo else 1 + ) logs = receipt.get("logs", []) used_logs: set[int] = set() + all_matches: list[MatchedTransferLog] = [] for transfer in sorted_expected: found = False for log_idx, log in enumerate(logs): if log_idx in used_logs: continue - if log.get("address", "").lower() != request.currency.lower(): + if ( + log.get("address", "").lower() + != request.currency.lower() + ): continue topics = log.get("topics", []) @@ -538,12 +560,15 @@ def _verify_transfer_logs( continue event_topic = topics[0] - from_address = "0x" + topics[1][-40:] - to_address = "0x" + topics[2][-40:] + from_addr = "0x" + topics[1][-40:] + to_addr = "0x" + topics[2][-40:] - if to_address.lower() != transfer.recipient.lower(): + if to_addr.lower() != transfer.recipient.lower(): continue - if expected_sender and from_address.lower() != expected_sender.lower(): + if ( + expected_sender + and from_addr.lower() != expected_sender.lower() + ): continue if transfer.memo is not None: @@ -554,14 +579,19 @@ def _verify_transfer_logs( data = log.get("data", "0x") if len(data) < 66: continue - amount = int(data[2:66], 16) + log_amount = int(data[2:66], 16) memo_topic = topics[3] - expected_memo_hex = "0x" + transfer.memo.hex() + expected_hex = "0x" + transfer.memo.hex() if ( - amount == transfer.amount - and memo_topic.lower() == expected_memo_hex.lower() + log_amount == transfer.amount + and memo_topic.lower() == expected_hex.lower() ): used_logs.add(log_idx) + all_matches.append( + MatchedTransferLog( + kind="memo", memo=memo_topic + ) + ) found = True break else: @@ -569,16 +599,19 @@ def _verify_transfer_logs( continue data = log.get("data", "0x") if len(data) >= 66: - amount = int(data, 16) - if amount == transfer.amount: + log_amount = int(data, 16) + if log_amount == transfer.amount: used_logs.add(log_idx) + all_matches.append( + MatchedTransferLog(kind="transfer") + ) found = True break if not found: - return False + return [] - return True + return all_matches async def _verify_transaction( self, From 3ad6b4094b691fa70f13fc05aaa4db4d952a8f35 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 6 Apr 2026 18:50:13 -0700 Subject: [PATCH 8/9] fix: update split payment tests for list return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _verify_transfer_logs and _verify_single_transfer_log now return list[MatchedTransferLog] instead of bool (for memo binding support). Update assertions from identity checks (is True/is False) to truthiness checks (assert result/assert not result). Also fix test_single_transfer_rejects_transfer_with_memo_log: when memo=None, TransferWithMemo logs ARE accepted in the single-transfer path — memo binding is checked later by _assert_challenge_bound_memo. --- tests/test_tempo.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test_tempo.py b/tests/test_tempo.py index cefd74e..8fe6bc8 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -2644,7 +2644,7 @@ def test_split_logs_accepted(self) -> None: self._make_transfer_log(self.SPLIT_RECIPIENT, 300000), # split ], } - assert intent._verify_transfer_logs(receipt, request) is True + assert intent._verify_transfer_logs(receipt, request) def test_split_logs_wrong_amount_rejected(self) -> None: """Receipt with wrong split amount should be rejected.""" @@ -2664,7 +2664,7 @@ def test_split_logs_wrong_amount_rejected(self) -> None: self._make_transfer_log(self.SPLIT_RECIPIENT, 200000), # wrong ], } - assert intent._verify_transfer_logs(receipt, request) is False + assert not intent._verify_transfer_logs(receipt, request) def test_split_logs_missing_split_rejected(self) -> None: """Receipt missing a split log should be rejected.""" @@ -2681,7 +2681,7 @@ def test_split_logs_missing_split_rejected(self) -> None: "status": "0x1", "logs": [self._make_transfer_log(self.RECIPIENT, 700000)], } - assert intent._verify_transfer_logs(receipt, request) is False + assert not intent._verify_transfer_logs(receipt, request) def test_split_with_memo_accepted(self) -> None: """Split with memo should match TransferWithMemo log.""" @@ -2702,7 +2702,7 @@ def test_split_with_memo_accepted(self) -> None: self._make_transfer_log(self.SPLIT_RECIPIENT, 300000, memo=split_memo), ], } - assert intent._verify_transfer_logs(receipt, request) is True + assert intent._verify_transfer_logs(receipt, request) def test_split_order_insensitive(self) -> None: """Logs in different order from splits should still match.""" @@ -2727,7 +2727,7 @@ def test_split_order_insensitive(self) -> None: self._make_transfer_log(self.SPLIT_RECIPIENT, 200000), ], } - assert intent._verify_transfer_logs(receipt, request) is True + assert intent._verify_transfer_logs(receipt, request) class TestSplitSchemas: @@ -2892,8 +2892,12 @@ def _make_log(self, topic: str, recipient: str, amount: int, memo: str | None = "data": "0x" + hex(amount)[2:].zfill(64), } - def test_single_transfer_rejects_transfer_with_memo_log(self) -> None: - """A memo-less single transfer must reject TransferWithMemo logs.""" + def test_single_transfer_accepts_transfer_with_memo_log(self) -> None: + """A memo-less single transfer accepts TransferWithMemo logs. + + When memo=None the single-transfer path matches TransferWithMemo + events (memo binding is checked later by _assert_challenge_bound_memo). + """ intent = ChargeIntent(rpc_url="https://rpc.test") request = ChargeRequest( amount=str(self.AMOUNT), @@ -2911,7 +2915,7 @@ def test_single_transfer_rejects_transfer_with_memo_log(self) -> None: ) ], } - assert intent._verify_transfer_logs(receipt, request) is False + assert intent._verify_transfer_logs(receipt, request) def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None: """Memo-less split legs must reject TransferWithMemo logs.""" @@ -2937,7 +2941,7 @@ def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None: ), ], } - assert intent._verify_transfer_logs(receipt, request) is False + assert not intent._verify_transfer_logs(receipt, request) class TestSplitsFeePayerRejection: From 5cef006661bcf13eff6942464563368619d961cb Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 6 Apr 2026 18:51:27 -0700 Subject: [PATCH 9/9] style: format intents.py with ruff --- src/mpp/methods/tempo/intents.py | 37 +++++++------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index d25b61d..464dcdf 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -492,9 +492,7 @@ def _verify_single_transfer_log( expected_memo_hex = "0x" + memo.hex() if log_memo.lower() != expected_memo_hex.lower(): continue - memo_matches.append( - MatchedTransferLog(kind="memo", memo=log_memo) - ) + memo_matches.append(MatchedTransferLog(kind="memo", memo=log_memo)) elif event_topic == TRANSFER_TOPIC: if memo is not None: continue @@ -502,9 +500,7 @@ def _verify_single_transfer_log( if len(data) >= 66: log_amount = int(data, 16) if log_amount == amount: - transfer_matches.append( - MatchedTransferLog(kind="transfer") - ) + transfer_matches.append(MatchedTransferLog(kind="transfer")) return memo_matches + transfer_matches @@ -537,9 +533,7 @@ def _verify_transfer_logs( ) # Multi-transfer: order-insensitive matching - sorted_expected = sorted( - expected, key=lambda t: 0 if t.memo else 1 - ) + sorted_expected = sorted(expected, key=lambda t: 0 if t.memo else 1) logs = receipt.get("logs", []) used_logs: set[int] = set() all_matches: list[MatchedTransferLog] = [] @@ -549,10 +543,7 @@ def _verify_transfer_logs( for log_idx, log in enumerate(logs): if log_idx in used_logs: continue - if ( - log.get("address", "").lower() - != request.currency.lower() - ): + if log.get("address", "").lower() != request.currency.lower(): continue topics = log.get("topics", []) @@ -565,10 +556,7 @@ def _verify_transfer_logs( if to_addr.lower() != transfer.recipient.lower(): continue - if ( - expected_sender - and from_addr.lower() != expected_sender.lower() - ): + if expected_sender and from_addr.lower() != expected_sender.lower(): continue if transfer.memo is not None: @@ -582,16 +570,9 @@ def _verify_transfer_logs( log_amount = int(data[2:66], 16) memo_topic = topics[3] expected_hex = "0x" + transfer.memo.hex() - if ( - log_amount == transfer.amount - and memo_topic.lower() == expected_hex.lower() - ): + if log_amount == transfer.amount and memo_topic.lower() == expected_hex.lower(): used_logs.add(log_idx) - all_matches.append( - MatchedTransferLog( - kind="memo", memo=memo_topic - ) - ) + all_matches.append(MatchedTransferLog(kind="memo", memo=memo_topic)) found = True break else: @@ -602,9 +583,7 @@ def _verify_transfer_logs( log_amount = int(data, 16) if log_amount == transfer.amount: used_logs.add(log_idx) - all_matches.append( - MatchedTransferLog(kind="transfer") - ) + all_matches.append(MatchedTransferLog(kind="transfer")) found = True break