Compare commits

...
5 Commits
Author SHA1 Message Date
sneak e31a90bffc chore: keep the internal view id out of the release banner (closes #375)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 0s
check / check (push) Successful in 56s
The debug/testnet banner appended the active view's internal id, so the
user saw text like "[TESTNET] (approve-tx)" — developer vocabulary, and on
the approval screen it sat directly above the carefully worded line stating
what is being authorized. The view id is now gated on the compile-time
DEBUG constant instead of isDebug(), so it survives only in a debug build.
A testnet or the runtime debug toggle still raises the banner, but without
the view id, which is what a release build shows.

Model: opus-4-8
2026-09-21 19:47:35 +00:00
clawbot 33fa25adca harden: bound the total network fee by gasLimit × fee, on both send paths (closes #399)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The two per-field ceilings in approvalVerify.js were checked independently,
but the fee a validator is paid is gasLimit × fee per gas: a gas limit and a
fee each under their own ceiling still multiply to thousands of ETH, which a
gas-consuming contract really collects. assertWithinCeilings now also bounds
that product against MAX_TOTAL_FEE (1 ETH), so both callers — populating the
dApp transaction and verifying the signed artifact — refuse it with a full
sentence naming the fee and the limit.

The wallet's own send in confirmTx.js pinned no fee fields, so ethers filled
them from the node with no bound; it now populates the transaction and runs the
same check before signing, showing the same error in the confirmation screen's
reserved errors box so nothing on screen moves.

Model: opus-4-8
2026-09-21 21:45:34 +02:00
clawbot 2fe6447625 fix: name a tracked or explorer-known token instead of "Unknown token" (closes #323)
check / check (push) Failing after 0s
e2e / e2e-firefox (push) Failing after 0s
e2e / e2e-chrome (push) Failing after 1m27s
The approval and transaction-status screens read a token's scale from the
bundled list, the tokens the user tracks, then the block explorer, but read
its symbol from the bundled list alone. A token the user added by hand was
scaled correctly yet labelled "Unknown token", and a non-bundled ERC-20 was
carried onto the wait screen as ETH.

resolveTokenSymbol() now draws the symbol through the same sources and
precedence as the scale, and the ERC-20 and Uniswap swap lines both use it. A
tracked or explorer-reported name stays subject to the spoof rule, so it
cannot claim a bundled or native ticker.

Folds in #354.

Model: opus-4-8
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-09-21 21:28:06 +02:00
clawbot 2da790fbe9 fix: say a second wallet's password is separate when one is chosen (closes #374)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The add-wallet screen offered only "Choose a password" while each wallet
keeps its own encrypted secret, so a second wallet silently accepted a
password different from the first with nothing marking it as separate. A
note now appears on that screen when the profile already holds a wallet,
saying each wallet has its own password and this one need not match any
already in use. It is shown only then — the first wallet has no other
password to differ from — and is decided on screen entry, so it does not
move the password fields. It promises no recovery or reset, staying
consistent with the no-password-reset design.

Model: opus-4-8
2026-09-21 21:11:09 +02:00
clawbot 9ac7df0128 harden: keep the test recovery phrase out of release bundles, match committed keys by content (closes #351)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The 12-word BIP-39 test phrase survived in every release bundle as dead
text: module.exports keeps DEBUG_MNEMONIC live even though wallet.js's only
use of it folds away in a release build, so it could not be tree-shaken.
Putting the value itself behind the __BUILD_DEBUG__ define makes esbuild fold
it to null, so no distributed bundle carries it. script/verify-build now
fails a release build if the phrase appears in any emitted file, so the fold
cannot silently regress; test-verify-build covers both the release failure
and the debug allowance.

tests/extensionId.test.js now scans the content of every tracked file for a
PEM private-key header instead of matching filename extensions alone, so a
key committed under an unexpected name is caught.

Model: opus-4-8
2026-09-21 18:29:39 +02:00
25 changed files with 898 additions and 75 deletions
+63
View File
@@ -45,6 +45,38 @@ but the review is broader than any of them.
# Completed Steps
- 2026-09-21: The network fee a transaction can commit is bounded by the product
of the gas limit and the fee per gas, not by each field alone, and the
wallet's own send is bounded the same way
([#399](https://git.eeqj.de/sneak/AutistMask/issues/399)). The two per-field
ceilings in `src/shared/approvalVerify.js` were checked independently, so a
gas limit and a fee that were each under their own ceiling still multiplied to
thousands of ETH — a fee a gas-consuming contract really collects — while the
comment claimed the ceiling caught exactly that. `assertWithinCeilings` now
also refuses a transaction whose gas limit times its fee per gas
(`maxFeePerGas` for a type-2 transaction, `gasPrice` for a legacy or type-1
one) exceeds `MAX_TOTAL_FEE`, a new constant of 1 ETH beside the existing
ceilings, so both callers — where the dApp transaction is populated and where
the signed artifact is verified — reject it with a full sentence naming the
fee and the limit. The wallet's own send in `src/popup/views/confirmTx.js`
pinned no fee fields, so ethers filled them from whatever the configured node
answered with nothing bounding them; it now populates the transaction and runs
the same check before signing, showing the same error in the confirmation
screen's reserved errors box so nothing on screen moves. Deliberately out of
scope: comparing a supplied fee against the node's own suggested fee, which
the absolute bound already makes unnecessary for the balance-draining case. 1
ETH is a plain constant, one line to change; the owner may prefer another
figure.
- 2026-09-21: The test recovery phrase no longer survives in a release bundle,
and the committed-key guard matches by content
([#351](https://git.eeqj.de/sneak/AutistMask/issues/351)). `DEBUG_MNEMONIC` in
`src/shared/constants.js` is now behind the `__BUILD_DEBUG__` define, so a
release build folds the phrase to `null` and no emitted bundle carries it; it
used to survive as dead text because `module.exports` keeps the const alive.
`script/verify-build` now fails a release build if the phrase appears in any
emitted file, so the fold cannot silently regress. `tests/extensionId.test.js`
scans the content of every tracked file for a PEM private-key header instead
of matching filename extensions alone.
- 2026-09-21: A transaction response is honoured only for a transaction
approval, and the three remaining approval-settlement paths are pinned
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
@@ -59,6 +91,37 @@ but the review is broader than any of them.
broadcasting behind them: each is declined and the dApp still receives its
broadcast result.
- 2026-09-21: Adding a second wallet no longer accepts a different password with
nothing saying it is a separate one
([#374](https://git.eeqj.de/sneak/AutistMask/issues/374)). Each wallet has its
own encrypted secret, so per-wallet passwords are by design; the add-wallet
screen said only "Choose a password". A note now appears on that screen when
the profile already holds a wallet, stating that each wallet has its own
password and this one need not match any already in use. It is shown only
then, since the first wallet has no other password to differ from, and it
stays consistent with the no-reset reality of
[#312](https://git.eeqj.de/sneak/AutistMask/issues/312) by promising no
recovery or reset.
- 2026-09-21: The dApp approval and transaction-status screens resolve a token's
symbol from the bundled list, then the tokens the user tracks, then the block
explorer's report — the same sources and precedence the amount line already
used for the token's scale
([#323](https://git.eeqj.de/sneak/AutistMask/issues/323), folding in
[#354](https://git.eeqj.de/sneak/AutistMask/issues/354)). A token the user
added by hand, or holds a balance of, is now named rather than labelled
`Unknown token`, and a non-bundled ERC-20 is no longer carried onto the wait
screen as `ETH`. A tracked or explorer-reported name stays subject to the
spoof rule, so resolving a symbol is not a new way to wear a known ticker.
- 2026-09-21: The debug/testnet banner no longer shows the internal view id to
the user in a release build
([#375](https://git.eeqj.de/sneak/AutistMask/issues/375)). The banner appended
the active view's id (e.g. `[TESTNET] (approve-tx)`), which is developer
vocabulary sitting directly above the approval screen's carefully worded
authorization text. The suffix is now gated on the compile-time `DEBUG`
constant rather than `isDebug()`, so it survives only in a debug build; a
testnet or the runtime debug toggle still raises the banner but without the
view id.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
+29
View File
@@ -37,6 +37,10 @@ DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
# The same test recovery phrase verify-build searches release bundles for. Held
# here too, the way the markers above are, so a case can plant it in a bundle.
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
RECEIPT_HEADER="autistmask-build-receipt v1"
NEWLINE='
@@ -516,6 +520,24 @@ c_debug_build() {
write_receipt
}
# A release bundle that still carries the test recovery phrase — the regression
# verify-build guards against, and the reason DEBUG_MNEMONIC is behind the
# __BUILD_DEBUG__ define in src/shared/constants.js. The receipt is regenerated
# so the phrase is caught as bundle content, not incidentally as a stale digest.
c_release_bundle_with_mnemonic() {
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
write_receipt
}
# The same phrase in a debug build is expected: make build-debug ships it on
# purpose, so the phrase check must stay quiet under --expect debug.
c_debug_bundle_with_mnemonic() {
write_bundle dist/chrome/src/popup/index.js "$MARKER_ON"
write_bundle dist/firefox/src/popup/index.js "$MARKER_ON"
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
write_receipt
}
c_no_dist() { rm -rf dist; }
# --- dist discard -----------------------------------------------------------
@@ -753,6 +775,13 @@ run_cases() {
check_case "debug bundles under --expect debug pass" \
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
check_case "release bundle carrying the test recovery phrase fails" \
no release 1 \
"carries the BIP-39 test recovery phrase" c_release_bundle_with_mnemonic
check_case "debug bundle carrying the test recovery phrase passes" \
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_bundle_with_mnemonic
check_case "no --expect argument" \
no no-expect 1 "no --expect argument." c_control
+27
View File
@@ -13,6 +13,12 @@
# fallback branch; the property only exists in the emitted output, so it has to
# be asserted against the emitted output.
#
# The DEBUG half also checks the phrase directly: a release build must not carry
# the test recovery phrase in any emitted file. The phrase is behind the
# __BUILD_DEBUG__ define in src/shared/constants.js and folds away in a release
# build, but the marker only proves DEBUG compiled off, not that the fold
# removed the string; the phrase grep is the assertion that it did.
#
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never
# taken from this script's environment. It used to be read from
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
@@ -67,6 +73,15 @@ TAB=' '
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
# The 12-word BIP-39 test recovery phrase from src/shared/constants.js. It is
# behind the __BUILD_DEBUG__ define there, so a release build folds it out of
# every bundle; this is the assertion that it stayed out. The phrase is a
# publicly committed test value rather than a secret, but a BIP-39 phrase in a
# distributed wallet artifact is exactly the string a scanner or auditor has to
# stop and reason about, so a release build must not ship it. A debug build
# ships it on purpose, so this is checked only when release is expected.
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
RECEIPT_HEADER="autistmask-build-receipt v1"
# Set by the arguments.
@@ -283,6 +298,18 @@ check_entry() {
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
to dist/ after the build, so this artifact is not the one that was built."
# No emitted file of a release build may carry the test recovery phrase.
# Checked on every file, not only the audited bundles, so a copy that
# reached some other emitted file fails here too. A debug build ships the
# phrase deliberately, so this runs only when release was expected.
if [ "$EXPECT" = "$MARKER_OFF" ] && has_marker "$TEST_MNEMONIC" "$ENTRY_PATH"; then
fail "$ENTRY_PATH carries the BIP-39 test recovery phrase, which a
release build must fold out. The __BUILD_DEBUG__ define in build.js is what
drops it from src/shared/constants.js; check that DEBUG_MNEMONIC is still
behind that flag. A recovery phrase in a distributed bundle is exactly the
string an auditor or scanner has to stop on, so this is a hard failure."
fi
if [ "$ENTRY_FLAG" = A ]; then
read_marker "$ENTRY_PATH"
[ "$MARKER" = "$EXPECT" ] ||
+15
View File
@@ -152,6 +152,21 @@
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section">
<!-- Shown only when the profile already holds a wallet:
each wallet has its own password (its own
encryptedSecret), so a second wallet does not reuse
the first one's. addWallet.js toggles this on screen
entry from state.wallets.length, so it is constant
while the screen is up and moves nothing. -->
<p
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
id="add-wallet-separate-password-note"
>
You already have a wallet. Each wallet has its own
password: the one you choose here is only for this new
wallet, and it need not match any password you already
use.
</p>
<label class="block mb-1">Choose a password</label>
<!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password
+15
View File
@@ -100,9 +100,24 @@ function clear() {
$("add-wallet-phrase-warning").style.visibility = "hidden";
}
// Each wallet has its own password (its own encryptedSecret), so adding a
// second wallet does not reuse the first one's. The note that says so is
// only meaningful once a wallet exists — on the first wallet there is no
// other password to be separate from — so it is shown only then. This is
// decided on entry and stays put while the screen is up, so it does not
// move the password fields the way a per-tab hint would.
function updateSeparatePasswordNote() {
const hasExistingWallet = state.wallets.length > 0;
$("add-wallet-separate-password-note").classList.toggle(
"hidden",
!hasExistingWallet,
);
}
function show() {
clear();
switchMode("mnemonic");
updateSeparatePasswordNote();
showView("add-wallet");
}
+32 -21
View File
@@ -20,9 +20,9 @@ const {
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this
@@ -63,9 +63,15 @@ function tokenAmountText(rawAmount, decimals, symbol) {
};
}
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs.
@@ -85,8 +91,7 @@ function decodeCalldata(data, toAddress) {
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
@@ -242,8 +247,11 @@ function showTxApproval(details) {
const approvedTx = details.approvedTx;
const toAddr = approvedTx.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(approvedTx.value || "0");
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens
pendingTxDetails = {
@@ -251,14 +259,17 @@ function showTxApproval(details) {
to: toAddr || "",
amount: formatTxValue(ethValue),
token: "ETH",
tokenSymbol: token ? token.symbol : null,
tokenSymbol: null,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
// The asset the status summary is counted in: an ERC-20 call's Token
// contract, or a swap's input token. Its symbol is resolved from the
// same sources as the approval screen, so a non-bundled token the
// wallet knows is not carried onto the wait and success screens as ETH.
let assetAddr = null;
for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
@@ -266,20 +277,20 @@ function showTxApproval(details) {
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
}
if (d.label === "Token In" && d.isToken && d.address) {
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
if (t) {
decodedTokenAddr = d.address;
decodedTokenSymbol = t.symbol;
if (
(d.label === "Token" || d.label === "Token In") &&
d.isToken &&
d.address
) {
assetAddr = d.address;
}
}
}
if (token) {
pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddr) {
pendingTxDetails.token = decodedTokenAddr;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
if (assetAddr) {
pendingTxDetails.token = assetAddr;
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
assetAddr,
sources,
);
}
}
+51 -24
View File
@@ -30,6 +30,7 @@ const {
displayedDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
const {
CODES,
FEE_PENDING,
@@ -394,6 +395,46 @@ async function estimateGas(txInfo) {
}
}
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. The send
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
// the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx) {
let request;
if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount) };
} else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the
// screen rendered this amount at, not to encode with: encoding from it
// signs whatever the contract answers now, which is not what the user
// read. A disagreement throws. See transferAmount.js.
const amount = transferAmountUnits(
tx.amount,
tx.tokenDecimals,
await contract.decimals(),
);
request = await contract.transfer.populateTransaction(tx.to, amount);
}
const populated = await connectedSigner.populateTransaction(request);
assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated);
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's
// own numbers, so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
@@ -467,29 +508,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
if (pendingTx.token === "ETH") {
tx = await connectedSigner.sendTransaction({
to: pendingTx.to,
value: parseEther(pendingTx.amount),
});
} else {
const contract = new Contract(
pendingTx.token,
ERC20_ABI,
connectedSigner,
);
// The contract's decimals() is read to be COMPARED with the
// scale the screen rendered this amount at, not to encode with:
// encoding from it signs whatever the contract answers now,
// which is not what the user read. A disagreement throws and is
// reported on the error screen. See transferAmount.js.
const amount = transferAmountUnits(
pendingTx.amount,
pendingTx.tokenDecimals,
await contract.decimals(),
);
tx = await contract.transfer(pendingTx.to, amount);
}
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
@@ -498,6 +517,14 @@ function init(_ctx) {
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the message stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
if (e && e.approvalMismatch) {
showSendError(e.message);
return;
}
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} finally {
@@ -511,4 +538,4 @@ function init(_ctx) {
});
}
module.exports = { init, show, restore };
module.exports = { init, show, restore, populateVerifyAndSend };
+6 -1
View File
@@ -12,6 +12,7 @@
// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices");
@@ -119,7 +120,11 @@ function updateDebugBanner(viewName) {
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner);
}
const suffix = viewName ? " (" + viewName + ")" : "";
// The view id is internal vocabulary; it helps while developing but
// means nothing to a user. Only a debug build appends it, gated on the
// compile-time DEBUG constant so a release build never shows it — not
// isDebug(), which is also true for a testnet or the runtime toggle.
const suffix = DEBUG && viewName ? " (" + viewName + ")" : "";
if (debug && net.isTestnet) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) {
+9 -3
View File
@@ -13,7 +13,7 @@ const {
displaySymbol,
clearViewStack,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -232,9 +232,15 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender();
}
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
function decodedDetailsHtml(decoded) {
+55
View File
@@ -30,6 +30,7 @@
// enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the
// addresses whose balances have been fetched. They describe one contract, so
@@ -74,6 +75,59 @@ function resolveTokenDecimals(tokenAddress, sources) {
return explorerDecimals(lower, sources && sources.wallets);
}
// Every symbol the explorer reported for this contract, across the addresses
// whose balances have been fetched. The counterpart to explorerDecimals(): one
// contract, so the reports should agree, and a set that does not agree is a
// name this screen has no way to choose between.
function explorerSymbol(lower, wallets) {
let found = null;
for (const wallet of wallets || []) {
for (const addr of wallet.addresses || []) {
for (const tb of addr.tokenBalances || []) {
if ((tb.address || "").toLowerCase() !== lower) continue;
if (!tb.symbol) continue;
if (found !== null && found !== tb.symbol) return null;
found = tb.symbol;
}
}
}
return found;
}
// The symbol to label a token with, or null when no source the wallet trusts
// names one — in which case the screen keeps saying `Unknown token` rather than
// guessing. The bundled list, then the tokens the user tracks, then what the
// explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// native ticker from a contract not entitled to it is refused and the next
// source tried, so resolving a symbol never becomes a new way to claim a known
// ticker. The bundled list is the wallet's own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find(
(t) => (t.address || "").toLowerCase() === lower,
);
const candidates = [];
if (tracked && tracked.symbol) candidates.push(tracked.symbol);
const reported = explorerSymbol(lower, sources && sources.wallets);
if (reported) candidates.push(reported);
for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
}
return null;
}
// What the amount line reads when the scale is unknown. The base units are
// exact and the caveat is part of the same string, so the number on the screen
// cannot be mistaken for a token quantity, and it can never read as zero for a
@@ -84,5 +138,6 @@ function unknownDecimalsAmount(rawAmount) {
module.exports = {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
};
+35 -2
View File
@@ -51,6 +51,7 @@
const {
Transaction,
accessListify,
formatEther,
getAddress,
getBytes,
verifyMessage,
@@ -134,10 +135,19 @@ const FORBIDDEN_FIELDS = [
const MAX_GAS_LIMIT = 100000000n;
// 100,000 gwei per gas: orders of magnitude above the highest fee either
// supported network has produced, and low enough to catch a fee that would
// hand the validator the balance.
// supported network has produced.
const MAX_FEE_PER_GAS = 100000000000000n;
// The largest total fee this wallet will sign, in wei. The two ceilings above
// bound the gas limit and the price per gas each on its own, but the fee a
// validator is actually paid is their product, and a gas limit and a price
// that are each under their own ceiling still multiply to thousands of ETH —
// 30,000,000 gas at 100,000 gwei is about 3,000 ETH. Bounding the product is
// what catches a fee that would hand the validator the balance; the per-field
// ceilings alone do not. A full 30,000,000-gas block at 33 gwei reaches this,
// which no ordinary wallet transaction approaches.
const MAX_TOTAL_FEE = 1000000000000000000n; // 1 ETH
// A refusal to act on an artifact: it is not the thing that was approved, so
// the approval it was offered against is spent and must not be retried. Every
// throw in this module is one of these; the background distinguishes them from
@@ -384,6 +394,28 @@ function assertWithinCeilings(tx) {
);
}
}
// The product: gasLimit × the most this transaction could pay per gas —
// maxFeePerGas for a type-2 transaction, gasPrice for a legacy or type-1
// one. This is the fee a gas-consuming contract can really extract, and it
// is the bound the two per-field ceilings above cannot express.
if (present(tx.gasLimit)) {
const gasLimit = normalizeQuantity(tx.gasLimit, "gas limit");
let price = null;
if (present(tx.maxFeePerGas)) {
price = normalizeQuantity(tx.maxFeePerGas, "maximum fee per gas");
} else if (present(tx.gasPrice)) {
price = normalizeQuantity(tx.gasPrice, "gas price");
}
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
throw refuse(
"This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) +
" ETH, which is more than the " +
formatEther(MAX_TOTAL_FEE) +
" ETH this wallet will sign for.",
);
}
}
}
// Refuse a field only a transaction type this wallet does not sign can carry.
@@ -775,4 +807,5 @@ module.exports = {
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
};
+9 -2
View File
@@ -22,8 +22,15 @@ const BUILD_DEBUG_MARKER = DEBUG
? "autistmask-build-debug=on"
: "autistmask-build-debug=off";
const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
// Behind DEBUG for the same reason BUILD_DEBUG_MARKER is above: in a release
// build __BUILD_DEBUG__ is a compile-time false, esbuild drops this branch, and
// the phrase never reaches a distributed bundle. The literal used to survive as
// dead text because module.exports keeps this const live even though wallet.js's
// only use of it is folded away; making the value itself fold to null removes
// it. script/verify-build fails a release build if the phrase appears anyway.
const DEBUG_MNEMONIC = DEBUG
? "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
: null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
+2 -3
View File
@@ -2,10 +2,10 @@
// swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
@@ -123,9 +123,8 @@ function tokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null };
}
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return {
symbol: t ? t.symbol : null,
symbol: resolveTokenSymbol(address, sources),
decimals: resolveTokenDecimals(address, sources),
address,
};
+58
View File
@@ -0,0 +1,58 @@
// Adding a second wallet accepts a password different from the first one's
// with nothing on screen saying the two are separate — each wallet has its
// own encryptedSecret, so per-wallet passwords are by design, but the add
// screen said only "Choose a password"
// (https://git.eeqj.de/sneak/AutistMask/issues/374).
//
// The fix is copy: a note on the password screen that says each wallet has
// its own password and this one need not match. It is only meaningful once
// a wallet exists — on the very first wallet there is no other password to
// be separate from — so it is shown then and hidden otherwise. These boot
// the real popup and reach the add-wallet screen through the same button a
// user presses, so the note's visibility is decided by the real show().
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
POPUP_HTML,
} = require("./support/popupBoot");
const NOTE = "add-wallet-separate-password-note";
afterEach(() => {
cleanupPopup();
});
describe("second-wallet password note", () => {
test("hidden while onboarding the first wallet", async () => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
expect(page.visibleViews()).toContain("add-wallet");
expect(page.hidden(NOTE)).toBe(true);
});
test("shown when a wallet already exists", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
await page.click("btn-main-add-wallet");
expect(page.visibleViews()).toContain("add-wallet");
expect(page.hidden(NOTE)).toBe(false);
});
// The copy states the two facts the definition of done asks for — each
// wallet has its own password, and this one need not match — and stays
// consistent with the no-password-reset reality of
// https://git.eeqj.de/sneak/AutistMask/issues/312 by not promising any
// recovery or reset here.
test("the note says the password is per-wallet and need not match", () => {
const note = /id="add-wallet-separate-password-note"[^>]*>([^]*?)<\/p>/
.exec(POPUP_HTML)[1]
.replace(/\s+/g, " ")
.trim();
expect(note).toContain("its own");
expect(note).toContain("need not match");
expect(note).not.toMatch(/recover|reset/i);
});
});
+5 -3
View File
@@ -143,16 +143,18 @@ describe("decodeCalldata amount", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
// The tracked entry supplies both: the scale (5000.0000) and, since
// issue #323, the symbol that the scale is counted in.
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
).toBe("5000.0000 NOVEL");
});
test("transfer priced off the explorer's decimals shows the true quantity", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
).toBe("5000.0000 NOVEL");
});
test("transfer of an unknown-decimals token shows base units, not a number", () => {
@@ -172,7 +174,7 @@ describe("decodeCalldata amount", () => {
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000.0000",
"5000.0000 NOVEL",
);
});
+18
View File
@@ -212,6 +212,24 @@ describe("prepareApprovalTx", () => {
).rejects.toThrow(/gas limit no network this wallet supports/);
});
// The combined bound at population: a gas limit and a fee that are each
// under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a
// balance-draining fee to click past.
test("refuses a fee whose product with the gas limit is over the bound", async () => {
const gouging = providerWith({
estimateGas: async () => 30000000n,
getFeeData: async () => ({
gasPrice: MAX_FEE_PER_GAS,
maxFeePerGas: MAX_FEE_PER_GAS,
maxPriorityFeePerGas: 1000000000n,
}),
});
await expect(
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
).rejects.toThrow(/network fee of up to/);
});
// No approval and no window: the failure goes back to the page the click
// came from, in a sentence.
test("reports a failed estimate as a full sentence", async () => {
+114
View File
@@ -28,6 +28,7 @@ const {
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const { getSignerForAddress } = require("../src/shared/wallet");
@@ -475,18 +476,131 @@ describe("verifySignedTx field comparison", () => {
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
).toThrow(/fee per gas far above any plausible value/);
}
// Each field at its own ceiling multiplies to about 10,000 ETH, which
// is exactly the combination the per-field ceilings cannot see and the
// product bound is for: it is refused, not accepted.
expect(() =>
assertWithinCeilings({
gasLimit: MAX_GAS_LIMIT,
maxFeePerGas: MAX_FEE_PER_GAS,
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
}),
).toThrow(/network fee of up to/);
// An ordinary transaction — a modest gas limit and a modest fee, each
// far under its ceiling and their product far under the bound — passes.
expect(() =>
assertWithinCeilings({
gasLimit: 21000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
).not.toThrow();
// Nothing to bound is not a failure: a type 2 approval carries no gas
// price, and a bare object must not be refused for lacking one.
expect(() => assertWithinCeilings({})).not.toThrow();
});
// The defect this issue closes: gasLimit and maxFeePerGas each under their
// own ceiling, but their product — the fee a gas-consuming contract can
// really extract — thousands of ETH. The per-field ceilings accept it; the
// product bound refuses it, on either side of the screen.
describe("the combined fee bound", () => {
// A gas limit and a fee that are each comfortably under their own
// ceiling but multiply to well over 1 ETH: 30,000,000 gas at 100,000
// gwei is about 3,000 ETH.
const OVER = { gasLimit: 30000000n, maxFeePerGas: 100000000000000n };
test("each field is under its own ceiling", () => {
expect(OVER.gasLimit).toBeLessThan(MAX_GAS_LIMIT);
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
MAX_TOTAL_FEE,
);
});
test("assertWithinCeilings refuses the product over the bound", () => {
expect(() =>
assertWithinCeilings({
...OVER,
maxPriorityFeePerGas: 1000000000n,
}),
).toThrow(/network fee of up to 3000\.0 ETH/);
});
test("assertWithinCeilings bounds a legacy gasPrice the same way", () => {
expect(() =>
assertWithinCeilings({
gasLimit: OVER.gasLimit,
gasPrice: OVER.maxFeePerGas,
}),
).toThrow(/network fee of up to/);
});
// The boundary itself, pinned rather than only some value well past
// it. Both fields stay under their own ceilings, so it is the product
// and nothing else that decides these two cases: a gas limit of 10,000
// at the per-gas ceiling is exactly 1 ETH.
test("assertWithinCeilings accepts a product exactly at the bound and refuses one wei over", () => {
expect(MAX_FEE_PER_GAS * 10000n).toBe(MAX_TOTAL_FEE);
expect(() =>
assertWithinCeilings({
gasLimit: 10000n,
maxFeePerGas: MAX_FEE_PER_GAS,
}),
).not.toThrow();
expect(() =>
assertWithinCeilings({
gasLimit: 10001n,
maxFeePerGas: MAX_FEE_PER_GAS,
}),
).toThrow(/network fee of up to/);
});
// The dApp path: an artifact whose fee is within each field's ceiling
// but over the product bound, both displayed and signed, is refused at
// verification just as it is at population.
test("verifySignedTx refuses an over-bound product even when displayed", async () => {
const raw = await signedWith(OVER);
expect(() =>
verifySignedTx(
raw,
approvedFor(TX_PARAMS, OVER),
signer.address,
SELECTED,
),
).toThrow(/network fee of up to/);
});
test("verifySignedTx accepts a product just under the bound", async () => {
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const raw = await signedWith(under);
expect(() =>
verifySignedTx(
raw,
approvedFor(TX_PARAMS, under),
signer.address,
SELECTED,
),
).not.toThrow();
});
test("the refusal names the fee and the limit in a full sentence", () => {
try {
assertWithinCeilings(OVER);
throw new Error("expected a rejection");
} catch (e) {
expect(e.approvalMismatch).toBe(true);
expect(e.message).toMatch(/^[A-Z].*\.$/);
expect(e.message).toContain("3000.0 ETH");
expect(e.message).toContain("1.0 ETH");
}
});
});
test("every field mismatch is a refusal, not a warning", async () => {
const raw = await signedWith({ nonce: 8 });
try {
+100
View File
@@ -0,0 +1,100 @@
// The wallet's OWN send path enforces the same combined fee bound the dApp
// path does (https://git.eeqj.de/sneak/AutistMask/issues/399).
//
// The send in src/popup/views/confirmTx.js pins no fee fields, so ethers fills
// maxFeePerGas and the gas limit from whatever the configured RPC node
// answers. Nothing bounded that: a hostile node could report a fee whose
// product with the gas limit is thousands of ETH, and it would be both
// displayed and signed. populateVerifyAndSend() populates the transaction and
// runs assertWithinCeilings() on the populated fees before signing, so an
// over-bound send is refused before anything is broadcast.
//
// The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real
// DOM path around it — reading the fee error into the reserved errors box — is
// covered by the Chrome e2e suite.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
const {
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A signer whose populateTransaction() fills in the fees a node quoted and
// whose sendTransaction() records the call, so a test can assert whether the
// send was reached at all.
function fakeSigner(fees) {
const sent = [];
return {
sent,
populateTransaction: async (request) => ({
...request,
from: RECIPIENT,
nonce: 0,
type: 2,
chainId: 1n,
gasLimit: fees.gasLimit,
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
}),
sendTransaction: async (tx) => {
sent.push(tx);
return { hash: "0xabc" };
},
};
}
const ETH_SEND = { token: "ETH", to: RECIPIENT, amount: "1.0" };
describe("populateVerifyAndSend enforces the combined fee bound", () => {
// A gas limit and a fee that are each under their own field ceiling, but
// multiply to about 3,000 ETH — the combination the per-field ceilings
// cannot see.
const OVER = { gasLimit: 30000000n, maxFeePerGas: MAX_FEE_PER_GAS };
test("each field is under its ceiling but the product is over the bound", () => {
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
MAX_TOTAL_FEE,
);
});
test("refuses an over-bound send without broadcasting it", async () => {
const signer = fakeSigner(OVER);
let thrown;
try {
await populateVerifyAndSend(signer, ETH_SEND);
} catch (e) {
thrown = e;
}
expect(thrown).toBeDefined();
expect(thrown.approvalMismatch).toBe(true);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
expect(thrown.message).toContain("3000.0 ETH");
expect(thrown.message).toContain("1.0 ETH");
// The one guarantee that matters: nothing was signed or sent.
expect(signer.sent).toHaveLength(0);
});
test("broadcasts a send whose product is just under the bound", async () => {
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(MAX_TOTAL_FEE);
const signer = fakeSigner(under);
const tx = await populateVerifyAndSend(signer, ETH_SEND);
expect(tx.hash).toBe("0xabc");
expect(signer.sent).toHaveLength(1);
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Tests for the debug/testnet banner (issue #375).
//
// On a testnet the banner is raised even in a release build, but it must not
// append the active view's internal id: the user should see "[TESTNET]", never
// "[TESTNET] (approve-tx)". The suffix is gated on the compile-time DEBUG
// constant, which is false in a plain test load, so this drives exactly the
// text a shipped build renders. Revert the gate to the old unconditional
// suffix and this fails.
//
// The banner is created on demand by updateDebugBanner(); the document stub
// records what it prepends so the assertion can read the resulting text.
function makeBanner() {
return {
id: "",
textContent: "",
style: { cssText: "" },
remove() {},
};
}
function makeDocument() {
let banner = null;
return {
getElementById(id) {
return id === "debug-banner" ? banner : null;
},
createElement: () => makeBanner(),
body: {
prepend(node) {
banner = node;
},
},
};
}
function load() {
jest.resetModules();
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
return { helpers, state };
}
describe("the release banner on a testnet", () => {
test("carries no internal view id", () => {
const { helpers, state } = load();
state.networkId = "sepolia";
helpers.updateDebugBanner("approve-tx");
expect(
globalThis.document.getElementById("debug-banner").textContent,
).toBe("[TESTNET]");
});
});
+17 -8
View File
@@ -89,19 +89,28 @@ describe("chrome extension identity", () => {
// The private half is a credential. It has never been in this repo and no
// target generates one into the working tree; this fails loudly if that
// ever changes, because a committed .pem is a key anyone can sign a CRX
// with under this extension's id.
// ever changes, because a committed private key is one anyone can sign a
// CRX with under this extension's id.
//
// Matched by CONTENT, not by filename: a key committed as notes.txt or with
// no extension carries the same risk as one named key.pem, and a
// filename-only check waves it through. The PEM header a private key opens
// with is the signature searched for. The pattern does not trip on its own
// source: the bracket-expression characters between the two anchors are not
// in the character class, so this file is not a match for it.
const PRIVATE_KEY_HEADER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
test("no private key is committed anywhere in the tree", () => {
const root = path.join(__dirname, "..");
const tracked = require("child_process")
.execSync("git ls-files", {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
})
.execSync("git ls-files", { cwd: root, encoding: "utf8" })
.split("\n")
.filter(Boolean);
expect(tracked.filter((f) => /\.(pem|key|p12|pfx)$/i.test(f))).toEqual(
[],
const offenders = tracked.filter((f) =>
PRIVATE_KEY_HEADER.test(
fs.readFileSync(path.join(root, f), "latin1"),
),
);
expect(offenders).toEqual([]);
});
});
+3 -2
View File
@@ -253,8 +253,9 @@ describe("the ERC-20 approval line reaches its refusal", () => {
test("a scale the explorer did report still formats", async () => {
await fetchOnto([row({ decimals: "6" })]);
// The same explorer entry now also names the token (issue #323).
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
"1000.0000",
"1000.0000 NOVEL",
);
});
});
@@ -273,7 +274,7 @@ describe("the swap approval line reaches its refusal", () => {
await fetchOnto([row({ decimals: "6" })]);
expect(
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
).toBe("1000.0000");
).toBe("1000.0000 NOVEL");
});
});
+156
View File
@@ -0,0 +1,156 @@
// The symbol the dApp approval and status screens label a token with.
//
// Issue #323: the approval screen labelled anything outside the bundled list
// `Unknown token`, even a token the user tracks or holds a balance of, while
// the amount line already read that token's *scale* from those same sources
// (issue #306). The name and the scale disagreed about which sources they
// trust. resolveTokenSymbol() closes that gap: it draws the symbol from the
// bundled list, then the tracked tokens, then the explorer's report — the
// precedence resolveTokenDecimals() uses — and returns null, not a guess,
// when nothing names it, so the screens keep saying `Unknown token`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it
// stays subject to the spoof rule (src/shared/symbolSpoof.js): resolving a
// symbol must not become a new way for a stray contract to wear a bundled or
// native ticker.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { resolveTokenSymbol } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const iface = new Interface(ERC20_ABI);
// Outside the bundled list, as the great majority of ERC-20s are.
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list: USDC at 6 decimals, DAI at 18.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const FIVE_THOUSAND_AT_SIX = 5000000000n;
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
// A wallet whose block-explorer balance for `token` reports `symbol`, shaped
// as balances.js writes it onto state.
function walletsReporting(token, symbol) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol,
decimals: 6,
balance: "5000.0",
},
],
},
],
},
];
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("resolveTokenSymbol", () => {
test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("reads a token the user tracks", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "NOVEL" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
});
test("reads the symbol the explorer reported", () => {
state.wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
});
test("is null when no source names the token", () => {
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("refuses a name the explorer's own entries disagree about", () => {
const wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
wallets[0].addresses.push({
address: "0x" + "b".repeat(40),
balance: "0.0",
tokenBalances: [{ address: NOVEL_TOKEN, symbol: "OTHER" }],
});
state.wallets = wallets;
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects an explorer entry claiming the native ETH ticker", () => {
state.wallets = walletsReporting(NOVEL_TOKEN, "ETH");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
});
describe("decodeCalldata symbol", () => {
test("a tracked token is named, not called Unknown", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer NOVEL");
const amount = decoded.details.find((d) => d.label === "Amount");
expect(amount.value).toBe("5000.0000 NOVEL");
});
test("a token nothing knows keeps a symbol-less label", () => {
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer ERC-20 token");
const token = decoded.details.find((d) => d.label === "Token");
// The Token line carries the address and is flagged for the screen's
// symbol lookup, which resolves to nothing here — so `Unknown token`.
expect(token.isToken).toBe(true);
expect(token.address).toBe(NOVEL_TOKEN);
expect(resolveTokenSymbol(token.address, state)).toBeNull();
});
test("a tracked token spoofing a bundled ticker is not named by it", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "USDC", decimals: 6 },
];
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer ERC-20 token");
const amount = decoded.details.find((d) => d.label === "Amount");
expect(amount.value).not.toMatch(/USDC/);
});
});
+8 -3
View File
@@ -78,15 +78,20 @@ describe("a swap to a token absent from the bundled list", () => {
);
});
test("names the address when the scale is known but the symbol is not", () => {
test("names the tracked symbol alongside the address (issue #323)", () => {
// The tracked entry supplies both halves now: the scale, and the
// symbol the output line is named by. Before #323 the symbol was read
// from the bundled list alone, so this line fell back to the address.
const sources = {
trackedTokens: [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
],
};
expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT);
expect(detail(data(), "Token Out", sources).value).toBe(
"NOVEL (" + NOVEL_OUT + ")",
);
expect(detail(data(), "Min. received", sources).value).toBe(
"1000.0000",
"1000.0000 NOVEL",
);
});
});
+4 -3
View File
@@ -98,12 +98,13 @@ describe("a swap of a token outside the bundled list", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
];
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
// The tracked entry names the token as well as scaling it (issue #323).
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
});
test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
});
test("refuses to format when nothing knows the scale", () => {
@@ -140,7 +141,7 @@ describe("the Min. received line takes the same rule", () => {
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
];
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL");
});
});
+8
View File
@@ -27,6 +27,14 @@ describe("generateMnemonic in a release build", () => {
expect(constants.DEBUG).toBe(false);
});
test("the test phrase folds away when DEBUG is false", () => {
// The release bundle is what must not carry the phrase; here, with the
// define absent, DEBUG_MNEMONIC is the null branch the bundler keeps,
// and the literal only exists in the branch it drops.
const { constants } = loadWallet();
expect(constants.DEBUG_MNEMONIC).toBeNull();
});
test("returns fresh, valid 12-word phrases that are not the test phrase", () => {
const { constants, wallet } = loadWallet();