feat: 32 bytes amount in ETH payment requests
What changed, and why it matters
This commit changes how payment-request amounts are encoded in Trezor firmware. Previously amounts were 8-byte numbers; now they can be 8 or 32 bytes, with 32 bytes used for Ethereum/EVM transactions. The change is a feature update to support larger EVM token amounts, not a clearly disclosed security fix. It does add length checks and an overflow guard, which reduces risk, but the change touches the trusted payment-request verification path, so any bug there could affect what amount the user is asked to approve.
Treat as a protocol-breaking feature change rather than a vulnerability. Review that all host-side libraries (Python, Rust, and third-party clients) are updated to send `bytes` at field 6, because old `uint64` values at field 4 will be ignored by the new firmware. Verify the length check and overflow assert cover all paths, and run the updated device tests for Bitcoin and Ethereum payment requests.
Security signals we found
Wire format change for a security-critical field (payment-request amount)
New length validation on amount bytes
Added overflow guard for MicroPython int.to_bytes() behavior
Ethereum payment requests now use 32-byte amounts
Field number 4 reserved, which can break older clients still sending uint64 at tag 4
Evidence from the diff
The PaymentRequest protobuf message’s amount field is changed from optional uint64 amount = 4 to optional bytes amount = 6, with field 4 reserved. A new parse_amount() helper decodes the little-endian byte amount. PaymentRequestVerifier now accepts an amount_size_bytes parameter (8 or 32) and validates that the supplied amount is exactly that length. For Ethereum sign_tx paths, amount_size_bytes=32 is passed. Output amount hashing uses the configured width and includes an assert to catch int.to_bytes() truncation on MicroPython. Display code for Bitcoin, Cardano, Ethereum, Ripple and Stellar now calls parse_amount() instead of reading a uint64 directly.
Changed components
common/protob/messages-common.protocore/src/apps/common/payment_request.pycore/src/apps/ethereum/sign_tx.pycore/src/apps/ethereum/sign_tx_eip1559.pycore/src/apps/bitcoin/sign_tx/layout.pycore/src/apps/cardano/layout.pycore/src/apps/ethereum/layout.pycore/src/apps/ripple/layout.pycore/src/apps/stellar/layout.pypython/src/trezorlib/messages.pyrust/trezor-client/src/protos/generated/messages_common.rsInspect captured patch +109 / −48
diff --git a/common/protob/messages-common.proto b/common/protob/messages-common.proto
index ea0beb51..1777b3c4 100644
--- a/common/protob/messages-common.proto
+++ b/common/protob/messages-common.proto
@@ -184,7 +184,8 @@ message PaymentRequest {
optional bytes nonce = 1; // the nonce used in the signature computation
required string recipient_name = 2; // merchant's name
repeated PaymentRequestMemo memos = 3; // any memos that were signed as part of the request
- optional uint64 amount = 4; // the sum of the external output amounts requested, required for non-CoinJoin
+ reserved 4; // this existed briefly. obsoleted by this change: https://github.com/satoshilabs/slips/commit/08d36aa61722275a21617ac6a713e31ec23fdec4
+ optional bytes amount = 6; // the sum of the external output amounts requested, required for non-CoinJoin, encoded in little endian on either 8 or 32 bytes
required bytes signature = 5; // the trusted party's signature of the paymentRequestDigest
message PaymentRequestMemo {
diff --git a/core/src/apps/bitcoin/sign_tx/layout.py b/core/src/apps/bitcoin/sign_tx/layout.py
index 60adb1ba..964a996f 100644
--- a/core/src/apps/bitcoin/sign_tx/layout.py
+++ b/core/src/apps/bitcoin/sign_tx/layout.py
@@ -163,8 +163,9 @@ async def show_payment_request_details(
) -> None:
from trezor import wire
- assert payment_request.amount is not None # required for non-CoinJoin
- total_amount = format_coin_amount(payment_request.amount, coin, amount_unit)
+ from apps.common.payment_request import parse_amount
+
+ total_amount = format_coin_amount(parse_amount(payment_request), coin, amount_unit)
texts = []
refunds = []
diff --git a/core/src/apps/cardano/layout.py b/core/src/apps/cardano/layout.py
index f9520478..e4b6aa0d 100644
--- a/core/src/apps/cardano/layout.py
+++ b/core/src/apps/cardano/layout.py
@@ -1215,8 +1215,11 @@ async def require_confirm_payment_request(
) -> None:
from trezor.ui.layouts import confirm_payment_request
- assert verified_payment_request.amount is not None # required for non-CoinJoin
- total_amount = format_coin_amount(verified_payment_request.amount, network_id)
+ from apps.common.payment_request import parse_amount
+
+ total_amount = format_coin_amount(
+ parse_amount(verified_payment_request), network_id
+ )
texts: list[tuple[str | None, str]] = []
refunds: list[tuple[str, str | None, str | None]] = []
diff --git a/core/src/apps/common/payment_request.py b/core/src/apps/common/payment_request.py
index 03359f2d..e4cc0b4a 100644
--- a/core/src/apps/common/payment_request.py
+++ b/core/src/apps/common/payment_request.py
@@ -6,16 +6,24 @@ from trezor.wire import DataError, context
from . import writers
if TYPE_CHECKING:
+ from typing import Literal
+
from trezor.messages import PaymentRequest
from apps.common.keychain import Keychain
+
_MEMO_TYPE_TEXT = const(1)
_MEMO_TYPE_REFUND = const(2)
_MEMO_TYPE_COIN_PURCHASE = const(3)
_MEMO_TYPE_TEXT_DETAILS = const(4)
+def parse_amount(payment_request: PaymentRequest) -> int:
+ assert payment_request.amount is not None
+ return int.from_bytes(payment_request.amount, "little")
+
+
class PaymentRequestVerifier:
if __debug__:
# nist256p1 public key of m/0h for "all all ... all" seed.
@@ -23,7 +31,15 @@ class PaymentRequestVerifier:
else:
PUBLIC_KEY = b""
- def __init__(self, msg: PaymentRequest, slip44_id: int, keychain: Keychain) -> None:
+ def __init__(
+ self,
+ payment_request: PaymentRequest,
+ slip44_id: int,
+ keychain: Keychain,
+ amount_size_bytes: Literal[
+ 8, 32
+ ] = 8, # amount is normally 8 bytes, but for EVM assets it is 32 bytes
+ ) -> None:
from storage.cache_common import APP_COMMON_NONCE
from trezor.crypto.hashlib import sha256
from trezor.utils import HashWriter
@@ -34,25 +50,32 @@ class PaymentRequestVerifier:
self.h_outputs = HashWriter(sha256())
self.amount = 0
- self.expected_amount = msg.amount
- self.signature = msg.signature
self.h_pr = HashWriter(sha256())
- if msg.nonce:
- nonce = bytes(msg.nonce)
+ if payment_request.amount is None:
+ self.expected_amount = None
+ else:
+ if len(payment_request.amount) != amount_size_bytes:
+ raise DataError(f"amount must be exactly {amount_size_bytes} bytes")
+ self.expected_amount = parse_amount(payment_request)
+ self.amount_size_bytes = amount_size_bytes
+ self.signature = payment_request.signature
+
+ if payment_request.nonce:
+ nonce = bytes(payment_request.nonce)
if context.cache_get(APP_COMMON_NONCE) != nonce:
raise DataError("Invalid nonce in payment request.")
context.cache_delete(APP_COMMON_NONCE)
else:
nonce = b""
- if msg.memos:
+ if payment_request.memos:
DataError("Missing nonce in payment request.")
writers.write_bytes_fixed(self.h_pr, b"SL\x00\x24", 4)
writers.write_bytes_prefixed(self.h_pr, nonce)
- writers.write_bytes_prefixed(self.h_pr, msg.recipient_name.encode())
- writers.write_compact_size(self.h_pr, len(msg.memos))
- for m in msg.memos:
+ writers.write_bytes_prefixed(self.h_pr, payment_request.recipient_name.encode())
+ writers.write_compact_size(self.h_pr, len(payment_request.memos))
+ for m in payment_request.memos:
if m.text_memo is not None:
memo = m.text_memo
writers.write_uint32_le(self.h_pr, _MEMO_TYPE_TEXT)
@@ -99,7 +122,11 @@ class PaymentRequestVerifier:
raise DataError("Invalid signature in payment request.")
def add_output(self, amount: int, address: str, change: bool = False) -> None:
- writers.write_uint64_le(self.h_outputs, amount)
+ encoded_amount = amount.to_bytes(self.amount_size_bytes, "little")
+ # Ensure that the amount fits on amount_size_bytes.
+ # Note that Micropython's int.to_bytes() doesn't raise OverflowError!
+ assert int.from_bytes(encoded_amount, "little") == amount
+ writers.write_bytes_unchecked(self.h_outputs, encoded_amount)
writers.write_bytes_prefixed(self.h_outputs, address.encode())
if not change:
self.amount += amount
diff --git a/core/src/apps/ethereum/layout.py b/core/src/apps/ethereum/layout.py
index 8182c589..d2fd0001 100644
--- a/core/src/apps/ethereum/layout.py
+++ b/core/src/apps/ethereum/layout.py
@@ -124,10 +124,11 @@ async def require_confirm_payment_request(
from trezor import wire
from trezor.ui.layouts import confirm_payment_request
- assert (
- verified_payment_req.amount is not None
- ) # amount is required for non-CoinJoin transactions
- total_amount = format_ethereum_amount(verified_payment_req.amount, token, network)
+ from apps.common.payment_request import parse_amount
+
+ total_amount = format_ethereum_amount(
+ parse_amount(verified_payment_req), token, network
+ )
texts = []
refunds = []
diff --git a/core/src/apps/ethereum/sign_tx.py b/core/src/apps/ethereum/sign_tx.py
index 41259a44..45be9de4 100644
--- a/core/src/apps/ethereum/sign_tx.py
+++ b/core/src/apps/ethereum/sign_tx.py
@@ -78,7 +78,10 @@ async def sign_tx(
slip44_id = paths.unharden(msg.address_n[1])
payment_req_verifier = PaymentRequestVerifier(
- msg.payment_req, slip44_id, keychain
+ msg.payment_req,
+ slip44_id,
+ keychain,
+ amount_size_bytes=32,
)
await confirm_tx_data(
diff --git a/core/src/apps/ethereum/sign_tx_eip1559.py b/core/src/apps/ethereum/sign_tx_eip1559.py
index ad7423fc..0792c218 100644
--- a/core/src/apps/ethereum/sign_tx_eip1559.py
+++ b/core/src/apps/ethereum/sign_tx_eip1559.py
@@ -78,7 +78,7 @@ async def sign_tx_eip1559(
slip44_id = paths.unharden(msg.address_n[1])
payment_req_verifier = PaymentRequestVerifier(
- msg.payment_req, slip44_id, keychain
+ msg.payment_req, slip44_id, keychain, amount_size_bytes=32
)
await confirm_tx_data(
diff --git a/core/src/apps/ripple/layout.py b/core/src/apps/ripple/layout.py
index d5b2cda1..d34bc9d3 100644
--- a/core/src/apps/ripple/layout.py
+++ b/core/src/apps/ripple/layout.py
@@ -49,10 +49,11 @@ async def require_confirm_payment_request(
from trezor.ui.layouts import confirm_payment_request
from apps.common.paths import address_n_to_str
+ from apps.common.payment_request import parse_amount
- assert verified_payment_request.amount is not None # required for non-CoinJoin
total_amount = format_amount_unit(
- format_amount(verified_payment_request.amount, DECIMALS), "XRP"
+ format_amount(parse_amount(verified_payment_request), DECIMALS),
+ "XRP",
)
texts = []
diff --git a/core/src/apps/stellar/layout.py b/core/src/apps/stellar/layout.py
index d80c6511..12795ffc 100644
--- a/core/src/apps/stellar/layout.py
+++ b/core/src/apps/stellar/layout.py
@@ -65,9 +65,9 @@ async def require_confirm_payment_request(
) -> None:
from trezor.ui.layouts import confirm_payment_request
- assert verified_payment_request.amount is not None # required for non-CoinJoin
+ from apps.common.payment_request import parse_amount
- total_amount = format_amount(verified_payment_request.amount, asset)
+ total_amount = format_amount(parse_amount(verified_payment_request), asset)
texts: list[tuple[str | None, str]] = []
refunds: list[tuple[str, str | None, str | None]] = []
diff --git a/core/src/trezor/messages.py b/core/src/trezor/messages.py
index f8133bba..60553825 100644
--- a/core/src/trezor/messages.py
+++ b/core/src/trezor/messages.py
@@ -253,7 +253,7 @@ if TYPE_CHECKING:
nonce: "AnyBytes | None"
recipient_name: "str"
memos: "list[PaymentRequestMemo]"
- amount: "int | None"
+ amount: "AnyBytes | None"
signature: "AnyBytes"
def __init__(
@@ -263,7 +263,7 @@ if TYPE_CHECKING:
signature: "AnyBytes",
memos: "list[PaymentRequestMemo] | None" = None,
nonce: "AnyBytes | None" = None,
- amount: "int | None" = None,
+ amount: "AnyBytes | None" = None,
) -> None:
pass
diff --git a/python/src/trezorlib/messages.py b/python/src/trezorlib/messages.py
index c87e1ee1..a8ba007f 100644
--- a/python/src/trezorlib/messages.py
+++ b/python/src/trezorlib/messages.py
@@ -903,7 +903,7 @@ class PaymentRequest(protobuf.MessageType):
1: protobuf.Field("nonce", "bytes", repeated=False, required=False, default=None),
2: protobuf.Field("recipient_name", "string", repeated=False, required=True),
3: protobuf.Field("memos", "PaymentRequestMemo", repeated=True, required=False, default=None),
- 4: protobuf.Field("amount", "uint64", repeated=False, required=False, default=None),
+ 6: protobuf.Field("amount", "bytes", repeated=False, required=False, default=None),
5: protobuf.Field("signature", "bytes", repeated=False, required=True),
}
@@ -914,7 +914,7 @@ class PaymentRequest(protobuf.MessageType):
signature: "bytes",
memos: Optional[Sequence["PaymentRequestMemo"]] = None,
nonce: Optional["bytes"] = None,
- amount: Optional["int"] = None,
+ amount: Optional["bytes"] = None,
) -> None:
self.memos: Sequence["PaymentRequestMemo"] = memos if memos is not None else []
self.recipient_name = recipient_name
diff --git a/rust/trezor-client/src/protos/generated/messages_common.rs b/rust/trezor-client/src/protos/generated/messages_common.rs
index 2fd0ad56..2c44e4cd 100644
--- a/rust/trezor-client/src/protos/generated/messages_common.rs
+++ b/rust/trezor-client/src/protos/generated/messages_common.rs
@@ -2512,7 +2512,7 @@ pub struct PaymentRequest {
// @@protoc_insertion_point(field:hw.trezor.messages.common.PaymentRequest.memos)
pub memos: ::std::vec::Vec<payment_request::PaymentRequestMemo>,
// @@protoc_insertion_point(field:hw.trezor.messages.common.PaymentRequest.amount)
- pub amount: ::std::option::Option<u64>,
+ pub amount: ::std::option::Option<::std::vec::Vec<u8>>,
// @@protoc_insertion_point(field:hw.trezor.messages.common.PaymentRequest.signature)
pub signature: ::std::option::Option<::std::vec::Vec<u8>>,
// special fields
@@ -2603,10 +2603,13 @@ impl PaymentRequest {
self.recipient_name.take().unwrap_or_else(|| ::std::string::String::new())
}
- // optional uint64 amount = 4;
+ // optional bytes amount = 6;
- pub fn amount(&self) -> u64 {
- self.amount.unwrap_or(0)
+ pub fn amount(&self) -> &[u8] {
+ match self.amount.as_ref() {
+ Some(v) => v,
+ None => &[],
+ }
}
pub fn clear_amount(&mut self) {
@@ -2618,10 +2621,24 @@ impl PaymentRequest {
}
// Param is passed by value, moved
- pub fn set_amount(&mut self, v: u64) {
+ pub fn set_amount(&mut self, v: ::std::vec::Vec<u8>) {
self.amount = ::std::option::Option::Some(v);
}
+ // Mutable pointer to the field.
+ // If field is not initialized, it is initialized with default value first.
+ pub fn mut_amount(&mut self) -> &mut ::std::vec::Vec<u8> {
+ if self.amount.is_none() {
+ self.amount = ::std::option::Option::Some(::std::vec::Vec::new());
+ }
+ self.amount.as_mut().unwrap()
+ }
+
+ // Take field
+ pub fn take_amount(&mut self) -> ::std::vec::Vec<u8> {
+ self.amount.take().unwrap_or_else(|| ::std::vec::Vec::new())
+ }
+
// required bytes signature = 5;
pub fn signature(&self) -> &[u8] {
@@ -2724,8 +2741,8 @@ impl ::protobuf::Message for PaymentRequest {
26 => {
self.memos.push(is.read_message()?);
},
- 32 => {
- self.amount = ::std::option::Option::Some(is.read_uint64()?);
+ 50 => {
+ self.amount = ::std::option::Option::Some(is.read_bytes()?);
},
42 => {
self.signature = ::std::option::Option::Some(is.read_bytes()?);
@@ -2752,8 +2769,8 @@ impl ::protobuf::Message for PaymentRequest {
let len = value.compute_size();
my_size += 1 + ::protobuf::rt::compute_raw_varint64_size(len) + len;
};
- if let Some(v) = self.amount {
- my_size += ::protobuf::rt::uint64_size(4, v);
+ if let Some(v) = self.amount.as_ref() {
+ my_size += ::protobuf::rt::bytes_size(6, &v);
}
if let Some(v) = self.signature.as_ref() {
my_size += ::protobuf::rt::bytes_size(5, &v);
@@ -2773,8 +2790,8 @@ impl ::protobuf::Message for PaymentRequest {
for v in &self.memos {
::protobuf::rt::write_message_field_with_cached_size(3, v, os)?;
};
- if let Some(v) = self.amount {
- os.write_uint64(4, v)?;
+ if let Some(v) = self.amount.as_ref() {
+ os.write_bytes(6, v)?;
}
if let Some(v) = self.signature.as_ref() {
os.write_bytes(5, v)?;
@@ -4043,11 +4060,11 @@ static file_descriptor_proto_data: &'static [u8] = b"\
gerprint\x18\x02\x20\x02(\rR\x0bfingerprint\x12\x1b\n\tchild_num\x18\x03\
\x20\x02(\rR\x08childNum\x12\x1d\n\nchain_code\x18\x04\x20\x02(\x0cR\tch\
ainCode\x12\x1f\n\x0bprivate_key\x18\x05\x20\x01(\x0cR\nprivateKey\x12\
- \x1d\n\npublic_key\x18\x06\x20\x02(\x0cR\tpublicKey\"\xb4\x07\n\x0ePayme\
+ \x1d\n\npublic_key\x18\x06\x20\x02(\x0cR\tpublicKey\"\xba\x07\n\x0ePayme\
ntRequest\x12\x14\n\x05nonce\x18\x01\x20\x01(\x0cR\x05nonce\x12%\n\x0ere\
cipient_name\x18\x02\x20\x02(\tR\rrecipientName\x12R\n\x05memos\x18\x03\
\x20\x03(\x0b2<.hw.trezor.messages.common.PaymentRequest.PaymentRequestM\
- emoR\x05memos\x12\x16\n\x06amount\x18\x04\x20\x01(\x04R\x06amount\x12\
+ emoR\x05memos\x12\x16\n\x06amount\x18\x06\x20\x01(\x0cR\x06amount\x12\
\x1c\n\tsignature\x18\x05\x20\x02(\x0cR\tsignature\x1a\x8d\x03\n\x12Paym\
entRequestMemo\x12O\n\ttext_memo\x18\x01\x20\x01(\x0b22.hw.trezor.messag\
es.common.PaymentRequest.TextMemoR\x08textMemo\x12U\n\x0brefund_memo\x18\
@@ -4065,8 +4082,8 @@ static file_descriptor_proto_data: &'static [u8] = b"\
\x12\x16\n\x06amount\x18\x02\x20\x02(\tR\x06amount\x12\x18\n\x07address\
\x18\x03\x20\x02(\tR\x07address\x12\x1b\n\taddress_n\x18\x04\x20\x03(\rR\
\x08addressN\x12\x10\n\x03mac\x18\x05\x20\x02(\x0cR\x03mac:\x04\x88\xb2\
- \x19\x01B>\n#com.satoshilabs.trezor.lib.protobufB\x13TrezorMessageCommon\
- \x80\xa6\x1d\x01\
+ \x19\x01J\x04\x08\x04\x10\x05B>\n#com.satoshilabs.trezor.lib.protobufB\
+ \x13TrezorMessageCommon\x80\xa6\x1d\x01\
";
/// `FileDescriptorProto` object which was a source for this generated file
diff --git a/tests/device_tests/bitcoin/test_signtx_payreq.py b/tests/device_tests/bitcoin/test_signtx_payreq.py
index b488e32a..d699e0fc 100644
--- a/tests/device_tests/bitcoin/test_signtx_payreq.py
+++ b/tests/device_tests/bitcoin/test_signtx_payreq.py
@@ -293,7 +293,9 @@ def test_payment_req_wrong_amount(session: Session):
)
# Decrease the total amount of the payment request.
- payment_req.amount -= 1
+ payment_req.amount = (int.from_bytes(payment_req.amount, "little") - 1).to_bytes(
+ 8, "little"
+ )
with pytest.raises(TrezorFailure, match="Invalid amount in payment request"):
btc.sign_tx(
diff --git a/tests/device_tests/ethereum/test_signtx.py b/tests/device_tests/ethereum/test_signtx.py
index c134c193..2cf2052c 100644
--- a/tests/device_tests/ethereum/test_signtx.py
+++ b/tests/device_tests/ethereum/test_signtx.py
@@ -568,7 +568,7 @@ def test_signtx_staking_eip1559(session: Session, parameters: dict, result: dict
def test_signtx_payment_req(
session: Session, has_refund: bool, has_text: bool, has_multiple_purchases: bool
):
- from trezorlib import btc, misc
+ from trezorlib import btc, ethereum, misc
from ..payment_req import (
CoinPurchaseMemo,
@@ -624,6 +624,7 @@ def test_signtx_payment_req(
outputs=[(int(params["value"], 16), params["to_address"])],
memos=memos,
nonce=nonce,
+ amount_size_bytes=32,
)
_do_test_signtx(
@@ -664,6 +665,7 @@ def test_signtx_payment_req_long_value(
outputs=[(int(params["value"], 16), params["to_address"])],
memos=memos,
nonce=nonce,
+ amount_size_bytes=32,
)
_do_test_signtx(
diff --git a/tests/device_tests/payment_req.py b/tests/device_tests/payment_req.py
index d6787197..ac1cebfa 100644
--- a/tests/device_tests/payment_req.py
+++ b/tests/device_tests/payment_req.py
@@ -54,6 +54,7 @@ def make_payment_request(
change_addresses=None,
memos=None,
nonce=None,
+ amount_size_bytes=8,
):
h_pr = sha256(b"SL\x00\x24")
@@ -116,7 +117,7 @@ def make_payment_request(
change_address = iter(change_addresses or [])
h_outputs = sha256()
for amount, address in outputs:
- h_outputs.update(amount.to_bytes(8, "little"))
+ h_outputs.update(amount.to_bytes(amount_size_bytes, "little"))
if not address:
address = next(change_address)
h_outputs.update(len(address).to_bytes(1, "little"))
@@ -124,9 +125,11 @@ def make_payment_request(
h_pr.update(h_outputs.digest())
+ amount = sum(amount for amount, address in outputs if address)
+
return messages.PaymentRequest(
recipient_name=recipient_name,
- amount=sum(amount for amount, address in outputs if address),
+ amount=amount.to_bytes(amount_size_bytes, "little"),
memos=msg_memos,
nonce=nonce,
signature=payment_req_signer.sign_digest_deterministic(h_pr.digest()),
Why this scored 36/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.