Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4cc127cbc |
@@ -1467,6 +1467,17 @@ view would leave a wallet one click from deletion.
|
|||||||
- Amount input with current balance display, which reads
|
- Amount input with current balance display, which reads
|
||||||
`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 a
|
||||||
|
token's 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
|
||||||
- "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**
|
||||||
@@ -1483,7 +1494,14 @@ view would leave a wallet one click from deletion.
|
|||||||
- Token contract: full address + etherscan link (ERC-20 only)
|
- Token contract: full address + etherscan link (ERC-20 only)
|
||||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses)
|
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
||||||
|
filled in is worked out again from this screen's own fee estimate when it
|
||||||
|
arrives, as the balance minus the reserve, and the transaction is signed
|
||||||
|
with that estimate's fee fields, so a fee fetched again at signing cannot
|
||||||
|
exceed what the amount leaves behind. The address keeps whatever part of
|
||||||
|
the reserve the transaction does not use. If the balance no longer covers
|
||||||
|
the fee, the amount is left as it was and the amount-plus-fee error below
|
||||||
|
blocks the send
|
||||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
||||||
for a token whose scale is unknown
|
for a token whose scale is unknown
|
||||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
- 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)).
|
||||||
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
|
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
|
||||||
|
|||||||
+3
-1
@@ -240,7 +240,9 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
|
|||||||
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
||||||
that survives the spam filters).
|
that survives the spam filters).
|
||||||
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
||||||
4. Enter the amount.
|
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
|
||||||
|
decimal places, or your ETH balance minus the amount reserved for the network
|
||||||
|
fee.
|
||||||
5. Click "Review" to see the confirmation screen.
|
5. Click "Review" to see the confirmation screen.
|
||||||
|
|
||||||
The confirmation screen shows:
|
The confirmation screen shows:
|
||||||
|
|||||||
+14
-6
@@ -551,12 +551,20 @@
|
|||||||
class="text-xs text-muted"
|
class="text-xs text-muted"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="flex gap-1">
|
||||||
type="text"
|
<input
|
||||||
id="send-amount"
|
type="text"
|
||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
id="send-amount"
|
||||||
placeholder="0.0"
|
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
|
||||||
/>
|
placeholder="0.0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="btn-send-max"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
Max
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="btn-send-review"
|
id="btn-send-review"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const {
|
|||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
|
maxEthAmount,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
} = require("../../shared/txValidation");
|
} = require("../../shared/txValidation");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -54,6 +55,10 @@ let pendingTx = null;
|
|||||||
// filled in by estimateGas() when the estimate resolves or fails.
|
// filled in by estimateGas() when the estimate resolves or fails.
|
||||||
let feeStatus = FEE_PENDING;
|
let feeStatus = FEE_PENDING;
|
||||||
let feeWei = null;
|
let feeWei = null;
|
||||||
|
// The fee fields a max ETH send is signed with: those of the estimate its
|
||||||
|
// amount was derived from. Null for any other send, which ethers prices from
|
||||||
|
// the node at signing time.
|
||||||
|
let maxSendFees = null;
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -82,10 +87,24 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
||||||
|
// once its amount is re-derived from the fee estimate.
|
||||||
|
function renderAmount(txInfo) {
|
||||||
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
|
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
||||||
|
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
|
||||||
|
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||||
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
|
txInfo.amount + " " + displaySymbol(rawSymbol),
|
||||||
|
amountUsd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = txInfo;
|
pendingTx = txInfo;
|
||||||
feeStatus = FEE_PENDING;
|
feeStatus = FEE_PENDING;
|
||||||
feeWei = null;
|
feeWei = null;
|
||||||
|
maxSendFees = null;
|
||||||
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
// The raw symbol is the price-table key; the capped one is what the
|
// The raw symbol is the price-table key; the capped one is what the
|
||||||
@@ -132,18 +151,11 @@ function show(txInfo) {
|
|||||||
);
|
);
|
||||||
$("confirm-to-ens").classList.add("hidden");
|
$("confirm-to-ens").classList.add("hidden");
|
||||||
|
|
||||||
// Amount (with inline USD)
|
renderAmount(txInfo);
|
||||||
const ethPrice = getPrice("ETH");
|
|
||||||
const tokenPrice = getPrice(rawSymbol);
|
|
||||||
const amountNum = parseFloat(txInfo.amount);
|
|
||||||
const price = isErc20 ? tokenPrice : ethPrice;
|
|
||||||
const amountUsd = price ? amountNum * price : null;
|
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
|
||||||
txInfo.amount + " " + symbol,
|
|
||||||
amountUsd,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Balance (with inline USD)
|
// Balance (with inline USD)
|
||||||
|
const ethPrice = getPrice("ETH");
|
||||||
|
const tokenPrice = getPrice(rawSymbol);
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
// null is a balance whose scale nothing knows, not a balance of zero
|
// null is a balance whose scale nothing knows, not a balance of zero
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||||
@@ -380,8 +392,8 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// What the node will require to be reserved, which is what the gate
|
// What the node will require to be reserved, which is what the gate
|
||||||
// must be: the send pins no fee fields, so it is broadcast as a
|
// must be: the send is broadcast as a type-2 transaction priced at
|
||||||
// type-2 transaction priced at maxFeePerGas.
|
// maxFeePerGas, which only a max ETH send pins (see below).
|
||||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||||
if (gasCostWei === null) {
|
if (gasCostWei === null) {
|
||||||
throw new Error("no usable gas price from the provider");
|
throw new Error("no usable gas price from the provider");
|
||||||
@@ -419,6 +431,30 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
feeStatus = FEE_KNOWN;
|
feeStatus = FEE_KNOWN;
|
||||||
feeWei = gasCostWei;
|
feeWei = gasCostWei;
|
||||||
|
// A max ETH send is the balance minus this estimate's reserve, not the
|
||||||
|
// Send screen's, and is signed with this estimate's fee fields: fees
|
||||||
|
// fetched again at signing could exceed the reserve it leaves, and the
|
||||||
|
// node would refuse it for want of funds. Where the balance no longer
|
||||||
|
// covers the fee, the amount is left as it is and the balance check
|
||||||
|
// below says so.
|
||||||
|
if (txInfo.max && txInfo.token === "ETH") {
|
||||||
|
const amount = maxEthAmount(txInfo.balance, gasCostWei);
|
||||||
|
if (amount !== null) {
|
||||||
|
txInfo.amount = amount;
|
||||||
|
renderAmount(txInfo);
|
||||||
|
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
|
||||||
|
// or gasPrice on a network with no type-2 pricing.
|
||||||
|
if (feeData.maxFeePerGas != null) {
|
||||||
|
maxSendFees = {
|
||||||
|
gasLimit,
|
||||||
|
maxFeePerGas: feeData.maxFeePerGas,
|
||||||
|
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
||||||
@@ -432,18 +468,19 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Populate the transaction this send describes, enforce the fee bound against
|
// Populate the transaction this send describes, enforce the fee bound against
|
||||||
// the fees that were actually filled in, then sign and broadcast it. The send
|
// the fees that were actually filled in, then sign and broadcast it. Apart
|
||||||
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
|
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
|
||||||
// the configured RPC node answers, with nothing otherwise bounding what a
|
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
|
||||||
|
// what the configured RPC node answers, with nothing otherwise bounding what a
|
||||||
// hostile node can set — the dApp path's ceilings never reached this one.
|
// hostile node can set — the dApp path's ceilings never reached this one.
|
||||||
// Populating before the check is what makes assertWithinCeilings() see the
|
// Populating before the check is what makes assertWithinCeilings() see the
|
||||||
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
||||||
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
||||||
// shows in the reserved error area rather than sending.
|
// shows in the reserved error area rather than sending.
|
||||||
async function populateVerifyAndSend(connectedSigner, tx) {
|
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||||
let request;
|
let request;
|
||||||
if (tx.token === "ETH") {
|
if (tx.token === "ETH") {
|
||||||
request = { to: tx.to, value: parseEther(tx.amount) };
|
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
|
||||||
} else {
|
} else {
|
||||||
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
||||||
// The contract's decimals() is read to be COMPARED with the scale the
|
// The contract's decimals() is read to be COMPARED with the scale the
|
||||||
@@ -548,7 +585,11 @@ function init(_ctx) {
|
|||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const connectedSigner = signer.connect(provider);
|
const connectedSigner = signer.connect(provider);
|
||||||
|
|
||||||
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
|
tx = await populateVerifyAndSend(
|
||||||
|
connectedSigner,
|
||||||
|
pendingTx,
|
||||||
|
maxSendFees,
|
||||||
|
);
|
||||||
|
|
||||||
// Best-effort: clear decrypted secret after use.
|
// Best-effort: clear decrypted secret after use.
|
||||||
// Note: JS strings are immutable; this nulls the reference but
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
|
|||||||
+114
-2
@@ -22,10 +22,25 @@ const {
|
|||||||
truncateAmountNeverZero,
|
truncateAmountNeverZero,
|
||||||
isBelowOneMillionth,
|
isBelowOneMillionth,
|
||||||
} = require("../../shared/amountDisplay");
|
} = require("../../shared/amountDisplay");
|
||||||
const { getAddress } = require("ethers");
|
const {
|
||||||
|
feeReserveWei,
|
||||||
|
maxEthAmount,
|
||||||
|
maxTokenAmount,
|
||||||
|
} = require("../../shared/txValidation");
|
||||||
|
const { log } = require("../../shared/log");
|
||||||
|
const { getAddress, parseEther } = require("ethers");
|
||||||
|
|
||||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
// Whether the amount field holds what Max filled in. The confirmation screen
|
||||||
|
// re-derives a max ETH amount from its own fee estimate; typing in the field
|
||||||
|
// 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.
|
* Validate a destination address string.
|
||||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||||
@@ -229,9 +244,102 @@ 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.
|
||||||
|
async function fillMaxAmount() {
|
||||||
|
const addr = currentAddress();
|
||||||
|
if (!addr) return;
|
||||||
|
const token = state.selectedToken || $("send-token").value;
|
||||||
|
|
||||||
|
if (token !== "ETH") {
|
||||||
|
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)) {
|
||||||
|
showFlash("This token's balance is zero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("send-amount").value = amount;
|
||||||
|
amountIsMax = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fee is estimated for this recipient, as the confirmation screen
|
||||||
|
// estimates it: sending to a contract can cost more gas.
|
||||||
|
const to = $("send-to").value.trim();
|
||||||
|
if (!validateToAddress(to).valid) {
|
||||||
|
showFlash("Please enter a recipient address first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typed = $("send-amount").value;
|
||||||
|
const opening = sendScreenOpenings;
|
||||||
|
let feeWei = null;
|
||||||
|
try {
|
||||||
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
|
const [feeData, gasLimit] = await Promise.all([
|
||||||
|
provider.getFeeData(),
|
||||||
|
provider.estimateGas({
|
||||||
|
from: addr.address,
|
||||||
|
to,
|
||||||
|
value: parseEther(addr.balance || "0"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
feeWei = feeReserveWei(gasLimit, feeData);
|
||||||
|
} catch (e) {
|
||||||
|
log.errorf(
|
||||||
|
"max amount fee estimate failed:",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feeWei === null) {
|
||||||
|
showFlash("The network fee could not be estimated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = maxEthAmount(addr.balance, feeWei);
|
||||||
|
if (amount === null) {
|
||||||
|
showFlash("Your balance does not cover the network fee.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("send-amount").value = amount;
|
||||||
|
amountIsMax = true;
|
||||||
|
}
|
||||||
|
|
||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
$("send-token").addEventListener("change", updateSendBalance);
|
$("send-token").addEventListener("change", () => {
|
||||||
|
// A filled-in maximum is the maximum of the holding it was filled in
|
||||||
|
// for.
|
||||||
|
if (amountIsMax) {
|
||||||
|
$("send-amount").value = "";
|
||||||
|
amountIsMax = false;
|
||||||
|
}
|
||||||
|
updateSendBalance();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-send-max").addEventListener("click", fillMaxAmount);
|
||||||
|
$("send-amount").addEventListener("input", () => {
|
||||||
|
amountIsMax = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Initial state: disable review button until address is entered
|
// Initial state: disable review button until address is entered
|
||||||
$("btn-send-review").disabled = true;
|
$("btn-send-review").disabled = true;
|
||||||
@@ -308,6 +416,7 @@ function init(_ctx) {
|
|||||||
tokenSymbol: tokenSymbol,
|
tokenSymbol: tokenSymbol,
|
||||||
tokenBalance: tokenBalance,
|
tokenBalance: tokenBalance,
|
||||||
tokenDecimals: tokenDecimals,
|
tokenDecimals: tokenDecimals,
|
||||||
|
max: amountIsMax,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,7 +427,10 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called each time the Send screen opens, with its fields cleared.
|
||||||
function resetSendValidation() {
|
function resetSendValidation() {
|
||||||
|
sendScreenOpenings++;
|
||||||
|
amountIsMax = false;
|
||||||
const errorEl = $("send-to-error");
|
const errorEl = $("send-to-error");
|
||||||
const btn = $("btn-send-review");
|
const btn = $("btn-send-review");
|
||||||
if (errorEl) errorEl.textContent = "";
|
if (errorEl) errorEl.textContent = "";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Balance arithmetic for the transaction confirmation screen.
|
// Balance arithmetic for the Send and transaction confirmation screens.
|
||||||
//
|
//
|
||||||
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
||||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// the token balance arrive as human decimal strings, so comparing them at a
|
// the token balance arrive as human decimal strings, so comparing them at a
|
||||||
// common scale is exact.
|
// common scale is exact.
|
||||||
|
|
||||||
const { parseUnits } = require("ethers");
|
const { parseUnits, formatEther } = require("ethers");
|
||||||
|
|
||||||
const SCALE_DECIMALS = 18;
|
const SCALE_DECIMALS = 18;
|
||||||
|
|
||||||
@@ -87,6 +87,28 @@ function toFixedPoint(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The most ETH a send can carry: the exact balance minus the fee reserve from
|
||||||
|
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
|
||||||
|
// exactly that reserve left behind. `ethBalance` is the exact decimal string
|
||||||
|
// balances.js stores, never a rounded one. Null when the balance does not
|
||||||
|
// leave anything to send once the fee is paid, or when either input is
|
||||||
|
// unusable.
|
||||||
|
function maxEthAmount(ethBalance, feeWei) {
|
||||||
|
const balanceWei = toFixedPoint(ethBalance);
|
||||||
|
if (balanceWei === null) return null;
|
||||||
|
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
|
||||||
|
const amountWei = balanceWei - feeWei;
|
||||||
|
if (amountWei <= 0n) return null;
|
||||||
|
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
|
||||||
@@ -139,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);
|
||||||
@@ -174,6 +195,8 @@ module.exports = {
|
|||||||
SCALE_DECIMALS,
|
SCALE_DECIMALS,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
|
maxEthAmount,
|
||||||
|
maxTokenAmount,
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
// The Send screen's "Max" control
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/198).
|
||||||
|
//
|
||||||
|
// For ETH it fills in the exact balance minus the fee reserve the
|
||||||
|
// confirmation screen's balance check gates on, never the four-decimal balance
|
||||||
|
// the Send screen shows; the confirmation screen re-derives that amount from
|
||||||
|
// its own estimate, and signs with that estimate's fee fields. For a token it
|
||||||
|
// fills in the whole balance, and the check that ETH covers the fee still
|
||||||
|
// applies.
|
||||||
|
//
|
||||||
|
// Driven through the real refreshBalances(), Send screen and confirmation
|
||||||
|
// screen, Sign & Send included, with only the node, the explorer and the DOM
|
||||||
|
// stubbed.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// What the stub node answers, and the signed transactions it was handed.
|
||||||
|
const mockNode = {
|
||||||
|
balanceWei: 0n,
|
||||||
|
feeData: null,
|
||||||
|
broadcast: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// The token rows the stub explorer reports for the address.
|
||||||
|
const mockExplorer = { items: [] };
|
||||||
|
|
||||||
|
jest.mock("ethers", () => {
|
||||||
|
const actual = jest.requireActual("ethers");
|
||||||
|
class StubProvider {
|
||||||
|
async getBalance() {
|
||||||
|
return mockNode.balanceWei;
|
||||||
|
}
|
||||||
|
async lookupAddress() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async getFeeData() {
|
||||||
|
return mockNode.feeData;
|
||||||
|
}
|
||||||
|
async estimateGas() {
|
||||||
|
return 21000n;
|
||||||
|
}
|
||||||
|
async getCode() {
|
||||||
|
return "0x";
|
||||||
|
}
|
||||||
|
async getTransactionCount() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
async getNetwork() {
|
||||||
|
return { chainId: 1n };
|
||||||
|
}
|
||||||
|
async broadcastTransaction(signed) {
|
||||||
|
mockNode.broadcast.push(signed);
|
||||||
|
return { hash: "0x" + "ab".repeat(32) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
JsonRpcProvider: StubProvider,
|
||||||
|
Network: { from: () => ({}) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../src/shared/log", () => ({
|
||||||
|
log: {
|
||||||
|
debugf: () => {},
|
||||||
|
infof: () => {},
|
||||||
|
warnf: () => {},
|
||||||
|
errorf: () => {},
|
||||||
|
},
|
||||||
|
// The explorer's token list, which refreshBalances() also fetches.
|
||||||
|
debugFetch: jest.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockExplorer.items,
|
||||||
|
})),
|
||||||
|
urlOrigin: () => "",
|
||||||
|
setRuntimeDebug: () => {},
|
||||||
|
isDebug: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The wait screen polls for a receipt; these tests stop at the broadcast.
|
||||||
|
jest.mock("../src/popup/views/txStatus", () => ({
|
||||||
|
showWait: jest.fn(),
|
||||||
|
showError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The confirmation screen's Etherscan label lookup is the only fetch() these
|
||||||
|
// screens make; it fails, as it does offline.
|
||||||
|
global.fetch = jest.fn(() => {
|
||||||
|
throw new Error("tests must not perform network requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||||
|
|
||||||
|
// A stub DOM: every id resolves to a recording element.
|
||||||
|
const elements = new Map();
|
||||||
|
|
||||||
|
function makeEl(id) {
|
||||||
|
const handlers = new Map();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
innerHTML: "",
|
||||||
|
value: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add() {},
|
||||||
|
remove() {},
|
||||||
|
toggle() {},
|
||||||
|
contains: () => false,
|
||||||
|
},
|
||||||
|
handlers,
|
||||||
|
children: [],
|
||||||
|
addEventListener(name, fn) {
|
||||||
|
handlers.set(name, fn);
|
||||||
|
},
|
||||||
|
appendChild(child) {
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
querySelector: () => null,
|
||||||
|
remove() {},
|
||||||
|
focus() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
global.document = {
|
||||||
|
getElementById(id) {
|
||||||
|
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||||
|
return elements.get(id);
|
||||||
|
},
|
||||||
|
createElement: (tag) => makeEl(tag),
|
||||||
|
body: { prepend() {}, appendChild() {} },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
global.navigator = { clipboard: { writeText() {} } };
|
||||||
|
|
||||||
|
const { Transaction, Wallet, formatEther } = require("ethers");
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
const { encryptWithPassword } = require("../src/shared/vault");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const send = require("../src/popup/views/send");
|
||||||
|
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;
|
||||||
|
const GAS = 21000n;
|
||||||
|
|
||||||
|
// The Send screen shows this balance as 1.2345 ETH.
|
||||||
|
const BALANCE_WEI = 1234567890123456789n;
|
||||||
|
|
||||||
|
// A token the bundled list does not know, with enough holders to be listed.
|
||||||
|
const TOKEN = "0x" + "d".repeat(40);
|
||||||
|
|
||||||
|
// Fee data whose reserve is 21000 gas at `maxFeePerGas`. The expected cost,
|
||||||
|
// at gasPrice, is lower, as it is on mainnet.
|
||||||
|
function fees(maxFeePerGas) {
|
||||||
|
return {
|
||||||
|
maxFeePerGas,
|
||||||
|
maxPriorityFeePerGas: GWEI,
|
||||||
|
gasPrice: maxFeePerGas / 2n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The balance minus a reserve of 21000 gas at `maxFeePerGas`.
|
||||||
|
function maxAfter(maxFeePerGas) {
|
||||||
|
return formatEther(BALANCE_WEI - GAS * maxFeePerGas);
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(id) {
|
||||||
|
return global.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(id) {
|
||||||
|
return el(id).textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ETH balance the node reports and the token rows the explorer reports,
|
||||||
|
// fetched and stored exactly where the popup stores them.
|
||||||
|
async function refreshWith(balanceWei, tokenItems = []) {
|
||||||
|
mockNode.balanceWei = balanceWei;
|
||||||
|
mockExplorer.items = tokenItems;
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
name: "Wallet 1",
|
||||||
|
encryptedSecret: await encryptWithPassword(PRIVATE_KEY, PASSWORD),
|
||||||
|
addresses: [{ address: HOLDER }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
await refreshBalances(
|
||||||
|
state.wallets,
|
||||||
|
"https://rpc.example.invalid",
|
||||||
|
"https://blockscout.example/api/v2",
|
||||||
|
state.trackedTokens,
|
||||||
|
"mainnet",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenRow(value, decimals = "18") {
|
||||||
|
return {
|
||||||
|
value: String(value),
|
||||||
|
token: {
|
||||||
|
type: "ERC-20",
|
||||||
|
address_hash: TOKEN,
|
||||||
|
symbol: "TOK",
|
||||||
|
name: "Token",
|
||||||
|
decimals,
|
||||||
|
holders_count: "50000",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirmation screen Review leads to, once shown.
|
||||||
|
let confirmed = null;
|
||||||
|
|
||||||
|
// Open the Send screen for `token` ("ETH" or a token address), with the
|
||||||
|
// recipient entered.
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressMax() {
|
||||||
|
await el("btn-send-max").handlers.get("click")();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press Review and show the confirmation screen with its fee estimate settled.
|
||||||
|
async function review() {
|
||||||
|
await el("btn-send-review").handlers.get("click")();
|
||||||
|
confirmTx.show(confirmed);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// show() starts the fee estimate without awaiting it; this lets it settle.
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSend() {
|
||||||
|
return !el("btn-confirm-send").disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elements.clear();
|
||||||
|
confirmed = null;
|
||||||
|
state.selectedToken = null;
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.fraudContracts = [];
|
||||||
|
state.currentView = null;
|
||||||
|
mockNode.feeData = fees(20n * GWEI);
|
||||||
|
mockNode.broadcast = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Max on an ETH send", () => {
|
||||||
|
test("fills in the exact balance minus the fee reserve", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
send.updateSendBalance();
|
||||||
|
expect(text("send-balance")).toBe("Current balance: 1.2345 ETH");
|
||||||
|
await pressMax();
|
||||||
|
// 1.234567890123456789 - 21000 * 20 gwei.
|
||||||
|
expect(el("send-amount").value).toBe("1.234147890123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves exactly the fee reserve behind, and the confirmation screen enables Send", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
await pressMax();
|
||||||
|
await review();
|
||||||
|
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
|
||||||
|
expect(el("confirm-errors").innerHTML).toBe("");
|
||||||
|
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
||||||
|
expect(canSend()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-derives the amount when the fee estimate changes", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe(maxAfter(20n * GWEI));
|
||||||
|
|
||||||
|
// The fee rises between Max and the confirmation screen's estimate.
|
||||||
|
// Kept, the Send screen's amount would be refused for want of funds.
|
||||||
|
mockNode.feeData = fees(30n * GWEI);
|
||||||
|
await review();
|
||||||
|
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
|
||||||
|
expect(canSend()).toBe(true);
|
||||||
|
|
||||||
|
// And falls when the screen is shown again, as on reopening the popup.
|
||||||
|
mockNode.feeData = fees(10n * GWEI);
|
||||||
|
confirmTx.restore();
|
||||||
|
await settle();
|
||||||
|
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
|
||||||
|
expect(canSend()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is signed with the fee its amount leaves behind", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
await pressMax();
|
||||||
|
await review();
|
||||||
|
// The fee the node quotes rises after the estimate. Fetched afresh
|
||||||
|
// at signing, it would make amount plus fee more than the balance.
|
||||||
|
mockNode.feeData = fees(25n * GWEI);
|
||||||
|
el("confirm-tx-password").value = PASSWORD;
|
||||||
|
await el("btn-confirm-send").handlers.get("click")();
|
||||||
|
|
||||||
|
expect(mockNode.broadcast).toHaveLength(1);
|
||||||
|
const tx = Transaction.from(mockNode.broadcast[0]);
|
||||||
|
expect(tx.to).toBe(RECIPIENT);
|
||||||
|
expect(tx.maxFeePerGas).toBe(20n * GWEI);
|
||||||
|
expect(tx.value + tx.gasLimit * tx.maxFeePerGas).toBe(BALANCE_WEI);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("says so instead of filling in an amount when the balance does not cover the fee", async () => {
|
||||||
|
// 0.0001 ETH against a reserve of 0.00042 ETH.
|
||||||
|
await refreshWith(100000000000000n);
|
||||||
|
openSend();
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe(
|
||||||
|
"Your balance does not cover the network fee.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks for the recipient first, since the fee depends on it", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
el("send-to").value = "";
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe(
|
||||||
|
"Please enter a recipient address first.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 nothing in when the holding was changed while the fee was estimated", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI, [tokenRow(10n ** 18n)]);
|
||||||
|
// Opened from the home screen, where the dropdown picks the holding.
|
||||||
|
openSend(null);
|
||||||
|
el("send-token").value = "ETH";
|
||||||
|
const release = holdFeeEstimate();
|
||||||
|
const pressed = pressMax();
|
||||||
|
el("send-token").value = TOKEN;
|
||||||
|
el("send-token").handlers.get("change")();
|
||||||
|
release();
|
||||||
|
await pressed;
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an amount typed while the fee was estimated", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
const release = holdFeeEstimate();
|
||||||
|
const pressed = pressMax();
|
||||||
|
el("send-amount").value = "0.5";
|
||||||
|
el("send-amount").handlers.get("input")();
|
||||||
|
release();
|
||||||
|
await pressed;
|
||||||
|
expect(el("send-amount").value).toBe("0.5");
|
||||||
|
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();
|
||||||
|
await pressMax();
|
||||||
|
el("send-amount").value = "0.5";
|
||||||
|
el("send-amount").handlers.get("input")();
|
||||||
|
mockNode.feeData = fees(30n * GWEI);
|
||||||
|
await review();
|
||||||
|
expect(text("confirm-amount")).toBe("0.5 ETH");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Max on a token send", () => {
|
||||||
|
// 1234.567890123456789012 TOK; the Send screen shows 1234.5678.
|
||||||
|
const TOKEN_UNITS = 1234567890123456789012n;
|
||||||
|
|
||||||
|
test("fills in the whole token balance", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI, [tokenRow(TOKEN_UNITS)]);
|
||||||
|
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("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);
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||||
|
await review();
|
||||||
|
expect(el("confirm-gas-error").style.visibility).toBe("visible");
|
||||||
|
expect(canSend()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("says so when the token balance is unknown", async () => {
|
||||||
|
// No scale from the explorer, the bundled list or a tracked token.
|
||||||
|
const row = tokenRow(TOKEN_UNITS);
|
||||||
|
delete row.token.decimals;
|
||||||
|
await refreshWith(BALANCE_WEI, [row]);
|
||||||
|
openSend(TOKEN);
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe("This token's balance is unknown.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("says so when the token balance is zero", async () => {
|
||||||
|
state.trackedTokens = [
|
||||||
|
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
||||||
|
];
|
||||||
|
await refreshWith(BALANCE_WEI, [tokenRow(0n)]);
|
||||||
|
openSend(TOKEN);
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe("This token's balance is zero.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ const {
|
|||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
|
maxEthAmount,
|
||||||
|
maxTokenAmount,
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
} = require("../src/shared/txValidation");
|
} = require("../src/shared/txValidation");
|
||||||
@@ -306,6 +308,72 @@ describe("feeEstimateWei", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The amount the Send screen's Max fills in for ETH: the exact balance, as
|
||||||
|
// balances.js stores it, minus the fee reserve. Never the four-decimal balance
|
||||||
|
// the Send screen shows.
|
||||||
|
describe("maxEthAmount", () => {
|
||||||
|
// The Send screen shows this balance as 1.2345.
|
||||||
|
const BALANCE = "1.234567890123456789";
|
||||||
|
|
||||||
|
test("is the exact balance minus the fee, to the wei", () => {
|
||||||
|
expect(maxEthAmount(BALANCE, FEE)).toBe("1.234147890123456789");
|
||||||
|
expect(
|
||||||
|
parseEther(BALANCE) - parseEther(maxEthAmount(BALANCE, FEE)),
|
||||||
|
).toBe(FEE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes validateTransfer with exactly the fee left behind", () => {
|
||||||
|
const r = validateTransfer({
|
||||||
|
isErc20: false,
|
||||||
|
amount: maxEthAmount(BALANCE, FEE),
|
||||||
|
ethBalance: BALANCE,
|
||||||
|
feeStatus: FEE_KNOWN,
|
||||||
|
feeWei: FEE,
|
||||||
|
});
|
||||||
|
expect(r).toEqual({ canSend: true, codes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is one wei when the balance is one wei more than the fee", () => {
|
||||||
|
expect(maxEthAmount("0.000420000000000001", FEE)).toBe(
|
||||||
|
"0.000000000000000001",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is null when the balance does not cover the fee", () => {
|
||||||
|
expect(maxEthAmount("0.0001", FEE)).toBe(null);
|
||||||
|
expect(maxEthAmount("0.0", FEE)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is null when the balance covers the fee and nothing more", () => {
|
||||||
|
expect(maxEthAmount("0.00042", FEE)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is null on a balance or fee it cannot do exact arithmetic on", () => {
|
||||||
|
expect(maxEthAmount(undefined, FEE)).toBe(null);
|
||||||
|
expect(maxEthAmount("not a number", FEE)).toBe(null);
|
||||||
|
expect(maxEthAmount(BALANCE, null)).toBe(null);
|
||||||
|
expect(maxEthAmount(BALANCE, -1n)).toBe(null);
|
||||||
|
expect(maxEthAmount(BALANCE, 420000000000000)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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