Merge remote-tracking branch 'agent/benma-agent/show-erc20-contract'
What changed, and why it matters
This commit improves the BitBox02 hardware wallet's Ethereum token-approval screen. When a user signs an ERC20 token transfer, the device now also shows the token's smart-contract address if the token symbol is ambiguous (the same ticker, like 'UNI', is used by multiple contracts) or if the token is completely unknown. This helps prevent 'look-alike' token scams where a malicious contract uses a familiar symbol but is actually a worthless or harmful token. The change also blocks payment requests for unknown tokens. It is a defensive hardening patch, not an exploit fix for already-broken code.
No urgent action required. This is a defensive UX/security improvement. Users benefit automatically after updating firmware. Developers should ensure the token registry (`tokens.txt`) remains accurate and that ambiguous-symbol detection is re-run whenever tokens are added or changed.
Security signals we found
UI hardening: adds contract-address confirmation for ERC20 tokens with ambiguous or unknown symbols
Registry validation: rejects payment requests for tokens not present in the firmware's ERC20 registry
Build-time ambiguity detection: generates a sorted list of units shared by multiple contracts
Defensive measure against token-symbol spoofing / look-alike contract attacks
No evidence of memory corruption, privilege escalation, or remote code execution
Evidence from the diff
The patch modifies verify_erc20_transaction in ethereum/sign.rs so that, before displaying the token amount, it checks whether the ERC20 contract is known and whether its displayed unit (symbol) is unique in the firmware’s token registry. If the contract is unknown or its unit is ambiguous (multiple contracts share the same unit), the device prompts the user to confirm the full contract address. A new unit_is_ambiguous method is added to erc20_params::Params, backed by an AMBIGUOUS_UNITS list generated at build time from tokens.txt. Payment requests are now rejected when the token is not in the local registry. The change is accompanied by unit tests verifying that ambiguous/unknown tokens show the contract address while known unique tokens do not.
Changed components
src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rssrc/rust/erc20_params/build.rssrc/rust/erc20_params/src/lib.rsInspect captured patch +203 / −12
### src/rust/bitbox02-rust/src/hww/api/ethereum/sign.rs
@@ -316,11 +316,11 @@ async fn prepare_streaming_standard_data(
/// Verifies an ERC20 transfer.
///
/// If the ERC20 contract is known (stored in our list of supported ERC20 tokens), the token name,
-/// amount, recipient, total and fee are shown for confirmation.
+/// amount, recipient, total and fee are shown. The contract is also shown if its symbol is ambiguous.
///
-/// If the ERC20 token is unknown, only the recipient and fee can be shown. The token name and
-/// amount are displayed as "unknown". The amount is not known because we don't know the number of
-/// decimal places (specified in the ERC20 contract).
+/// If the ERC20 token is unknown, only the contract, recipient and fee can be shown. The token name
+/// and amount are displayed as "unknown". The amount is not known because we don't know the number
+/// of decimal places (specified in the ERC20 contract).
async fn verify_erc20_transaction(
hal: &mut impl crate::hal::Hal,
request: &Transaction<'_>,
@@ -329,8 +329,34 @@ async fn verify_erc20_transaction(
erc20_value: BigUint,
payment_request: Option<&pb::BtcPaymentRequestRequest>,
) -> Result<(), Error> {
- let erc20_params = erc20_params::get(params.chain_id, parse_recipient(request.recipient())?);
+ let contract = parse_recipient(request.recipient())?;
+ let erc20_params = erc20_params::get(params.chain_id, contract);
let recipient_address = super::address::from_pubkey_hash(&erc20_recipient, request.case()?);
+
+ // Payment requests only support tokens whose metadata is stored locally.
+ if payment_request.is_some() && erc20_params.is_none() {
+ return Err(Error::InvalidInput);
+ }
+
+ // Only the firmware registry can establish an unambiguous token identity.
+ // Unknown tokens and colliding symbols require the full contract address.
+ if erc20_params
+ .as_ref()
+ .is_none_or(erc20_params::Params::unit_is_ambiguous)
+ {
+ let contract_address = super::address::from_pubkey_hash(&contract, request.case()?);
+ let contract_address_display = super::address::format_display_address(&contract_address);
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "Token\ncontract",
+ body: &contract_address_display,
+ scrollable: true,
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ }
+
if let Some(payment_request) = payment_request {
let token_params = erc20_params.ok_or(Error::InvalidInput)?;
let displayed_source_amount = Amount {
@@ -1371,6 +1397,127 @@ mod tests {
assert_eq!(mock_hal.ui.screens, expected_screens);
}
+ async fn erc20_transfer_screens(contract: [u8; 20], eip1559: bool, value: u64) -> Vec<Screen> {
+ const KEYPATH: &[u32] = &[44 + HARDENED, 60 + HARDENED, 0 + HARDENED, 0, 0];
+ let mut data = hex!(
+ "a9059cbb000000000000000000000000e6ce0a092a99700cd4ccccbb1fedc39cf53e63300000000000000000000000000000000000000000000000000de0b6b3a7640000"
+ );
+ data[60..].copy_from_slice(&value.to_be_bytes());
+
+ mock_unlocked();
+ let mut mock_hal = TestingHal::new();
+ if eip1559 {
+ process(
+ &mut mock_hal,
+ &Transaction::Eip1559(&pb::EthSignEip1559Request {
+ keypath: KEYPATH.to_vec(),
+ nonce: hex!("2367").to_vec(),
+ max_priority_fee_per_gas: b"".to_vec(),
+ max_fee_per_gas: hex!("3b9aca00").to_vec(),
+ gas_limit: hex!("5208").to_vec(),
+ recipient: contract.to_vec(),
+ value: b"".to_vec(),
+ data: data.to_vec(),
+ host_nonce_commitment: None,
+ chain_id: 1,
+ address_case: pb::EthAddressCase::Mixed as _,
+ data_length: 0,
+ payment_request: None,
+ }),
+ )
+ .await
+ .unwrap();
+ } else {
+ process(
+ &mut mock_hal,
+ &Transaction::Legacy(&pb::EthSignRequest {
+ coin: pb::EthCoin::Eth as _,
+ keypath: KEYPATH.to_vec(),
+ nonce: hex!("2367").to_vec(),
+ gas_price: hex!("3b9aca00").to_vec(),
+ gas_limit: hex!("5208").to_vec(),
+ recipient: contract.to_vec(),
+ value: b"".to_vec(),
+ data: data.to_vec(),
+ host_nonce_commitment: None,
+ chain_id: 1,
+ address_case: pb::EthAddressCase::Mixed as _,
+ data_length: 0,
+ }),
+ )
+ .await
+ .unwrap();
+ }
+ mock_hal.ui.screens
+ }
+
+ #[async_test::test]
+ async fn test_process_erc20_contract_identity() {
+ const KNOWN_UNI_A: [u8; 20] = hex!("e6877ea9c28fbdec631ffbc087956d0023a76bf2");
+ const KNOWN_UNI_B: [u8; 20] = hex!("1f9840a85d5af5bf1d1762f925bdaddc4201f984");
+ const KNOWN_UNI_8_DECIMALS: [u8; 20] = hex!("2730d6fdc86c95a74253beffaa8306b40fedecbb");
+ const UNKNOWN_A: [u8; 20] = hex!("1111111111111111111111111111111111111111");
+ const UNKNOWN_B: [u8; 20] = hex!("2222222222222222222222222222222222222222");
+
+ for eip1559 in [false, true] {
+ for (contract_a, contract_b, value_a, value_b, expected_amount, expected_total) in [
+ (
+ KNOWN_UNI_A,
+ KNOWN_UNI_B,
+ 10u64.pow(18),
+ 10u64.pow(18),
+ "1 UNI",
+ "1 UNI",
+ ),
+ // Different raw amounts compensate for differing decimals, preserving the UI.
+ (
+ KNOWN_UNI_B,
+ KNOWN_UNI_8_DECIMALS,
+ 10u64.pow(18),
+ 10u64.pow(8),
+ "1 UNI",
+ "1 UNI",
+ ),
+ (
+ UNKNOWN_A,
+ UNKNOWN_B,
+ 10u64.pow(18),
+ 10u64.pow(18),
+ "Unknown token",
+ "Unknown amount",
+ ),
+ ] {
+ let screens_a = erc20_transfer_screens(contract_a, eip1559, value_a).await;
+ let screens_b = erc20_transfer_screens(contract_b, eip1559, value_b).await;
+
+ assert_eq!(screens_a[0], screens_b[0]);
+ assert_ne!(screens_a[1], screens_b[1]);
+ assert_eq!(screens_a[2..], screens_b[2..]);
+ for (screens, contract) in [(&screens_a, contract_a), (&screens_b, contract_b)] {
+ assert_eq!(
+ screens[1],
+ Screen::Confirm {
+ title: "Token\ncontract".into(),
+ body: address::format_display_address(&address::from_pubkey_hash(
+ &contract,
+ pb::EthAddressCase::Mixed,
+ )),
+ longtouch: false,
+ }
+ );
+ assert!(matches!(
+ &screens[2],
+ Screen::Recipient { amount, .. } if amount == expected_amount
+ ));
+ assert!(matches!(
+ &screens[3],
+ Screen::TotalFee { total, .. } if total == expected_total
+ ));
+ }
+ }
+ }
+ }
+
/// ERC20 transaction: recipient is an ERC20 contract address, and
/// the data field contains an ERC20 transfer method invocation.
#[async_test::test]
@@ -1458,6 +1605,14 @@ mod tests {
body: "Sign transaction on\n\nEthereum".into(),
longtouch: false,
},
+ Screen::Confirm {
+ title: "Token\ncontract".into(),
+ body: address::format_display_address(&address::from_pubkey_hash(
+ &hex!("9c23d67aea7b95d80942e3836bcdf7e708a747c1"),
+ pb::EthAddressCase::Mixed,
+ )),
+ longtouch: false,
+ },
Screen::Recipient {
recipient: "0x 857B 3D96 9eAc B775 a9f7 9cab c62E c4bB 1D1c d60e".into(),
amount: "Unknown token".into(),
### src/rust/erc20_params/build.rs
@@ -2,7 +2,7 @@
#![allow(clippy::format_collect)]
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
use std::path::Path;
@@ -43,6 +43,16 @@ fn main() {
});
}
+ // A symbol is ambiguous if multiple contracts use it, even with different decimals:
+ // changing the raw transfer value can still produce the same displayed amount.
+ let mut contracts_by_unit: BTreeMap<&str, BTreeSet<[u8; 20]>> = BTreeMap::new();
+ for token in &tokens {
+ contracts_by_unit
+ .entry(&token.unit)
+ .or_default()
+ .insert(token.contract_address);
+ }
+
// Group tokens by decimals
let mut grouped_tokens: BTreeMap<(u8, u8), Vec<&Token>> = BTreeMap::new();
for token in &tokens {
@@ -60,6 +70,15 @@ fn main() {
.open(out_filename)
.unwrap();
+ // BTreeMap iteration keeps this list sorted for binary search at runtime.
+ writeln!(output_file, "const AMBIGUOUS_UNITS: &[&str] = &[").unwrap();
+ for (unit, contracts) in &contracts_by_unit {
+ if contracts.len() > 1 {
+ writeln!(output_file, " \"{}\",", unit.escape_default()).unwrap();
+ }
+ }
+ writeln!(output_file, "];\n").unwrap();
+
for ((decimals, unit_len), tokens) in &mut grouped_tokens {
// Sort by contract address so we can look up by contract
// address more efficiently.
### src/rust/erc20_params/src/lib.rs
@@ -19,6 +19,11 @@ pub struct Params {
}
impl Params {
+ /// Whether multiple registered contracts share this displayed unit, regardless of decimals.
+ pub fn unit_is_ambiguous(&self) -> bool {
+ AMBIGUOUS_UNITS.binary_search(&self.unit).is_ok()
+ }
+
fn from_p(p: &P, decimals: u8, unit_len: u8) -> Self {
let unit = unsafe { core::slice::from_raw_parts(p.unit, unit_len as usize) };
Params {
@@ -33,6 +38,7 @@ impl Params {
// Includes `const PARAMS_18: &[P] = ...` for each existing decimals.
// And `const ALL: &[(u8, &[P])] = ...` listing all params by decimals so we can iterate them.
// This way we don't repeat the decimal in every token, saving ~1 byte of binary space per token.
+// Also includes the sorted AMBIGUOUS_UNITS list for contract-identity confirmation.
// Generated by build.rs.
include!(concat!(env!("OUT_DIR"), "/tokens.rs"));
@@ -102,13 +108,14 @@ mod tests {
fn test_get_all() {
let file = File::open("src/tokens.txt").unwrap();
let reader = io::BufReader::new(file);
+ let lines: Vec<_> = reader
+ .lines()
+ .map(Result::unwrap)
+ .filter(|line| !line.starts_with('#'))
+ .collect();
+ let tokens: Vec<Vec<&str>> = lines.iter().map(|line| line.split(';').collect()).collect();
- for line in reader.lines() {
- let line = line.unwrap();
- if line.starts_with('#') {
- continue;
- }
- let parts: Vec<&str> = line.split(';').collect();
+ for parts in &tokens {
let expected_unit = parts[0];
let contract_address: [u8; 20] = hex::decode(parts[1].strip_prefix("0x").unwrap())
.unwrap()
@@ -120,6 +127,16 @@ mod tests {
assert_eq!(params.contract_address, contract_address,);
assert_eq!(params.unit, expected_unit);
assert_eq!(params.decimals, expected_decimals);
+ // Check all registry entries, including same-symbol/different-decimal pairs.
+ assert_eq!(
+ params.unit_is_ambiguous(),
+ tokens
+ .iter()
+ .any(|other| other[0] == expected_unit && other[1] != parts[1]),
+ "{} ({})",
+ expected_unit,
+ parts[1],
+ );
}
}
}Why this scored 47/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.