// The base-unit amount an ERC-20 transfer from the wallet's own Send screen is // encoded with. // // A token amount is a decimal string plus a scale, and the two come from // different places. The confirmation screen renders the amount, the balance and // the symbol from the block explorer's cached metadata (see // fetchTokenBalances() in balances.js); the transfer used to be encoded from // decimals() read off the contract at signing time, and nothing compared the // two. A token whose on-chain scale differs from the cached one — an // upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer // entry — therefore signed an amount that was never displayed, off by a power // of ten for every decimal place of disagreement. // // So the scale used to encode is the scale the screen rendered with, carried // forward on the pending transaction, and the contract's own answer is read // only to be compared with it. A disagreement is a refusal, never a preference // for either number: the wallet cannot tell which of the two the user meant, // and both candidate transfers move an amount nobody approved. // // This is the confirmTx counterpart to approvalVerify.js, which does the same // job for the dApp approval path, and it takes the same stance: a quantity that // cannot be compared with what was displayed has not been checked, so an absent // or unusable value is refused rather than filled in. // // Every message here is shown to the user on the transaction error screen, so // each is a full sentence and names the numbers it is refusing over. const { parseUnits } = require("ethers"); // Solidity's decimals() returns a uint8, so anything outside that range is not // an answer this wallet can use. const MAX_DECIMALS = 255; const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE = "The transfer was not sent, because the number of decimal places this" + " amount was shown with is unknown, so the amount that would be signed" + " cannot be shown to be the amount that was displayed."; const UNREADABLE_CONTRACT_DECIMALS_MESSAGE = "The transfer was not sent, because the token contract did not report a" + " usable number of decimal places, so the amount that would be signed" + " cannot be checked against the amount that was displayed."; function mismatchMessage(displayed, onChain) { return ( "The transfer was not sent. The token contract reports " + onChain + " decimal places, but the amount was displayed using " + displayed + ", so signing it would move a different amount than the one shown." + " Reopen the wallet to reload this token's details and try again." ); } // A decimals value from either source as a number, or null if it is not one. // decimals() comes back from ethers as a bigint and the explorer's copy arrives // as a string, so both of those are accepted alongside a plain number; anything // fractional, negative, out of uint8 range, or of any other type at all is not. // // The types are enumerated rather than coerced because Number() is far too // willing: Number([]) is 0 and Number(true) is 1, so a coercing check would // admit an empty array as a scale of zero and encode a whole-token transfer // against it. function toDecimals(value) { let n; if (typeof value === "number") { n = value; } else if (typeof value === "bigint") { if (value < 0n || value > BigInt(MAX_DECIMALS)) return null; n = Number(value); } else if (typeof value === "string") { if (!/^[0-9]+$/.test(value)) return null; n = Number(value); } else { return null; } if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null; return n; } // The decimals the confirmation screen rendered an amount with, as a number. // Throws when the pending transaction does not carry a usable one — which is // also what keeps the gas estimate from quietly estimating a different transfer // than the one that would be signed. function displayedDecimals(value) { const displayed = toDecimals(value); if (displayed === null) { throw new Error(UNKNOWN_DISPLAYED_DECIMALS_MESSAGE); } return displayed; } // The transfer amount in the token's base units, or a throw. `amount` is the // decimal string the user typed and the screen displayed, `displayed` is the // scale it was displayed at, and `onChain` is what the contract's decimals() // answered at signing time. The two scales must agree. function transferAmountUnits(amount, displayed, onChain) { const shown = displayedDecimals(displayed); const reported = toDecimals(onChain); if (reported === null) { throw new Error(UNREADABLE_CONTRACT_DECIMALS_MESSAGE); } if (reported !== shown) { throw new Error(mismatchMessage(shown, reported)); } return parseUnits(String(amount), shown); } module.exports = { displayedDecimals, transferAmountUnits, mismatchMessage, MAX_DECIMALS, UNKNOWN_DISPLAYED_DECIMALS_MESSAGE, UNREADABLE_CONTRACT_DECIMALS_MESSAGE, };