From bff67842d86952a8e0e25fdb4f2e434e2774b6bd Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 17:20:11 -0700 Subject: [PATCH 1/6] 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 | 320 +++++++++++++++++++++++++----- src/mpp/methods/tempo/schemas.py | 9 + src/mpp/server/mpp.py | 5 +- tests/test_tempo.py | 255 ++++++++++++++++++++++++ 7 files changed, 567 insertions(+), 62 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 79ce6b0..aac27ba 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -37,4 +37,5 @@ ) from mpp.methods.tempo.account import TempoAccount from mpp.methods.tempo.client import TempoMethod, TransactionError, tempo -from mpp.methods.tempo.intents import ChargeIntent +from mpp.methods.tempo.intents import ChargeIntent, Transfer, get_transfers +from mpp.methods.tempo.schemas import Split diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 4f14cbc..61b0f4f 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -118,6 +118,8 @@ async def create_credential(self, challenge: Challenge) -> Credential: if memo is None: memo = encode_attribution(server_id=challenge.realm, 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 @@ -152,6 +154,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 @@ -174,6 +177,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. @@ -208,10 +212,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. @@ -236,7 +258,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 @@ -251,7 +273,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 57cb111..f5c9d9b 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -7,6 +7,7 @@ import asyncio import time +from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -19,6 +20,7 @@ ChargeRequest, CredentialPayload, HashCredentialPayload, + Split, TransactionCredentialPayload, ) from mpp.store import Store @@ -43,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 + def _rpc_error_msg(result: dict) -> str: """Extract error message from a JSON-RPC error response.""" @@ -277,30 +388,19 @@ async def _verify_hash( return Receipt.success(payload.hash) - 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, ) -> bool: - """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: - True if a matching Transfer/TransferWithMemo log is found, - False otherwise. - """ - expected_memo = request.methodDetails.memo - + """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 @@ -309,40 +409,113 @@ 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 expected_memo: + if memo is not None: if event_topic != TRANSFER_WITH_MEMO_TOPIC: continue - # TransferWithMemo has 3 indexed params (from, to, memo) - # so memo is in topics[3] and only amount is in data if len(topics) < 4: continue data = log.get("data", "0x") if len(data) < 66: continue - amount = int(data[2:66], 16) - memo = topics[3] - memo_clean = expected_memo.lower() - if not memo_clean.startswith("0x"): - memo_clean = "0x" + memo_clean - if amount == int(request.amount) and memo.lower() == memo_clean: + 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 != TRANSFER_TOPIC: continue data = log.get("data", "0x") if len(data) >= 66: - amount = int(data, 16) - if amount == int(request.amount): + log_amount = 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 + + 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, payload: TransactionCredentialPayload, @@ -536,16 +709,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.""" @@ -570,18 +762,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 1615dc1..3c320cb 100644 --- a/src/mpp/methods/tempo/schemas.py +++ b/src/mpp/methods/tempo/schemas.py @@ -7,6 +7,14 @@ from pydantic import BaseModel, Field +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 class ChargeRequest(BaseModel): 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 492c9b9..cb4ce62 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -27,13 +27,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 @@ -1842,3 +1845,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 bc13896b9bf708c75eb57d7471a54c1a56b6c904 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 00:20:41 +0000 Subject: [PATCH 2/6] 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 5f76145d81bf0bb65b831391c81e0a8ea181ed71 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:00:00 -0700 Subject: [PATCH 3/6] 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 61b0f4f..ee53352 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -226,7 +226,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) @@ -258,8 +257,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 f5c9d9b..7154e79 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] @@ -176,7 +182,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] @@ -501,11 +507,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 cb4ce62..a19e87a 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -1584,15 +1584,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 84a6998e1d3712de6ffa5f05ac92f2ab8ea3fe3f Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:13:24 -0700 Subject: [PATCH 4/6] 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 a19e87a..386bde5 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -28,7 +28,9 @@ TRANSFER_WITH_MEMO_TOPIC, ChargeIntent, Transfer, + _match_single_transfer_calldata, _match_transfer_calldata, + _parse_memo_bytes, _rpc_error_msg, get_transfers, ) @@ -2097,3 +2099,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 29271461babb173b53587fb6cbe9337594187f2a Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:38:38 -0700 Subject: [PATCH 5/6] 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 7154e79..d9e3c62 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 @@ -461,12 +467,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() @@ -502,7 +512,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 @@ -723,7 +736,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: @@ -775,7 +788,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: @@ -788,7 +801,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 386bde5..bb0d093 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -27,7 +27,6 @@ TRANSFER_WITH_MEMO_SELECTOR, TRANSFER_WITH_MEMO_TOPIC, ChargeIntent, - Transfer, _match_single_transfer_calldata, _match_transfer_calldata, _parse_memo_bytes, @@ -1864,23 +1863,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 @@ -1890,7 +1920,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 @@ -1914,18 +1949,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 @@ -2144,18 +2182,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.""" @@ -2198,10 +2268,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 @@ -2222,7 +2296,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, ), ], @@ -2235,8 +2311,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( @@ -2249,7 +2325,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, ) @@ -2266,11 +2347,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 d8f6052965cfc17adc2e6e856199a7b06715f560 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 30 Mar 2026 20:41:06 -0700 Subject: [PATCH 6/6] 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 ee53352..0461b92 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -212,6 +212,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 @@ -264,7 +266,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 )