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