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.
191 lines
7.3 KiB
JavaScript
191 lines
7.3 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) {
|
|
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",
|
|
);
|
|
});
|
|
});
|