The swap decoder read no recipient, so a swap paying its output to another address still showed Min. received. It now reads the address each swap step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays. Min. received shows only when some step pays the sender, none pays another address, none that may pay one went unread, and the output does not stay in the router with no later SWEEP or UNWRAP_WETH paying it out. A payment to the V2 pair the next V2 swap spends as already paid is a hop, not a payout. Otherwise it reads Min. paid out, with Paid to lines. Model: opus-5-5
192 lines
7.6 KiB
JavaScript
192 lines
7.6 KiB
JavaScript
// The scale the swap lines of the dApp approval screen are displayed with.
|
|
//
|
|
// Issue #340: `tokenInfo()` in `src/shared/uniswap.js` returned `decimals: 18`
|
|
// for any token absent from the bundled list — the same guess
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/306 removed from the ERC-20
|
|
// amount line, still live on the swap path. A 1,000-token swap of a 6-decimal
|
|
// token then rendered as `0.000000001` on the one screen whose job is to state
|
|
// what is being authorized, and every newly listed token reaches it.
|
|
//
|
|
// What is asserted here is the rule #306 established: resolve the real scale
|
|
// wherever the wallet already has it, and where nothing has it refuse to
|
|
// format — base units with the scale stated, never a quantity.
|
|
|
|
globalThis.chrome = {
|
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
};
|
|
|
|
const { AbiCoder, Interface } = require("ethers");
|
|
const { state } = require("../src/shared/state");
|
|
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
|
|
const { decodeCalldata } = require("../src/popup/views/approval");
|
|
|
|
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
// Outside the bundled list, as every newly listed token is.
|
|
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
|
|
// Also outside it, standing in for the swap's output side.
|
|
const NOVEL_OUT = "0xd0d0000000000000000000000000000000000d0d";
|
|
// In the bundled list, at 18 decimals.
|
|
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
|
|
|
// 1,000.00 of a 6-decimal token — the amount from the issue, which the 18
|
|
// guess rendered as 0.000000001.
|
|
const THOUSAND_AT_SIX = 1000000000n;
|
|
// 0.5 WETH out, so the Min. received line has a real number of its own.
|
|
const HALF_WETH = 500000000000000000n;
|
|
|
|
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 `tokenIn`
|
|
// for at least `amountOutMin` of `tokenOut`.
|
|
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
|
|
const input = coder.encode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
|
|
);
|
|
return routerIface.encodeFunctionData("execute", [
|
|
"0x08",
|
|
[input],
|
|
9999999999n,
|
|
]);
|
|
}
|
|
|
|
// A wallet holding `token` with the decimals the block explorer reported,
|
|
// shaped as balances.js writes it onto state.
|
|
function walletsHolding(token, decimals) {
|
|
return [
|
|
{
|
|
name: "Wallet 1",
|
|
addresses: [
|
|
{
|
|
address: "0x" + "a".repeat(40),
|
|
balance: "1.0",
|
|
tokenBalances: [
|
|
{
|
|
address: token,
|
|
symbol: "NOVEL",
|
|
decimals,
|
|
balance: "1000.0",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
// The swap detail line as the approval screen renders it. It goes through
|
|
// decodeCalldata() rather than uniswap.decode() directly, because the scale
|
|
// sources the screen supplies are part of what is under test.
|
|
function swapDetail(data, label) {
|
|
// Sent by the address the swap pays, so the output is the user's.
|
|
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
|
|
return decoded.details.find((d) => d.label === label);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.trackedTokens = [];
|
|
state.wallets = [];
|
|
});
|
|
|
|
describe("a swap of a token outside the bundled list", () => {
|
|
const data = () => swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH);
|
|
|
|
test("shows the true quantity when the user tracks the token", () => {
|
|
state.trackedTokens = [
|
|
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
|
|
];
|
|
// The tracked entry names the token as well as scaling it (issue #323).
|
|
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
|
});
|
|
|
|
test("shows the true quantity from the explorer's decimals", () => {
|
|
state.wallets = walletsHolding(NOVEL, "6");
|
|
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
|
});
|
|
|
|
test("refuses to format when nothing knows the scale", () => {
|
|
const detail = swapDetail(data(), "Amount");
|
|
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
|
|
expect(detail.value).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
|
// The defect: an 18-decimal guess renders this swap as 0.000000001, a
|
|
// quantity, and a wrong one.
|
|
expect(detail.value).not.toMatch(/^0\./);
|
|
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
|
|
});
|
|
|
|
test("the amount carried to the status screens is the same refusal", () => {
|
|
expect(swapDetail(data(), "Amount").rawValue).toBe(
|
|
"1000000000 base units (decimals unknown)",
|
|
);
|
|
});
|
|
|
|
test("a bundled token on the other side still formats", () => {
|
|
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
|
|
});
|
|
|
|
// A token added by hand carries whatever its decimals() returned, and a
|
|
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
|
|
// whole swap undecoded rather than refused
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
|
test("refuses to format when the token reports more than 80 decimals", () => {
|
|
state.trackedTokens = [
|
|
{ address: NOVEL, symbol: "NOVEL", decimals: 81 },
|
|
];
|
|
expect(swapDetail(data(), "Amount").value).toBe(
|
|
"1000000000 base units (decimals unknown)",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the Min. received line takes the same rule", () => {
|
|
test("refuses to format an output token of unknown scale", () => {
|
|
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
|
|
const detail = swapDetail(data, "Min. received");
|
|
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
|
|
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
|
|
});
|
|
|
|
test("shows the true quantity when the user tracks the output token", () => {
|
|
state.trackedTokens = [
|
|
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
|
|
];
|
|
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
|
|
expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL");
|
|
});
|
|
});
|
|
|
|
describe("the permit amount takes the same rule", () => {
|
|
// PERMIT2_PERMIT (command 0x0a): the input token and amount come from the
|
|
// permit rather than from a swap step.
|
|
function permitData(token, amount) {
|
|
const input = coder.encode(
|
|
[
|
|
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
|
"bytes",
|
|
],
|
|
[[[token, amount, 0, 0], ROUTER, 9999999999], "0x1234"],
|
|
);
|
|
return routerIface.encodeFunctionData("execute", [
|
|
"0x0a",
|
|
[input],
|
|
9999999999n,
|
|
]);
|
|
}
|
|
|
|
test("refuses to format a permit on a token of unknown scale", () => {
|
|
const detail = swapDetail(permitData(NOVEL, THOUSAND_AT_SIX), "Amount");
|
|
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
|
|
});
|
|
|
|
test("an unbounded permit is still named, with or without a scale", () => {
|
|
const maxUint160 = (1n << 160n) - 1n;
|
|
expect(swapDetail(permitData(NOVEL, maxUint160), "Amount").value).toBe(
|
|
"Unlimited",
|
|
);
|
|
});
|
|
});
|