fix: Send refuses every amount the confirmation screen would refuse (closes #532)
Send accepted any amount parseFloat() read as above zero, so "1e-3", "1abc" and an amount with a nonzero digit past the token's decimal places reached ConfirmTx, which then refused them. isValidAmount() in src/shared/txValidation.js is now the one check: Review and Max on Send call it with the token's decimal places, and validateTransfer() calls it for ConfirmTx, which now also refuses a zero amount and a token amount with a nonzero digit past the token's places. Zeros past those places are accepted, since the amount encoded for the transfer is the same. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// The Send screen refuses every amount the confirmation screen would refuse,
|
||||
// by the same check (https://git.eeqj.de/sneak/AutistMask/issues/532).
|
||||
//
|
||||
// Send used to accept anything parseFloat() read as above zero, so "1e-3",
|
||||
// "1abc" and an amount with more decimal places than the token has went on to
|
||||
// a confirmation screen that then refused them. Both screens now call
|
||||
// isValidAmount() in src/shared/txValidation.js.
|
||||
//
|
||||
// Driven through the real Send review handler and the real confirmation
|
||||
// screen, with only the node and the DOM stubbed.
|
||||
|
||||
"use strict";
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
// What the confirmation screen asks the node for.
|
||||
const mockProvider = {
|
||||
getFeeData: async () => ({
|
||||
maxFeePerGas: 2000000000n,
|
||||
gasPrice: 1000000000n,
|
||||
}),
|
||||
estimateGas: async () => 21000n,
|
||||
getCode: async () => "0x",
|
||||
getTransactionCount: async () => 1,
|
||||
getBalance: async () => 0n,
|
||||
};
|
||||
|
||||
jest.mock("../src/shared/balances", () => {
|
||||
const actual = jest.requireActual("../src/shared/balances");
|
||||
return { ...actual, getProvider: () => mockProvider };
|
||||
});
|
||||
|
||||
// The confirmation screen's Etherscan label lookup fails, as it does offline.
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
// A stub DOM: every id resolves to a recording element.
|
||||
const elements = new Map();
|
||||
|
||||
function makeEl(id) {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
toggle() {},
|
||||
contains: () => false,
|
||||
},
|
||||
handlers,
|
||||
addEventListener(name, fn) {
|
||||
handlers.set(name, fn);
|
||||
},
|
||||
appendChild(child) {
|
||||
return child;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
remove() {},
|
||||
focus() {},
|
||||
};
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: (tag) => makeEl(tag),
|
||||
body: { prepend() {}, appendChild() {} },
|
||||
addEventListener() {},
|
||||
};
|
||||
global.navigator = { clipboard: { writeText() {} } };
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const send = require("../src/popup/views/send");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
|
||||
const HOLDER = "0x" + "a".repeat(40);
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
// A token with 6 decimal places, as USDC has.
|
||||
const TOKEN = "0x" + "d".repeat(40);
|
||||
const TOKEN_BY_SYMBOL = { ETH: "ETH", TOK: TOKEN };
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
// Press Review on the Send screen with `amount` entered, for a send of `token`
|
||||
// ("ETH" or TOKEN), and return what it handed the confirmation screen, or null
|
||||
// when it handed nothing over.
|
||||
async function review(amount, token) {
|
||||
let handed = null;
|
||||
send.init({ showConfirmTx: (info) => (handed = info) });
|
||||
state.selectedToken = token;
|
||||
el("send-to").value = RECIPIENT;
|
||||
el("send-amount").value = amount;
|
||||
await el("btn-send-review").handlers.get("click")();
|
||||
return handed;
|
||||
}
|
||||
|
||||
// Show the confirmation screen for the send Review hands over for 0.5, with
|
||||
// `amount` in its place, and let its fee estimate settle.
|
||||
async function confirm(amount, token) {
|
||||
const txInfo = await review("0.5", token);
|
||||
confirmTx.show({ ...txInfo, amount });
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [
|
||||
{
|
||||
address: HOLDER,
|
||||
balance: "1.0",
|
||||
tokenBalances: [
|
||||
{
|
||||
address: TOKEN,
|
||||
name: "Token",
|
||||
symbol: "TOK",
|
||||
decimals: 6,
|
||||
balance: "250.0",
|
||||
holders: 50000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.trackedTokens = [
|
||||
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 6 },
|
||||
];
|
||||
state.fraudContracts = [];
|
||||
state.selectedToken = null;
|
||||
state.currentView = null;
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["1e-3", "ETH"],
|
||||
["1e-3", "TOK"],
|
||||
["1abc", "ETH"],
|
||||
// 19 decimal places; ETH has 18.
|
||||
["0.0000000000000000001", "ETH"],
|
||||
// 7 decimal places; the token has 6.
|
||||
["0.0000001", "TOK"],
|
||||
["0", "ETH"],
|
||||
["-1", "ETH"],
|
||||
])("%s %s", (amount, symbol) => {
|
||||
const token = TOKEN_BY_SYMBOL[symbol];
|
||||
|
||||
test("Send refuses it with a flash message and opens nothing", async () => {
|
||||
expect(await review(amount, token)).toBe(null);
|
||||
expect(el("flash-msg").textContent).toBe(
|
||||
"Please enter a valid amount.",
|
||||
);
|
||||
});
|
||||
|
||||
test("the confirmation screen refuses it too", async () => {
|
||||
await confirm(amount, token);
|
||||
expect(el("confirm-errors").innerHTML).toContain(
|
||||
"Please enter a valid amount to send.",
|
||||
);
|
||||
expect(el("btn-confirm-send").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["0.5", "ETH"],
|
||||
["0.5", "TOK"],
|
||||
// Exactly the 6 decimal places the token has.
|
||||
["0.000001", "TOK"],
|
||||
// Only zeros past the token's 6 places, and past ETH's 18.
|
||||
["1.0000000", "TOK"],
|
||||
["0.5000000000000000000", "ETH"],
|
||||
])("%s %s", (amount, symbol) => {
|
||||
const token = TOKEN_BY_SYMBOL[symbol];
|
||||
|
||||
test("Send hands it to the confirmation screen", async () => {
|
||||
expect((await review(amount, token)).amount).toBe(amount);
|
||||
expect(el("flash-msg").textContent).toBe("");
|
||||
});
|
||||
|
||||
test("the confirmation screen lets it be sent", async () => {
|
||||
await confirm(amount, token);
|
||||
expect(el("confirm-errors").innerHTML).toBe("");
|
||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user