fix: never render a nonzero approval amount as zero (closes #322)
An amount below the 4-decimal display floor now extends to its first significant digit on the approval and confirmation screens, instead of stating a real transfer, allowance or swap Min. received as 0.0000. The rule had been implemented three times; all three now share src/shared/amountDisplay.js, which holds the plain truncation and the floored variant side by side. History and balance lists keep the unfloored rule, pinned by test.
This commit was merged in pull request #339.
This commit is contained in:
190
tests/approvalDisplayFloor.test.js
Normal file
190
tests/approvalDisplayFloor.test.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// 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) {
|
||||
const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER);
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user