Compare commits
4
Commits
8741f491e9
...
93cc072a0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cc072a0b | ||
|
|
a68f30c480 | ||
|
|
43c236451f | ||
|
|
1247c24c4d |
+3
-3
@@ -9,9 +9,9 @@ WORKDIR /app
|
|||||||
ENV AUTISTMASK_LINT_NATIVE=1
|
ENV AUTISTMASK_LINT_NATIVE=1
|
||||||
|
|
||||||
# script/test's default 30s bound is the host figure, against a suite that
|
# script/test's default 30s bound is the host figure, against a suite that
|
||||||
# runs in about 8s there. In here the same suite starts on a cold jest cache
|
# takes 23-29s there with three jest workers. In here the same suite starts on
|
||||||
# and shares the runner with the rest of the build, so 30s is marginal rather
|
# a cold jest cache and shares the runner with the rest of the build, so 30s
|
||||||
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
# is too tight — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
||||||
# still catches a hang in three minutes and cannot be tripped by a suite that
|
# still catches a hang in three minutes and cannot be tripped by a suite that
|
||||||
# is merely running on contended hardware.
|
# is merely running on contended hardware.
|
||||||
ENV AUTISTMASK_TEST_TIMEOUT=180
|
ENV AUTISTMASK_TEST_TIMEOUT=180
|
||||||
|
|||||||
@@ -949,6 +949,13 @@ and compare against. Reading the stored field directly instead answers `null`
|
|||||||
for a bundled or tracked token the explorer merely omitted, which is not a
|
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||||
refusal the wallet has any reason to make.
|
refusal the wallet has any reason to make.
|
||||||
|
|
||||||
|
The Send screen also consults every address's explorer reports, so a contract
|
||||||
|
two addresses report different `decimals` for has no scale there, and the stored
|
||||||
|
balance, formatted at one of those scales, is withdrawn with it. The Send
|
||||||
|
screen's `Current balance` and the confirmation screen's balance line then both
|
||||||
|
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
|
||||||
|
fetched, without that cross-address check, and shows the row's figure.
|
||||||
|
|
||||||
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
||||||
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
||||||
decoded swap (see TxApproval below), do not always read as a number. They can
|
decoded swap (see TxApproval below), do not always read as a number. They can
|
||||||
@@ -962,16 +969,25 @@ read:
|
|||||||
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
|
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
|
||||||
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
|
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
|
||||||
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
|
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
|
||||||
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
|
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
|
||||||
or `WRAP_ETH` amount that large, which is not an allowance. The router's
|
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
|
||||||
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
|
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
|
||||||
|
amount.
|
||||||
|
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
|
||||||
|
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
||||||
|
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
||||||
|
not necessarily all of it; the wait, success and error screens show it with
|
||||||
|
the same words. `Unlimited` and `All available (V4 open delta)` keep their
|
||||||
|
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
||||||
|
`amountOut`, the exact amount it buys.
|
||||||
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
- `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
|
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
|
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
|
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,
|
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
|
||||||
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
|
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
||||||
readable exact-in action.
|
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
||||||
|
exact-in action.
|
||||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
||||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
||||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
||||||
@@ -980,9 +996,14 @@ read:
|
|||||||
|
|
||||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
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
|
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
||||||
that side. The token permission warning on the signature screen has its own
|
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
|
||||||
amount wording, including `Unknown`; the SignApproval section below describes
|
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
|
||||||
it.
|
`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.
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
@@ -1395,7 +1416,9 @@ view would leave a wallet one click from deletion.
|
|||||||
- What to send: token dropdown (or static display with contract address when
|
- What to send: token dropdown (or static display with contract address when
|
||||||
locked from AddressToken)
|
locked from AddressToken)
|
||||||
- To: address or ENS name input, with an inline validation message
|
- To: address or ENS name input, with an inline validation message
|
||||||
- Amount input with current balance display
|
- Amount input with current balance display, which reads
|
||||||
|
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||||
|
ConfirmTx's balance line does (see Unknown token scale)
|
||||||
- "Review" button, disabled until the recipient validates
|
- "Review" button, disabled until the recipient validates
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||||
@@ -1413,7 +1436,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses)
|
- Amount: value + symbol (USD in parentheses)
|
||||||
- Your balance: value + symbol (USD in parentheses)
|
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
||||||
|
for a token whose scale is unknown
|
||||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||||
fetched async. The first line is what the transfer is expected to cost,
|
fetched async. The first line is what the transfer is expected to cost,
|
||||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||||
@@ -1429,7 +1453,11 @@ view would leave a wallet one click from deletion.
|
|||||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
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
|
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
|
estimated. The first two are mutually exclusive per transfer type, so only
|
||||||
the applicable one holds space
|
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
|
- Password: an inline field on this screen, not a modal, with its own error
|
||||||
line
|
line
|
||||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||||
|
|||||||
@@ -45,6 +45,54 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-04: A token whose scale is unknown reads the same on the Send screen
|
||||||
|
as on the confirmation screen
|
||||||
|
([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses'
|
||||||
|
explorer reports disagree on a token's `decimals`, the Send screen showed the
|
||||||
|
stored figure while the confirmation screen it leads to said
|
||||||
|
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
|
||||||
|
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
|
||||||
|
its cause: for an unknown scale it says the wallet does not know how many
|
||||||
|
decimal places the token uses and that the transaction cannot be sent, instead
|
||||||
|
of asking the user to go back and try again, which cannot help. For any other
|
||||||
|
cause it is unchanged.
|
||||||
|
|
||||||
|
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
|
||||||
|
decoded on the approval screen
|
||||||
|
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
|
||||||
|
`src/shared/uniswap.js` had no arm for it, so the screen named the step and
|
||||||
|
showed no token or amount. The input side is the path's first token with
|
||||||
|
`amountInMax`, the output side the last token with `amountOut`. When the
|
||||||
|
transaction has such a step, the `Amount` figure reads `Up to <amount>`,
|
||||||
|
whichever step set it, there and on the wait, success and error screens,
|
||||||
|
except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any
|
||||||
|
amount at or above the `uint160` maximum) or `All available (V4 open delta)`.
|
||||||
|
In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side
|
||||||
|
is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise
|
||||||
|
`Token Out` and `Min. received` keep the output side's own token and figure.
|
||||||
|
V3 exact-out (`0x01`) is still not decoded.
|
||||||
|
|
||||||
|
- 2026-10-04: `make test` runs jest in three worker processes
|
||||||
|
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
|
||||||
|
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
|
||||||
|
one worker per CPU core: about 47 processes and 7-8 GiB per run on the shared
|
||||||
|
48-core build host. They now pass `--maxWorkers=3`, and the suite takes 23-29s
|
||||||
|
there: inside the 30-second cap in `script/test`, which is unchanged, but not
|
||||||
|
by much, because `tests/persistedFieldContract.test.js` alone takes most of it
|
||||||
|
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). One or two workers
|
||||||
|
went past the cap. `make check`, the pre-commit hook and `script/cibuild` all
|
||||||
|
run the suite through these scripts.
|
||||||
|
|
||||||
|
- 2026-10-04: The error container on each dApp approval screen keeps its height
|
||||||
|
when an error appears
|
||||||
|
([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
|
||||||
|
and `#approve-sign-error` reserved 20px, but their border and padding took
|
||||||
|
10px of it, so a one-line error grew them to 26px and pushed the buttons below
|
||||||
|
down 6px. They now reserve 30px. A new test in `tests/e2e/run.js` shows each
|
||||||
|
of the six password error containers on its own screen, empty and then with an
|
||||||
|
error, and fails if one changes height or the element below it moves. Some of
|
||||||
|
the longer messages these two containers can show still take two lines.
|
||||||
|
|
||||||
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
|
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
|
||||||
contract. It has no recipient." on its recipient line and in its transaction
|
contract. It has no recipient." on its recipient line and in its transaction
|
||||||
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
|
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit",
|
"test": "jest --forceExit --maxWorkers=3",
|
||||||
"test:verbose": "jest --forceExit --verbose",
|
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
+10
-7
@@ -1,13 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite.
|
# script/test: run the test suite.
|
||||||
#
|
#
|
||||||
# The timeout bounds a hung suite; it is not a performance budget. On a
|
# jest runs three worker processes (package.json), not one per CPU core: on a
|
||||||
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
|
# many-core shared host one per core took gigabytes of RAM per run.
|
||||||
# the bound. Inside the image the same suite also pays a cold jest cache and
|
#
|
||||||
# shares the runner with the rest of the build, which is not what that budget
|
# The timeout bounds a hung suite; it is not a performance budget. On the busy
|
||||||
# describes, so the Dockerfile raises the bound through
|
# shared build host the suite takes 23-29s with three workers, so
|
||||||
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
# REPO_POLICIES' 30s cap is tight there, not comfortable. Inside the image the
|
||||||
# produces a red that means nothing, and teaches "just run it again".
|
# same suite also pays a cold jest cache and shares the runner with the rest of
|
||||||
|
# the build, which is not what that budget describes, so the Dockerfile raises
|
||||||
|
# the bound through AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a
|
||||||
|
# cold cache produces a red that means nothing, and teaches "just run it again".
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
@@ -698,15 +698,13 @@
|
|||||||
You do not have enough ETH to pay the network fee for this
|
You do not have enough ETH to pay the network fee for this
|
||||||
transfer. Please add ETH to this address and try again.
|
transfer. Please add ETH to this address and try again.
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Its sentence names why the fee could not be estimated,
|
||||||
|
so show() in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-fee-unknown-error"
|
id="confirm-fee-unknown-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
>
|
></div>
|
||||||
The network fee could not be estimated, so this transaction
|
|
||||||
cannot be checked against your balance. Please go back and
|
|
||||||
try again.
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -1633,7 +1631,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="approve-tx-error"
|
id="approve-tx-error"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -1710,7 +1708,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="approve-sign-error"
|
id="approve-sign-error"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
|
|||||||
@@ -198,6 +198,19 @@ function show(txInfo) {
|
|||||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||||
|
|
||||||
|
// The fee-unknown message names its cause, which is also known here.
|
||||||
|
// Without the token's scale estimateGas() cannot encode the transfer, so
|
||||||
|
// the estimate fails every time and going back cannot help; any other
|
||||||
|
// failure may clear on a retry.
|
||||||
|
$("confirm-fee-unknown-error").textContent =
|
||||||
|
isErc20 && txInfo.tokenDecimals == null
|
||||||
|
? "The network fee could not be estimated, because this wallet" +
|
||||||
|
" does not know how many decimal places this token uses, so" +
|
||||||
|
" this transaction cannot be sent."
|
||||||
|
: "The network fee could not be estimated, so this transaction" +
|
||||||
|
" cannot be checked against your balance. Please go back and" +
|
||||||
|
" try again.";
|
||||||
|
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
|
|
||||||
// Reset password field and error
|
// Reset password field and error
|
||||||
@@ -244,7 +257,8 @@ function renderValidation(txInfo) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Messages carrying the user's own numbers are built here; the fixed
|
// Messages carrying the user's own numbers are built here; the fixed
|
||||||
// sentences live in the reserved elements in index.html.
|
// sentences live in the reserved elements in index.html, except the
|
||||||
|
// fee-unknown one, which show() sets.
|
||||||
const messages = [];
|
const messages = [];
|
||||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||||
messages.push("Please enter a valid amount to send.");
|
messages.push("Please enter a valid amount to send.");
|
||||||
|
|||||||
+52
-52
@@ -146,6 +146,50 @@ 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 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
|
||||||
|
// tokens, then the explorer. The stored tokenBalances[].decimals is the
|
||||||
|
// explorer's own answer alone, so reading it raw carries a null forward for a
|
||||||
|
// token the wallet does know the scale of — and displayedDecimals() then throws
|
||||||
|
// inside estimateGas(), which the confirmation screen reports as an unestimable
|
||||||
|
// fee. Unsendable, over a scale that was never in doubt
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
|
||||||
|
// knows: no fallback.
|
||||||
|
//
|
||||||
|
// Resolved WITH `wallets`, which balances.js does not pass: that adds
|
||||||
|
// explorerDecimals()'s cross-address check, so a contract two addresses report
|
||||||
|
// different scales for answers null rather than picking one. That check has to
|
||||||
|
// apply here, because this scale encodes the transfer — it is carried forward
|
||||||
|
// so the transfer is encoded with the number the user read rather than with
|
||||||
|
// whatever the contract answers at signing time (see
|
||||||
|
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
||||||
|
// fetch time and cannot consult a state it is in the middle of replacing.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
function tokenBalanceAndDecimals(addr, token) {
|
||||||
|
const tb = (addr.tokenBalances || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
|
const tokenDecimals = resolveTokenDecimals(token, {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
|
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
||||||
|
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||||
|
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||||
|
}
|
||||||
|
|
||||||
function updateSendBalance() {
|
function updateSendBalance() {
|
||||||
const addr = currentAddress();
|
const addr = currentAddress();
|
||||||
if (!addr) return;
|
if (!addr) return;
|
||||||
@@ -162,18 +206,16 @@ function updateSendBalance() {
|
|||||||
truncateAmountNeverZero(addr.balance || "0") +
|
truncateAmountNeverZero(addr.balance || "0") +
|
||||||
" ETH";
|
" ETH";
|
||||||
} else {
|
} else {
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
||||||
);
|
|
||||||
const symbol = resolveSymbol(
|
const symbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
// A null balance is a holding whose scale nothing knows. Saying "0"
|
// A null balance is a holding whose scale is unknown. Saying a figure
|
||||||
// for it would be a claim about the amount; the send itself is
|
// for it would be a claim about the amount, so it reads as the
|
||||||
|
// confirmation screen's balance line reads it; the send itself is
|
||||||
// refused later by transferAmountUnits() for the same missing scale.
|
// refused later by transferAmountUnits() for the same missing scale.
|
||||||
const bal = tb ? tb.balance : "0";
|
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "Current balance: unknown (" + symbol + ")"
|
? "Current balance: unknown (" + symbol + ")"
|
||||||
@@ -240,59 +282,17 @@ function init(_ctx) {
|
|||||||
|
|
||||||
let tokenSymbol = null;
|
let tokenSymbol = null;
|
||||||
let tokenBalance = null;
|
let tokenBalance = null;
|
||||||
// The scale the amount and the balance below are rendered at, carried
|
|
||||||
// forward so the transfer is encoded with the number the user read
|
|
||||||
// rather than with whatever the contract answers at signing time. See
|
|
||||||
// src/shared/transferAmount.js.
|
|
||||||
let tokenDecimals = null;
|
let tokenDecimals = null;
|
||||||
if (token !== "ETH") {
|
if (token !== "ETH") {
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
||||||
);
|
|
||||||
tokenSymbol = resolveSymbol(
|
tokenSymbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
// null carried through rather than flattened to "0": the confirm
|
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
||||||
// screen states an unknown balance as unknown, and
|
addr,
|
||||||
// validateTransfer() treats it as no balance to spend from, which
|
token,
|
||||||
// is the fail-closed side of an amount nobody can check.
|
));
|
||||||
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
|
||||||
// Resolved the same way balances.js resolved the scale it
|
|
||||||
// DISPLAYED this token's balance at: bundled list, then the user's
|
|
||||||
// tracked tokens, then the explorer. The stored
|
|
||||||
// tokenBalances[].decimals is the explorer's own answer alone, so
|
|
||||||
// reading it raw carries a null forward for a token the wallet
|
|
||||||
// does know the scale of — and displayedDecimals() then throws
|
|
||||||
// inside estimateGas(), which the confirmation screen reports as
|
|
||||||
// an unestimable fee. Unsendable, over a scale that was never in
|
|
||||||
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
||||||
// Still null when nothing knows: no fallback.
|
|
||||||
//
|
|
||||||
// Resolved WITH `wallets`, which balances.js does not pass: that
|
|
||||||
// adds explorerDecimals()'s cross-address check, so a contract two
|
|
||||||
// addresses report different scales for answers null rather than
|
|
||||||
// picking one. That check has to apply here, because this value
|
|
||||||
// encodes a transfer; balances.js is formatting one explorer row
|
|
||||||
// at fetch time and cannot consult a state it is in the middle of
|
|
||||||
// replacing.
|
|
||||||
tokenDecimals = resolveTokenDecimals(token, {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.wallets,
|
|
||||||
});
|
|
||||||
// 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, and — since the unknown-balance path
|
|
||||||
// is gated on the balance, not on the scale — would leave the
|
|
||||||
// fee-estimate failure as the only thing on the confirmation
|
|
||||||
// screen, which says nothing about decimals. Unknown scale means
|
|
||||||
// unknown balance. 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.
|
|
||||||
if (tb && tokenDecimals === null) tokenBalance = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
|
|||||||
+41
-10
@@ -100,6 +100,13 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
|
|||||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
||||||
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
||||||
|
|
||||||
|
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
|
||||||
|
// decode() is not told the network, so it takes either.
|
||||||
|
const WETH_ADDRESSES = [
|
||||||
|
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
||||||
|
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
||||||
|
];
|
||||||
|
|
||||||
// `decimals` is null when nothing knows this token's scale. It is not
|
// `decimals` is null when nothing knows this token's scale. It is not
|
||||||
// defaulted to 18: the swap lines land on the same approval screen as the
|
// defaulted to 18: the swap lines land on the same approval screen as the
|
||||||
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
||||||
@@ -198,11 +205,6 @@ function decodeV2SwapExactIn(input) {
|
|||||||
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
//
|
|
||||||
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
|
|
||||||
// its command name and no token or amount detail. Kept for the fix, which is
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/283.
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
function decodeV2SwapExactOut(input) {
|
function decodeV2SwapExactOut(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -447,6 +449,7 @@ function decode(data, toAddress, sources) {
|
|||||||
let outputToken = null;
|
let outputToken = null;
|
||||||
let minOutput = null;
|
let minOutput = null;
|
||||||
let hasUnwrapWeth = false;
|
let hasUnwrapWeth = false;
|
||||||
|
let hasV2ExactOut = false;
|
||||||
const commandNames = [];
|
const commandNames = [];
|
||||||
|
|
||||||
// THE INVARIANT: an amount and the token it is counted in always come
|
// THE INVARIANT: an amount and the token it is counted in always come
|
||||||
@@ -521,6 +524,16 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x09) {
|
||||||
|
// Buys exactly amountOut and spends at most amountInMax.
|
||||||
|
hasV2ExactOut = true;
|
||||||
|
const s = decodeV2SwapExactOut(inputs[i]);
|
||||||
|
if (s) {
|
||||||
|
setInputOnce(s.tokenIn, s.amountInMax);
|
||||||
|
setOutput(s.tokenOut, s.amountOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cmdId === 0x0b) {
|
if (cmdId === 0x0b) {
|
||||||
const w = decodeWrapEth(inputs[i]);
|
const w = decodeWrapEth(inputs[i]);
|
||||||
if (w) {
|
if (w) {
|
||||||
@@ -559,12 +572,19 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
// Resolve token info. A null token on either side means the calldata
|
// Resolve token info. A null token on either side means the calldata
|
||||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
// named no currency for it; tokenInfo() refuses rather than calling it
|
||||||
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
|
||||||
// decode, and it is answered here rather than left to that rule.
|
// when the output side is WETH, or when no step set the output side.
|
||||||
|
// 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);
|
||||||
const inInfo = tokenInfo(inputToken, sources);
|
const inInfo = tokenInfo(inputToken, sources);
|
||||||
const outInfo = hasUnwrapWeth
|
const outInfo =
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
hasUnwrapWeth && (outputIsWeth || outputUnset)
|
||||||
: tokenInfo(outputToken, sources);
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
|
: tokenInfo(outputToken, sources);
|
||||||
|
|
||||||
const inSymbol = inInfo.symbol;
|
const inSymbol = inInfo.symbol;
|
||||||
const outSymbol = outInfo.symbol;
|
const outSymbol = outInfo.symbol;
|
||||||
@@ -613,6 +633,17 @@ function decode(data, toAddress, sources) {
|
|||||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||||
} else if (inputAmount >= MAX_UINT160) {
|
} else if (inputAmount >= MAX_UINT160) {
|
||||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
amount = { raw: "Unlimited", display: "Unlimited" };
|
||||||
|
} else if (hasV2ExactOut) {
|
||||||
|
// A V2 exact-out swap spends at most this figure, whichever
|
||||||
|
// step set the line (its amountInMax, the WRAP_ETH of a swap
|
||||||
|
// paid in ETH, a permit), so it is said to be a maximum, in
|
||||||
|
// `raw` too: the wait, success and error screens show `raw` as
|
||||||
|
// the transaction's amount.
|
||||||
|
const most = amountText(inputAmount, inInfo);
|
||||||
|
amount = {
|
||||||
|
raw: "Up to " + most.raw,
|
||||||
|
display: "Up to " + most.display,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
amount = amountText(inputAmount, inInfo);
|
amount = amountText(inputAmount, inInfo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1449,6 +1449,81 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------------------------------------- password error containers (#297)
|
||||||
|
|
||||||
|
// Every screen that asks for a password reserves room for one line of error.
|
||||||
|
// The two on the dApp approval screens also have a border and padding, which
|
||||||
|
// that reserved height has to cover too.
|
||||||
|
const PASSWORD_ERROR_CONTAINERS = [
|
||||||
|
"approve-tx-error",
|
||||||
|
"approve-sign-error",
|
||||||
|
"export-privkey-flash",
|
||||||
|
"show-phrase-flash",
|
||||||
|
"delete-wallet-flash",
|
||||||
|
"confirm-tx-password-error",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Shows only the screen holding the container, then measures the container
|
||||||
|
// and the element below it empty and again filled the way showError() in
|
||||||
|
// src/popup/views/helpers.js fills it. Runs in the page.
|
||||||
|
function measurePasswordError(id) {
|
||||||
|
const container = document.getElementById(id);
|
||||||
|
const screen = container.closest(".view");
|
||||||
|
for (const view of document.querySelectorAll(".view")) {
|
||||||
|
view.classList.toggle("hidden", view !== screen);
|
||||||
|
}
|
||||||
|
const below = container.nextElementSibling;
|
||||||
|
const measure = () => ({
|
||||||
|
height: container.getBoundingClientRect().height,
|
||||||
|
belowTop: below.getBoundingClientRect().top + window.scrollY,
|
||||||
|
belowHeight: below.getBoundingClientRect().height,
|
||||||
|
});
|
||||||
|
const empty = measure();
|
||||||
|
container.textContent = "Please enter your password.";
|
||||||
|
container.style.visibility = "visible";
|
||||||
|
const filled = measure();
|
||||||
|
container.textContent = "";
|
||||||
|
container.style.visibility = "hidden";
|
||||||
|
return { empty, filled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a password error moves nothing on any screen (#297)", async (env) => {
|
||||||
|
const page = await openPopup(env.ctx, env.popupUrl);
|
||||||
|
try {
|
||||||
|
await page.setViewportSize(POPUP_VIEWPORT);
|
||||||
|
for (const id of PASSWORD_ERROR_CONTAINERS) {
|
||||||
|
const { empty, filled } = await page.evaluate(
|
||||||
|
measurePasswordError,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
empty.belowHeight > 0,
|
||||||
|
"nothing is shown below #" + id + ", so nothing was measured",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
filled.height === empty.height,
|
||||||
|
"#" +
|
||||||
|
id +
|
||||||
|
" is " +
|
||||||
|
empty.height +
|
||||||
|
"px empty and " +
|
||||||
|
filled.height +
|
||||||
|
"px with an error",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
filled.belowTop === empty.belowTop,
|
||||||
|
"the element below #" +
|
||||||
|
id +
|
||||||
|
" moved " +
|
||||||
|
(filled.belowTop - empty.belowTop) +
|
||||||
|
"px when the error appeared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------- confirmation screen (#238)
|
// --------------------------------------------- confirmation screen (#238)
|
||||||
//
|
//
|
||||||
// The screen that decides what gets signed. The arithmetic underneath it
|
// The screen that decides what gets signed. The arithmetic underneath it
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|||||||
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||||
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||||
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
|
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
||||||
|
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
||||||
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
// AutistMask's first-ever swap, 2026-02-27.
|
// AutistMask's first-ever swap, 2026-02-27.
|
||||||
@@ -75,6 +77,14 @@ function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09)
|
||||||
|
function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
|
||||||
|
return coder.encode(
|
||||||
|
["address", "uint256", "uint256", "address[]", "bool"],
|
||||||
|
[recipient, amountOut, amountInMax, pathAddrs, true],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
|
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
|
||||||
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
|
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
|
||||||
// V3 path: token(20) + fee(3) + token(20) ...
|
// V3 path: token(20) + fee(3) + token(20) ...
|
||||||
@@ -223,6 +233,233 @@ describe("uniswap decoder", () => {
|
|||||||
expect(minOut.value).toContain("WETH");
|
expect(minOut.value).toContain("WETH");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Buy exactly 0.5 WETH for at most 1,500 USDC, paid by the user, sent to
|
||||||
|
// the caller (the router's MSG_SENDER recipient, address(1)).
|
||||||
|
test("decodes V2_SWAP_EXACT_OUT, stating the input amount as a maximum", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
"0x09", // V2_SWAP_EXACT_OUT
|
||||||
|
[
|
||||||
|
encodeV2SwapExactOut(
|
||||||
|
"0x0000000000000000000000000000000000000001",
|
||||||
|
500000000000000000n, // amountOut: 0.5 WETH
|
||||||
|
1500000000n, // amountInMax: 1,500 USDC (6 decimals)
|
||||||
|
[USDC_ADDR, WETH_ADDR],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
1767225600n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result.name).toBe("Swap USDC → WETH");
|
||||||
|
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
|
||||||
|
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
|
||||||
|
|
||||||
|
// The wait, success and error screens show rawValue as the amount, so
|
||||||
|
// it says "Up to" as well.
|
||||||
|
const amount = detail(result, "Amount");
|
||||||
|
expect(amount.value).toBe("Up to 1500.0000 USDC");
|
||||||
|
expect(amount.rawValue).toBe("Up to 1500.0000");
|
||||||
|
|
||||||
|
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buy exactly 1,500 USDC for at most 0.5 ETH: WRAP_ETH of the maximum, the
|
||||||
|
// swap, then UNWRAP_WETH of 0, which returns the ETH the swap did not spend.
|
||||||
|
test("a V2 exact-out swap paid in ETH shows the token it buys and a maximum", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x09, 0x0c]),
|
||||||
|
[
|
||||||
|
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
|
||||||
|
encodeV2SwapExactOut(
|
||||||
|
USER_ADDR,
|
||||||
|
1500000000n, // amountOut: 1,500 USDC
|
||||||
|
500000000000000000n, // amountInMax: 0.5 WETH
|
||||||
|
[WETH_ADDR, USDC_ADDR],
|
||||||
|
),
|
||||||
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result.name).toBe("Swap ETH → USDC");
|
||||||
|
|
||||||
|
const amount = detail(result, "Amount");
|
||||||
|
expect(amount.value).toBe("Up to 0.5000 ETH");
|
||||||
|
expect(amount.rawValue).toBe("Up to 0.5000");
|
||||||
|
|
||||||
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
||||||
|
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buy exactly 0.5 ETH for at most 1,500 USDC under a permit: the swap buys
|
||||||
|
// WETH and UNWRAP_WETH turns it into ETH.
|
||||||
|
test("a V2 exact-out swap that buys ETH shows ETH and the permit as a maximum", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
|
||||||
|
[
|
||||||
|
encodePermit2(USDC_ADDR, 1500000000n, ROUTER_ADDR),
|
||||||
|
encodeV2SwapExactOut(
|
||||||
|
ROUTER_ADDR,
|
||||||
|
500000000000000000n, // amountOut: 0.5 WETH
|
||||||
|
1500000000n, // amountInMax: 1,500 USDC
|
||||||
|
[USDC_ADDR, WETH_ADDR],
|
||||||
|
),
|
||||||
|
encodeWrapEth(USER_ADDR, 500000000000000000n), // UNWRAP_WETH
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result.name).toBe("Swap USDC → ETH");
|
||||||
|
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
|
||||||
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paid in a token, an UNWRAP_WETH of 0 after a swap that buys something
|
||||||
|
// other than WETH leaves the output side as it is: Token Out and Min.
|
||||||
|
// received are the token bought and its figure, not ETH.
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
paidIn: "WETH",
|
||||||
|
tokenIn: WETH_ADDR,
|
||||||
|
amountInMax: 500000000000000000n, // 0.5 WETH
|
||||||
|
tokenOut: USDC_ADDR,
|
||||||
|
amountOut: 1300000000n, // 1,300 USDC
|
||||||
|
minReceived: "1300.0000 USDC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
paidIn: "USDC",
|
||||||
|
tokenIn: USDC_ADDR,
|
||||||
|
amountInMax: 8000000n, // 8 USDC
|
||||||
|
tokenOut: DAI_ADDR,
|
||||||
|
amountOut: 7000000000000000000n, // 7 DAI
|
||||||
|
minReceived: "7.0000 DAI",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"a V2 exact-out swap paid in $paidIn, then UNWRAP_WETH, shows the token it buys",
|
||||||
|
({ tokenIn, amountInMax, tokenOut, amountOut, minReceived }) => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
|
||||||
|
[
|
||||||
|
encodePermit2(tokenIn, amountInMax, ROUTER_ADDR),
|
||||||
|
encodeV2SwapExactOut(USER_ADDR, amountOut, amountInMax, [
|
||||||
|
tokenIn,
|
||||||
|
tokenOut,
|
||||||
|
]),
|
||||||
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(detail(result, "Token Out").address).toBe(tokenOut);
|
||||||
|
expect(detail(result, "Min. received").value).toBe(minReceived);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// An exact-in swap is held to the same rule: buying USDC, then UNWRAP_WETH,
|
||||||
|
// receives USDC.
|
||||||
|
test("an exact-in swap to a token other than WETH, then UNWRAP_WETH, shows that token", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
||||||
|
[
|
||||||
|
encodeV2SwapExactIn(USER_ADDR, 2000000n, 1900000n, [
|
||||||
|
USDT_ADDR,
|
||||||
|
USDC_ADDR,
|
||||||
|
]),
|
||||||
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result.name).toBe("Swap USDT → USDC");
|
||||||
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
||||||
|
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
|
||||||
|
// UNWRAP_WETH makes the output ETH.
|
||||||
|
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(
|
||||||
|
["uint8", "uint8", "uint8", "uint8"],
|
||||||
|
[0x0b, 0x09, 0x08, 0x0c],
|
||||||
|
),
|
||||||
|
[
|
||||||
|
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
|
||||||
|
encodeV2SwapExactOut(
|
||||||
|
ROUTER_ADDR,
|
||||||
|
1500000000n, // amountOut: 1,500 USDC
|
||||||
|
500000000000000000n, // amountInMax: 0.5 WETH
|
||||||
|
[WETH_ADDR, USDC_ADDR],
|
||||||
|
),
|
||||||
|
encodeV2SwapExactIn(
|
||||||
|
ROUTER_ADDR,
|
||||||
|
1500000000n, // amountIn: 1,500 USDC
|
||||||
|
400000000000000000n, // amountOutMin: 0.4 WETH
|
||||||
|
[USDC_ADDR, WETH_ADDR],
|
||||||
|
),
|
||||||
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
|
||||||
|
test("a swap to Sepolia WETH, then UNWRAP_WETH, shows ETH", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
|
||||||
|
[
|
||||||
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
||||||
|
USDC_ADDR,
|
||||||
|
SEPOLIA_WETH_ADDR,
|
||||||
|
]),
|
||||||
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A step that states a Min. received figure but names no output token has
|
||||||
|
// set the output side, so UNWRAP_WETH does not make it ETH: nothing says
|
||||||
|
// the figure is counted in WETH.
|
||||||
|
test("a step with a minimum but no output token, then UNWRAP_WETH, names no token", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
|
||||||
|
[
|
||||||
|
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
|
||||||
|
encodeV4ExactIn(
|
||||||
|
USDC_ADDR,
|
||||||
|
[], // no path: this step names no output currency
|
||||||
|
1000000000n, // 1,000 USDC
|
||||||
|
400000000000000000n, // amountOutMin
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
|
"Unknown (not named in the calldata)",
|
||||||
|
);
|
||||||
|
expect(detail(result, "Min. received").value).toBe(
|
||||||
|
"400000000000000000 base units (decimals unknown)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
|
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
"0x00", // V3_SWAP_EXACT_IN
|
"0x00", // V3_SWAP_EXACT_IN
|
||||||
|
|||||||
@@ -382,8 +382,56 @@ describe("a scale the explorer's own rows disagree about", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/377. The Send screen read the
|
||||||
|
// stored balance and said "5.0000 NOVEL", the confirmation screen it leads
|
||||||
|
// to said "unknown (NOVEL)", and the fee message asked the user to go back
|
||||||
|
// and try again, which cannot supply a scale.
|
||||||
|
test("reads the same on the Send screen and the confirmation screen, and the fee message names the scale", async () => {
|
||||||
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
||||||
|
state.selectedToken = NOVEL;
|
||||||
|
send.updateSendBalance();
|
||||||
|
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
|
||||||
|
|
||||||
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
await settle();
|
||||||
|
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
||||||
|
expect(text("confirm-fee-unknown-error")).toBe(
|
||||||
|
"The network fee could not be estimated, because this wallet" +
|
||||||
|
" does not know how many decimal places this token uses, so" +
|
||||||
|
" this transaction cannot be sent.",
|
||||||
|
);
|
||||||
|
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
|
||||||
|
"visible",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("while a fee that fails for any other reason keeps its retry", async () => {
|
||||||
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
||||||
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
|
const getFeeData = mockProvider.getFeeData;
|
||||||
|
mockProvider.getFeeData = async () => {
|
||||||
|
throw new Error("the node did not answer");
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
await settle();
|
||||||
|
} finally {
|
||||||
|
mockProvider.getFeeData = getFeeData;
|
||||||
|
}
|
||||||
|
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
|
||||||
|
expect(text("confirm-fee-unknown-error")).toBe(
|
||||||
|
"The network fee could not be estimated, so this transaction" +
|
||||||
|
" cannot be checked against your balance. Please go back and" +
|
||||||
|
" try again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("while agreeing rows leave the scale usable", async () => {
|
test("while agreeing rows leave the scale usable", async () => {
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
||||||
|
state.selectedToken = NOVEL;
|
||||||
|
send.updateSendBalance();
|
||||||
|
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
expect(txInfo.tokenDecimals).toBe(6);
|
expect(txInfo.tokenDecimals).toBe(6);
|
||||||
expect(txInfo.tokenBalance).toBe("5.0");
|
expect(txInfo.tokenBalance).toBe("5.0");
|
||||||
|
|||||||
Reference in New Issue
Block a user