// 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", ); }); });