Files
AutistMask/tests/approvalDisplayFloor.test.js
T
clawbot ab86707a77
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: the approval screen names the address a swap's output is paid to (closes #594)
The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now collects the address that every
swap step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays, across the
whole transaction, leaving out payments to the router itself. The minimum
reads Min. received only when some step pays the sender, none pays another
address, and no step that may pay an address went unread: a recipient that
does not decode, or a command or V4 action the decoder does not read.
Otherwise it reads Min. paid out, with a Paid to line for each other address
and one saying when who is paid is unknown. The approval and transaction
detail screens pass the sender in.

Model: opus-5-5
2026-10-19 06:58:59 +00:00

198 lines
7.5 KiB
JavaScript

// The floor of the approval screen's amount line.
//
// Amounts are truncated to four decimal places for scannability (README.md,
// Display Consistency). With the token's true scale resolved, that truncation
// can still take a real amount below the floor and print it as `0.0000`: one
// base unit of an 18-decimal token, or a few hundred of an 8-decimal one. On
// the one screen whose job is to state what is being authorized, a nonzero
// transfer or allowance then reads as nothing.
//
// The invariant asserted here is narrow: a nonzero amount never renders as
// zero. The four-decimal rule itself is unchanged, and the string the
// confirmation screens carry as `txInfo.amount` is the same one, so it is
// asserted on `rawValue` alongside the displayed line.
//
// Both amount paths of that screen are covered: the ERC-20 line decoded by
// `src/popup/views/approval.js`, and the swap's `Amount` and `Min. received`
// lines decoded by `src/shared/uniswap.js`.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { decodeCalldata } = require("../src/popup/views/approval");
const uniswap = require("../src/shared/uniswap");
const {
truncateAmount,
truncateAmountNeverZero,
} = require("../src/shared/amountDisplay");
const iface = new Interface(ERC20_ABI);
// Bundled tokens, so the scale and the symbol both come from the list.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // 6 decimals
const WBT = "0x925206b8a707096Ed26ae47C84747fE0bb734F59"; // 8 decimals
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; // 18 decimals
// Outside the list, so the scale comes from what the user tracks and the line
// carries no symbol.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// The Uniswap swap lines land on this same approval screen.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; // 6 decimals
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; // 18 decimals
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of USDT for at
// least `amountOutMin` of WETH.
function swapData(amountIn, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [USDT, WETH], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
function swapDetail(amountIn, amountOutMin, label) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
swapData(amountIn, amountOutMin),
ROUTER,
"0x1",
{},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label);
}
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return iface.encodeFunctionData("approve", [SPENDER, amount]);
}
// The Amount detail as the approval screen renders it: `value` is the line on
// the screen, `rawValue` is what is carried to the wait/success/error screens.
function amount(data, token) {
const decoded = decodeCalldata(data, token);
return decoded.details.find((d) => d.label === "Amount");
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a nonzero amount never renders as zero", () => {
test("500 base units of a 6-decimal token", () => {
const detail = amount(transferData(500n), USDC);
expect(detail.value).toBe("0.0005 USDC");
expect(detail.rawValue).toBe("0.0005");
});
test("1 base unit of an 18-decimal token", () => {
const detail = amount(transferData(1n), DAI);
expect(detail.value).toBe("0.000000000000000001 DAI");
expect(detail.rawValue).toBe("0.000000000000000001");
});
test("500 base units of an 8-decimal token", () => {
expect(amount(transferData(500n), WBT).rawValue).toBe("0.000005");
});
test("an allowance below the floor is not rendered as zero either", () => {
expect(amount(approveData(1n), DAI).value).toBe(
"0.000000000000000001 DAI",
);
});
// The floor holds at any scale, not only the three above: for every
// decimals a token can declare, one base unit has to show a digit.
test("one base unit shows a significant digit at every scale", () => {
for (let decimals = 0; decimals <= 30; decimals++) {
state.trackedTokens = [{ address: NOVEL, decimals }];
expect(amount(transferData(1n), NOVEL).rawValue).toMatch(/[1-9]/);
}
});
});
// The swap decoder formats its own amounts, so the same floor has to hold on
// the swap lines of the same screen. `Min. received` is the sharper of the
// two: the slippage floor rendered as `0.0000` states that the swap may return
// nothing.
describe("a swap's amounts never render as zero either", () => {
test("a swap input below the floor keeps a significant digit", () => {
// 50 base units of a 6-decimal token is 0.00005.
const detail = swapDetail(50n, 10n ** 15n, "Amount");
expect(detail.value).toBe("0.00005 USDT");
expect(detail.rawValue).toBe("0.00005");
});
test("a min-received below the floor keeps a significant digit", () => {
// 1 wei of an 18-decimal token.
expect(swapDetail(10n ** 6n, 1n, "Min. received").value).toBe(
"0.000000000000000001 WETH",
);
});
test("swap amounts at or above the floor are still truncated", () => {
expect(swapDetail(1000000n, 10n ** 15n, "Amount").rawValue).toBe(
"1.0000",
);
expect(
swapDetail(1000000n, 999999999999999999n, "Min. received").value,
).toBe("0.9999 WETH");
});
});
describe("the four-decimal rule is otherwise unchanged", () => {
test("a whole amount keeps exactly four decimals", () => {
expect(amount(transferData(5000000000n), USDC).rawValue).toBe(
"5000.0000",
);
});
test("precision beyond four decimals is still truncated", () => {
expect(amount(transferData(1234567890123456789n), DAI).rawValue).toBe(
"1.2345",
);
});
test("an amount at the floor is not extended", () => {
expect(amount(transferData(100000000000000n), DAI).rawValue).toBe(
"0.0001",
);
});
test("a genuine zero still renders as zero", () => {
expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000");
});
// The three truncators now share one module. The floor is a policy of the
// approval and confirmation screens only: the history and balance lists
// keep plain truncation, because the transaction detail view is the
// authoritative record and already shows exact precision.
test("the list rule stays unfloored", () => {
expect(truncateAmount("0.000000000000000001")).toBe("0.0000");
expect(truncateAmountNeverZero("0.000000000000000001")).toBe(
"0.000000000000000001",
);
});
});