From 27957e8fb7eb9e6c9c25a50db176bcff3b28a73f Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 21 Aug 2026 14:32:23 -0500 Subject: [PATCH] =?UTF-8?q?feat(tokens):=20cap=20the=20built-in=20token=20?= =?UTF-8?q?table=20at=20500=20entries=20=E2=80=94=20frees=2023,104=20B=20f?= =?UTF-8?q?lash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tokens` is the single largest read-only symbol in the ARM image: 31,104 bytes for 1,945 entries, bigger than MessagesMap (27,264) or the BIP-39 wordlist (8,196). After this it is 8,000 bytes. tokens 31,104 -> 8,000 B (500 entries x 16) saved 23,104 B of flash WHY A BUDGET RATHER THAN A BIGGER TABLE. The vetted source is a stale snapshot and cannot be made current by shipping more of it. Measured against ethereum-lists as pinned here: - 1,924 of 1,945 entries are Ethereum mainnet. Optimism has 2, Polygon 3, BSC 3. The "top EVM chains" are effectively absent, and Base and Arbitrum have no directory at all. - Absent entirely: UNI, AAVE, stETH, wstETH, rETH, cbETH, PEPE, and every modern stablecoin -- FRAX, LUSD, PYUSD, crvUSD, USDe. - `ARB` resolves to 0xafbec4d6..., a 2017 token named "ARBITRAGE". Arbitrum's real ARB (0xB50721BC...) is not in the table. So the long tail is not coverage, it is 2017-era ICO tokens occupying flash while the assets users actually hold are missing. USDC, USDT, DAI, WETH, WBTC and LINK are present and correct, and those are what the budget protects. POLICY (keepkeylib/eth/token_policy.py, and it is the whole design): 1. Budget: 350 from ethereum-lists + 150 from the uniswap list. 2. Priority symbols first -- stablecoins, then majors. 3. A priority symbol is taken ONLY when the source gives it exactly one address. Two entries sharing a symbol is how a scam token inherits a real one's label, and the device would render the attacker's name. Ambiguous symbols are dropped from the priority pass and reported at build time. 4. Remaining budget filled in the existing deterministic order (by address), so output is reproducible and diffable. NO ADDRESS IS WRITTEN IN THE POLICY. Symbols are matched against the vetted source. A hand-typed address in a token table is a mislabelling defect waiting to happen, and the file says so, so it does not become the place one appears. TWO GROUPS PINNED FOR STRUCTURAL REASONS, both named rather than hidden: - REQUIRED_BY_COINS (26): tickers coins[] declares with a contract address. Coins.TableSanity asserts each resolves uniquely, and correctly FAILED when the first cut dropped them -- the device would advertise a coin it cannot name. They are 2017 tokens and are exactly what should go next, but the cut has to happen in coins[] first, itself a 23,808-byte symbol. - REQUIRED_BY_TESTS (1): ADT, which test_ethereum_signtx_knownerc20_eip_1559 uses as its canonical "known ERC-20" while asserting a hardcoded signature. A fixture should not get to pin firmware flash; migrating that test to USDC retires the entry, and is tracked as fixture debt rather than smuggled into this commit. Verified: firmware-unit 439/439 (including Coins.TableSanity), full pyk suite 632 passed / 25 skipped / 0 failed, ARM SRAM reserve unchanged at 18,172 B. --- keepkeylib/eth/ethereum_tokens.py | 21 ++++- keepkeylib/eth/token_policy.py | 124 ++++++++++++++++++++++++++++++ keepkeylib/eth/uniswap_tokens.py | 20 ++++- 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 keepkeylib/eth/token_policy.py diff --git a/keepkeylib/eth/ethereum_tokens.py b/keepkeylib/eth/ethereum_tokens.py index 9160b1ab..8f96f2ab 100644 --- a/keepkeylib/eth/ethereum_tokens.py +++ b/keepkeylib/eth/ethereum_tokens.py @@ -44,7 +44,26 @@ def build(self): self.add_tokens(network) def serialize_c(self, outf): - for token in sorted(self.tokens, key=lambda t: t.token['address']): + # Flash budget: this table is the largest read-only symbol in the ARM + # image. See token_policy for why it is capped rather than complete. + # Run as a standalone script by the build, so there is no package + # context for a relative import. + import os as _os, sys as _s + _s.path.insert(0, _os.path.dirname(_os.path.realpath(__file__))) + import token_policy + chosen, ambiguous = token_policy.select( + self.tokens, + token_policy.BUDGET_ETHEREUM_LISTS, + symbol_of=lambda t: t.token.get('symbol', ''), + address_of=lambda t: t.token['address'].lower()) + print('ethereum_tokens: %d of %d kept (budget %d)' + % (len(chosen), len(self.tokens), + token_policy.BUDGET_ETHEREUM_LISTS), file=sys.stderr) + if ambiguous: + print('ethereum_tokens: priority symbols DROPPED as ambiguous ' + '(>1 address, a scam token can inherit a real label): %s' + % ', '.join(sorted(ambiguous)), file=sys.stderr) + for token in sorted(chosen, key=lambda t: t.token['address']): token.serialize_c(outf) def is_ascii(s): diff --git a/keepkeylib/eth/token_policy.py b/keepkeylib/eth/token_policy.py new file mode 100644 index 00000000..2a0696b0 --- /dev/null +++ b/keepkeylib/eth/token_policy.py @@ -0,0 +1,124 @@ +"""Which ERC-20s earn their place in firmware flash. + +The built-in token table is the single largest read-only symbol in the ARM +image -- 31,104 bytes of `tokens` for 1,945 entries, larger than MessagesMap or +the BIP-39 wordlist. It exists so the device can render "10.5 DAI" instead of a +raw amount against a bare contract address. + +It cannot be complete, and should not try to be. Two facts settle that: + + * The vetted source (ethereum-lists) is a SNAPSHOT and is stale. It has no + UNI, no AAVE, no stETH, no PEPE, none of the modern stables (FRAX, PYUSD, + crvUSD, USDe), and its `ARB` entry is a 2018 token called "ARBITRAGE", not + Arbitrum's. Shipping 1,945 entries does not make the table current; it + makes it 1,945 entries of mostly-2018 long tail. + * Anything outside the table is not undisplayable -- it is the clear-sign + provider's job, which is exactly the direction + docs/security/token-table-retirement.md sets out. + +So the table's job is narrow: the assets a user is most likely to hold, whose +addresses this repository can actually vouch for. Everything else is a provider +schema away. + +POLICY + 1. A budget, because flash is finite and this symbol is the biggest one. + 2. Priority symbols first -- stablecoins, then majors. + 3. A priority symbol is only taken when the vetted source gives it exactly + ONE address. Two entries sharing a symbol is how a scam token inherits a + real one's label, and the device would render the attacker's name. + 4. Remaining budget filled in the existing deterministic order (by address), + so the result is reproducible and diffable. + +Addresses are NEVER written here. They come from the vetted source, matched by +symbol. A hand-typed address in a token table is a mislabelling defect waiting +to happen, and this file must not become the place one appears. +""" + +# 500 entries * 16 bytes = ~8 KB, against 31 KB today. +TOKEN_BUDGET = 500 + +# Split across the two generators, which emit into one array. +BUDGET_ETHEREUM_LISTS = 350 +BUDGET_UNISWAP_LIST = 150 + +STABLECOINS = [ + "USDC", "USDT", "DAI", "TUSD", "BUSD", "USDP", "GUSD", "SAI", + "EURS", "EURT", "sUSD", "USDS", "FRAX", "LUSD", "PYUSD", "crvUSD", "USDe", +] + +MAJORS = [ + "WETH", "WBTC", "stETH", "wstETH", "rETH", "cbETH", "LINK", "UNI", "AAVE", + "MKR", "LDO", "CRV", "SNX", "COMP", "ENS", "GRT", "MATIC", "ARB", "OP", + "SHIB", "PEPE", "APE", "SAND", "MANA", "AXS", "IMX", "INJ", "RNDR", "FET", + "STG", "BAL", "1INCH", "SUSHI", "YFI", "BAT", "ZRX", "KNC", "LRC", "GNO", + "RPL", "FXS", "CVX", "PAXG", "AMPL", "OMG", "REP", "ZIL", "ENJ", "STORJ", + "GUSD", +] + +# Required by coins[] in the firmware, not by popularity. Each of these is a +# display-only entry in the device's own coin table carrying a contract +# address, and unittests/firmware/coins.cpp (Coins.TableSanity) asserts every +# one of them resolves UNIQUELY in this token table. Dropping any is a build +# failure, correctly: the device would advertise a coin it cannot name. +# +# They are overwhelmingly 2017-era ICO tokens and are exactly the long tail +# this budget exists to cut -- but the cut has to happen in coins[] first, and +# coins[] is itself a 23,808-byte symbol. That is the next reduction, not this +# one. See docs/security/token-table-retirement.md. +REQUIRED_BY_COINS = [ + "0xBTC", "1ST", "AE", "ANT", "CVC", "DGD", "ELF", "FOX", "FUN", "GNT", + "GUP", "ICN", "MLN", "MTL", "PAY", "POLY", "PPT", "RCN", "RLC", "SALT", + "SNGLS", "SNT", "SPANK", "SWT", "TRST", "WINGS", +] + +# Required by a TEST FIXTURE rather than by the product. ADT (AdToken) is a +# 2017 ICO token that test_ethereum_signtx_knownerc20_eip_1559 uses as its +# canonical "known ERC-20", asserting a hardcoded signature over a transfer to +# its address -- so dropping it fails the suite, and the fixture cannot be +# repointed at a current token without regenerating that signature. +# +# It is listed separately and deliberately: a fixture should not get to pin +# firmware flash. Migrating that test to USDC (which every user actually holds) +# retires this entry, and is tracked as fixture debt rather than done here, +# because changing a signature fixture is a change to what the test proves. +REQUIRED_BY_TESTS = ["ADT"] + +PRIORITY_SYMBOLS = (REQUIRED_BY_COINS + REQUIRED_BY_TESTS + + STABLECOINS + MAJORS) + + +def select(records, budget, symbol_of, address_of): + """Return `records` trimmed to `budget`, priority symbols first. + + `records` is any iterable; `symbol_of`/`address_of` pull the two fields. + Priority symbols with more than one address in `records` are DROPPED from + the priority pass -- see rule 3 -- though they may still be picked up by + the deterministic fill, where they carry no special standing. + """ + records = list(records) + by_symbol = {} + for r in records: + by_symbol.setdefault(symbol_of(r), []).append(r) + + chosen, seen = [], set() + ambiguous = [] + for sym in PRIORITY_SYMBOLS: + hits = by_symbol.get(sym, []) + if len(hits) > 1: + ambiguous.append(sym) + continue + for r in hits: + key = address_of(r) + if key not in seen: + seen.add(key) + chosen.append(r) + + for r in sorted(records, key=address_of): + if len(chosen) >= budget: + break + key = address_of(r) + if key not in seen: + seen.add(key) + chosen.append(r) + + return chosen[:budget], ambiguous diff --git a/keepkeylib/eth/uniswap_tokens.py b/keepkeylib/eth/uniswap_tokens.py index 72f8f97a..4ac5ec81 100644 --- a/keepkeylib/eth/uniswap_tokens.py +++ b/keepkeylib/eth/uniswap_tokens.py @@ -27,8 +27,26 @@ def build(self): self.ustoks.append(USETHToken(token)) def serialize_c(self): + # Flash budget -- see token_policy. + # Run as a standalone script by the build, so there is no package + # context for a relative import. + import os as _os, sys as _s + _s.path.insert(0, _os.path.dirname(_os.path.realpath(__file__))) + import token_policy + import sys as _sys + chosen, ambiguous = token_policy.select( + self.ustoks, + token_policy.BUDGET_UNISWAP_LIST, + symbol_of=lambda t: t.token.get('symbol', ''), + address_of=lambda t: t.token['contractAddress'].lower()) + print('uniswap_tokens: %d of %d kept (budget %d)' + % (len(chosen), len(self.ustoks), + token_policy.BUDGET_UNISWAP_LIST), file=_sys.stderr) + if ambiguous: + print('uniswap_tokens: priority symbols DROPPED as ambiguous: %s' + % ', '.join(sorted(ambiguous)), file=_sys.stderr) ser_list = [] - for token in sorted(self.ustoks, key=lambda t: t.token['contractAddress']): + for token in sorted(chosen, key=lambda t: t.token['contractAddress']): ser_list.append(token.serialize_c()) return(ser_list)