Skip to content

Commit ef34f35

Browse files
Merge pull request #39 from BitHighlander/feat/715-token-flash-budget
feat(tokens): cap the built-in token table at 500 entries — frees 23,104 B flash
2 parents eaf386a + 27957e8 commit ef34f35

3 files changed

Lines changed: 163 additions & 2 deletions

File tree

keepkeylib/eth/ethereum_tokens.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,26 @@ def build(self):
4444
self.add_tokens(network)
4545

4646
def serialize_c(self, outf):
47-
for token in sorted(self.tokens, key=lambda t: t.token['address']):
47+
# Flash budget: this table is the largest read-only symbol in the ARM
48+
# image. See token_policy for why it is capped rather than complete.
49+
# Run as a standalone script by the build, so there is no package
50+
# context for a relative import.
51+
import os as _os, sys as _s
52+
_s.path.insert(0, _os.path.dirname(_os.path.realpath(__file__)))
53+
import token_policy
54+
chosen, ambiguous = token_policy.select(
55+
self.tokens,
56+
token_policy.BUDGET_ETHEREUM_LISTS,
57+
symbol_of=lambda t: t.token.get('symbol', ''),
58+
address_of=lambda t: t.token['address'].lower())
59+
print('ethereum_tokens: %d of %d kept (budget %d)'
60+
% (len(chosen), len(self.tokens),
61+
token_policy.BUDGET_ETHEREUM_LISTS), file=sys.stderr)
62+
if ambiguous:
63+
print('ethereum_tokens: priority symbols DROPPED as ambiguous '
64+
'(>1 address, a scam token can inherit a real label): %s'
65+
% ', '.join(sorted(ambiguous)), file=sys.stderr)
66+
for token in sorted(chosen, key=lambda t: t.token['address']):
4867
token.serialize_c(outf)
4968

5069
def is_ascii(s):

keepkeylib/eth/token_policy.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Which ERC-20s earn their place in firmware flash.
2+
3+
The built-in token table is the single largest read-only symbol in the ARM
4+
image -- 31,104 bytes of `tokens` for 1,945 entries, larger than MessagesMap or
5+
the BIP-39 wordlist. It exists so the device can render "10.5 DAI" instead of a
6+
raw amount against a bare contract address.
7+
8+
It cannot be complete, and should not try to be. Two facts settle that:
9+
10+
* The vetted source (ethereum-lists) is a SNAPSHOT and is stale. It has no
11+
UNI, no AAVE, no stETH, no PEPE, none of the modern stables (FRAX, PYUSD,
12+
crvUSD, USDe), and its `ARB` entry is a 2018 token called "ARBITRAGE", not
13+
Arbitrum's. Shipping 1,945 entries does not make the table current; it
14+
makes it 1,945 entries of mostly-2018 long tail.
15+
* Anything outside the table is not undisplayable -- it is the clear-sign
16+
provider's job, which is exactly the direction
17+
docs/security/token-table-retirement.md sets out.
18+
19+
So the table's job is narrow: the assets a user is most likely to hold, whose
20+
addresses this repository can actually vouch for. Everything else is a provider
21+
schema away.
22+
23+
POLICY
24+
1. A budget, because flash is finite and this symbol is the biggest one.
25+
2. Priority symbols first -- stablecoins, then majors.
26+
3. A priority symbol is only taken when the vetted source gives it exactly
27+
ONE address. Two entries sharing a symbol is how a scam token inherits a
28+
real one's label, and the device would render the attacker's name.
29+
4. Remaining budget filled in the existing deterministic order (by address),
30+
so the result is reproducible and diffable.
31+
32+
Addresses are NEVER written here. They come from the vetted source, matched by
33+
symbol. A hand-typed address in a token table is a mislabelling defect waiting
34+
to happen, and this file must not become the place one appears.
35+
"""
36+
37+
# 500 entries * 16 bytes = ~8 KB, against 31 KB today.
38+
TOKEN_BUDGET = 500
39+
40+
# Split across the two generators, which emit into one array.
41+
BUDGET_ETHEREUM_LISTS = 350
42+
BUDGET_UNISWAP_LIST = 150
43+
44+
STABLECOINS = [
45+
"USDC", "USDT", "DAI", "TUSD", "BUSD", "USDP", "GUSD", "SAI",
46+
"EURS", "EURT", "sUSD", "USDS", "FRAX", "LUSD", "PYUSD", "crvUSD", "USDe",
47+
]
48+
49+
MAJORS = [
50+
"WETH", "WBTC", "stETH", "wstETH", "rETH", "cbETH", "LINK", "UNI", "AAVE",
51+
"MKR", "LDO", "CRV", "SNX", "COMP", "ENS", "GRT", "MATIC", "ARB", "OP",
52+
"SHIB", "PEPE", "APE", "SAND", "MANA", "AXS", "IMX", "INJ", "RNDR", "FET",
53+
"STG", "BAL", "1INCH", "SUSHI", "YFI", "BAT", "ZRX", "KNC", "LRC", "GNO",
54+
"RPL", "FXS", "CVX", "PAXG", "AMPL", "OMG", "REP", "ZIL", "ENJ", "STORJ",
55+
"GUSD",
56+
]
57+
58+
# Required by coins[] in the firmware, not by popularity. Each of these is a
59+
# display-only entry in the device's own coin table carrying a contract
60+
# address, and unittests/firmware/coins.cpp (Coins.TableSanity) asserts every
61+
# one of them resolves UNIQUELY in this token table. Dropping any is a build
62+
# failure, correctly: the device would advertise a coin it cannot name.
63+
#
64+
# They are overwhelmingly 2017-era ICO tokens and are exactly the long tail
65+
# this budget exists to cut -- but the cut has to happen in coins[] first, and
66+
# coins[] is itself a 23,808-byte symbol. That is the next reduction, not this
67+
# one. See docs/security/token-table-retirement.md.
68+
REQUIRED_BY_COINS = [
69+
"0xBTC", "1ST", "AE", "ANT", "CVC", "DGD", "ELF", "FOX", "FUN", "GNT",
70+
"GUP", "ICN", "MLN", "MTL", "PAY", "POLY", "PPT", "RCN", "RLC", "SALT",
71+
"SNGLS", "SNT", "SPANK", "SWT", "TRST", "WINGS",
72+
]
73+
74+
# Required by a TEST FIXTURE rather than by the product. ADT (AdToken) is a
75+
# 2017 ICO token that test_ethereum_signtx_knownerc20_eip_1559 uses as its
76+
# canonical "known ERC-20", asserting a hardcoded signature over a transfer to
77+
# its address -- so dropping it fails the suite, and the fixture cannot be
78+
# repointed at a current token without regenerating that signature.
79+
#
80+
# It is listed separately and deliberately: a fixture should not get to pin
81+
# firmware flash. Migrating that test to USDC (which every user actually holds)
82+
# retires this entry, and is tracked as fixture debt rather than done here,
83+
# because changing a signature fixture is a change to what the test proves.
84+
REQUIRED_BY_TESTS = ["ADT"]
85+
86+
PRIORITY_SYMBOLS = (REQUIRED_BY_COINS + REQUIRED_BY_TESTS
87+
+ STABLECOINS + MAJORS)
88+
89+
90+
def select(records, budget, symbol_of, address_of):
91+
"""Return `records` trimmed to `budget`, priority symbols first.
92+
93+
`records` is any iterable; `symbol_of`/`address_of` pull the two fields.
94+
Priority symbols with more than one address in `records` are DROPPED from
95+
the priority pass -- see rule 3 -- though they may still be picked up by
96+
the deterministic fill, where they carry no special standing.
97+
"""
98+
records = list(records)
99+
by_symbol = {}
100+
for r in records:
101+
by_symbol.setdefault(symbol_of(r), []).append(r)
102+
103+
chosen, seen = [], set()
104+
ambiguous = []
105+
for sym in PRIORITY_SYMBOLS:
106+
hits = by_symbol.get(sym, [])
107+
if len(hits) > 1:
108+
ambiguous.append(sym)
109+
continue
110+
for r in hits:
111+
key = address_of(r)
112+
if key not in seen:
113+
seen.add(key)
114+
chosen.append(r)
115+
116+
for r in sorted(records, key=address_of):
117+
if len(chosen) >= budget:
118+
break
119+
key = address_of(r)
120+
if key not in seen:
121+
seen.add(key)
122+
chosen.append(r)
123+
124+
return chosen[:budget], ambiguous

keepkeylib/eth/uniswap_tokens.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,26 @@ def build(self):
2727
self.ustoks.append(USETHToken(token))
2828

2929
def serialize_c(self):
30+
# Flash budget -- see token_policy.
31+
# Run as a standalone script by the build, so there is no package
32+
# context for a relative import.
33+
import os as _os, sys as _s
34+
_s.path.insert(0, _os.path.dirname(_os.path.realpath(__file__)))
35+
import token_policy
36+
import sys as _sys
37+
chosen, ambiguous = token_policy.select(
38+
self.ustoks,
39+
token_policy.BUDGET_UNISWAP_LIST,
40+
symbol_of=lambda t: t.token.get('symbol', ''),
41+
address_of=lambda t: t.token['contractAddress'].lower())
42+
print('uniswap_tokens: %d of %d kept (budget %d)'
43+
% (len(chosen), len(self.ustoks),
44+
token_policy.BUDGET_UNISWAP_LIST), file=_sys.stderr)
45+
if ambiguous:
46+
print('uniswap_tokens: priority symbols DROPPED as ambiguous: %s'
47+
% ', '.join(sorted(ambiguous)), file=_sys.stderr)
3048
ser_list = []
31-
for token in sorted(self.ustoks, key=lambda t: t.token['contractAddress']):
49+
for token in sorted(chosen, key=lambda t: t.token['contractAddress']):
3250
ser_list.append(token.serialize_c())
3351
return(ser_list)
3452

0 commit comments

Comments
 (0)