fix: judge the symbol a user sees, not the bytes a contract returns (closes #260) #270
Reference in New Issue
Block a user
Delete Branch "fix/issue-260-padded-symbol-spoof"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #260.
isSpoofedSymbol()compared the raw contract-returned symbol againstKNOWN_SYMBOLS, so" ETH "missed the table on all three surfaces whileHTML collapsed the padding and painted it as
ETHnext to the user's realETH. Normalization now happens inside
src/shared/symbolSpoof.js, so thehistory, the send selector and the balance list inherit it with no call-site
change.
The normalization, and why this far
The question the rule asks is "does this reach the user's eye as a known
symbol", so the folding goes exactly as far as the rendering does and no
further.
The rule the strip implements is "remove what paints nothing." The
Unicode classes are how that is spelled, not what it means — which is why
U+007F DELETE is named on its own: it is
Ccand not default-ignorable, sono class in the strip reaches it, yet it measures the same as no character
at all. A property name is not the boundary; measured invisibility is.
Covered:
" ETH ", tabs, newlines.NFKC maps them onto U+0020 and
trim()removes them.characters
\p{Cf}(U+200B zero-width space, U+200C/U+200D joiners,U+2060 word joiner, U+00AD soft hyphen, U+FEFF BOM, U+200E/U+200F bidi
marks),
\p{Default_Ignorable_Code_Point}, which is where most of therest of invisibility lives —
\p{Cf}alone was too narrow, since aHangul filler is
Loand a variation selector isMn— and U+007F,which neither class covers.
ETH,styled mathematical letters.
What "invisible" means here, measured rather than asserted
Rendering is not a definition, so it was measured — 16px sans-serif span in
the repo's pinned e2e Chromium (
mcr.microsoft.com/playwrightv1.56.0-noble, the same digest
script/test-e2epins), where a plainETHis 32.00px, so an invisible prefix leaves 32.00px:
isSpoofedSymbolETHbaselineETHtrueETHtrueETHtrueETH+ U+FE0F VARIATION SELECTOR-16trueE+ U+FE00 VARIATION SELECTOR-1 +THtrueETHtrueETHtrueETHtrueETHfalseETHfalseETHfalseETHtrueETHtrueU+007F was the finding of the last review round, and it is closed here. The
review had it at 48.00px, i.e. a visible box and correctly excluded; the
measurement above says 32.00px, which makes it a live bypass, and the
measurement is what this change follows. Re-measured on this head, in the
same pinned container, with the visible controls measured alongside it in
the same run.
Two members of the stripped class do not render as nothing, and saying
otherwise would be the same kind of overclaim an earlier revision made:
U+1160 measures 48.00px and U+FFA0 40.00px, because font fallback draws a
box. Both are stripped anyway — they are
Default_Ignorable_Code_Point, andNFKC folds U+3164 and U+FFA0 onto U+1160 before the strip runs. The error
that introduces is hiding a token that does not look like
ETH, which isthe harmless direction, and no bundled symbol is affected (below).
Knowingly left open, each asserted as open by a test so the boundary is a
fact in the suite rather than a claim here:
Cyrillic capital Ie (U+0415), Greek capital Epsilon (U+0395). NFKC does
not touch them by design. Closing this needs a confusable-skeleton table
(UTS #39), which is a separate change with its own false-positive
question.
HTErenders asETH. Droppingthe control character leaves
HTE, which does not match; undoing thereordering needs the bidi algorithm, not a character filter.
E T His deliberately not folded, because itrenders as
E T H. Folding it would filter a token that presents noconfusion at all.
NEL, U+0001, U+0090), i.e. a visible box, so a symbol carrying one does
not look like
ETHand must not be judged a spoof. This is the reasonU+007F is added by itself rather than by widening to
\p{Cc}: the classis not the rule, and widening to it fails the suite.
Normalization decides only how the question is asked. Nothing here changes
what any surface displays; a token still shows the symbol it reports.
False positives
Bundled data. No entry in
KNOWN_SYMBOLS(506 symbols) and no symbol inthe bundled token list (512 tokens) contains whitespace or any non-ASCII
character, so the normalization cannot newly filter a bundled token. Nearest
neighbours are hyphenated (
MF-ONE) and numeric (0G,69420), none ofwhich normalization touches. A test walks the whole table and asserts both
properties plus
isSpoofedSymbol(symbol, itsOwnAddress) === falsefor everyentry, so a future list entry with a space fails the suite rather than
silently disappearing from users' balance lists.
Arbitrary explorer-supplied symbols, which is what the filter actually
runs against — stated explicitly rather than left as an unexamined case.
Sweeping every non-ASCII code point: 210 single code points normalize
onto a bundled symbol. U+2121 TELEPHONE SIGN folds to
TEL, U+33CC SQUAREIN folds to
IN, U+24BB and U+FF26 both fold toF. This is intended, nota defect: such a symbol renders as the thing it folds to, which is exactly
the property the rule is built on, and a contract that returns U+2121 while
not being the
TELcontract is impersonatingTELon screen. Theconsequence is that a token whose symbol is a single compatibility glyph
gets judged against the bundled symbol it looks like, which is the intended
behaviour of the whole change.
The two adjacent findings
item.token.type— handled.src/shared/balances.jscompared
!== "ERC-20"exactly, which fails closed for spam but alsodrops a real holding if an explorer ever writes
erc-20. The comparisonis now case-insensitive on an explorer-supplied label. Which types are
admitted is unchanged:
erc-721andERC-20-EXTRAare still dropped,and both directions are tested.
hideSpoofedSymbolsoff leaves history showing what the balance listhides — ruled out, deliberate, unchanged. Confirmed still the intent
from #176: the switch
is a user-controlled escape hatch for the history only, and the balance
list and send selector have no such switch by design (the balance list is
where a user forms their belief about what they own). The three surfaces
agree whenever the user has not explicitly asked the history to show
everything.
Verification
Failing first, on unmodified
nextwith only the tests applied — 10 testsfailed, on all three surfaces:
The U+007F test is the eleventh, added in this round; its failing-first
evidence is the
\x7Fmutation row below, which is exactly this branch withthat one character removed from the strip.
make check: green. 659 tests in 27 suites,test-verify-build18 cases,prettier clean.
make test-e2e: green, 27/27 in the pinned Playwrightcontainer.
Mutation checks — the strip's boundary is pinned from both sides, so neither
narrowing it nor widening it past what is invisible survives:
.trim()\p{Cf}only\x7Ffrom the strip\p{Cc}.normalize("NFKC").toUpperCase()The last two strip rows are the two sides of the boundary: dropping
\x7Fkills the U+007F test, widening to
\p{Cc}kills the visible-controls test.Widening previously killed 2 because it also killed the assertion that
U+007F was not caught; that assertion is now inverted, so the count is 1
and the discrimination is unchanged.
The native-asset exemption is untouched: it still turns on "has no contract
address" and never on the symbol, the second-null-mapped-symbol test still
passes and still dies under the guard mutation, and the real native ETH
balance never enters the filtered loop.
Invisible characters in the tests are built with
String.fromCodePointfromnamed constants rather than pasted in, so the source stays reviewable ASCII.
The bundled-symbol guard was
/^[ -~]+$/, which admitted an interior spaceand was therefore weaker than the "no bundled symbol contains whitespace"
claim it stood for; it is now
/^[!-~]+$/. The property held either way.FAIL —
needs-rework(a rebase is also required).1.
src/shared/symbolSpoof.js:56— the invisible-character strip is narrower than the boundary this PR claims, and four classes that render pixel-identically toETHbypass the filter\p{Cf}is used as a proxy for "renders as nothing", but invisibility is not confined toCf. Measured in the pinned e2e Chromium (mcr.microsoft.com/playwright@sha256:35246d87...), each string rendered in a 16px sans-serif span, width compared against plainETH(32.00px):isSpoofedSymbol(sym, fakeContract)ETH(baseline)ETHfalseETHfalseETH+ U+FE0F VARIATION SELECTOR-16falseE+ U+FE00 VARIATION SELECTOR-1 +THfalseReproduction:
The Hangul fillers are
Loand the variation selectors areMn, so neither is caught, yet both are default-ignorable and paint nothing. This is exactly the issue #260 failure mode — a fake token sitting next to the user's real ETH readingETH— reachable by swapping one character for another. U+3164 is the blank character in common use for exactly this purpose.The claim is wrong as well as the coverage: the PR body and the comment at
src/shared/symbolSpoof.js:35-39justify the strip with "These render as nothing at all, anywhere in the string", and that justification does not hold for the set actually stripped. Either the set or the claim has to change.Acceptable, and verified here: strip the Unicode default-ignorable set alongside
Cf—All four cases above become
true, and the full suite stays 595/595 green, so nothing legitimate is newly folded and the bundled-symbol walk still holds. The genuinely-open classes stay open and correctly so: U+0085 NEL, the C0/C1 controls and U+007F all render as a visible box (48px, wider than baseline), as do U+1160 and U+FFA0. Add each of the four as a test, in the same code-point style the file already uses.2. Not fast-forwardable onto current
origin/nextorigin/nextis at18b47cd; head13bf482is parented on5af89a1.git rebase origin/nextconflicts inTODO.md— both sides add a bullet at the top of# Completed Steps. Rebase, keep both entries, re-push.Verified and passing
DoD items 1-3, 5 and 6; failing-first reproduced exactly (10 tests, all three surfaces, on
5af89a1with only the test file applied); mutation kills reproduce as claimed (native guard 21, inverted address comparison 26,.trim()6,\p{Cf}2) plus NFKC 1 and.toUpperCase()3, no survivors; the #257 property (native exemption keyed on absence of a contract address, second-null-mapped-symbol test) intact; thebalances.jstype gate admitserc-20and still dropserc-721/ERC-20-EXTRA, and case folding admits no other Blockscout type;make check595 tests / 25 suites executed,test-verify-build18 cases,script/cibuildwithRUN make checkexecuting uncached (18.7s, notCACHED),make test-e2e27/27,make fmtclean; single commit, author and committerclawbot, oneTODO.mdbullet at the top of# Completed Stepswith no landed entry lost, title ends(closes #260), no attribution trailers, no scope creep.Disclosures
KNOWN_SYMBOLSand 512TOKENS, zero whitespace and zero non-ASCII — but not the population the filter actually runs against, which is arbitrary explorer-supplied symbols. 209 single code points now NFKC-fold onto a bundled symbol (U+2121 TELEPHONE SIGN toTEL, U+33CC SQUARE IN toIN, U+33FF SQUARE GAL toGAL, U+24BB / U+FF26 / U+1D405 toF), so such a token at a non-matching address is now hidden. I judge that intended rather than a defect, since it renders as the symbol it folds to, but it is outside the stated analysis. I could not construct a legitimate token that is now wrongly hidden.tests/symbolSpoof.test.js:238asserts/^[ -~]+$/, which admits an interior space, so that guard is weaker than "no bundled symbol contains whitespace". The property itself holds — verified independently — and interior whitespace is not folded, so nothing is at risk today.script/checkruns prettier on the host; the containerized evidence above comes fromscript/cibuild.13bf482327toada643cb44Reworked, head
ada643c, rebased ontonextata08ba6a.Finding 1 — fixed. Strip is now
/[\p{Cf}\p{Default_Ignorable_Code_Point}]/gu. Re-measured the fourbypasses myself in the pinned e2e Chromium (16px sans-serif, plain
ETH=32.00px): U+3164 HANGUL FILLER 32.00px, U+115F HANGUL CHOSEONG FILLER
32.00px, U+FE0F VARIATION SELECTOR-16 32.00px, U+FE00 VARIATION SELECTOR-1
32.00px — all four now return
true, all four are tests in theString.fromCodePointstyle.Two corrections to the review's table, both measured here. Neither
changes the fix; both change what the PR body may claim.
are
Default_Ignorable_Code_Point, and NFKC folds U+3164 and U+FFA0 ontoU+1160 before the strip runs, so both are stripped. I measured them at
48.00px and 40.00px respectively, so the class does contain two members
that paint a fallback box. Rather than repeat "these render as nothing"
about a class where that is false, the body and the code comment now say
what the class is, record the two exceptions, and note the error they
introduce is hiding a token that does not look like
ETH— theharmless direction, and no bundled symbol is affected.
Cc, so the strip does not reach it. It is a live bypass, not a correctexclusion. I did not widen, per your "do not widen past
default-ignorable", because a rule for the controls is a decision of its
own; it is asserted in the suite and disclosed in the body so it cannot
be forgotten. Say the word if you want it closed here.
The correctly-open classes that really are correctly open stayed open and
are now pinned from both sides: U+0085 NEL, U+0001 and U+0090 all measure
48.00px and are asserted
false, and widening the strip to\p{Cc}kills 2tests.
Finding 2 — rebased onto current
origin/next(a08ba6a).TODO.mdresolved keeping every landed entry,
#260bullet at the top;mergeableis now true.
Minor — fixed.
/^[ -~]+$/was admitting an interior space; now/^[!-~]+$/, which is the claim it stands for. Property held either way.Recorded in the body: the filter runs against arbitrary explorer-supplied
symbols, and my own sweep of every non-ASCII code point finds 210 (not
209) single code points that normalize onto a bundled symbol — U+2121 to
TEL, U+33CC toIN, U+24BB and U+FF26 toF. Classed as intended, withthe reasoning stated.
Gates.
make checkgreen: 622 tests / 26 suites,test-verify-build18cases, prettier clean.
make test-e2egreen: 27/27. Mutation table, allre-run on this head: native guard 21, inverted comparison 26,
.trim()6,whole strip 3, narrowing back to
\p{Cf}1, widening to\p{Cc}2, NFKC 1,.toUpperCase()3 — no survivors. The#257 property is intact:
the native exemption is still keyed on absence of a contract address, and
the second-null-mapped-symbol test still passes and still dies under the
guard mutation. The
balances.jstype gate still admitserc-20and stilldrops
erc-721andERC-20-EXTRA.ada643cb44to681f2bf83cU+007F DELETE closed: added to the strip by itself (not
\p{Cc}), re-measured on this head in the pinned container at 32.00px against a 32.00px plainETHwhile U+0085, U+0001 and U+0090 measure 48.00px, and the assertion is nowisSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT) === true. Boundary wording in the module comment and the PR body is now "strip what paints nothing", with the Unicode classes as the spelling rather than the rule; the honest records are kept (U+1160 at 48.00px and U+FFA0 at 40.00px stripped anyway as the harmless direction, and the 210 NFKC folds as intended).Both sides still pinned: dropping
\x7Fkills the U+007F test, widening to\p{Cc}kills the visible-controls test (1 each; the widen row was 2 only because it also killed the now-inverted "knowingly not caught" assertion). Rebased ontonextat52c7c1b, head681f2bf;make checkgreen (659 tests / 27 suites,test-verify-build18 cases, prettier clean) andmake test-e2egreen 27/27.PASS — independent re-review of
681f2bf: every figure in the module comment reproduces under my own measurement in the pinned Playwright container (plainETH32.00; U+007F, U+3164, U+115F, U+FE0F, U+FE00, U+200B, U+034F, U+2065 all 32.00; U+FFA0 40.00; U+1160 48.00; U+0001/U+0085/U+0090 48.00) withisSpoofedSymbolagreeing on every row; both boundary mutations reproduce at 1 killed each;make check27 suites / 659 tests plustest-verify-build18 cases,make test-e2e27/27, fast-forwardable ontonextat52c7c1b, single commit, all 46 landedTODO.mdentries intact with this one on top, no attribution trailers.Disclosures. (1) I swept all 1,114,112 code points in prefix, interior and suffix position in that container looking for another character that paints nothing and escapes. The only survivors are five combining marks — U+07EB, U+07F3, U+08EA, U+08EB, U+11181 — and they are not invisible: they rasterize to zero pixels only at 16px under
deviceScaleFactor1, and paint clearly at 2 and 3 (439-467 and 882-941 differing pixels) and at 64px. Nothing that genuinely paints nothing escapes the strip. (2) My first pass over-reported roughly 1100 escapees because an element screenshot clips ink that a combining mark paints outside the span box; the corrected harness screenshots a padded container and calls U+0301 and U+0323 visible, as it must. (3) Unrelated and pre-existing, not a finding against this PR: 7 bundled tokens (FRAX,REUSD,TON,EURE,MSUSD,MUSD,JPYC) carry a symbol whoseKNOWN_SYMBOLSentry points at a different address, so each is judged a spoof of its own sibling — identical onorigin/next, so untouched by this change.