Skip to content

Commit 707758c

Browse files
fix: bind Tempo attribution memos to challenge IDs (#111)
* fix: bind Tempo attribution memos to challenge IDs * chore: add changelog * fix: normalize empty memos and defer hash replay marking * fix: record verified transaction hashes in replay store * Delete .changelog/brisk-wolves-track.md --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent ba52420 commit 707758c

7 files changed

Lines changed: 812 additions & 77 deletions

File tree

.changelog/quiet-birds-swim.md

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+
Fixed Tempo attribution memos to be deterministically bound to challenge IDs using a keccak256-derived nonce instead of random bytes. Added `verify_challenge_binding` to enforce that verified payments carry a memo matching the specific challenge being redeemed.

src/mpp/methods/tempo/_attribution.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@
1212
| 4 | 1 | version (0x01) |
1313
| 5..14 | 10 | serverId = keccak256(serverId)[0..9] |
1414
| 15..24 | 10 | clientId = keccak256(clientId)[0..9] or 0s |
15-
| 25..31 | 7 | nonce (random bytes) |
15+
| 25..31 | 7 | nonce = keccak256(challengeId)[0..6] |
1616
"""
1717

1818
from __future__ import annotations
1919

20-
import os
2120
from dataclasses import dataclass
2221

2322
_VERSION = 0x01
@@ -43,21 +42,25 @@ def _fingerprint(value: str) -> bytes:
4342
return _keccak(value.encode())[:10]
4443

4544

45+
def challenge_nonce(challenge_id: str) -> bytes:
46+
return _keccak(challenge_id.encode())[:7]
47+
48+
4649
def __getattr__(name: str): # type: ignore[reportReturnType]
4750
if name == "TAG":
4851
return _get_tag()
4952
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
5053

5154

52-
def encode(server_id: str, client_id: str | None = None) -> str:
55+
def encode(challenge_id: str, server_id: str, client_id: str | None = None) -> str:
5356
tag = _get_tag()
5457
buf = bytearray(32)
5558
buf[0:4] = tag
5659
buf[4] = _VERSION
5760
buf[5:15] = _fingerprint(server_id)
5861
if client_id:
5962
buf[15:25] = _fingerprint(client_id)
60-
buf[25:32] = os.urandom(7)
63+
buf[25:32] = challenge_nonce(challenge_id)
6164
return "0x" + buf.hex()
6265

6366

@@ -82,6 +85,16 @@ def verify_server(memo: str, server_id: str) -> bool:
8285
return memo_server == _fingerprint(server_id)
8386

8487

88+
def verify_challenge_binding(memo: str, challenge_id: str) -> bool:
89+
if not is_mpp_memo(memo):
90+
return False
91+
try:
92+
memo_nonce = bytes.fromhex(memo[52:66])
93+
except ValueError:
94+
return False
95+
return memo_nonce == challenge_nonce(challenge_id)
96+
97+
8598
@dataclass(frozen=True, slots=True)
8699
class DecodedMemo:
87100
version: int

src/mpp/methods/tempo/client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,14 @@ async def create_credential(self, challenge: Challenge) -> Credential:
116116
nonce_key = int(nonce_key)
117117

118118
memo = method_details.get("memo") if isinstance(method_details, dict) else None
119+
if memo == "":
120+
memo = None
119121
if memo is None:
120-
memo = encode_attribution(server_id=challenge.realm, client_id=self.client_id)
122+
memo = encode_attribution(
123+
challenge_id=challenge.id,
124+
server_id=challenge.realm,
125+
client_id=self.client_id,
126+
)
121127

122128
# Resolve RPC URL from challenge's chainId (like mppx), falling back
123129
# to the method-level rpc_url.

src/mpp/methods/tempo/intents.py

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
import asyncio
99
import time
10+
from dataclasses import dataclass
1011
from datetime import UTC, datetime
11-
from typing import TYPE_CHECKING, Any
12+
from typing import TYPE_CHECKING, Any, Literal
1213

1314
import attrs
1415

@@ -44,6 +45,12 @@
4445
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
4546

4647

48+
@dataclass(frozen=True, slots=True)
49+
class MatchedTransferLog:
50+
kind: Literal["memo", "transfer"]
51+
memo: str | None = None
52+
53+
4754
def _rpc_error_msg(result: dict) -> str:
4855
"""Extract error message from a JSON-RPC error response."""
4956
error_obj = result["error"]
@@ -230,21 +237,23 @@ async def verify(
230237
raise VerificationError(f"Invalid credential type: {payload_data['type']}")
231238

232239
if isinstance(payload, HashCredentialPayload):
233-
return await self._verify_hash(payload, req)
240+
return await self._verify_hash(
241+
payload,
242+
req,
243+
challenge_id=credential.challenge.id,
244+
realm=credential.challenge.realm,
245+
)
234246
else:
235247
return await self._verify_transaction(payload, req)
236248

237249
async def _verify_hash(
238250
self,
239251
payload: HashCredentialPayload,
240252
request: ChargeRequest,
253+
challenge_id: str,
254+
realm: str,
241255
) -> Receipt:
242256
"""Verify a credential with a transaction hash."""
243-
if self._store is not None:
244-
store_key = f"mpp:charge:{payload.hash.lower()}"
245-
if not await self._store.put_if_absent(store_key, payload.hash):
246-
raise VerificationError("Transaction hash already used")
247-
248257
client = await self._get_client()
249258

250259
rpc_url = self._get_rpc_url()
@@ -270,19 +279,56 @@ async def _verify_hash(
270279
if receipt_data.get("status") != "0x1":
271280
raise VerificationError("Transaction reverted")
272281

273-
if not self._verify_transfer_logs(receipt_data, request):
282+
matched_logs = self._verify_transfer_logs(receipt_data, request)
283+
if not matched_logs:
274284
raise VerificationError(
275285
"Transaction must contain a Transfer log matching request parameters"
276286
)
277287

288+
# Only verify challenge binding when using auto-generated attribution memos.
289+
# Explicit memos (set by the server) are strictly matched by _verify_transfer_logs
290+
# but are NOT challenge-bound. Callers that set explicit memos are responsible
291+
# for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse.
292+
if request.methodDetails.memo is None:
293+
self._assert_challenge_bound_memo(
294+
matched_logs,
295+
challenge_id=challenge_id,
296+
realm=realm,
297+
)
298+
299+
if self._store is not None:
300+
store_key = f"mpp:charge:{payload.hash.lower()}"
301+
if not await self._store.put_if_absent(store_key, payload.hash):
302+
raise VerificationError("Transaction hash already used")
303+
278304
return Receipt.success(payload.hash)
279305

306+
def _assert_challenge_bound_memo(
307+
self,
308+
matched_logs: list[MatchedTransferLog],
309+
challenge_id: str,
310+
realm: str,
311+
) -> None:
312+
from mpp.methods.tempo._attribution import verify_challenge_binding, verify_server
313+
314+
bound = any(
315+
matched_log.kind == "memo"
316+
and matched_log.memo is not None
317+
and verify_server(matched_log.memo, realm)
318+
and verify_challenge_binding(matched_log.memo, challenge_id)
319+
for matched_log in matched_logs
320+
)
321+
if not bound:
322+
raise VerificationError(
323+
"Payment verification failed: memo is not bound to this challenge."
324+
)
325+
280326
def _verify_transfer_logs(
281327
self,
282328
receipt: dict[str, Any],
283329
request: ChargeRequest,
284330
expected_sender: str | None = None,
285-
) -> bool:
331+
) -> list[MatchedTransferLog]:
286332
"""Check if receipt contains matching Transfer or TransferWithMemo logs.
287333
288334
Args:
@@ -292,10 +338,13 @@ def _verify_transfer_logs(
292338
Transfer log matches this address (for payer identity verification).
293339
294340
Returns:
295-
True if a matching Transfer/TransferWithMemo log is found,
296-
False otherwise.
341+
Matched logs in priority order, with memo logs before plain
342+
transfers so downstream verification can inspect the memo that
343+
actually satisfied the payment.
297344
"""
298345
expected_memo = request.methodDetails.memo
346+
memo_matches: list[MatchedTransferLog] = []
347+
transfer_matches: list[MatchedTransferLog] = []
299348

300349
for log in receipt.get("logs", []):
301350
if log.get("address", "").lower() != request.currency.lower():
@@ -315,9 +364,7 @@ def _verify_transfer_logs(
315364
if expected_sender and from_address.lower() != expected_sender.lower():
316365
continue
317366

318-
if expected_memo:
319-
if event_topic != TRANSFER_WITH_MEMO_TOPIC:
320-
continue
367+
if event_topic == TRANSFER_WITH_MEMO_TOPIC:
321368
# TransferWithMemo has 3 indexed params (from, to, memo)
322369
# so memo is in topics[3] and only amount is in data
323370
if len(topics) < 4:
@@ -326,22 +373,26 @@ def _verify_transfer_logs(
326373
if len(data) < 66:
327374
continue
328375
amount = int(data[2:66], 16)
329-
memo = topics[3]
330-
memo_clean = expected_memo.lower()
331-
if not memo_clean.startswith("0x"):
332-
memo_clean = "0x" + memo_clean
333-
if amount == int(request.amount) and memo.lower() == memo_clean:
334-
return True
335-
else:
336-
if event_topic != TRANSFER_TOPIC:
376+
if amount != int(request.amount):
337377
continue
378+
memo = topics[3]
379+
if expected_memo:
380+
memo_clean = expected_memo.lower()
381+
if not memo_clean.startswith("0x"):
382+
memo_clean = "0x" + memo_clean
383+
if memo.lower() != memo_clean:
384+
continue
385+
memo_matches.append(MatchedTransferLog(kind="memo", memo=memo))
386+
continue
387+
388+
if event_topic == TRANSFER_TOPIC and expected_memo is None:
338389
data = log.get("data", "0x")
339390
if len(data) >= 66:
340391
amount = int(data, 16)
341392
if amount == int(request.amount):
342-
return True
393+
transfer_matches.append(MatchedTransferLog(kind="transfer"))
343394

344-
return False
395+
return memo_matches + transfer_matches
345396

346397
async def _verify_transaction(
347398
self,
@@ -449,6 +500,11 @@ async def _verify_transaction(
449500
"Transaction must contain a Transfer log matching request parameters"
450501
)
451502

503+
if self._store is not None:
504+
store_key = f"mpp:charge:{tx_hash.lower()}"
505+
if not await self._store.put_if_absent(store_key, tx_hash):
506+
raise VerificationError("Transaction hash already used")
507+
452508
return Receipt.success(tx_hash)
453509

454510
def _cosign_as_fee_payer(

src/mpp/methods/tempo/schemas.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from __future__ import annotations
44

5-
from typing import Annotated, Literal
5+
from typing import Annotated, Any, Literal
66

7-
from pydantic import BaseModel, Field
7+
from pydantic import BaseModel, Field, field_validator
88

99

1010
class MethodDetails(BaseModel):
@@ -15,6 +15,11 @@ class MethodDetails(BaseModel):
1515
feePayerUrl: str | None = None
1616
memo: str | None = None
1717

18+
@field_validator("memo", mode="before")
19+
@classmethod
20+
def normalize_empty_memo(cls, value: Any) -> Any:
21+
return None if value == "" else value
22+
1823

1924
class ChargeRequest(BaseModel):
2025
"""Request schema for the charge intent.

0 commit comments

Comments
 (0)