2 Commits
Author SHA1 Message Date
sneak 8098402d83 fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
ConfirmTx showed and checked the balance Send handed it, so a popup reopened onto a confirmation saved before a network switch stated the other network's balance and checked the amount against it.

Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node of the network it is drawn for, and gives that node to the fee estimate, which starts once the read is done. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns.

Model: opus-5-5
2026-10-19 01:05:50 +00:00
clawbot b923b2c19c fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A V4 step's input side is what SETTLE or SETTLE_ALL names, else the one
currency its swap actions spend and never buy; its output side is what
TAKE or TAKE_ALL names, else the one they buy and never spend. Amount sums
the amountIn of the actions spending the input side, shown only when each
states one; Min. received likewise. An unread swap action leaves the sides
to SETTLE, SETTLE_ALL, TAKE and TAKE_ALL, with no figure. A step with a
swap action always sets the output side, sets the input side only if no
earlier step did, and leaves a side its rules cannot name unknown, with no
figure. SWAP_EXACT_OUT spends its first path entry.

Model: opus-5-5
2026-10-19 02:50:16 +02:00
17 changed files with 1425 additions and 411 deletions
+68 -31
View File
@@ -1013,8 +1013,10 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the
active network, use the active network's. A transaction's figures use the one of
wallet's balances and the Send screen, which sends on the active network, use
the active network's. The confirmation screen uses the one of the network it is
drawn for, the network active when it was shown, even after the network is
switched in Settings while it is open. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
@@ -1168,11 +1170,13 @@ read:
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1194,16 +1198,38 @@ The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does,
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
`Token Out` and `Min. received` keep the output side's own token and figure,
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys
USDC and then unwraps the WETH it did not spend shows USDC. The token permission
warning on the signature screen has its own amount wording, including `Unknown`;
the SignApproval section below describes it.
with a swap action always sets the output side, and sets the input side only
when no earlier step set it. It finds each side by three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its
output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one
currency that some swap action buys and none spends. If there is not exactly
one such currency, that side is unknown, and the step sets it as unknown with
no figure. A step that buys WETH with USDT and USDC with that WETH, with
nothing naming its sides, reads `Swap USDT → USDC`.
2. Its `Amount` is the `amountIn` of the swap action that spends the input
currency. If several exact-in actions spend it, each with a stated amount, it
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
step sets it as unknown with no figure.
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1719,10 +1745,18 @@ view would leave a wallet one click from deletion.
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)`
for a token whose scale is unknown
- Your balance: the From address's balance on the network the screen is
drawn for, read from that network's RPC endpoint each time the screen is
shown, so a screen the popup reopens onto after the network was switched
states and checks the balance on the new network. "Loading..." until the
read returns, then value + symbol (USD in parentheses), or
`unknown (SYMBOL)` for a token whose scale is unknown or when the read
failed. The amount check uses the same balances: the ETH balance, and for
a token transfer the token balance as well
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost,
fetched async once the balance is read, from the same RPC endpoint as the
balance, even if the network is switched in Settings while the balance is
being read. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the
`gasLimit * maxFeePerGas` reserve the node requires, which is what the
balance check gates on. The second line is omitted on a network with no
@@ -1734,19 +1768,22 @@ view would leave a wallet one click from deletion.
a contract, burn address, and an Etherscan phishing/scam label. An address
that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning
- Errors (insufficient balance), plus three reserved error boxes — the
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
estimated. The first two are mutually exclusive per transfer type, so only
the applicable one holds space. The last names its cause: for a token
whose scale is unknown the fee can never be estimated, and it says the
wallet does not know how many decimal places the token uses and that the
transaction cannot be sent; for any other failure it asks the user to go
back and try again
- Errors (an invalid amount), plus five reserved error boxes, so the balance
and the fee arriving later move nothing — the amount exceeds the balance
(for a token whose scale is unknown it says the token's balance is
unknown), the balance could not be read (it asks the user to go back and
try again), the amount plus the fee exceeds the balance (ETH transfers),
not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
fee could not be estimated. The third and fourth are mutually exclusive
per transfer type, so only the applicable one holds space. The last names
its cause: for a token whose scale is unknown the fee can never be
estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, and while the network fee
estimate is pending or unavailable)
- "Sign & Send" button (disabled if errors, while the balance is being read,
and while the network fee estimate is pending or unavailable)
- **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
+14
View File
@@ -664,6 +664,20 @@
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div>
<!-- Its sentence names the token, or why the token's balance
is unknown, so show() in confirmTx.js sets it. -->
<div
id="confirm-balance-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<div
id="confirm-balance-read-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
Your balance could not be read from the network, so this
transaction cannot be checked against it. Please go back and
try again.
</div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
+154 -98
View File
@@ -2,7 +2,13 @@
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const {
$,
showError,
@@ -11,7 +17,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
@@ -20,6 +25,7 @@ const {
formatFee,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -32,6 +38,7 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
toDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
@@ -60,6 +67,14 @@ let feeWei = null;
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
let maxSendFees = null;
// The From address's balances on the network this screen is drawn for, read
// from that network's node by readBalances() each time the screen is shown.
// The popup can reopen onto this screen after the network was switched, so
// any balance Send knew may be another network's. Null until the read
// returns; then `eth` and `token` are decimal strings, `token` null for an
// ETH send or a token whose scale is unknown, and both null if the read
// failed.
let balances = null;
function restore() {
const d = state.viewData;
@@ -87,7 +102,9 @@ function valueWithUsd(text, usdAmount) {
// 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 rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
@@ -96,21 +113,56 @@ function renderAmount(txInfo) {
);
}
// The balance line, with its USD value: "Loading..." until readBalances()
// returns, then the balance it read.
function renderBalance(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
if (balances === null) {
$("confirm-balance").textContent = "Loading...";
return;
}
// A null balance is one nobody established, for a token whose scale is
// unknown or after a read that failed, and is stated as unknown, never as
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
const bal = isErc20 ? balances.token : balances.eth;
if (bal === null) {
$("confirm-balance").textContent = "unknown (" + symbol + ")";
return;
}
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
function show(txInfo) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
balances = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
// Settings can switch the active network while the screen is open. This
// screen and the wait, success and error screens label amounts by it.
txInfo.chainId = currentNetwork().chainId;
// That network's node, read here once and passed to every read below, so
// a read still in flight when the network is switched, and the fee
// estimate that follows the balance read, stay on it.
const provider = getProvider(state.rpcUrl, state.networkId);
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
// Transaction type
@@ -152,33 +204,7 @@ function show(txInfo) {
$("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
// refused at encode time for the same missing scale; what this line
// must not do is state a quantity nobody established.
const bal = txInfo.tokenBalance;
const balUsd =
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
renderBalance(txInfo);
// Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, {
@@ -212,9 +238,9 @@ function show(txInfo) {
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again.";
// Both shown later, by checkRecipientHistory(). The first sentence says
@@ -222,12 +248,12 @@ function show(txInfo) {
$("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" +
" holds no " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
". This may indicate a fresh or unused address. Double-check the" +
" address before sending.";
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
@@ -244,6 +270,20 @@ function show(txInfo) {
" cannot be checked against your balance. Please go back and" +
" try again.";
// So does the message for an amount the balance does not cover. It is
// set here, before the first paint, so the balance read returning only
// makes it visible and moves nothing, which is why it quotes no balance.
// Without the token's scale readBalances() reads no token balance.
$("confirm-balance-error").textContent =
isErc20 && toDecimals(txInfo.tokenDecimals) === null
? "This token's balance is unknown, because nothing this wallet" +
" can consult reports how many decimal places it uses, so the" +
" amount you are trying to send cannot be checked against it."
: "Your " +
symbol +
" balance does not cover this amount. Please go back and send" +
" a smaller amount.";
renderValidation(txInfo);
// Reset password field and error
@@ -269,69 +309,77 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible";
}
estimateGas(txInfo);
checkRecipientHistory(txInfo);
// The fee is estimated once the balances are read, because a max ETH
// send's amount is the ETH balance minus the estimate's reserve.
readBalances(txInfo, provider).then(() => estimateGas(txInfo, provider));
checkRecipientHistory(txInfo, provider);
}
// Read the From address's balances from `provider`, the node of the network
// this screen is drawn for: ETH, and for a token send the token, at the scale
// the screen renders the amount with. A token whose scale is unknown has no
// balance to read. Never rejects.
async function readBalances(txInfo, provider) {
let read;
try {
const decimals = toDecimals(txInfo.tokenDecimals);
const readToken = txInfo.token !== "ETH" && decimals !== null;
const [ethWei, tokenUnits] = await Promise.all([
provider.getBalance(txInfo.from),
readToken
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
txInfo.from,
)
: null,
]);
read = {
eth: formatEther(ethWei),
token: readToken ? formatUnits(tokenUnits, decimals) : null,
};
} catch (e) {
log.errorf("balance read failed:", e.shortMessage || e.message);
read = { eth: null, token: null };
}
// The user may have left this transaction while the read was in flight.
if (pendingTx !== txInfo) return;
balances = read;
renderBalance(txInfo);
renderValidation(txInfo);
}
// Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it
// touches already occupies its space, so re-running it never moves anything.
// show(), again when the balances are read, and again when the fee estimate
// resolves or fails. Every element it touches already occupies its space, so
// re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
// Null while the balances are being read and after a read that failed.
// validateTransfer() takes a missing balance as nothing to spend from, so
// sending stays blocked either way, and the only message shown for it is
// the one saying the read failed.
const ethBalance = balances ? balances.eth : null;
const tokenBalance = balances ? balances.token : null;
const balanceRead = ethBalance !== null;
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
ethBalance,
tokenBalance,
feeStatus,
feeWei,
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the
// gas and fee-unknown ones, which show() sets.
// An invalid amount is known before the first paint, so its message is
// written here. Every message a later result brings has a reserved
// element, its sentence fixed in index.html or set by show(), and is only
// made visible below, so nothing moves when the result arrives.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
txInfo.tokenBalance == null
? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" +
" checked against it."
: "Insufficient " +
symbol +
" balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
const errorsEl = $("confirm-errors");
if (messages.length > 0) {
@@ -344,13 +392,20 @@ function renderValidation(txInfo) {
errorsEl.style.visibility = "hidden";
}
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
setVisible(
"confirm-balance-error",
balanceRead &&
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
codes.includes(CODES.INSUFFICIENT_ETH)),
);
setVisible(
"confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
@@ -369,9 +424,10 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
async function estimateGas(txInfo) {
// Estimate the network fee on `provider`, the node of the network this screen
// is drawn for.
async function estimateGas(txInfo, provider) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -419,12 +475,13 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
"~" +
formatFee(estimateWei, nativeCurrencyByChainId(txInfo.chainId));
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
@@ -433,20 +490,20 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
nativeCurrencyByChainId(txInfo.chainId),
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
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.
// A max ETH send is the balance readBalances() read 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 is unknown or 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);
const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
@@ -521,9 +578,8 @@ function showSendError(message) {
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) {
async function checkRecipientHistory(txInfo, provider) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
+9 -16
View File
@@ -164,8 +164,9 @@ function renderSendTokenSelect(addr) {
}
}
// The token balance and scale the Send screen states and hands the
// confirmation screen, so the two screens describe the holding the same way.
// The token balance the Send screen states, and the scale it hands the
// confirmation screen, which reads the balance itself from the network it is
// drawn for.
//
// The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked
@@ -188,13 +189,11 @@ function renderSendTokenSelect(addr) {
//
// The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount
// against a number the wallet does not vouch for, so it is withdrawn: unknown
// scale means unknown balance. It is null rather than "0": both screens state
// an unknown balance as unknown, and validateTransfer() treats it as no balance
// to spend from, which is the fail-closed side of an amount nobody can check.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
// stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// null rather than "0": this screen states it as unknown, as the confirmation
// screen does, and Max fills nothing in for it. Only a stored quantity is
// withdrawn: the "0" for a token that has no row at all is an absence of
// holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -378,7 +377,6 @@ function init(_ctx) {
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
@@ -386,10 +384,7 @@ function init(_ctx) {
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
}
// The confirmation screen's own amount check, so it is never shown
@@ -424,9 +419,7 @@ function init(_ctx) {
ensName: ensName,
amount: amount,
token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
max: amountIsMax,
});
+2 -2
View File
@@ -111,8 +111,8 @@ function isValidAmount(amount, tokenDecimals = null) {
// 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
// exactly that reserve left behind. `ethBalance` is an exact decimal string as
// formatEther() writes it, 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) {
+222 -131
View File
@@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -318,10 +320,133 @@ function decodeExactly(types, data) {
return decoded;
}
// Decode one V4 swap action's parameters into the currency it spends
// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action,
// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts.
// A field the action does not state is null. Throws when the parameters are
// not in the deployed shape (see decodeExactly()).
function decodeV4SwapAction(actionId, param) {
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
const path = s[0][1];
return {
tokenIn: s[0][0],
tokenOut: path.length > 0 ? path[path.length - 1][0] : null,
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_IN_SINGLE) {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_OUT) {
// ExactOutputParams: (address currencyOut,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountOut, uint128 amountInMaximum)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
// The router works through the path from its last entry back to its
// first, starting from currencyOut, so the first entry is what the
// action spends.
const path = s[0][1];
return {
tokenIn: path.length > 0 ? path[0][0] : null,
tokenOut: s[0][0],
amountIn: null,
amountOutMin: null,
};
}
// V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above,
// bool zeroForOne, uint128 amountOut, uint128 amountInMaximum,
// bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: null,
amountOutMin: null,
};
}
// The currencies in `list` that `other` does not hold, each listed once.
function currenciesNotIn(list, other) {
return [...new Set(list.filter((c) => !other.includes(c)))];
}
// The amount that the swap actions on one side of a V4 step state together:
// the one action's own amount, or the sum when there are several and each
// states a quantity. Exact-out actions state none here, and the open delta is
// not a quantity. Otherwise null.
function totalAmount(amounts) {
if (amounts.length === 1) return amounts[0];
if (amounts.length === 0) return null;
if (!amounts.every((a) => typeof a === "bigint")) return null;
return amounts.reduce((sum, a) => sum + a, 0n);
}
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
// The input is ABI-encoded as (bytes actions, bytes[] params). Each swap
// action gives the currency it spends and the currency it buys (see
// decodeV4SwapAction()). The step's sides then follow three rules:
//
// 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one
// currency that some swap action spends and none buys. Token Out is the
// currency TAKE or TAKE_ALL names; otherwise the one currency that some
// swap action buys and none spends. If there is not exactly one such
// currency, that side is unknown.
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
//
// A currency a swap action does not name, such as what an exact-in action
// with an empty path buys, is null and counts in rules 1 and 2 as a currency
// like any other: a step of only that action has an unknown Token Out and
// the action's amountOutMin as Min. received.
//
// `hasSwapAction` is true when the step has a swap action, read or not.
// decode() then always sets the output side from this step, and sets the
// input side from it only when no earlier step set it. A side it sets that
// these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -330,11 +455,12 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
// Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE
// and TAKE may still name both currencies.
// (see decodeExactly()), so nothing was read from that action. SETTLE,
// SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies.
let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) {
@@ -347,6 +473,10 @@ function decodeV4Swap(input) {
params[i],
);
settleToken = s[0];
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -354,107 +484,20 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
// Extract amounts from exact-in swap actions
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
swapUndecoded = true;
}
} else {
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
swapUndecoded = true;
}
try {
swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch {
swapUndecoded = true;
}
}
} catch {
@@ -462,12 +505,54 @@ function decodeV4Swap(input) {
}
}
// Rule 3.
if (swapUndecoded) {
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
swapUndecoded,
hasSwapAction: true,
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
let tokenIn = null;
let amountIn = null;
const spentOnly = currenciesNotIn(spent, bought);
if (present(settleToken) || spentOnly.length === 1) {
tokenIn = present(settleToken) ? settleToken : spentOnly[0];
amountIn = totalAmount(
swaps
.filter((s) => s.tokenIn === tokenIn)
.map((s) => s.amountIn),
);
}
// Rules 1 and 2 for Token Out.
let tokenOut = null;
let amountOutMin = null;
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
return {
tokenIn: settleToken,
tokenOut: takeToken,
tokenIn,
tokenOut,
amountIn,
amountOutMin,
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
} catch {
return null;
@@ -491,6 +576,7 @@ function decode(data, toAddress, sources) {
let inputEstablished = false;
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
@@ -510,8 +596,11 @@ function decode(data, toAddress, sources) {
//
// The input side is fixed by the first hop that states either half, the
// output side by the last, because the final leg is what the user
// receives. A half the establishing hop did not state stays null and
// the line says so, rather than being filled in from a different hop.
// receives. A V4 step with a swap action sets a side even when it
// states neither half: the output side always, and the input side
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -526,6 +615,7 @@ function decode(data, toAddress, sources) {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputEstablished = true;
};
for (let i = 0; i < commandsBytes.length; i++) {
@@ -597,30 +687,33 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies
// and amounts, so the step is marked and still sets
// both sides as a swap step does. A side its SETTLE or
// TAKE did not name is set undetermined, not left to
// an earlier or later step's token and figure.
// A swap action of this step went unread, so the step
// is marked.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
}
if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output
// side, as the last swap step wins, and sets the
// input side only when no earlier step set it. Each
// side it sets is set as a pair, even when the step
// states neither half, so a side decodeV4Swap() left
// unknown is set unknown with no figure. A token
// stated without a figure, or a figure without a
// token, likewise never sits beside an earlier step's
// other half.
if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
outputEstablished = true;
} else if (v4) {
// A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no
// output currency makes the output *undetermined* — it
// is neither ETH nor whatever an earlier step named,
// and that figure is no longer counted in that token.
// Equally, a step that names an output currency but no
// minimum leaves Min. received unstated rather than
// keeping an earlier step's figure beside the new
// token. setOutput() is both rules; a step that states
// neither half leaves the output alone.
setOutput(v4.tokenOut, v4.amountOutMin);
}
}
@@ -634,21 +727,19 @@ function decode(data, toAddress, sources) {
}
// Resolve token info. A null token on either side means no step named
// a currency for it, or a V4 step whose swap action went unread left
// it undetermined; tokenInfo() refuses rather than calling it ETH.
// a currency for it, or a V4 step with a swap action left it
// unknown; tokenInfo() refuses rather than calling it ETH.
// UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// output side is WETH, or when no step set the output side. A step
// whose swap action went unread did set it, to undetermined. Any other
// output side is WETH, or when no step set the output side. A V4 step
// with a swap action did set it, even when it set it unknown. Any other
// output keeps its own token and figure: a swap that buys USDC and
// then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset =
!present(outputToken) && !present(minOutput) && !swapUndecoded;
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
hasUnwrapWeth && (outputIsWeth || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
-1
View File
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
+259 -11
View File
@@ -7,17 +7,35 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between.
//
// Its balance line and balance check are that network's too
// (https://git.eeqj.de/sneak/AutistMask/issues/558). The popup can reopen onto
// a confirmation screen saved before the network was switched in another
// popup, and the screen, drawn again for the new network, showed and checked
// the balance Send had read on the old one. The balance read and the fee
// estimate after it stay on that network when it is switched while they run.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
// provider was built for, so a broadcast records the network it went to, and
// a balance read answers with that network's balance.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
// The balances the stub node reports on each network, by network name: ETH
// in wei and the token in its base units. The networks whose node was asked
// for the fee, in order. And the signed transactions it was handed, with its
// network's chain id.
const mockNode = {
balanceWei: {},
tokenUnits: {},
feeAskedOn: [],
broadcast: [],
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider {
constructor(url, network) {
this.network = network;
@@ -25,7 +43,22 @@ jest.mock("ethers", () => {
async getNetwork() {
return this.network;
}
async getBalance() {
return mockNode.balanceWei[this.network.name];
}
// balanceOf() is the one contract call the screen makes before Sign
// & Send.
async call(tx) {
if (!tx.data.startsWith(BALANCE_OF)) {
throw new Error("the stub node answers only balanceOf()");
}
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() {
mockNode.feeAskedOn.push(this.network.name);
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
@@ -123,7 +156,13 @@ global.document = {
};
global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
const {
BaseWallet,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
} = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
@@ -135,6 +174,8 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
// A token with 6 decimal places, which the bundled list does not know.
const TOKEN = "0x" + "d".repeat(40);
function el(id) {
return global.document.getElementById(id);
@@ -150,6 +191,12 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.feeAskedOn = [];
mockNode.broadcast = [];
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
@@ -160,16 +207,23 @@ afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
// show() reads the balances and estimates the fee without awaiting either;
// this lets both settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for what `send` describes instead, as Review on the Send screen does, with
// its balance read and its fee estimate settled.
async function review(networkId, send = {}) {
await onChainSwitch(networkId);
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }],
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
@@ -181,13 +235,36 @@ async function review(networkId) {
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
...send,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
await settle();
}
// Switch the network to `networkId` as Settings in another popup does, then
// open the popup again onto the confirmation screen it saved.
async function reopenOn(networkId) {
const saved = JSON.parse(JSON.stringify(state.viewData));
await onChainSwitch(networkId);
elements.clear();
state.viewData = saved;
confirmTx.init({});
confirmTx.restore();
await settle();
}
function errors() {
return el("confirm-errors").innerHTML;
}
function shown(id) {
return el(id).style.visibility === "visible";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
async function signAndSend() {
@@ -257,3 +334,174 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
describe("a confirmation screen saved on mainnet and reopened after a switch to Sepolia", () => {
test("states and checks the Sepolia balance, which covers the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("0.5"),
sepolia: parseEther("5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("0.5000 ETH");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-type").textContent).toBe(
"Native SepoliaETH transfer",
);
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia balance, which does not cover the send", async () => {
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("0.5"),
};
await review("mainnet");
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(sendDisabled()).toBe(false);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("states and checks the Sepolia token balance, which covers the send", async () => {
mockNode.tokenUnits = { mainnet: 0n, sepolia: 10000000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("0.0000 TOK");
expect(sendDisabled()).toBe(true);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("confirm-gas-error").style.visibility).toBe("hidden");
expect(sendDisabled()).toBe(false);
});
test("states and checks the Sepolia token balance, which does not cover the send", async () => {
mockNode.tokenUnits = { mainnet: 10000000n, sepolia: 500000n };
await review("mainnet", tokenSend);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(sendDisabled()).toBe(false);
await reopenOn("sepolia");
expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your TOK balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
});
describe("the balance read", () => {
test("leaves the balance line Loading... and Sign & Send disabled until it returns", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("Loading...");
expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
expect(el("confirm-errors").style.visibility).toBe("hidden");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-balance-read-error")).toBe(false);
expect(sendDisabled()).toBe(true);
answer(parseEther("5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(sendDisabled()).toBe(false);
});
// The Chrome suite measures that nothing moves; this checks the message
// is already written, only hidden, before the read returns.
test("that finds the amount not covered only makes a message written before it visible", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
const message =
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.";
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(false);
answer(parseEther("0.5"));
await settle();
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(true);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
// The fee is estimated once the read returns, so a node chosen then would
// be the new network's.
test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("mainnet");
} finally {
getBalance.mockRestore();
}
expect(mockNode.feeAskedOn).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(mockNode.feeAskedOn).toEqual(["mainnet"]);
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
test("that fails states the balance as unknown, not zero, and blocks sending", async () => {
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockRejectedValueOnce(new Error("the node did not answer"));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)");
expect(shown("confirm-balance-read-error")).toBe(true);
expect(shown("confirm-balance-error")).toBe(false);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
});
+23 -9
View File
@@ -244,16 +244,21 @@ function latestBlock() {
};
}
// keccak("decimals()")[0:4], and the same for symbol() and name().
// keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol()
// and name() on the stub token. The wallet reads decimals() back at signing
// time to compare with the scale the confirmation screen rendered (issue
// #305). Adding the token by its contract address reads all three (issue
// #295); symbol() and name() answer what the explorer reports for it.
// Every eth_call still answers with a zero word except decimals(), symbol(),
// name() and balanceOf() on the stub token. The wallet reads decimals() back
// at signing time to compare with the scale the confirmation screen rendered
// (issue #305). Adding the token by its contract address reads the first three
// (issue #295); symbol() and name() answer what the explorer reports for it.
// The confirmation screen reads balanceOf()
// (https://git.eeqj.de/sneak/AutistMask/issues/558), which answers the holding
// the explorer reports, or zero when no holding is seeded.
//
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through
@@ -278,6 +283,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]);
}
if (data.startsWith(SELECTOR_BALANCE_OF) && opts.seedTokenBalance) {
return word(tokenBalanceItems(opts)[0].value);
}
return ZERO_WORD;
}
@@ -420,9 +428,10 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false.
//
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
// here rather than captured, so a test flips it on the same options object the
// route was registered with — the same pattern as seedTokenTransfer. This is
// The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// holdBlockscout) is read here rather than captured, so a test flips it on the
// same options object the route was registered with — the same pattern as
// seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine.
@@ -557,6 +566,9 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url());
return route.abort();
}
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report);
}
@@ -615,6 +627,8 @@ function traceEnabled(raw) {
* fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
* eth_getBalance until this is cleared again.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+92 -35
View File
@@ -2232,7 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is.
// fee block says, how tall the whole view is, and where Sign & Send sits in it.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2245,6 +2245,9 @@ async function confirmState(page) {
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,6 +2256,9 @@ async function confirmState(page) {
errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim()
: "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
@@ -2271,6 +2277,16 @@ async function waitForEstimate(page) {
);
}
async function waitForBalance(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back");
@@ -2282,13 +2298,14 @@ async function backToAddress(page) {
await openAddressDetail(page);
}
// Drive the popup to the confirmation screen for one send.
// Drive the popup to the confirmation screen for one send, and return as soon
// as the screen is shown, its balance read still in flight.
//
// It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change.
async function goToConfirm(page, { token, balance, amount }) {
async function openConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
await visible(page, "#view-send");
@@ -2303,6 +2320,14 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount);
await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx");
}
// The same, returning once the screen has read the balance. It reads it
// before it estimates the fee, so every state below is read with the balance
// in place, the pending fee included.
async function goToConfirm(page, send) {
await openConfirm(page, send);
await waitForBalance(page);
await assertAddressesFit(page, "the confirmation screen");
}
@@ -2378,7 +2403,10 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
"the estimate-failed message is shown for an estimate that is merely pending",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
!st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown before the fee is known",
);
});
@@ -2409,6 +2437,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
);
assert(
st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError &&
!st.gasError &&
!st.feeUnknownError,
@@ -2475,30 +2504,54 @@ test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", as
);
});
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
// The screen is first measured with its balance read held, so the message the
// read brings must already hold its space when the read returns (#558).
test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
env.routeOpts.holdBalance = true;
let reading;
try {
await openConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
reading = await confirmState(env.page);
} finally {
env.routeOpts.holdBalance = false;
}
assert(
reading.balance === "Loading...",
"the balance read was not held: " + JSON.stringify(reading.balance),
);
await waitForBalance(env.page);
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert(
st.errors === want,
"wrong over-balance message: " + JSON.stringify(st.errors),
st.balance === FUNDED_ETH_TEXT + " ETH",
"the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
const want =
"Your ETH balance does not cover this amount. Please go back and" +
" send a smaller amount.";
assert(
st.balanceError === want,
"wrong over-balance message: " + JSON.stringify(st.balanceError),
);
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert(
!st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance",
);
assert(
st.sendTop === reading.sendTop,
"Sign & Send moved when the balance read returned: " +
reading.sendTop +
"px -> " +
st.sendTop +
"px",
);
});
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2542,7 +2595,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
!st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
@@ -2633,21 +2689,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert(
st.errors === want,
"wrong over-token-balance message: " + JSON.stringify(st.errors),
st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the confirmation screen shows the wrong token balance: " +
JSON.stringify(st.balance),
);
const want =
"Your " +
STUB_TOKEN.symbol +
" balance does not cover this amount. Please go back and send a" +
" smaller amount.";
assert(
st.balanceError === want,
"wrong over-token-balance message: " + JSON.stringify(st.balanceError),
);
assert(
st.sendDisabled,
@@ -2703,9 +2757,9 @@ test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#2
"the native-ETH over-budget message is shown on an ERC-20 transfer",
);
assert(
st.errors === "",
st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors),
JSON.stringify(st.errors + st.balanceError),
);
assert(
st.height === env.erc20PendingHeight,
@@ -4859,6 +4913,9 @@ async function main() {
seedTokenBalance: false,
ethBalanceWei: null,
failGasEstimate: false,
// Whether a JSON-RPC batch asking for a balance is held unanswered:
// the confirmation screen's own balance read (#558).
holdBalance: false,
holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475).
+8 -7
View File
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() {
return null;
}
// The 1.5 of the native token the address holds on either network.
async getBalance() {
return 1500000000000000000n;
}
// 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -339,14 +343,11 @@ describe.each([
test("the insufficient-balance error", async () => {
await confirmSend("2");
expect(
global.document.getElementById("confirm-errors").innerHTML,
).toContain(
"You have 1.5000 " +
expect(text("confirm-balance-error")).toBe(
"Your " +
symbol +
" but are trying to send 2 " +
symbol +
".",
" balance does not cover this amount. Please go back and send" +
" a smaller amount.",
);
});
});
-1
View File
@@ -767,7 +767,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS,
to: ADDRESS,
amount: "1",
balance: "2",
},
pendingWait: {
hash: "0x1",
+9 -2
View File
@@ -23,7 +23,10 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false,
}));
// What the confirmation screen asks the node for.
const { AbiCoder } = require("ethers");
// What the confirmation screen asks the node for. The address holds the 1 ETH
// and 250 TOK the wallet below records.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -32,7 +35,11 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
getBalance: async () => 1000000000000000000n,
// The one contract call the screen makes, balanceOf(), answered at the
// token's 6 decimal places.
call: async () =>
AbiCoder.defaultAbiCoder().encode(["uint256"], [250000000n]),
};
jest.mock("../src/shared/balances", () => {
+27 -8
View File
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
};
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() {
return mockNode.balanceWei;
}
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() {
return null;
}
@@ -195,10 +203,19 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML;
}
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
@@ -300,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
@@ -316,6 +334,7 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
});
test("with more than 18 decimals and a holding below 10^-18", async () => {
@@ -329,9 +348,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
);
await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(errors()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" +
" 0.000000000000000001 TOK.",
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
});
@@ -356,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
});
+13 -1
View File
@@ -17,6 +17,7 @@
// What the stub node answers, and the signed transactions it was handed.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
feeData: null,
broadcast: [],
};
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() {
return mockNode.balanceWei;
}
// balanceOf(), the one contract call these screens make before Sign
// & Send.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() {
return null;
}
@@ -187,9 +196,12 @@ function text(id) {
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [
{
+474 -20
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) {
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
@@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
);
}
// Helper: V4 ExactOutputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
function encodeV4ExactOutSingle(
currency0,
currency1,
amountOut,
amountInMaximum,
) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountOut,
amountInMaximum,
"0x",
],
],
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
@@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) {
);
}
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) {
@@ -279,6 +303,12 @@ function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]);
}
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
const V4_CLOSE_CURRENCY_ID = 0x12;
function encodeV4CloseCurrency(currency) {
return coder.encode(["address"], [currency]);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
@@ -358,10 +388,9 @@ describe("uniswap decoder", () => {
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
// SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded",
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
({ id, newer }) => {
const data = buildExecute(
"0x10",
@@ -381,6 +410,27 @@ describe("uniswap decoder", () => {
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
@@ -394,7 +444,7 @@ describe("uniswap decoder", () => {
// action names. The V3 step's WETH and its Min. received figure were
// shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output",
"a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
@@ -403,10 +453,7 @@ describe("uniswap decoder", () => {
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
],
9999999999n,
);
@@ -423,15 +470,12 @@ describe("uniswap decoder", () => {
// The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input",
"a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeV3SwapExactIn(
USER_ADDR,
990000n,
@@ -458,10 +502,7 @@ describe("uniswap decoder", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
@@ -1124,6 +1165,419 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several
// swap actions. The screen showed the first action's output as Token Out.
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
// USDC whichever kind of swap actions it holds and in whatever order they
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
],
params: [
encodeV4CloseCurrency(USDT_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
],
},
];
// 1,000 USDT for at least 0.5 WETH
const USDT_FOR_WETH_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
500000000000000000n,
),
};
// 0.5 WETH for at least 990 USDC
const WETH_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
500000000000000000n,
990000000n,
),
};
// exactly 0.5 WETH for at most 1,000 USDT
const USDT_FOR_WETH_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
500000000000000000n,
1000000000n,
),
};
// exactly 990 USDC for at most 0.5 WETH
const WETH_FOR_USDC_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
};
// 400 USDT for at least 396 USDC
const USDT_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
400000000n,
396000000n,
),
};
// 600 USDT through WETH for at least 594 USDC
const USDT_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
600000000n,
594000000n,
),
};
// the open delta of USDT through WETH for at least 594 USDC
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
0n,
594000000n,
),
};
// Amount is the amountIn of the action that spends USDT, or the sum when
// several exact-in actions each state one. Min. received is the same for
// the actions that buy USDC. The decoder reads no amount from an exact-out
// action, and an open delta is not a stated amount.
const V4_STEPS = [
{
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
amount: undefined,
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
amount: undefined,
min: "990.0000 USDC",
},
];
test.each(
V4_STEPS.flatMap((step) =>
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
[...swaps.map((s) => s.param), ...params],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
},
);
// A step with a swap action that could not be read is named only by its
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
// USDT for WETH action names neither side. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
// shape.
test.each([
{
...V4_STEP_ENDINGS[0],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[1],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[2],
tokenIn: "Unknown (could not be decoded)",
tokenOut: "Unknown (could not be decoded)",
},
])(
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
({ ids, params, tokenIn, tokenOut }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
V4_SWAP_ACTION_SHAPES[0].id,
...ids,
]),
[
USDT_FOR_WETH_IN.param,
V4_SWAP_ACTION_SHAPES[0].newer,
...params,
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(tokenIn);
expect(detail(result, "Token Out").value).toBe(tokenOut);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown.
const WETH_FOR_USDC_AND_DAI = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 0.25 WETH for at least 495 USDC
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
250000000000000000n,
495000000n,
),
// 0.25 WETH for at least 495 DAI
encodeV4ExactInSingle(
WETH_ADDR,
DAI_ADDR,
250000000000000000n,
495000000000000000000n,
),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
],
);
// The user ends with USDC and DAI, not the V3 step's WETH. That WETH and
// the V3 step's Min. received figure were shown instead.
test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(
USER_ADDR,
1000000000n,
500000000000000000n,
[USDT_ADDR, WETH_ADDR],
),
WETH_FOR_USDC_AND_DAI,
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received")).toBeUndefined();
});
// The input side of the same gap: the V4 step's swap actions spend USDC
// and DAI, two currencies that none of them buys, and nothing names its
// input side. The V3 step's WETH and its amount were shown instead.
test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 500 USDC for at least 0.25 WETH
encodeV4ExactInSingle(
USDC_ADDR,
WETH_ADDR,
500000000n,
250000000000000000n,
),
// 500 DAI for at least 0.25 WETH
encodeV4ExactInSingle(
DAI_ADDR,
WETH_ADDR,
500000000000000000000n,
250000000000000000n,
),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
],
),
encodeV3SwapExactIn(
USER_ADDR,
500000000000000000n,
990000000n,
[WETH_ADDR, USDT_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
// UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4
// step with a swap action did set it, to unknown here.
test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
WETH_FOR_USDC_AND_DAI,
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
});
// The router works through an exact-out path from its last entry back to
// its first: the WETH entry buys the USDC, then the USDT entry buys that
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([0x09]), [
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
],
990000000n,
1000000000n,
],
],
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
+51 -38
View File
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub.
// in these tests is a failure of the scale and not of the stub. The address
// holds the 1 ETH the wallets below record, and `tokenUnits` of the token.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
getBalance: async () => 1000000000000000000n,
tokenUnits: 0n,
// The one contract call the screen makes, balanceOf().
call: async () =>
AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockProvider.tokenUnits],
),
};
jest.mock("../src/shared/balances", () => {
@@ -114,7 +122,7 @@ global.document = {
};
global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers");
const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
@@ -155,8 +163,9 @@ function row(token = {}, value = FIVE_WETH) {
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them.
// refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -239,6 +249,12 @@ function errors() {
return el("confirm-errors").innerHTML;
}
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = el("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
@@ -270,7 +286,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
});
test("that scale estimates a fee and leaves Send enabled", async () => {
@@ -307,11 +322,10 @@ describe("the Send screen resolves the scale rather than reading the stored one"
// No fallback was introduced: resolution answers null here, and the
// null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(balanceError()).toContain("This token's balance is unknown");
expect(sendDisabled()).toBe(true);
});
});
@@ -355,7 +369,8 @@ describe("a scale the explorer's own rows disagree about", () => {
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull();
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
});
test("so the user is told the balance is unknown, not only that the fee failed", async () => {
@@ -367,8 +382,7 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed.
expect(errors()).not.toBe("");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(balanceError()).toContain("This token's balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it
@@ -434,31 +448,20 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false);
});
});
describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) {
return {
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
// A send of 1.5 NOVEL from an address the node reports none of, with the
// token's scale `tokenDecimals`, or null for a scale nobody knows.
async function render(tokenDecimals) {
mockProvider.tokenUnits = 0n;
state.wallets = [
{
name: "Wallet 1",
@@ -469,30 +472,40 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance));
confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
tokenSymbol: "NOVEL",
tokenDecimals,
});
await settle();
return { balance: text("confirm-balance"), errors: errors() };
return { balance: text("confirm-balance"), message: balanceError() };
}
test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null);
const zero = await render("0.0");
const zero = await render(18);
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
// about the holding, and this one has no established quantity to claim.
test("the insufficient-balance message names the reason, not a figure", async () => {
// spend from, which is the fail-closed side — but "your balance does not
// cover this amount" is a claim about the holding, and this one has no
// established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null);
const zero = await render("0.0");
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
const zero = await render(18);
expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.message).not.toContain("does not cover");
expect(zero.message).toBe(
"Your NOVEL balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
});
});