fix: sign the ERC-20 amount the send screen displayed (closes #305)
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:
211
tests/e2e/run.js
211
tests/e2e/run.js
@@ -12,10 +12,12 @@
|
||||
const {
|
||||
Transaction,
|
||||
formatEther,
|
||||
formatUnits,
|
||||
getAddress,
|
||||
getBytes,
|
||||
hexlify,
|
||||
parseEther,
|
||||
parseUnits,
|
||||
toQuantity,
|
||||
toUtf8Bytes,
|
||||
verifyMessage,
|
||||
@@ -1964,6 +1966,208 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------- the popup's own send, end to end (#305)
|
||||
//
|
||||
// Everything above this point stops at the confirmation screen. Nothing in
|
||||
// the suite had ever clicked #btn-confirm-send, so the wallet's own Send ->
|
||||
// ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, and issue
|
||||
// #305 shipped through the gap: the screen was rendered from the explorer's
|
||||
// decimals while the transfer was encoded from decimals() read off the
|
||||
// contract at signing time, with nothing comparing the two.
|
||||
//
|
||||
// These two tests drive that path to a broadcast and read the amount out of
|
||||
// the bytes the node was handed. The first asserts those bytes against what
|
||||
// the screen displayed; the second makes the contract answer a different
|
||||
// scale after the screen was built, and requires that nothing is broadcast.
|
||||
|
||||
// keccak("transfer(address,uint256)")[0:4].
|
||||
const SELECTOR_TRANSFER = "0xa9059cbb";
|
||||
|
||||
// What decimals() starts answering once the confirmation screen has been
|
||||
// built. The explorer reports 6 for the same token, so a wallet that encodes
|
||||
// from the contract signs 10^12 times the amount it displayed.
|
||||
const LYING_DECIMALS = "18";
|
||||
|
||||
const TOKEN_DECIMALS = Number(STUB_TOKEN.decimals);
|
||||
|
||||
// The transfer() call inside a raw signed transaction, hand-decoded.
|
||||
//
|
||||
// Deliberately not run through an ethers Interface built from the
|
||||
// extension's own ABI: what is under assertion is the bytes that reached the
|
||||
// node, and the fewer assumptions the wallet and the assertion share, the
|
||||
// less room there is for both to be wrong in the same direction.
|
||||
function decodeTransfer(rawSignedTx) {
|
||||
const signed = Transaction.from(rawSignedTx);
|
||||
const data = signed.data.toLowerCase();
|
||||
assert(
|
||||
data.startsWith(SELECTOR_TRANSFER) && data.length === 10 + 128,
|
||||
"the broadcast transaction is not an ERC-20 transfer() call: " + data,
|
||||
);
|
||||
return {
|
||||
signed,
|
||||
recipient: getAddress("0x" + data.slice(34, 74)),
|
||||
rawAmount: BigInt("0x" + data.slice(74)),
|
||||
};
|
||||
}
|
||||
|
||||
// The amount the confirmation screen is showing, verbatim.
|
||||
async function shownAmount(page) {
|
||||
return (await page.locator("#confirm-amount").innerText()).trim();
|
||||
}
|
||||
|
||||
async function fillPasswordAndSend(page) {
|
||||
await page.fill("#confirm-tx-password", PASSWORD);
|
||||
await page.click("#btn-confirm-send");
|
||||
}
|
||||
|
||||
async function goToTokenConfirm(env) {
|
||||
await goToConfirm(env.page, {
|
||||
token: STUB_TOKEN.address,
|
||||
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
|
||||
amount: TOKEN_AMOUNT,
|
||||
});
|
||||
await waitForEstimate(env.page);
|
||||
const shown = await shownAmount(env.page);
|
||||
assert(
|
||||
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
|
||||
"the confirmation screen is not showing the amount that was entered: " +
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
return shown;
|
||||
}
|
||||
|
||||
test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", async (env) => {
|
||||
// The previous test left the ETH balance at the fee-only fixture, which
|
||||
// blocks sending outright; this one has to be able to press Send.
|
||||
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
|
||||
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
|
||||
const shown = await goToTokenConfirm(env);
|
||||
|
||||
const before = env.routeOpts.broadcastTransactions.length;
|
||||
// Confirm the transaction once it is broadcast, so the wait screen
|
||||
// resolves to the success view instead of polling for the rest of the run.
|
||||
env.routeOpts.seedReceipt = true;
|
||||
await fillPasswordAndSend(env.page);
|
||||
await visible(env.page, "#view-wait-tx", 60000);
|
||||
|
||||
const broadcast = env.routeOpts.broadcastTransactions;
|
||||
assert(
|
||||
broadcast.length === before + 1,
|
||||
"expected exactly one raw transaction to reach the RPC, got " +
|
||||
(broadcast.length - before),
|
||||
);
|
||||
const { signed, recipient, rawAmount } = decodeTransfer(
|
||||
broadcast[broadcast.length - 1],
|
||||
);
|
||||
|
||||
// The measurement, printed on every run: the amount the user read, and
|
||||
// what the signed bytes mean at each of the two candidate scales. Under
|
||||
// the defect these three lines disagree.
|
||||
console.log(
|
||||
"# erc-20 send artifact: displayed=" +
|
||||
JSON.stringify(shown) +
|
||||
" rawAmount=" +
|
||||
rawAmount +
|
||||
" asIf" +
|
||||
TOKEN_DECIMALS +
|
||||
"Decimals=" +
|
||||
formatUnits(rawAmount, TOKEN_DECIMALS) +
|
||||
" asIf" +
|
||||
LYING_DECIMALS +
|
||||
"Decimals=" +
|
||||
formatUnits(rawAmount, Number(LYING_DECIMALS)),
|
||||
);
|
||||
|
||||
assert(
|
||||
getAddress(signed.to) === getAddress(STUB_TOKEN.address),
|
||||
"the broadcast transaction does not call the token contract: " +
|
||||
signed.to,
|
||||
);
|
||||
assert(
|
||||
recipient === getAddress(STUB_COUNTERPARTY),
|
||||
"the broadcast transfer goes to " + recipient,
|
||||
);
|
||||
// What the whole issue turns on: the signed amount, read back at the
|
||||
// scale the SCREEN rendered with, is the number the screen rendered.
|
||||
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
|
||||
assert(
|
||||
rawAmount === wanted,
|
||||
"the broadcast transfer moves " +
|
||||
rawAmount +
|
||||
" base units, which is " +
|
||||
formatUnits(rawAmount, TOKEN_DECIMALS) +
|
||||
" " +
|
||||
STUB_TOKEN.symbol +
|
||||
" at the scale the confirmation screen displayed — but the screen" +
|
||||
" displayed " +
|
||||
JSON.stringify(shown) +
|
||||
", i.e. " +
|
||||
wanted +
|
||||
" base units (#305)",
|
||||
);
|
||||
|
||||
const summary = (
|
||||
await env.page.locator("#wait-tx-summary").innerText()
|
||||
).trim();
|
||||
assert(
|
||||
summary === shown,
|
||||
"the wait screen summarises the send as " +
|
||||
JSON.stringify(summary) +
|
||||
", not as the approved " +
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
|
||||
await visible(env.page, "#view-success-tx", 60000);
|
||||
await env.page.click("#btn-success-tx-done");
|
||||
await visible(env.page, "#view-address");
|
||||
env.routeOpts.seedReceipt = false;
|
||||
});
|
||||
|
||||
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
|
||||
const shown = await goToTokenConfirm(env);
|
||||
|
||||
// Only now, with the screen already built and its estimate already taken
|
||||
// at the explorer's scale, does the contract start answering differently.
|
||||
// This is the whole shape of the defect: a value read at signing time that
|
||||
// nothing on screen was ever derived from.
|
||||
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
|
||||
const before = env.routeOpts.broadcastTransactions.length;
|
||||
await fillPasswordAndSend(env.page);
|
||||
await visible(env.page, "#view-error-tx", 60000);
|
||||
env.routeOpts.tokenDecimalsOverride = null;
|
||||
|
||||
assert(
|
||||
env.routeOpts.broadcastTransactions.length === before,
|
||||
"a transfer encoded against a contract that contradicts the " +
|
||||
"confirmation screen still reached the RPC (#305)",
|
||||
);
|
||||
|
||||
const message = (
|
||||
await env.page.locator("#error-tx-message").innerText()
|
||||
).trim();
|
||||
console.log(
|
||||
"# erc-20 decimals refusal: displayed=" +
|
||||
JSON.stringify(shown) +
|
||||
" contract=" +
|
||||
LYING_DECIMALS +
|
||||
" message=" +
|
||||
JSON.stringify(message),
|
||||
);
|
||||
assert(
|
||||
message.includes("reports " + LYING_DECIMALS + " decimal places") &&
|
||||
message.includes("displayed using " + STUB_TOKEN.decimals),
|
||||
"the refusal does not name both scales it is refusing over: " +
|
||||
JSON.stringify(message),
|
||||
);
|
||||
assert(
|
||||
/^[A-Z].*\.$/s.test(message),
|
||||
"the refusal is not a full sentence: " + JSON.stringify(message),
|
||||
);
|
||||
|
||||
await env.page.click("#btn-error-tx-done");
|
||||
await visible(env.page, "#view-address");
|
||||
});
|
||||
|
||||
// ------------------------------------------- dApp round trips (#183)
|
||||
//
|
||||
// The seam. Everything above drives the popup on its own; this section is
|
||||
@@ -3094,6 +3298,13 @@ async function main() {
|
||||
ethBalanceWei: null,
|
||||
failGasEstimate: false,
|
||||
holdGasEstimate: false,
|
||||
// What decimals() answers for the stub token, when it is to answer
|
||||
// something other than the value the same fixture reports through
|
||||
// Blockscout. The token that lies about its scale (#305).
|
||||
tokenDecimalsOverride: null,
|
||||
// Whether eth_getTransactionReceipt confirms a transaction rather than
|
||||
// answering "not mined yet".
|
||||
seedReceipt: false,
|
||||
// Every raw signed transaction handed to eth_sendRawTransaction, in
|
||||
// order. The dApp transaction round trip asserts against these bytes
|
||||
// rather than against anything the extension reported about them.
|
||||
|
||||
Reference in New Issue
Block a user