fix: sign the ERC-20 amount the send screen displayed (closes #305)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 23s

The wallet's own Send screen renders the amount, the balance and the symbol
from the block explorer's cached decimals, but confirmTx encoded the transfer
from decimals() read off the contract at signing time and nothing compared the
two. A token whose on-chain scale disagrees with the cached one -- an
upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
entry, a compromised Blockscout -- therefore signed an amount that was never
displayed, off by a power of ten for every decimal place of disagreement. The
reproduction on the issue approves 0.25 and signs 250,000,000,000.

The scale is now carried forward on the pending transaction, taken from the
same tokenBalances entry the screen's own numbers come from, and the contract's
decimals() is read at signing time only to be compared with it. A disagreement
is a refusal that names both numbers, never a preference for either: both
candidate transfers move an amount nobody approved. New
src/shared/transferAmount.js holds that check, as the confirmTx counterpart to
approvalVerify.js, and takes the same stance on an absent or unusable value --
a quantity that cannot be compared with what was displayed has not been
checked. The gas estimate encodes from the same carried value and no longer
reads decimals() at all, so the estimate is for the transfer that would be
signed.

Nothing in the e2e suite had ever clicked #btn-confirm-send, so the popup's own
Send -> ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, which is
how this shipped. It is now driven end to end to a broadcast, with the
transfer() amount hand-decoded out of the raw signed bytes and asserted against
the amount read off the confirmation screen, plus a case where the fixture's
decimals() starts answering 18 after the screen was built and nothing reaches
eth_sendRawTransaction. The fixture gains that override and a receipt, so the
wait screen resolves to the success view instead of polling for the rest of the
run.
This commit is contained in:
2026-08-20 10:14:55 +00:00
parent ff3387d8cf
commit 8fadc4107f
8 changed files with 570 additions and 16 deletions

View File

@@ -221,11 +221,6 @@ const RPC_RESULTS = {
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "not mined yet", which is what a node answers for a transaction it has
// only just accepted. The wait screen the dApp transaction approval hands
// off to polls this every 10 seconds; leaving it unstubbed would report
// the poll as escaping traffic the moment a test outlived one tick.
eth_getTransactionReceipt: null,
};
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
@@ -253,17 +248,22 @@ function latestBlock() {
const SELECTOR_DECIMALS = "0x313ce567";
// Every eth_call still answers with a zero word except decimals() on the
// stub token. ethers reads that before it can encode an ERC-20 transfer,
// and a zero there makes parseUnits() reject any fractional amount — so the
// ERC-20 confirmation path would fail its gas estimate for a reason that
// has nothing to do with what is being tested.
function ethCallResult(req) {
// stub token, which the wallet reads back at signing time to compare with
// the scale the confirmation screen rendered (issue #305).
//
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through
// Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has
// been built — without re-registering anything.
function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(STUB_TOKEN.decimals);
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
}
return ZERO_WORD;
}
@@ -346,6 +346,38 @@ function transactionDetails(hash) {
};
}
// The receipt for a transaction this run broadcast.
//
// eth_getTransactionReceipt otherwise answers null — "not mined yet", which is
// what a node says about a transaction it has only just accepted, and what the
// wait screen has to keep polling through. opts.seedReceipt confirms it
// instead, which is how a test that drives the popup's own send to a broadcast
// gets off the wait screen: the wait resolves to the success view, which has a
// Done button, rather than polling for a receipt for the rest of the suite.
//
// Every field ethers' receipt formatter requires is present. A receipt it
// cannot parse throws inside the poll, which the wallet reports through
// log.errorf — i.e. console.error — and the harness fails the run on, so a
// half-populated fixture here would surface as an unrelated-looking failure.
function transactionReceipt(hash) {
return {
transactionHash: hash,
transactionIndex: "0x0",
blockHash: "0x" + "33".repeat(32),
blockNumber: hex(STUB_BLOCK_NUMBER),
from: STUB_COUNTERPARTY,
to: STUB_TOKEN.address,
cumulativeGasUsed: hex(GAS_LIMIT),
gasUsed: hex(GAS_LIMIT),
effectiveGasPrice: hex(GAS_PRICE_WEI),
contractAddress: null,
logs: [],
logsBloom: "0x" + "00".repeat(256),
status: "0x1",
type: "0x2",
};
}
function jsonResponse(route, body) {
return route.fulfill({
status: 200,
@@ -408,7 +440,13 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_call") {
return Object.assign(envelope, { result: ethCallResult(req) });
return Object.assign(envelope, { result: ethCallResult(req, opts) });
}
if (req.method === "eth_getTransactionReceipt") {
const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
@@ -555,6 +593,11 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/