// 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 { 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); }); test("refuses anything that is not a uint8", () => { 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(/\.$/); } }); });