2 Commits
Author SHA1 Message Date
clawbot 05f2eea033 fix: the approval screen names the address a swap's output is paid to (closes #594)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now reads the address that every swap
step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays. The minimum
reads Min. received only when some step pays the sender, none pays another
address, none that may pay one went unread, and the output is not left in the
router: an output paid to the router needs a later SWEEP of that token, or
UNWRAP_WETH of WETH, paying it out. A payment that the next V2 swap spends as
already paid is a hop, not a payout. Otherwise it reads Min. paid out, with
Paid to lines saying who is paid.

Model: opus-5-5
2026-10-19 08:01:45 +00:00
clawbot 5e4b8ff3b0 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 screen the popup reopened onto stated the balance as Send knew it, not as it is on the network the screen is drawn for.

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 reviewedNetworkProvider() picks for the network the transaction was reviewed for, the node the fee estimate and recipient checks ask. The fee estimate starts once the read returns. 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 09:54:09 +02:00
18 changed files with 963 additions and 316 deletions
+59 -41
View File
@@ -1744,37 +1744,46 @@ view would leave a wallet one click from deletion.
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance no longer covers
the fee, the amount is left as it was and the amount-plus-fee error below
blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown
the reserve the transaction does not use. If the balance could not be read
or no longer covers the fee, the amount is left as it was and one of the
errors below blocks the send
- Your balance: the From address's balance on the network its transaction
was reviewed on, read from that network's RPC endpoint each time the
screen is shown, so a screen the popup reopens onto states and checks that
network's balance as it is then, whichever network is active. "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,
`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
type-2 pricing, where the two are the same number, but its space is
reserved either way
fetched async once the balance is 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 type-2 pricing, where the two are the same number, but its
space is reserved either way
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient that has never sent a transaction and holds no ETH, recipient is
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**
@@ -1796,9 +1805,9 @@ view would leave a wallet one click from deletion.
whichever network is active: its amounts are in that network's native
currency, its USD values are that network's, so a testnet transaction
shows none, its From, To and token contract links open that network's
explorer, the fee estimate and the contract and never-sent recipient
checks ask that network's RPC endpoint, and the Etherscan label check asks
that network's explorer
explorer, the balance read, the fee estimate and the contract and
never-sent recipient checks ask that network's RPC endpoint, and the
Etherscan label check asks that network's explorer
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
@@ -2281,8 +2290,8 @@ view would leave a wallet one click from deletion.
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying who it pays is
unknown (see Transaction Decoding)
`Paid to` line for each other address it pays, or saying that its output
stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
@@ -2922,20 +2931,29 @@ Currently supported:
sender. The router reads `address(1)` as the transaction's sender, and
`address(2)` as the router itself, which holds what it is paid until a later
step pays it out; a payment to the router, as `address(2)` or by its own
address, is not counted as paid out. Every payment counts, in whichever step
and of whatever token. The minimum reads `Min. received` only when some step
pays the transaction's sender (the From address on TxApproval, the sender the
explorer reports on TransactionDetail), no step pays any other address, and no
step that may pay an address went unread. Otherwise it reads `Min. paid out`,
a `Paid to` line after `Token Out` names each other address paid, and one more
`Paid to` line reads `Unknown (could not be decoded)` when such a step went
unread, or `Unknown (not named in the calldata)` when no step pays anyone. A
step goes unread when its recipient does not decode as an address, or when it
is a command or V4 action the decoder does not read: every command other than
`PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`,
`V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and `EXECUTE_SUB_PLAN` among
them), and V4 `TAKE_PORTION`.
address, is not counted as paid out. When a swap step or `TAKE` pays the
router the swap's output token, the output stays in the router unless a later
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
A swap step paying an address that the very next step spends, as a V2 exact-in
of the same token whose `amountIn` is `ALREADY_PAID` (zero) or
`CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes pay the
next V2 pair directly, and the router enforces that swap's own minimum. Every
other payment counts, in whichever step and of whatever token. The minimum
reads `Min. received` only when some step pays the transaction's sender (the
From address on TxApproval, the sender the explorer reports on
TransactionDetail), no step pays any other address, no step that may pay an
address went unread, and the output does not stay in the router. Otherwise it
reads `Min. paid out`, a `Paid to` line after `Token Out` names each other
address paid, one reads
`The router, where it stays (no later step pays it out)` when the output stays
in the router, and one more reads `Unknown (could not be decoded)` when such a
step went unread, or `Unknown (not named in the calldata)` when no step pays
anyone. A step goes unread when its recipient does not decode as an address,
or when it is a command or V4 action the decoder does not read: every command
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`.
### Non-Goals Forever
+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"
+135 -87
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,
@@ -35,11 +41,12 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
toDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages go
// through it, as the approval screen's amounts do.
// The balance line and the fee reserve go through it, as the approval screen's
// amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -63,6 +70,13 @@ 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,
// so a screen the popup reopens onto states them as they are then, not as
// Send knew them. 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;
@@ -101,18 +115,48 @@ 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 = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
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: the
// one active when Send handed the transaction over. Drawn again from the
// saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native
// currency, USD values, explorer links, and the node and explorer its
// checks ask. The wait, success and error screens label amounts by it.
// currency, USD values, explorer links, the balance, and the node and
// explorer its checks ask. The wait, success and error screens label
// amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
@@ -165,33 +209,7 @@ function show(txInfo) {
$("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH", txInfo.chainId);
const tokenPrice = getPrice(rawSymbol, txInfo.chainId);
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, {
@@ -257,6 +275,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
@@ -282,69 +314,78 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible";
}
estimateGas(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).then(() => estimateGas(txInfo));
checkRecipientHistory(txInfo);
}
// Read the From address's balances from the node of the network this
// transaction was reviewed 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) {
let read;
try {
const provider = reviewedNetworkProvider(txInfo);
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 || "?")
: nativeCurrencyByChainId(txInfo.chainId);
// 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) {
@@ -357,13 +398,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",
@@ -472,14 +520,14 @@ async function estimateGas(txInfo) {
}
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);
@@ -546,8 +594,8 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's
// own numbers, so it never moves anything on the screen.
// element and markup renderValidation() uses for the invalid-amount message,
// so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
+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,
});
+5 -5
View File
@@ -2,11 +2,11 @@
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// different places. The confirmation screen renders the amount, the symbol and
// the balance it reads from the node with the block explorer's cached metadata
// (see fetchTokenBalances() in balances.js); the transfer used to be encoded
// from decimals() read off the contract at signing time, and nothing compared
// the two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
+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) {
+105 -37
View File
@@ -113,6 +113,11 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line when the swap's output is paid to the router and no later
// step pays it out of the router.
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -162,6 +167,11 @@ const OPEN_DELTA = Symbol("v4-open-delta");
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// The router's whole-balance amount, universal-router
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
// pays the pair all the router holds of the input token.
const CONTRACT_BALANCE = 2n ** 255n;
// The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say
@@ -352,7 +362,7 @@ function decodeV3SwapExactIn(input) {
function decodeSweep(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { recipient: d[1] };
return { token: d[0], recipient: d[1] };
} catch {
return null;
}
@@ -547,9 +557,9 @@ function totalAmount(amounts) {
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
//
// `recipients` is the recipient of every TAKE as written, and MSG_SENDER for
// every TAKE_ALL, whatever currency each takes. `recipientUnread` is set when
// the step has a payout this decoder did not read.
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set
// when the step has a payout this decoder did not read.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -558,7 +568,7 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
const recipients = [];
const takes = [];
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
@@ -589,12 +599,12 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
recipients.push(t[1]);
takes.push({ currency: t[0], recipient: t[1] });
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
recipients.push(MSG_SENDER);
takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
@@ -629,7 +639,7 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
recipients,
takes,
recipientUnread,
swapUndecoded,
hasSwapAction: true,
@@ -670,7 +680,7 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
recipients,
takes,
recipientUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
@@ -746,16 +756,55 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true;
};
// Every address any step pays, whatever it pays, with MSG_SENDER read
// as the sender. The router itself, as ADDRESS_THIS or by its own
// address, is left out: what the router holds is not yet paid out.
const payees = [];
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
// its own address, holds what it is paid until a later step pays it
// out.
const sender = (fromAddress || "").toLowerCase();
let paidToSender = false;
// Each other address paid, once.
const paidElsewhere = [];
// Set when a step may pay an address this decoder did not read.
let payeeUnread = false;
const addPayee = (recipient) => {
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH,
// paid out.
const heldByRouter = new Set();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
if (r === ADDRESS_THIS || r === toAddress.toLowerCase()) return;
payees.push(r === MSG_SENDER ? fromAddress || "" : recipient);
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE pays; other steps pass none.
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase());
} else if (r === MSG_SENDER || r === sender) {
paidToSender = true;
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
paidElsewhere.push(recipient);
}
};
// A swap step that pays `token` to an address the very next step
// spends, as a V2 exact-in of that token whose input is already paid,
// is a hop, not a payout: Uniswap's mixed routes pay the next V2 pair
// directly, and the router enforces that next swap's own minimum. Its
// input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds.
const isHop = (i, token) => {
if (i + 1 >= commandsBytes.length) return false;
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
const next = decodeV2SwapExactIn(inputs[i + 1]);
return (
next !== null &&
(next.amountIn === ALREADY_PAID ||
next.amountIn === CONTRACT_BALANCE) &&
present(token) &&
present(next.tokenIn) &&
next.tokenIn.toLowerCase() === token.toLowerCase()
);
};
for (let i = 0; i < commandsBytes.length; i++) {
@@ -798,7 +847,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin);
addPayee(s.recipient);
if (!isHop(i, s.tokenOut)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
}
@@ -807,7 +858,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
addPayee(s.recipient);
if (!isHop(i, s.tokenOut)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
}
@@ -818,7 +871,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
addPayee(s.recipient);
if (!isHop(i, s.tokenOut)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
}
@@ -826,6 +881,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const s = decodeSweep(inputs[i]);
if (s) {
addPayee(s.recipient);
if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase());
}
} else {
payeeUnread = true;
}
@@ -843,7 +901,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
v4.recipients.forEach(addPayee);
for (const t of v4.takes) {
if (!isHop(i, t.currency)) {
addPayee(t.recipient, t.currency);
}
}
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
@@ -883,6 +945,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
if (!isRouter(u.recipient)) {
WETH_ADDRESSES.forEach((w) =>
heldByRouter.delete(w),
);
}
} else {
payeeUnread = true;
}
@@ -1042,27 +1109,28 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
// The output is received by the sender only when some step pays the
// sender, none pays another address, and none may pay an address
// this decoder did not read. Otherwise each other address paid is
// named, once, and a further line says when a payout could not be
// read or no step names anyone paid.
const sender = (fromAddress || "").toLowerCase();
const paidElsewhere = payees.filter(
(a, i) =>
a.toLowerCase() !== sender &&
payees.findIndex((b) => b.toLowerCase() === a.toLowerCase()) ===
i,
);
// sender, none pays another address, none may pay an address this
// decoder did not read, and the output token is not left in the
// router. Otherwise each other address paid is named, and further
// lines say when the output stays in the router, a payout could not
// be read, or no step names anyone paid.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const received =
payees.length > 0 && paidElsewhere.length === 0 && !payeeUnread;
paidToSender &&
paidElsewhere.length === 0 &&
!payeeUnread &&
!outputHeld;
for (const address of paidElsewhere) {
details.push({ label: "Paid to", value: address, address });
}
if (payeeUnread || payees.length === 0) {
details.push({
label: "Paid to",
value: payeeUnread ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
if (outputHeld) {
details.push({ label: "Paid to", value: STAYS_IN_ROUTER });
}
if (payeeUnread) {
details.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
details.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
if (present(minOutput)) {
-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;
+273 -21
View File
@@ -15,20 +15,37 @@
// reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it.
//
// Its balance line and balance check are that network's balance, read from
// that network's node each time the screen is drawn
// (https://git.eeqj.de/sneak/AutistMask/issues/558), not the balance Send
// handed over. The fee estimate after the read stays on that network when
// the network is switched while the read runs.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node, fetch 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.
// its 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 questions the stub node was asked for the fee and the recipient checks,
// by method, network name and RPC URL, in order. The signed transactions it
// was handed, with its network's chain id and RPC URL. And whether it quotes
// fees as a network with no type-2 pricing does.
const mockNode = { asked: [], broadcast: [], legacyFees: false };
// The questions the stub node was asked for the balance, the fee and the
// recipient checks, by method, network name and RPC URL, in order. The
// balances it reports on each network, by network name: ETH in wei and the
// token in its base units. The signed transactions it was handed, with its
// network's chain id and RPC URL. And whether it quotes fees as a network
// with no type-2 pricing does.
const mockNode = {
asked: [],
balanceWei: {},
tokenUnits: {},
broadcast: [],
legacyFees: false,
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider {
constructor(url, network) {
this.url = url;
@@ -44,6 +61,22 @@ jest.mock("ethers", () => {
async getNetwork() {
return this.network;
}
async getBalance() {
this.record("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()");
}
this.record("balanceOf");
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() {
this.record("getFeeData");
if (mockNode.legacyFees) {
@@ -158,7 +191,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, saveState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
@@ -198,6 +237,11 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
mockNode.asked = [];
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
@@ -212,7 +256,7 @@ afterEach(() => {
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for the transfer `changes` makes of it, as Review on the Send screen does,
// with its fee estimate settled.
// with its balance read and its fee estimate settled.
async function review(networkId, changes = {}) {
await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes);
@@ -225,7 +269,7 @@ async function reviewOnActiveNetwork(changes = {}) {
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }],
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
@@ -237,9 +281,7 @@ async function reviewOnActiveNetwork(changes = {}) {
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
...changes,
@@ -323,12 +365,24 @@ function expectNativeTransferOn(networkId) {
);
}
// show() estimates the fee and checks the recipient without awaiting either;
// this lets both finish.
// show() reads the balance and then estimates the fee, and checks the
// recipient, without awaiting any of them; this lets all of them finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
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() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
@@ -359,9 +413,10 @@ test("a transfer reviewed after the active network's RPC endpoint was changed in
await reviewOnActiveNetwork();
expect(mockNode.asked).toEqual([
{ method: "getFeeData", network: "sepolia", url: newNode },
{ method: "getBalance", network: "sepolia", url: newNode },
{ method: "getCode", network: "sepolia", url: newNode },
{ method: "getTransactionCount", network: "sepolia", url: newNode },
{ method: "getFeeData", network: "sepolia", url: newNode },
]);
});
@@ -411,7 +466,7 @@ describe.each([
expectNativeTransferOn(reviewedOn);
expect(mockNode.asked).toEqual([
{
method: "getFeeData",
method: "getBalance",
network: reviewedOn,
url: ownNode(reviewedOn),
},
@@ -425,6 +480,11 @@ describe.each([
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
]);
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
explorerUrl + "/address/" + RECIPIENT,
@@ -472,12 +532,106 @@ describe.each([
await review(reviewedOn, { amount: "10" });
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-errors").innerHTML).toContain(
"Insufficient balance. You have 5.0000 " +
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your " +
symbol +
" but are trying to send 10 " +
symbol +
".",
" balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
});
test("states and checks that network's balance as it is then, which no longer covers the send", async () => {
await review(reviewedOn);
expect(sendDisabled()).toBe(false);
mockNode.balanceWei = {
[reviewedOn]: parseEther("0.5"),
[switchedTo]: parseEther("5"),
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe(
"0.5000 " + symbol + usdOn(reviewedOn, "$1,000.00"),
);
expect(shown("confirm-balance-error")).toBe(true);
expect(sendDisabled()).toBe(true);
});
test("states and checks that network's balance as it is then, which now covers the send, and refuses to send it", async () => {
mockNode.balanceWei[reviewedOn] = parseEther("0.5");
await review(reviewedOn);
expect(sendDisabled()).toBe(true);
mockNode.balanceWei = {
[reviewedOn]: parseEther("5"),
[switchedTo]: parseEther("0.5"),
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(reviewedOn, "$10,000.00"),
);
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-amount-fee-error")).toBe(false);
expect(sendDisabled()).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("of a token, states and checks that network's token balance as it is then, which no longer covers the send", async () => {
mockNode.tokenUnits[reviewedOn] = 10000000n;
await review(reviewedOn, tokenSend);
expect(sendDisabled()).toBe(false);
mockNode.tokenUnits = {
[reviewedOn]: 500000n,
[switchedTo]: 10000000n,
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
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);
});
test("of a token, states and checks that network's token balance as it is then, which now covers the send, and refuses to send it", async () => {
await review(reviewedOn, tokenSend);
expect(sendDisabled()).toBe(true);
mockNode.tokenUnits = {
[reviewedOn]: 10000000n,
[switchedTo]: 0n,
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-gas-error")).toBe(false);
expect(sendDisabled()).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
@@ -492,10 +646,10 @@ describe.each([
});
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
mockNode.tokenUnits[reviewedOn] = 50000000n;
await review(reviewedOn, {
token: TOKEN,
tokenSymbol: "USDC",
tokenBalance: "50",
tokenDecimals: 6,
amount: "10",
});
@@ -536,3 +690,101 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
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, after the switch, and still
// asks the node of the network the screen was drawn for.
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();
}
const feeAskedOn = () =>
mockNode.asked
.filter((q) => q.method === "getFeeData")
.map((q) => q.network);
expect(feeAskedOn()).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(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
@@ -781,7 +781,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 = [
{
+138 -5
View File
@@ -57,6 +57,8 @@ const USDC_1990 = 1990000000n;
// The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)";
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
@@ -77,12 +79,12 @@ function execute(...steps) {
}
// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout.
function v2Swap(command, recipient, amount, limit, path) {
function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
return [
command,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amount, limit, path, true],
[recipient, amount, limit, path, payerIsUser],
),
];
}
@@ -303,10 +305,10 @@ describe("an output the router holds", () => {
expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("with nothing paying it out, no one is named as paid", () => {
test("with nothing paying it out, it stays in the router", () => {
const result = decode(execute(toRouter));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([UNNAMED]);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
});
test("the router's own address as a recipient is read as address(2) is", () => {
@@ -317,7 +319,9 @@ describe("an output the router holds", () => {
USDT,
USDC,
);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([UNNAMED]);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([
STAYS_IN_ROUTER,
]);
expect(
labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))),
).toEqual(PAID_TO_USER);
@@ -366,6 +370,120 @@ describe("an output the router holds", () => {
});
});
// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the
// router's output out. A payment to the user of another token, of nothing, or
// before the swap leaves the output in the router.
describe("an output left in the router is not received", () => {
const toRouter = swapToWeth(ADDRESS_THIS);
test.each([
{
kind: "a SWEEP of WETH to the user, then the swap",
data: execute(sweep(WETH, MSG_SENDER), toRouter),
min: "1.0000 WETH",
},
{
kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 ETH",
},
{
kind: "the swap, then a WRAP_ETH of zero to the user",
data: execute(toRouter, wrapEth(MSG_SENDER, 0n)),
min: "1.0000 WETH",
},
{
kind: "the swap, then a SWEEP of a token it does not buy to the user",
data: execute(toRouter, sweep(USDC, MSG_SENDER)),
min: "1.0000 WETH",
},
])("$kind", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe(min);
});
test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
takeAll(USDC),
),
),
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
});
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID.
describe("a swap paid to the pair the next V2 swap spends", () => {
const PAIR = "0x5555555555555555555555555555555555555555";
const CONTRACT_BALANCE = 2n ** 255n;
const swapToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
const pairSpends = (amountIn, path) =>
v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false);
test.each([
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
])(
"a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = decode(
execute(swapToPair, pairSpends(amountIn, [WETH, USDC])),
);
expect(result.name).toBe("Swap USDT → USDC");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Amount").value).toBe("2000.0000 USDT");
expect(detail(result, "Min. received").value).toBe(
"1990.0000 USDC",
);
},
);
test("a V4 step whose TAKE pays the pair, then the V2 swap, reads the same", () => {
const result = decode(
execute(
v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, PAIR)),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
[USDC, WETH],
false,
),
),
);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
test.each([
{
kind: "takes its input from the user",
next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
},
{
kind: "spends another token",
next: pairSpends(0n, [USDC, WETH]),
},
])("when the V2 swap $kind, the pair is named", ({ next }) => {
const result = decode(execute(swapToPair, next));
expect(paidTo(result)).toEqual([PAIR]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("every step's payee counts, whichever step sets the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => {
const toUser = decode(
@@ -466,6 +584,10 @@ describe("a payout that cannot be read is never said to be received", () => {
kind: "V2_SWAP_EXACT_IN",
step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]),
},
{
kind: "V2_SWAP_EXACT_OUT",
step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]),
},
{ kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) },
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
@@ -482,6 +604,17 @@ describe("a payout that cannot be read is never said to be received", () => {
},
);
// The router reads only the low 32 bits of the action list's length, so
// the step still runs, and may pay anyone.
test("a V4_SWAP whose action list's length word has its top byte set", () => {
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER));
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("an output set only by BALANCE_CHECK_ERC20", () => {
const result = decode(execute(balanceCheck(WETH, ONE_WETH)));
expect(paidTo(result)).toEqual([UNNAMED]);
+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.",
);
});
});