toDecimals() accepted any uint8 scale, but formatUnits() and parseUnits() refuse more than 80 decimal places. A token reporting 81 to 255 made the formatter throw, and the catch in the swap decoder and in the ERC-20 decoder turned that into an undecoded approval screen with nothing saying why. MAX_DECIMALS is now 80, the formatter's own limit, so such a scale is treated exactly like an unknown one: both approval paths show the base-unit amount with the scale stated as unknown. The balance list, the history list and the Send screen use the same check. Model: opus-5-5
139 lines
5.1 KiB
JavaScript
139 lines
5.1 KiB
JavaScript
// The scale an ERC-20 transfer from the wallet's own Send screen is encoded
|
|
// with (issue #305). The screen renders from the block explorer's cached
|
|
// decimals; the transfer used to be encoded from decimals() read off the
|
|
// contract at signing time, with nothing comparing the two, so a token whose
|
|
// on-chain scale differed signed an amount that was never displayed.
|
|
|
|
const { formatUnits, parseUnits } = require("ethers");
|
|
const {
|
|
displayedDecimals,
|
|
transferAmountUnits,
|
|
MAX_DECIMALS,
|
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
|
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
|
} = require("../src/shared/transferAmount");
|
|
|
|
describe("displayedDecimals", () => {
|
|
test("accepts what the explorer and the contract each answer with", () => {
|
|
// A string is what fetchTokenBalances() parses out of Blockscout, a
|
|
// number is what it stores, and a bigint is what ethers hands back
|
|
// from a uint8 return.
|
|
expect(displayedDecimals("6")).toBe(6);
|
|
expect(displayedDecimals(6)).toBe(6);
|
|
expect(displayedDecimals(6n)).toBe(6);
|
|
expect(displayedDecimals(0)).toBe(0);
|
|
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
|
|
});
|
|
|
|
// decimals() is a uint8, but formatUnits() and parseUnits() stop at 80
|
|
// places, so a larger scale cannot be shown or encoded
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
|
test("accepts exactly the scales the formatter accepts", () => {
|
|
expect(() => formatUnits(1n, MAX_DECIMALS)).not.toThrow();
|
|
expect(() => parseUnits("1", MAX_DECIMALS)).not.toThrow();
|
|
expect(() => formatUnits(1n, MAX_DECIMALS + 1)).toThrow();
|
|
expect(() => parseUnits("1", MAX_DECIMALS + 1)).toThrow();
|
|
});
|
|
|
|
test("refuses anything that is not a uint8 the formatter accepts", () => {
|
|
for (const bad of [
|
|
null,
|
|
undefined,
|
|
"",
|
|
"eighteen",
|
|
NaN,
|
|
6.5,
|
|
-1,
|
|
MAX_DECIMALS + 1,
|
|
true,
|
|
{},
|
|
[],
|
|
]) {
|
|
expect(() => displayedDecimals(bad)).toThrow(
|
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("transferAmountUnits", () => {
|
|
test("encodes with the displayed scale when the contract agrees", () => {
|
|
expect(transferAmountUnits("0.25", 6, 6n)).toBe(parseUnits("0.25", 6));
|
|
expect(transferAmountUnits("0.25", "6", 6n)).toBe(
|
|
parseUnits("0.25", 6),
|
|
);
|
|
expect(transferAmountUnits("1.5", 18, 18n)).toBe(parseUnits("1.5", 18));
|
|
});
|
|
|
|
// The reproduction on the issue: 0.25 of a token displayed at 6 decimals,
|
|
// signed against a contract answering 18, moves 10^12 times the amount
|
|
// that was approved.
|
|
test("refuses the reproduction rather than signing either amount", () => {
|
|
expect(() => transferAmountUnits("0.25", 6, 18n)).toThrow(
|
|
/contract reports 18 decimal places, but the amount was displayed using 6/,
|
|
);
|
|
});
|
|
|
|
test("refuses a disagreement in the other direction too", () => {
|
|
expect(() => transferAmountUnits("0.25", 18, 6n)).toThrow(
|
|
/contract reports 6 decimal places, but the amount was displayed using 18/,
|
|
);
|
|
});
|
|
|
|
test("never returns the amount at either scale on a disagreement", () => {
|
|
// The point of the refusal: both candidate encodings exist, and the
|
|
// wallet must produce neither.
|
|
let thrown = null;
|
|
try {
|
|
transferAmountUnits("0.25", 6, 18n);
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect(thrown).toBeInstanceOf(Error);
|
|
expect(thrown.message).toMatch(/was not sent/);
|
|
});
|
|
|
|
test("refuses when the screen's scale is unknown", () => {
|
|
expect(() => transferAmountUnits("0.25", null, 6n)).toThrow(
|
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
|
);
|
|
expect(() => transferAmountUnits("0.25", undefined, 6n)).toThrow(
|
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
|
);
|
|
});
|
|
|
|
test("refuses when the contract's answer is not a uint8", () => {
|
|
for (const bad of [null, undefined, "", "eighteen", 6.5, -1, 256]) {
|
|
expect(() => transferAmountUnits("0.25", 6, bad)).toThrow(
|
|
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("rejects an amount finer than the token's scale", () => {
|
|
// parseUnits' own refusal, reached only once the scales agree: a
|
|
// fractional base unit cannot be sent and must not be truncated.
|
|
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
|
|
});
|
|
|
|
test("every refusal is a full sentence", () => {
|
|
const messages = [];
|
|
for (const args of [
|
|
["0.25", 6, 18n],
|
|
["0.25", null, 6n],
|
|
["0.25", 6, "eighteen"],
|
|
]) {
|
|
try {
|
|
transferAmountUnits(...args);
|
|
} catch (e) {
|
|
messages.push(e.message);
|
|
}
|
|
}
|
|
expect(messages).toHaveLength(3);
|
|
for (const m of messages) {
|
|
expect(m).toMatch(/^[A-Z]/);
|
|
expect(m).toMatch(/\.$/);
|
|
}
|
|
});
|
|
});
|