Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f1e98323 |
@@ -1468,17 +1468,14 @@ view would leave a wallet one click from deletion.
|
||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||
ConfirmTx's balance line does (see Unknown token scale)
|
||||
- "Max" button beside the amount input, always in place. It fills in the
|
||||
most the selected holding can send: a token's whole balance, cut down to
|
||||
the 18 decimal places ConfirmTx accepts for a token that has more, or for
|
||||
ETH the exact balance minus the network fee reserve that ConfirmTx's
|
||||
balance check gates on, never the rounded balance shown above it. The ETH
|
||||
fee is estimated for the recipient entered, so it asks for a recipient
|
||||
first; an estimate that finishes after the screen was left or the address,
|
||||
holding or recipient changed fills nothing in. Where there is nothing to
|
||||
fill in, a flash message says why: the balance does not cover the fee, the
|
||||
fee could not be estimated, or the token's balance is unknown or zero.
|
||||
Typing in the amount makes it an ordinary amount; changing what to send
|
||||
clears an amount Max filled in
|
||||
most the selected holding can send: a token's whole balance, or for ETH
|
||||
the exact balance minus the network fee reserve that ConfirmTx's balance
|
||||
check gates on, never the rounded balance shown above it. The ETH fee is
|
||||
estimated for the recipient entered, so it asks for a recipient first.
|
||||
Where there is nothing to fill in, a flash message says why: the balance
|
||||
does not cover the fee, the fee could not be estimated, or the token's
|
||||
balance is unknown or zero. Typing in the amount makes it an ordinary
|
||||
amount; changing what to send clears an amount Max filled in
|
||||
- "Review" button, disabled until the recipient validates
|
||||
- **Transitions**:
|
||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||
|
||||
@@ -48,16 +48,14 @@ but the review is broader than any of them.
|
||||
- 2026-10-05: The Send screen has a "Max" button
|
||||
([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH
|
||||
address took guessing an amount and being refused by the confirmation screen's
|
||||
balance check. Max fills in a token's whole balance, cut to the 18 decimal
|
||||
places the confirmation screen accepts, or for ETH the exact balance minus the
|
||||
fee reserve that check gates on, never the four-decimal balance shown; a fee
|
||||
estimate that finishes after the Send screen was left, or its address, holding
|
||||
or recipient changed, fills nothing in. The confirmation screen works a max
|
||||
ETH amount out again from its own fee estimate and signs it with that
|
||||
estimate's fee fields: fetched again at signing, a fee that had risen since
|
||||
would leave amount plus fee above the balance, and the node would refuse the
|
||||
send. A token's maximum is still refused when ETH cannot pay the fee. Where
|
||||
there is nothing to fill in, a flash message says why.
|
||||
balance check. Max fills in a token's whole balance, or for ETH the exact
|
||||
balance minus the fee reserve that check gates on, never the four-decimal
|
||||
balance shown. The confirmation screen works a max ETH amount out again from
|
||||
its own fee estimate and signs it with that estimate's fee fields: fetched
|
||||
again at signing, a fee that had risen since would leave amount plus fee above
|
||||
the balance, and the node would refuse the send. A token's maximum is still
|
||||
refused when ETH cannot pay the fee. Where there is nothing to fill in, a
|
||||
flash message says why.
|
||||
|
||||
- 2026-10-05: A token scale of zero decimals is tested
|
||||
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
|
||||
|
||||
+12
-35
@@ -22,11 +22,7 @@ const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const {
|
||||
feeReserveWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
} = require("../../shared/txValidation");
|
||||
const { feeReserveWei, maxEthAmount } = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const { getAddress, parseEther } = require("ethers");
|
||||
|
||||
@@ -37,10 +33,6 @@ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
// makes it an ordinary amount again.
|
||||
let amountIsMax = false;
|
||||
|
||||
// Counts the times the Send screen has opened, so a Max fee estimate started
|
||||
// before it was last opened fills nothing in.
|
||||
let sendScreenOpenings = 0;
|
||||
|
||||
/**
|
||||
* Validate a destination address string.
|
||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||
@@ -245,10 +237,9 @@ function updateSendBalance() {
|
||||
}
|
||||
|
||||
// Fill the amount field with the most the selected holding can send: a
|
||||
// token's whole balance (cut to 18 decimal places), or for ETH the exact
|
||||
// balance minus the fee reserve the confirmation screen checks against, never
|
||||
// the rounded balance the screen shows. Where there is nothing to fill in, a
|
||||
// flash message says why.
|
||||
// token's whole balance, or for ETH the exact balance minus the fee reserve
|
||||
// the confirmation screen checks against, never the rounded balance the screen
|
||||
// shows. Where there is nothing to fill in, a flash message says why.
|
||||
async function fillMaxAmount() {
|
||||
const addr = currentAddress();
|
||||
if (!addr) return;
|
||||
@@ -258,15 +249,12 @@ async function fillMaxAmount() {
|
||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||
if (bal == null) {
|
||||
showFlash("This token's balance is unknown.");
|
||||
return;
|
||||
}
|
||||
const amount = maxTokenAmount(bal);
|
||||
if (!(parseFloat(amount) > 0)) {
|
||||
} else if (!(parseFloat(bal) > 0)) {
|
||||
showFlash("This token's balance is zero.");
|
||||
return;
|
||||
} else {
|
||||
$("send-amount").value = bal;
|
||||
amountIsMax = true;
|
||||
}
|
||||
$("send-amount").value = amount;
|
||||
amountIsMax = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +266,6 @@ async function fillMaxAmount() {
|
||||
return;
|
||||
}
|
||||
const typed = $("send-amount").value;
|
||||
const opening = sendScreenOpenings;
|
||||
let feeWei = null;
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
@@ -297,19 +284,10 @@ async function fillMaxAmount() {
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
// While the estimate was in flight the user left the screen (and perhaps
|
||||
// opened it again), typed an amount, or changed the address, the holding
|
||||
// or the recipient: what they did wins.
|
||||
if (
|
||||
state.currentView !== "send" ||
|
||||
sendScreenOpenings !== opening ||
|
||||
currentAddress()?.address !== addr.address ||
|
||||
(state.selectedToken || $("send-token").value) !== token ||
|
||||
$("send-to").value.trim() !== to ||
|
||||
$("send-amount").value !== typed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The user typed an amount or chose another holding while the estimate
|
||||
// was in flight: what they did wins.
|
||||
if ($("send-amount").value !== typed) return;
|
||||
if ((state.selectedToken || $("send-token").value) !== token) return;
|
||||
|
||||
if (feeWei === null) {
|
||||
showFlash("The network fee could not be estimated.");
|
||||
@@ -429,7 +407,6 @@ function init(_ctx) {
|
||||
|
||||
// Called each time the Send screen opens, with its fields cleared.
|
||||
function resetSendValidation() {
|
||||
sendScreenOpenings++;
|
||||
amountIsMax = false;
|
||||
const errorEl = $("send-to-error");
|
||||
const btn = $("btn-send-review");
|
||||
|
||||
@@ -102,13 +102,6 @@ function maxEthAmount(ethBalance, feeWei) {
|
||||
return formatEther(amountWei);
|
||||
}
|
||||
|
||||
// The most of a token a send can carry: its balance cut down, never rounded
|
||||
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
|
||||
// declare more than 18 decimals, and its balance is stored with all of them.
|
||||
function maxTokenAmount(tokenBalance) {
|
||||
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
|
||||
}
|
||||
|
||||
// Validate a pending transfer against the balances that must cover it.
|
||||
//
|
||||
// isErc20 — token transfer rather than a native ETH transfer
|
||||
@@ -161,12 +154,13 @@ function validateTransfer({
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
// Only the first 18 places of the balance are read: an amount with
|
||||
// more was refused above, so the places after them cannot decide
|
||||
// whether the amount fits.
|
||||
// A token can declare more than 18 decimals, and its balance is
|
||||
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
|
||||
// are read: an amount with more was refused above, so the places
|
||||
// after them cannot decide whether the amount fits.
|
||||
const tokenText =
|
||||
typeof tokenBalance === "string"
|
||||
? maxTokenAmount(tokenBalance)
|
||||
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
|
||||
: tokenBalance;
|
||||
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||
@@ -196,7 +190,6 @@ module.exports = {
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
};
|
||||
|
||||
+2
-95
@@ -149,8 +149,6 @@ const confirmTx = require("../src/popup/views/confirmTx");
|
||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
// A second address of the wallet, and a second recipient.
|
||||
const OTHER = "0x" + "e".repeat(40);
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
const GWEI = 1000000000n;
|
||||
@@ -209,7 +207,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
|
||||
);
|
||||
}
|
||||
|
||||
function tokenRow(value, decimals = "18") {
|
||||
function tokenRow(value) {
|
||||
return {
|
||||
value: String(value),
|
||||
token: {
|
||||
@@ -217,7 +215,7 @@ function tokenRow(value, decimals = "18") {
|
||||
address_hash: TOKEN,
|
||||
symbol: "TOK",
|
||||
name: "Token",
|
||||
decimals,
|
||||
decimals: "18",
|
||||
holders_count: "50000",
|
||||
},
|
||||
};
|
||||
@@ -232,7 +230,6 @@ function openSend(token = "ETH") {
|
||||
send.init({ showConfirmTx: (info) => (confirmed = info) });
|
||||
confirmTx.init({});
|
||||
send.resetSendValidation();
|
||||
state.currentView = "send";
|
||||
state.selectedToken = token;
|
||||
el("send-to").value = RECIPIENT;
|
||||
el("send-amount").value = "";
|
||||
@@ -352,83 +349,6 @@ describe("Max on an ETH send", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Holds the node's fee answer, so Max's estimate is still running, until
|
||||
// the returned function is called.
|
||||
function holdFeeEstimate() {
|
||||
let release;
|
||||
mockNode.feeData = new Promise((resolve) => {
|
||||
release = () => resolve(fees(20n * GWEI));
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
test.each([
|
||||
["the same address", 0],
|
||||
["another address", 1],
|
||||
])(
|
||||
"fills nothing in once Send was left and opened again for %s while the fee was estimated",
|
||||
async (_, addressIndex) => {
|
||||
await refreshWith(BALANCE_WEI);
|
||||
state.wallets[0].addresses.push({
|
||||
address: OTHER,
|
||||
balance: "2.0",
|
||||
tokenBalances: [],
|
||||
});
|
||||
openSend();
|
||||
const release = holdFeeEstimate();
|
||||
const pressed = pressMax();
|
||||
|
||||
// Back, then Send again as home.js opens it, with the same
|
||||
// recipient typed in again.
|
||||
state.selectedAddress = addressIndex;
|
||||
el("send-to").value = "";
|
||||
el("send-amount").value = "";
|
||||
send.resetSendValidation();
|
||||
el("send-to").value = RECIPIENT;
|
||||
|
||||
release();
|
||||
await pressed;
|
||||
expect(el("send-amount").value).toBe("");
|
||||
expect(text("flash-msg")).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
|
||||
// 0.0001 ETH, which does not cover the fee: a result that landed
|
||||
// would say so on whichever screen is shown.
|
||||
await refreshWith(100000000000000n);
|
||||
openSend();
|
||||
const release = holdFeeEstimate();
|
||||
const pressed = pressMax();
|
||||
state.currentView = "home";
|
||||
release();
|
||||
await pressed;
|
||||
expect(el("send-amount").value).toBe("");
|
||||
expect(text("flash-msg")).toBe("");
|
||||
});
|
||||
|
||||
test("fills nothing in when the recipient changed while the fee was estimated", async () => {
|
||||
await refreshWith(BALANCE_WEI);
|
||||
openSend();
|
||||
const release = holdFeeEstimate();
|
||||
const pressed = pressMax();
|
||||
el("send-to").value = OTHER;
|
||||
release();
|
||||
await pressed;
|
||||
expect(el("send-amount").value).toBe("");
|
||||
expect(text("flash-msg")).toBe("");
|
||||
});
|
||||
|
||||
test("fills in once the held fee estimate arrives with nothing changed", async () => {
|
||||
await refreshWith(BALANCE_WEI);
|
||||
openSend();
|
||||
const release = holdFeeEstimate();
|
||||
const pressed = pressMax();
|
||||
release();
|
||||
await pressed;
|
||||
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
|
||||
});
|
||||
|
||||
test("typed over, is an ordinary amount the confirmation screen keeps", async () => {
|
||||
await refreshWith(BALANCE_WEI);
|
||||
openSend();
|
||||
@@ -455,19 +375,6 @@ describe("Max on a token send", () => {
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
test("of a token with more than 18 decimal places, fills in the balance cut down to the 18 the confirmation screen accepts", async () => {
|
||||
// 1234.567890123456789012999999 TOK at 24 decimal places.
|
||||
await refreshWith(BALANCE_WEI, [
|
||||
tokenRow(1234567890123456789012999999n, "24"),
|
||||
]);
|
||||
openSend(TOKEN);
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
test("is still refused when ETH cannot cover the fee", async () => {
|
||||
await refreshWith(0n, [tokenRow(TOKEN_UNITS)]);
|
||||
openSend(TOKEN);
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
} = require("../src/shared/txValidation");
|
||||
@@ -357,23 +356,6 @@ describe("maxEthAmount", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The amount the Send screen's Max fills in for a token.
|
||||
describe("maxTokenAmount", () => {
|
||||
test("cuts a balance with more than 18 places down, never up", () => {
|
||||
const amount = maxTokenAmount("1234.567890123456789012999999");
|
||||
expect(amount).toBe("1234.567890123456789012");
|
||||
expect(toFixedPoint(amount)).not.toBe(null);
|
||||
});
|
||||
|
||||
test("leaves a balance with 18 places or fewer as it is", () => {
|
||||
expect(maxTokenAmount("0.123456789012345678")).toBe(
|
||||
"0.123456789012345678",
|
||||
);
|
||||
expect(maxTokenAmount("1.5")).toBe("1.5");
|
||||
expect(maxTokenAmount("100")).toBe("100");
|
||||
});
|
||||
});
|
||||
|
||||
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
|
||||
// Each of these previously returned { canSend: true, codes: [] } — counting no
|
||||
// fee at all, on a full-balance send, in the direction that lets money out.
|
||||
|
||||
Reference in New Issue
Block a user