fix: sign the ERC-20 amount the confirmation screen displayed (closes #305)
The send screen was built from the indexer's decimals while the transfer was encoded from the contract's decimals() read at signing time, with nothing comparing them. A token whose scales disagree moved 10^12 times the approved amount. The displayed scale is now carried on pendingTx from the same tokenBalances entry the amount, balance and symbol were rendered from, and both encode sites use it. transferAmount.js refuses rather than falling back when the two scales disagree or either is unusable. Adds the first end-to-end coverage of the popup's own Send -> ConfirmTx -> Sign & Send path; #btn-confirm-send had never been clicked by any test.
This commit was merged in pull request #314.
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