Skip to content

Commit 08ecec4

Browse files
fix: accept attribution memos in pull-mode preflight (#130)
* fix: accept attribution memos in pull-mode preflight * chore: add changelog * fix: allow attribution memos in split receipt matching * style: format tempo intent matcher * chore: consolidate changelog entries --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent b644dd0 commit 08ecec4

3 files changed

Lines changed: 128 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pympp: patch
3+
---
4+
5+
Allowed Tempo pull-mode validation to accept client attribution memos in both preflight calldata checks and split receipt matching while still rejecting truncated `transferWithMemo` calldata.

src/mpp/methods/tempo/intents.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ def _match_single_transfer_calldata(
139139
if memo is not None:
140140
if selector != TRANSFER_WITH_MEMO_SELECTOR:
141141
return False
142+
elif selector == TRANSFER_WITH_MEMO_SELECTOR:
143+
if len(call_data_hex) < 200:
144+
return False
142145
elif selector != TRANSFER_SELECTOR:
143146
return False
144147

@@ -187,6 +190,9 @@ def _match_transfer_calldata(call_data_hex: str, request: ChargeRequest) -> bool
187190
if expected_memo:
188191
if selector != TRANSFER_WITH_MEMO_SELECTOR:
189192
return False
193+
elif selector == TRANSFER_WITH_MEMO_SELECTOR:
194+
if len(call_data_hex) < 200:
195+
return False
190196
elif selector != TRANSFER_SELECTOR:
191197
return False
192198

@@ -527,13 +533,20 @@ def _verify_transfer_logs(
527533

528534
# Multi-transfer: order-insensitive matching
529535
sorted_expected = sorted(expected, key=lambda t: 0 if t.memo else 1)
530-
logs = receipt.get("logs", [])
536+
indexed_logs = list(enumerate(receipt.get("logs", [])))
537+
# Prefer memo logs so memo-less transfers still preserve attribution
538+
# memos for challenge binding verification when both log types exist.
539+
indexed_logs.sort(
540+
key=lambda item: (
541+
0 if item[1].get("topics", [None])[0] == TRANSFER_WITH_MEMO_TOPIC else 1
542+
)
543+
)
531544
used_logs: set[int] = set()
532545
all_matches: list[MatchedTransferLog] = []
533546

534547
for transfer in sorted_expected:
535548
found = False
536-
for log_idx, log in enumerate(logs):
549+
for log_idx, log in indexed_logs:
537550
if log_idx in used_logs:
538551
continue
539552
if log.get("address", "").lower() != request.currency.lower():
@@ -569,10 +582,21 @@ def _verify_transfer_logs(
569582
found = True
570583
break
571584
else:
572-
if event_topic != TRANSFER_TOPIC:
573-
continue
574585
data = log.get("data", "0x")
575-
if len(data) >= 66:
586+
if event_topic == TRANSFER_WITH_MEMO_TOPIC:
587+
if len(topics) < 4:
588+
continue
589+
if len(data) < 66:
590+
continue
591+
log_amount = int(data[2:66], 16)
592+
if log_amount == transfer.amount:
593+
used_logs.add(log_idx)
594+
all_matches.append(MatchedTransferLog(kind="memo", memo=topics[3]))
595+
found = True
596+
break
597+
elif event_topic == TRANSFER_TOPIC:
598+
if len(data) < 66:
599+
continue
576600
log_amount = int(data, 16)
577601
if log_amount == transfer.amount:
578602
used_logs.add(log_idx)

tests/test_tempo.py

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,16 +1418,19 @@ def _build_0x78_envelope(
14181418
currency: str = "0x20c0000000000000000000000000000000000000",
14191419
recipient: str = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00",
14201420
amount: int = 1000000,
1421+
memo: str | None = None,
14211422
) -> str:
14221423
"""Build a 0x78 fee payer envelope."""
14231424
from pytempo import Call, TempoTransaction
14241425

14251426
from mpp.methods.tempo.fee_payer_envelope import encode_fee_payer_envelope
14261427

1427-
selector = "a9059cbb"
1428+
selector = TRANSFER_WITH_MEMO_SELECTOR if memo is not None else TRANSFER_SELECTOR
14281429
to_padded = recipient[2:].lower().zfill(64)
14291430
amount_padded = hex(amount)[2:].zfill(64)
14301431
transfer_data = f"0x{selector}{to_padded}{amount_padded}"
1432+
if memo is not None:
1433+
transfer_data += memo[2:] if memo.startswith("0x") else memo
14311434

14321435
tx = TempoTransaction.create(
14331436
chain_id=42431,
@@ -1449,16 +1452,19 @@ def _build_0x76_tx(
14491452
currency: str = "0x20c0000000000000000000000000000000000000",
14501453
recipient: str = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00",
14511454
amount: int = 1000000,
1455+
memo: str | None = None,
14521456
) -> str:
14531457
"""Build a standard 0x76 transaction."""
14541458
import attrs
14551459
from pytempo import Call, TempoTransaction
14561460
from pytempo.models import Signature
14571461

1458-
selector = "a9059cbb"
1462+
selector = TRANSFER_WITH_MEMO_SELECTOR if memo is not None else TRANSFER_SELECTOR
14591463
to_padded = recipient[2:].lower().zfill(64)
14601464
amount_padded = hex(amount)[2:].zfill(64)
14611465
transfer_data = f"0x{selector}{to_padded}{amount_padded}"
1466+
if memo is not None:
1467+
transfer_data += memo[2:] if memo.startswith("0x") else memo
14621468

14631469
tx = TempoTransaction.create(
14641470
chain_id=42431,
@@ -1486,6 +1492,19 @@ def test_accepts_0x78_with_matching_call(self) -> None:
14861492
# Should not raise
14871493
intent._validate_transaction_payload(sig, request)
14881494

1495+
def test_accepts_0x78_with_transfer_with_memo_when_no_server_memo(self) -> None:
1496+
"""Should accept 0x78 envelopes with client attribution memos."""
1497+
intent = ChargeIntent(rpc_url="https://rpc.test")
1498+
request = ChargeRequest(
1499+
amount="1000000",
1500+
currency="0x20c0000000000000000000000000000000000000",
1501+
recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00",
1502+
)
1503+
memo = encode_attribution(challenge_id="challenge-123", server_id="api.example.com")
1504+
sig = self._build_0x78_envelope(memo=memo)
1505+
1506+
intent._validate_transaction_payload(sig, request)
1507+
14891508
def test_rejects_0x78_with_wrong_amount(self) -> None:
14901509
"""Should reject a 0x78 envelope with mismatched amount."""
14911510
intent = ChargeIntent(rpc_url="https://rpc.test")
@@ -2092,15 +2111,21 @@ def test_memo_normalization_no_0x_prefix(self) -> None:
20922111
)
20932112
assert _match_transfer_calldata(calldata, request) is True
20942113

2095-
def test_no_memo_accepts_only_transfer_selector(self) -> None:
2096-
"""When no memo, only plain transfer selector should be accepted."""
2114+
def test_no_memo_accepts_plain_transfer_and_transfer_with_memo(self) -> None:
2115+
"""When no memo, plain transfer and transferWithMemo should be accepted."""
20972116
request = self._make_request(memo=None)
20982117
calldata_plain = self._build_calldata(TRANSFER_SELECTOR, self.RECIPIENT, self.AMOUNT)
20992118
calldata_memo = self._build_calldata(
2100-
TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT
2119+
TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT, self.MEMO
21012120
)
21022121
assert _match_transfer_calldata(calldata_plain, request) is True
2103-
assert _match_transfer_calldata(calldata_memo, request) is False
2122+
assert _match_transfer_calldata(calldata_memo, request) is True
2123+
2124+
def test_no_memo_rejects_short_transfer_with_memo_calldata(self) -> None:
2125+
"""When no memo, truncated transferWithMemo calldata should still be rejected."""
2126+
request = self._make_request(memo=None)
2127+
calldata = self._build_calldata(TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT)
2128+
assert _match_transfer_calldata(calldata, request) is False
21042129

21052130
def test_short_calldata_rejected(self) -> None:
21062131
"""Calldata shorter than 136 chars should always be rejected."""
@@ -2335,6 +2360,25 @@ def test_valid_tx_with_matching_call_passes(self) -> None:
23352360

23362361
intent._validate_transaction_payload(sig, request)
23372362

2363+
def test_valid_tx_with_transfer_with_memo_passes_when_no_server_memo(self) -> None:
2364+
"""Transaction credentials should allow client attribution memos."""
2365+
intent = ChargeIntent(rpc_url="https://rpc.test")
2366+
request = self._make_request()
2367+
memo = encode_attribution(challenge_id="challenge-123", server_id="api.example.com")
2368+
2369+
selector = bytes.fromhex(TRANSFER_WITH_MEMO_SELECTOR)
2370+
to_padded = bytes.fromhex(self.RECIPIENT[2:].lower().zfill(64))
2371+
amount_padded = bytes.fromhex(hex(1000000)[2:].zfill(64))
2372+
call_data = selector + to_padded + amount_padded + bytes.fromhex(memo[2:])
2373+
2374+
currency_bytes = bytes.fromhex(self.CURRENCY[2:])
2375+
call = [currency_bytes, b"", call_data]
2376+
decoded = [b"\x01", b"\x01", b"\x01", b"\x01", [call], b"", b"", b"\x00", b"", b"", b""]
2377+
payload = b"\x76" + rlp.encode(decoded) # type: ignore[operator]
2378+
sig = "0x" + payload.hex()
2379+
2380+
intent._validate_transaction_payload(sig, request)
2381+
23382382
def test_no_matching_call_raises(self) -> None:
23392383
"""Transaction with no matching call should raise VerificationError."""
23402384
intent = ChargeIntent(rpc_url="https://rpc.test")
@@ -2774,8 +2818,18 @@ def test_memo_accepts_correct_selector(self) -> None:
27742818
is True
27752819
)
27762820

2777-
def test_no_memo_rejects_transfer_with_memo_selector(self) -> None:
2778-
"""When no memo expected, transferWithMemo calldata must be rejected."""
2821+
def test_no_memo_accepts_transfer_with_memo_selector(self) -> None:
2822+
"""When no memo expected, transferWithMemo calldata should be accepted."""
2823+
calldata = self._build_calldata(
2824+
TRANSFER_WITH_MEMO_SELECTOR,
2825+
self.RECIPIENT,
2826+
self.AMOUNT,
2827+
"ab" * 32,
2828+
)
2829+
assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, None) is True
2830+
2831+
def test_no_memo_rejects_short_transfer_with_memo_calldata(self) -> None:
2832+
"""When no memo expected, truncated transferWithMemo calldata should be rejected."""
27792833
calldata = self._build_calldata(TRANSFER_WITH_MEMO_SELECTOR, self.RECIPIENT, self.AMOUNT)
27802834
assert _match_single_transfer_calldata(calldata, self.RECIPIENT, self.AMOUNT, None) is False
27812835

@@ -2785,7 +2839,7 @@ def test_no_memo_accepts_plain_transfer(self) -> None:
27852839

27862840

27872841
class TestSplitLogMemoStrictness:
2788-
"""Tests that memo-less split logs reject transferWithMemo events."""
2842+
"""Tests that memo-less split logs can still preserve attribution memos."""
27892843

27902844
CURRENCY = "0x20c0000000000000000000000000000000000000"
27912845
RECIPIENT = "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00"
@@ -2830,8 +2884,8 @@ def test_single_transfer_accepts_transfer_with_memo_log(self) -> None:
28302884
}
28312885
assert intent._verify_transfer_logs(receipt, request)
28322886

2833-
def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None:
2834-
"""Memo-less split legs must reject TransferWithMemo logs."""
2887+
def test_multi_split_accepts_transfer_with_memo_log_for_memoless(self) -> None:
2888+
"""Memo-less split legs should accept TransferWithMemo logs."""
28352889
intent = ChargeIntent(rpc_url="https://rpc.test")
28362890
request = ChargeRequest(
28372891
amount=str(self.AMOUNT),
@@ -2845,7 +2899,7 @@ def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None:
28452899
"logs": [
28462900
# primary as Transfer (correct)
28472901
self._make_log(TRANSFER_TOPIC, self.RECIPIENT, 700000),
2848-
# split as TransferWithMemo (should be rejected)
2902+
# split as TransferWithMemo (accepted; challenge binding is checked later)
28492903
self._make_log(
28502904
TRANSFER_WITH_MEMO_TOPIC,
28512905
self.SPLIT_RECIPIENT,
@@ -2854,7 +2908,34 @@ def test_multi_split_rejects_transfer_with_memo_log_for_memoless(self) -> None:
28542908
),
28552909
],
28562910
}
2857-
assert not intent._verify_transfer_logs(receipt, request)
2911+
matched_logs = intent._verify_transfer_logs(receipt, request)
2912+
assert [matched_log.kind for matched_log in matched_logs] == ["transfer", "memo"]
2913+
2914+
def test_multi_split_prefers_transfer_with_memo_log_for_memoless(self) -> None:
2915+
"""Memo-less split legs should prefer memo logs over plain transfers when both exist."""
2916+
intent = ChargeIntent(rpc_url="https://rpc.test")
2917+
request = ChargeRequest(
2918+
amount=str(self.AMOUNT),
2919+
currency=self.CURRENCY,
2920+
recipient=self.RECIPIENT,
2921+
methodDetails=MethodDetails(
2922+
splits=[Split(amount="300000", recipient=self.SPLIT_RECIPIENT)]
2923+
),
2924+
)
2925+
receipt = {
2926+
"logs": [
2927+
self._make_log(TRANSFER_TOPIC, self.RECIPIENT, 700000),
2928+
self._make_log(TRANSFER_TOPIC, self.SPLIT_RECIPIENT, 300000),
2929+
self._make_log(
2930+
TRANSFER_WITH_MEMO_TOPIC,
2931+
self.SPLIT_RECIPIENT,
2932+
300000,
2933+
memo="0x" + "ee" * 32,
2934+
),
2935+
],
2936+
}
2937+
matched_logs = intent._verify_transfer_logs(receipt, request)
2938+
assert [matched_log.kind for matched_log in matched_logs] == ["transfer", "memo"]
28582939

28592940

28602941
class TestSplitsFeePayerRejection:

0 commit comments

Comments
 (0)