Skip to content

tempo: transferWithMemo encoder accepts a non-hex memo and emits undecodable calldata #213

Description

@mehmetkr-31

Describe the bug

TempoMethod._encode_transfer_with_memo validates the memo's length but not that it is hexadecimal, then concatenates it into the calldata:

memo_clean = memo[2:] if memo.startswith("0x") else memo
if len(memo_clean) != 64:
    raise ValueError(f"memo must be exactly 32 bytes (64 hex chars), got {len(memo_clean)}")
return f"0x{selector}{to_padded}{amount_padded}{memo_clean.lower()}"

A 64-character non-hex memo passes that check and produces a transferWithMemo call no node can decode.

The two memo paths in this SDK disagree on the same input. The splits path goes through get_transfers and _parse_memo_bytes, which decodes the hex and raises VerificationError; the single-transfer path in TempoMethod.charge passes the raw string to the encoder. The memo arrives from the challenge's methodDetails, so it is server-supplied rather than caller-controlled.

This is not a security issue as far as I can tell — the calldata is malformed rather than misdirected, so the transaction is rejected rather than sent somewhere unintended. Filing it publicly on that basis.

Steps to reproduce

from mpp.methods.tempo.client import TempoMethod

encode = TempoMethod._encode_transfer_with_memo
address = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"

print(encode(None, address, 500000, "0x" + "zz" * 32))

Observed:

0x95777d59...07a120zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz

Whitespace ("0x" + " a" * 32) and non-ASCII ("0x" + "é" * 64) are accepted the same way. The sibling path rejects the first of those:

>>> from mpp.methods.tempo.intents import _parse_memo_bytes
>>> _parse_memo_bytes("0x" + "zz" * 32)
VerificationError: Invalid memo hex: 0xzzzz...

Logs

No response

Platform(s)

Mac (Apple Silicon), Linux (x86)

Container Type

Not running in a container

What version/commit are you on?

main — 9793ef5 (also present in 0.10.0)

If you've built from source, provide the full command you used

No response


Suggested fix

Decode the memo and emit memo_bytes.hex(), which also normalizes the output to canonical lowercase. Keeping the length check first preserves its message and position, so existing callers see no change.

I have this ready with tests on mehmetkr-31:fix/validate-memo-hex-on-encode (commit 35a4857): non-hex, whitespace and non-ASCII memos rejected; a short memo still reports the length error; uppercase hex normalizes. 808 passed, 41 skipped; ruff clean.

I could not open it as a pull request — this repository currently limits opening pull requests to collaborators — so the branch is on my fork if it is useful. Happy to adjust the approach.

The Go SDK made the same change in tempoxyz/mpp-go#100, which rejects non-hexadecimal 32-byte memos in EncodeTransferWithMemo.

Disclosure: I used an AI assistant while investigating and preparing this; the analysis and conclusions are my own to defend.

Code of Conduct

  • I agree to follow the Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions