Skip to content

Commit 759bf07

Browse files
feat: add split payments support for Tempo charges (#104)
* 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 * chore: add changelog * 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 * 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 * fix: resolve split-payments lint failures * fix: avoid unbound gas estimate data * fix: return MatchedTransferLog lists from verify methods for memo binding * fix: update split payment tests for list return type _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. * style: format intents.py with ruff --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 06df968 commit 759bf07

9 files changed

Lines changed: 901 additions & 70 deletions

File tree

.changelog/brave-cats-split.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pympp: minor
3+
---
4+
5+
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).

.changelog/shy-frogs-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pympp: minor
3+
---
4+
5+
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).

src/mpp/methods/tempo/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
_LAZY_EXPORTS = {
4444
"mpp.methods.tempo.account": ("TempoAccount",),
4545
"mpp.methods.tempo.client": ("TempoMethod", "TransactionError", "tempo"),
46-
"mpp.methods.tempo.intents": ("ChargeIntent",),
46+
"mpp.methods.tempo.intents": ("ChargeIntent", "Transfer", "get_transfers"),
47+
"mpp.methods.tempo.schemas": ("Split",),
4748
}
4849

4950

src/mpp/methods/tempo/__init__.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ from mpp.methods.tempo.client import TempoMethod as _TempoMethod
1010
from mpp.methods.tempo.client import TransactionError as _TransactionError
1111
from mpp.methods.tempo.client import tempo as _tempo
1212
from mpp.methods.tempo.intents import ChargeIntent as _ChargeIntent
13+
from mpp.methods.tempo.intents import Transfer as _Transfer
14+
from mpp.methods.tempo.intents import get_transfers as _get_transfers
15+
from mpp.methods.tempo.schemas import Split as _Split
1316

1417
CHAIN_ID = _CHAIN_ID
1518
ESCROW_CONTRACTS = _ESCROW_CONTRACTS
@@ -23,3 +26,6 @@ TempoMethod = _TempoMethod
2326
TransactionError = _TransactionError
2427
tempo = _tempo
2528
ChargeIntent = _ChargeIntent
29+
Transfer = _Transfer
30+
get_transfers = _get_transfers
31+
Split = _Split

src/mpp/methods/tempo/client.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ async def create_credential(self, challenge: Challenge) -> Credential:
125125
client_id=self.client_id,
126126
)
127127

128+
splits = method_details.get("splits") if isinstance(method_details, dict) else None
129+
128130
# Resolve RPC URL from challenge's chainId (like mppx), falling back
129131
# to the method-level rpc_url.
130132
rpc_url = self.rpc_url
@@ -159,6 +161,7 @@ async def create_credential(self, challenge: Challenge) -> Credential:
159161
rpc_url=rpc_url,
160162
expected_chain_id=expected_chain_id,
161163
awaiting_fee_payer=use_fee_payer,
164+
splits=splits,
162165
)
163166

164167
# When signing with an access key, the credential source is the
@@ -181,6 +184,7 @@ async def _build_tempo_transfer(
181184
rpc_url: str | None = None,
182185
expected_chain_id: int | None = None,
183186
awaiting_fee_payer: bool = False,
187+
splits: list[dict] | None = None,
184188
) -> tuple[str, int]:
185189
"""Build a client-signed Tempo transaction.
186190
@@ -215,10 +219,29 @@ async def _build_tempo_transfer(
215219

216220
resolved_rpc = rpc_url or self.rpc_url
217221

218-
if memo:
219-
transfer_data = self._encode_transfer_with_memo(recipient, int(amount), memo)
222+
gas_estimate_data: str | None = None
223+
224+
if splits:
225+
from mpp.methods.tempo.intents import get_transfers
226+
from mpp.methods.tempo.schemas import Split as SplitModel
227+
228+
parsed_splits = [SplitModel(**s) for s in splits]
229+
transfer_list = get_transfers(int(amount), recipient, memo, parsed_splits)
230+
call_list = []
231+
for t in transfer_list:
232+
if t.memo is not None:
233+
td = self._encode_transfer_with_memo(t.recipient, t.amount, "0x" + t.memo.hex())
234+
else:
235+
td = self._encode_transfer(t.recipient, t.amount)
236+
call_list.append(Call.create(to=currency, value=0, data=td))
237+
calls_tuple = tuple(call_list)
220238
else:
221-
transfer_data = self._encode_transfer(recipient, int(amount))
239+
if memo:
240+
transfer_data = self._encode_transfer_with_memo(recipient, int(amount), memo)
241+
else:
242+
transfer_data = self._encode_transfer(recipient, int(amount))
243+
calls_tuple = (Call.create(to=currency, value=0, data=transfer_data),)
244+
gas_estimate_data = transfer_data
222245

223246
# When using an access key, fetch nonce from the root account
224247
# (smart wallet), not the access key address.
@@ -243,8 +266,18 @@ async def _build_tempo_transfer(
243266

244267
gas_limit = DEFAULT_GAS_LIMIT
245268
try:
246-
estimated = await estimate_gas(resolved_rpc, nonce_address, currency, transfer_data)
247-
gas_limit = max(gas_limit, estimated + 5_000)
269+
if splits:
270+
total_estimated = 0
271+
for c in calls_tuple:
272+
total_estimated += await estimate_gas(
273+
resolved_rpc, nonce_address, currency, c.data.hex()
274+
)
275+
gas_limit = max(gas_limit, total_estimated + 5_000 * len(calls_tuple))
276+
elif gas_estimate_data is not None:
277+
estimated = await estimate_gas(
278+
resolved_rpc, nonce_address, currency, gas_estimate_data
279+
)
280+
gas_limit = max(gas_limit, estimated + 5_000)
248281
except Exception:
249282
pass
250283

@@ -258,7 +291,7 @@ async def _build_tempo_transfer(
258291
fee_token=None if awaiting_fee_payer else currency,
259292
awaiting_fee_payer=awaiting_fee_payer,
260293
valid_before=valid_before,
261-
calls=(Call.create(to=currency, value=0, data=transfer_data),),
294+
calls=calls_tuple,
262295
)
263296

264297
if self.root_account:

0 commit comments

Comments
 (0)