Compare commits
1
Commits
next
..
457dd18642
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
457dd18642 |
+6
-5
@@ -8,11 +8,12 @@ WORKDIR /app
|
|||||||
# image sets it.
|
# image sets it.
|
||||||
ENV AUTISTMASK_LINT_NATIVE=1
|
ENV AUTISTMASK_LINT_NATIVE=1
|
||||||
|
|
||||||
# script/test's default 30s bound is the host figure. In here the same suite
|
# script/test's default 30s bound is the host figure, against a suite that
|
||||||
# starts on a cold jest cache and shares the runner with the rest of the build,
|
# runs in about 8s there. In here the same suite starts on a cold jest cache
|
||||||
# so 30s is too tight — it killed a healthy suite at 30.6s on a cold CI cache.
|
# and shares the runner with the rest of the build, so 30s is marginal rather
|
||||||
# 180s still catches a hang in three minutes and cannot be tripped by a suite
|
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
||||||
# that is merely running on contended hardware.
|
# still catches a hang in three minutes and cannot be tripped by a suite that
|
||||||
|
# is merely running on contended hardware.
|
||||||
ENV AUTISTMASK_TEST_TIMEOUT=180
|
ENV AUTISTMASK_TEST_TIMEOUT=180
|
||||||
|
|
||||||
# script/bootstrap installs all prerequisites (make via apt here; node
|
# script/bootstrap installs all prerequisites (make via apt here; node
|
||||||
|
|||||||
@@ -882,31 +882,19 @@ On those screens, when the truncated string would contain no digit from 1 to 9
|
|||||||
and the value does, the amount is extended to its first significant digit
|
and the value does, the amount is extended to its first significant digit
|
||||||
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
|
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
|
||||||
truncated string, integer part included, so `1.00005` still shows as `1.0000` —
|
truncated string, integer part included, so `1.00005` still shows as `1.0000` —
|
||||||
the exception only fires where the entire displayed figure would read as zero.
|
the exception only fires where the entire displayed figure would read as zero. A
|
||||||
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
|
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
|
||||||
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
shows as `0.9999`, never rounded up.
|
||||||
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
|
|
||||||
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
|
|
||||||
shows is a V4 exact-in `amountIn` of zero.
|
|
||||||
|
|
||||||
The rule and its exception live in `src/shared/amountDisplay.js` as
|
The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||||
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
||||||
confirmation screens display goes through the floored one — the ERC-20 amount,
|
confirmation screens display goes through the floored one — the ERC-20 amount,
|
||||||
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
|
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
|
||||||
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
|
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
|
||||||
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
|
balance lists (`src/shared/transactions.js`) use the unfloored one: the
|
||||||
on the confirmation screen for the wallet's own send
|
transaction detail view is the authoritative record and already shows exact
|
||||||
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
|
precision. The 4-decimal rule is unchanged everywhere else, including for
|
||||||
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
|
amounts at or above the floor on the approval screens.
|
||||||
rather than its truncated figure, so the same fee reads the same on both, USD
|
|
||||||
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
|
|
||||||
decimals a token declares, so a balance below the floor reaches these screens as
|
|
||||||
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
|
|
||||||
the transaction detail view is the authoritative record and already shows exact
|
|
||||||
precision. The balance lists use neither: they round to four places with
|
|
||||||
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
|
|
||||||
rule is unchanged everywhere else, including for amounts at or above the floor
|
|
||||||
on the approval screens.
|
|
||||||
|
|
||||||
The floor applies only where the token's scale is known. Where it is not, the
|
The floor applies only where the token's scale is known. Where it is not, the
|
||||||
approval screen states base units instead of a quantity — see Unknown token
|
approval screen states base units instead of a quantity — see Unknown token
|
||||||
@@ -949,62 +937,6 @@ 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
|
|
||||||
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
|
|
||||||
read:
|
|
||||||
|
|
||||||
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
|
|
||||||
truncated to four decimals, with the floor and the zero cases described above.
|
|
||||||
- `<amount> base units (decimals unknown)` when the scale is unknown: the
|
|
||||||
base-unit integer, rather than a figure at a guessed scale (see Unknown token
|
|
||||||
scale above).
|
|
||||||
- `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
|
|
||||||
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,
|
|
||||||
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
|
|
||||||
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
|
|
||||||
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
|
|
||||||
whole open delta", so the calldata states no quantity. The line shows the
|
|
||||||
amount of one step that names an input token or amount: the last
|
|
||||||
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
|
|
||||||
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
|
||||||
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
|
||||||
exact-in action.
|
|
||||||
- `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
|
|
||||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
|
||||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
|
||||||
read `0.0000` when the token's scale was known.
|
|
||||||
|
|
||||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
|
||||||
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
|
||||||
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
|
|
||||||
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
|
|
||||||
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
|
|
||||||
`Min. received` keep the output side's own token and figure, whether the swap
|
|
||||||
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
|
|
||||||
unwraps the WETH it did not spend shows USDC. The token permission warning on
|
|
||||||
the signature screen has its own amount wording, including `Unknown`; the
|
|
||||||
SignApproval section below describes it.
|
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
@@ -1085,13 +1017,8 @@ list from any other contract address is always dropped, and so is any token
|
|||||||
claiming a symbol that belongs to the native asset and therefore has no
|
claiming a symbol that belongs to the native asset and therefore has no
|
||||||
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
||||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||||||
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
|
and the send-screen token selector, not this list. Tracked tokens with a zero
|
||||||
every nonzero holding of a token it admits, however small, but a holding below
|
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
||||||
0.000001 is left out of the balance lists, the send-screen token selector, the
|
|
||||||
address total and the remove-address warning (`isBelowOneMillionth()` in
|
|
||||||
`src/shared/amountDisplay.js`). The Send and confirmation screens show it when
|
|
||||||
its token is the one being sent. Tracked tokens with a zero balance are listed
|
|
||||||
as well while "Show tracked tokens with zero balance" is on.
|
|
||||||
|
|
||||||
#### Stored state and its version
|
#### Stored state and its version
|
||||||
|
|
||||||
@@ -1279,10 +1206,7 @@ view would leave a wallet one click from deletion.
|
|||||||
of every wallet, deduplicated by hash and filtered. Each row is three
|
of every wallet, deduplicated by hash and filtered. Each row is three
|
||||||
lines: age and direction, then the counterparty's colour dot (with our own
|
lines: age and direction, then the counterparty's colour dot (with our own
|
||||||
name for it, where it is one of our addresses) and the amount, then the
|
name for it, where it is one of our addresses) and the amount, then the
|
||||||
counterparty's full address on a row of its own. A contract creation has
|
counterparty's full address on a row of its own
|
||||||
no counterparty: its second line is the amount alone and its third line
|
|
||||||
says "This transaction creates a new contract. It has no recipient." The
|
|
||||||
transaction lists on AddressDetail and AddressToken draw the same rows
|
|
||||||
- "Add additional wallet..." link at bottom
|
- "Add additional wallet..." link at bottom
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Tap address row → sets the active address and broadcasts
|
- Tap address row → sets the active address and broadcasts
|
||||||
@@ -1416,9 +1340,7 @@ 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, which reads
|
- Amount input with current balance display
|
||||||
`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**
|
||||||
@@ -1436,8 +1358,7 @@ 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), or `unknown (SYMBOL)`
|
- Your balance: value + symbol (USD in parentheses)
|
||||||
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
|
||||||
@@ -1453,11 +1374,7 @@ 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 last names its cause: for a token
|
the applicable one holds space
|
||||||
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
|
||||||
@@ -1482,9 +1399,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link; for a contract creation,
|
- To: color dot + full address + etherscan link
|
||||||
which has no recipient, "This transaction creates a new contract. It has
|
|
||||||
no recipient." instead
|
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Count-up timer: "Waiting for confirmation... Ns"
|
- Count-up timer: "Waiting for confirmation... Ns"
|
||||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||||
@@ -1513,8 +1428,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- Decoded action well (shown when the transaction carried recognized
|
- Decoded action well (shown when the transaction carried recognized
|
||||||
calldata; the top-level Amount and To are hidden in that case)
|
calldata; the top-level Amount and To are hidden in that case)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link, or for a contract creation
|
- To: color dot + full address + etherscan link
|
||||||
the same sentence as on WaitTx
|
|
||||||
- Block number
|
- Block number
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- "Done" button
|
- "Done" button
|
||||||
@@ -1529,8 +1443,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Failed" heading
|
- "Transaction Failed" heading
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link, or for a contract creation
|
- To: color dot + full address + etherscan link
|
||||||
the same sentence as on WaitTx
|
|
||||||
- Error message (dashed border box)
|
- Error message (dashed border box)
|
||||||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||||||
full hash (tap to copy) + etherscan link
|
full hash (tap to copy) + etherscan link
|
||||||
@@ -1568,7 +1481,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||||
ENS name if available
|
ENS name if available
|
||||||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||||||
name if available. For a contract creation, the same sentence as on WaitTx
|
name if available
|
||||||
- Time: ISO datetime + relative age in parentheses
|
- Time: ISO datetime + relative age in parentheses
|
||||||
- Block: block number (tap to copy) + etherscan block link
|
- Block: block number (tap to copy) + etherscan block link
|
||||||
- Amount: value + symbol (bold)
|
- Amount: value + symbol (bold)
|
||||||
@@ -1631,14 +1544,8 @@ view would leave a wallet one click from deletion.
|
|||||||
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
||||||
is refused with a flash message and the field snaps back to the stored
|
is refused with a flash message and the field snaps back to the stored
|
||||||
threshold, so a number the user did not type is never stored.
|
threshold, so a number the user did not type is never stored.
|
||||||
- Allowed Sites: the hostnames remembered as allowed, under any address,
|
- Allowed Sites: list with remove buttons
|
||||||
with remove buttons
|
- Denied Sites: list with remove buttons
|
||||||
- Connected Sites: the hostnames of the sites allowed without "Remember my
|
|
||||||
choice" that are still connected, with remove buttons. Only the background
|
|
||||||
holds these, in memory, and Settings asks it for them with
|
|
||||||
`AUTISTMASK_GET_CONNECTED_SITES`
|
|
||||||
- Denied Sites: the hostnames remembered as denied, under any address, with
|
|
||||||
remove buttons
|
|
||||||
- About: project link, license, author, version, release date, and the
|
- About: project link, license, author, version, release date, and the
|
||||||
commit, which links to the commit in the repository
|
commit, which links to the commit in the repository
|
||||||
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
||||||
@@ -1649,15 +1556,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
|
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
|
||||||
- `[x]` on a wallet → **DeleteWallet**
|
- `[x]` on a wallet → **DeleteWallet**
|
||||||
- Tap wallet name → inline rename field (no screen change)
|
- Tap wallet name → inline rename field (no screen change)
|
||||||
- `[x]` on a tracked token → removes it in place (no screen change)
|
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||||||
- `[x]` on an allowed or connected site → disconnects that site, in place:
|
change)
|
||||||
its hostname is dropped from Allowed Sites under every address, and
|
|
||||||
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
|
||||||
without "Remember" from an origin with that hostname, under any address,
|
|
||||||
and send `accountsChanged` with an empty list to the site's open tabs.
|
|
||||||
Only the extension's own pages may send either message
|
|
||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
|
||||||
nothing and tells the background nothing
|
|
||||||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||||
- "Back" (or Settings gear again) → previous screen (Home)
|
- "Back" (or Settings gear again) → previous screen (Home)
|
||||||
|
|
||||||
@@ -1708,10 +1608,6 @@ view would leave a wallet one click from deletion.
|
|||||||
- Either way, the active address moves only if it belonged to the deleted
|
- Either way, the active address moves only if it belonged to the deleted
|
||||||
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
||||||
(`src/shared/walletDelete.js`)
|
(`src/shared/walletDelete.js`)
|
||||||
- Either way, every address the wallet held loses its site permissions of
|
|
||||||
both kinds: the remembered ones in storage, and the connections approved
|
|
||||||
without "Remember", which only the background holds, in memory, and drops
|
|
||||||
on `AUTISTMASK_ADDRESSES_REMOVED`
|
|
||||||
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
|
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
|
||||||
try again." on the error line, nothing deleted
|
try again." on the error line, nothing deleted
|
||||||
- "I have lost my password" → **DeleteWalletLostPassword**
|
- "I have lost my password" → **DeleteWalletLostPassword**
|
||||||
@@ -1808,10 +1704,6 @@ view would leave a wallet one click from deletion.
|
|||||||
so a connected site stops being told about an address the user removed
|
so a connected site stops being told about an address the user removed
|
||||||
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
|
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
|
||||||
one in this wallet follows the splice.
|
one in this wallet follows the splice.
|
||||||
- The address loses its site permissions of both kinds, whether or not it was
|
|
||||||
the active one: the remembered ones in storage, and any connection approved
|
|
||||||
without "Remember", which only the background holds, in memory, and drops on
|
|
||||||
`AUTISTMASK_ADDRESSES_REMOVED`.
|
|
||||||
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
||||||
fresh address rather than handing back the one just removed.
|
fresh address rather than handing back the one just removed.
|
||||||
|
|
||||||
@@ -1852,8 +1744,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||||||
choice is persisted to the allowed or denied list when "Remember" is
|
choice is persisted to the allowed or denied list when "Remember" is
|
||||||
checked, and an "Allow" without it is listed under Connected Sites in
|
checked)
|
||||||
**Settings**)
|
|
||||||
- Popup closed without answering → treated as a denial
|
- Popup closed without answering → treated as a denial
|
||||||
|
|
||||||
#### TxApproval (`approve-tx`)
|
#### TxApproval (`approve-tx`)
|
||||||
@@ -1879,8 +1770,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- Decoded action (if calldata is recognized): action name, token details,
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
amounts, steps, deadline (see Transaction Decoding)
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
- Contract: color dot + full address + etherscan link (or "contract
|
||||||
known; for a contract creation, the same sentence as on WaitTx
|
creation"), token symbol label if known
|
||||||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||||
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
||||||
in parentheses), with the gas limit and the fee per gas in gwei below it
|
in parentheses), with the gas limit and the fee per gas in gwei below it
|
||||||
|
|||||||
@@ -45,132 +45,6 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-04: `make test` takes 8-13s on the shared build host, down from
|
|
||||||
17-25s, measured in alternating runs before and after the change
|
|
||||||
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
|
|
||||||
the tests (`tests/support/popupBoot.js`) resets jest's module registry so that
|
|
||||||
everything under `src/` loads fresh, and that also reloaded `ethers`,
|
|
||||||
`libsodium-wrappers-sumo`, `qrcode` and `ethereum-blockies-base64` every time.
|
|
||||||
Those four libraries are now loaded once per test file and handed to every
|
|
||||||
boot. No test or assertion changed.
|
|
||||||
|
|
||||||
- 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
|
|
||||||
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
|
|
||||||
wait, success and error screens, the transaction detail view and the history
|
|
||||||
rows on Home, AddressDetail and AddressToken showed a blank address there,
|
|
||||||
with a colour dot whose colour was `undefined`; the approval screen showed
|
|
||||||
"(contract creation)". A transaction with a real `to` is unchanged.
|
|
||||||
|
|
||||||
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
|
|
||||||
token balance or a network fee below 0.000001 as zero
|
|
||||||
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
|
|
||||||
(`src/shared/balances.js`) and the confirmation screen's fee were each cut to
|
|
||||||
six decimal places by a rule of their own, and a token holding cut to zero was
|
|
||||||
dropped. Balances are now stored exactly, whatever decimals a token declares,
|
|
||||||
and every nonzero token holding is kept; the balance check reads a token
|
|
||||||
balance to its first 18 places, the most an amount can have. The balance
|
|
||||||
lists, the send-screen token selector, the address total and the
|
|
||||||
remove-address warning leave out a holding below 0.000001 themselves, as
|
|
||||||
before. The Send screen's `Current balance`, and the confirmation screen's
|
|
||||||
balance, fee, reserve and insufficient-balance messages, go through
|
|
||||||
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the
|
|
||||||
approval screen already used. The confirmation and approval screens both
|
|
||||||
render the fee through `formatFee()` in `src/popup/views/helpers.js`, which
|
|
||||||
prices the exact fee in USD, so the same fee reads the same on both, USD value
|
|
||||||
included.
|
|
||||||
|
|
||||||
- 2026-10-04: The flash line keeps to the one line it reserves at any message
|
|
||||||
length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message
|
|
||||||
that wrapped pushed the whole screen below it down. `#flash-msg` no longer
|
|
||||||
wraps: text too long for the line is cut with an ellipsis, and `showFlash()`
|
|
||||||
puts the whole message in the line's title. Every message is also reworded to
|
|
||||||
at most 50 characters so none is cut; none carries a wallet name or text from
|
|
||||||
a server, and the add-token screens flash a fixed line for any error other
|
|
||||||
than a contract that is not a token. A new test in `tests/e2e/run.js` puts a
|
|
||||||
message several lines long on the line and fails if the line or the screen
|
|
||||||
below it moves. The two approval-screen error boxes are left to
|
|
||||||
[#297](https://git.eeqj.de/sneak/AutistMask/issues/297).
|
|
||||||
|
|
||||||
- 2026-10-04: A method the wallet does not implement is refused with EIP-1193
|
|
||||||
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
|
|
||||||
background's `Unsupported method: <method>` error carried no code, so a site
|
|
||||||
probing for an optional method could not tell "not implemented" from "the call
|
|
||||||
failed". The message is unchanged; the background's other errors with no code
|
|
||||||
are untouched.
|
|
||||||
|
|
||||||
- 2026-10-04: Settings lists the sites connected without "Remember", and
|
|
||||||
removing a site there disconnects it
|
|
||||||
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
|
|
||||||
lives only in the background's in-memory `connectedSites` map, so Settings
|
|
||||||
never showed it and the user could not end it; `AUTISTMASK_REMOVE_SITE`, sent
|
|
||||||
on every remove, did nothing. Settings now asks the background for those sites
|
|
||||||
(`AUTISTMASK_GET_CONNECTED_SITES`) and lists them under Connected Sites.
|
|
||||||
Removing a site from Allowed Sites or Connected Sites drops its remembered
|
|
||||||
entry under every address and sends `AUTISTMASK_REMOVE_SITE` with the
|
|
||||||
hostname; the background deletes every `connectedSites` entry for that
|
|
||||||
hostname and sends `accountsChanged` with an empty list to its open tabs. Only
|
|
||||||
the extension's own pages may send either message. Removing a denied site no
|
|
||||||
longer sends it, since forgetting a refusal ends no connection.
|
|
||||||
- 2026-10-04: Removing an address or deleting a wallet ends every site
|
|
||||||
connection approved without "Remember" for the addresses removed
|
|
||||||
([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection
|
|
||||||
lives only in the background's in-memory `connectedSites` map. Both removal
|
|
||||||
paths dropped the remembered `allowedSites`/`deniedSites` entries, but nothing
|
|
||||||
told the background, so its entry was cleared only as a side effect: every
|
|
||||||
change of active address empties the whole map, and removing the active
|
|
||||||
address changes it. `dropSitePermissions()` in `src/shared/walletDelete.js`,
|
|
||||||
shared by both paths, now also sends `AUTISTMASK_ADDRESSES_REMOVED` with the
|
|
||||||
removed addresses, and the background deletes their `connectedSites` entries;
|
|
||||||
only the extension's own pages may send it.
|
|
||||||
- 2026-10-03: The typed-data signing screen warns for a token permission, and
|
- 2026-10-03: The typed-data signing screen warns for a token permission, and
|
||||||
names the primary type ethers signs
|
names the primary type ethers signs
|
||||||
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
|
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
|
||||||
@@ -299,20 +173,6 @@ but the review is broader than any of them.
|
|||||||
standalone storage entry, never part of the versioned `autistmask` profile, so
|
standalone storage entry, never part of the versioned `autistmask` profile, so
|
||||||
the state schema is untouched and no existing profile is affected.
|
the state schema is untouched and no existing profile is affected.
|
||||||
|
|
||||||
- 2026-09-21: `README.md` no longer says a genuine zero always renders `0.0000`
|
|
||||||
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The amount rule
|
|
||||||
still renders one as `0.0000`, but two swap lines say a zero in words instead:
|
|
||||||
`Min. received` reads `None (no minimum guaranteed)` for a zero minimum, and
|
|
||||||
`Amount` reads `All available (V4 open delta)` when the amount it shows is a
|
|
||||||
V4 exact-in `amountIn` of zero. A new list says what the decoded ERC-20 and
|
|
||||||
swap amount lines on the transaction approval screen can read: a formatted
|
|
||||||
quantity, base units with decimals unknown, `Unlimited`,
|
|
||||||
`All available (V4 open delta)` and `None (no minimum guaranteed)`, which a
|
|
||||||
zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads instead of
|
|
||||||
`0.0000` at a known scale. The README also names
|
|
||||||
`Unknown (not named in the calldata)` on the swap's token lines, and points to
|
|
||||||
SignApproval for the token permission warning's own wording. Docs only.
|
|
||||||
|
|
||||||
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
||||||
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
||||||
wallet list was the reported case: the address shared one row with the
|
wallet list was the reported case: the address shared one row with the
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit --maxWorkers=3",
|
"test": "jest --forceExit",
|
||||||
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
|
"test:verbose": "jest --forceExit --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
+5
-8
@@ -1,14 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite.
|
# script/test: run the test suite.
|
||||||
#
|
#
|
||||||
# jest runs three worker processes (package.json), not one per CPU core: on a
|
# The timeout bounds a hung suite; it is not a performance budget. On a
|
||||||
# many-core shared host one per core took gigabytes of RAM per run.
|
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
|
||||||
#
|
# the bound. Inside the image the same suite also pays a cold jest cache and
|
||||||
# The timeout bounds a hung suite; it is not a performance budget. On the busy
|
# shares the runner with the rest of the build, which is not what that budget
|
||||||
# shared build host the suite takes 8-13s with three workers, inside
|
# describes, so the Dockerfile raises the bound through
|
||||||
# REPO_POLICIES' 20s budget. 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 describes, so the Dockerfile raises the bound through
|
|
||||||
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
# 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".
|
# produces a red that means nothing, and teaches "just run it again".
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+2
-59
@@ -932,9 +932,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EIP-1193 4200 lets a site tell "this wallet does not implement that"
|
return { error: { message: "Unsupported method: " + method } };
|
||||||
// from "that call failed", and fall back.
|
|
||||||
return { error: { code: 4200, message: "Unsupported method: " + method } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body of eth_sendTransaction, from the connection check through to the
|
// The body of eth_sendTransaction, from the connection check through to the
|
||||||
@@ -1112,24 +1110,6 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell every open tab of a site Settings removed that it has no account.
|
|
||||||
async function broadcastSiteRemoved(hostname) {
|
|
||||||
let tabs;
|
|
||||||
try {
|
|
||||||
tabs = await tabsQuery({});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const tab of tabs) {
|
|
||||||
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
|
|
||||||
tabsSendMessage(tab.id, {
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [],
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||||
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
||||||
// fresh, so this naturally skips.
|
// fresh, so this naturally skips.
|
||||||
@@ -1325,9 +1305,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
"AUTISTMASK_GET_APPROVAL",
|
"AUTISTMASK_GET_APPROVAL",
|
||||||
"AUTISTMASK_TX_RESPONSE",
|
"AUTISTMASK_TX_RESPONSE",
|
||||||
"AUTISTMASK_SIGN_RESPONSE",
|
"AUTISTMASK_SIGN_RESPONSE",
|
||||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
|
||||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
|
||||||
"AUTISTMASK_REMOVE_SITE",
|
|
||||||
];
|
];
|
||||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||||
sendResponse({ error: "Unauthorized sender" });
|
sendResponse({ error: "Unauthorized sender" });
|
||||||
@@ -1670,42 +1647,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup removed these addresses, so no site stays connected to them.
|
|
||||||
// A connectedSites key is origin + ":" + address, and an origin can carry
|
|
||||||
// a port, so the address is what follows the last colon.
|
|
||||||
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
|
|
||||||
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
|
|
||||||
for (const key of Object.keys(connectedSites)) {
|
|
||||||
const address = key.slice(key.lastIndexOf(":") + 1);
|
|
||||||
if (removed.some((a) => sameAddress(a, address))) {
|
|
||||||
delete connectedSites[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings lists the sites connected without "Remember", which only this
|
|
||||||
// worker knows. The origin is what precedes the key's last colon.
|
|
||||||
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
|
||||||
sendResponse(
|
|
||||||
Object.keys(connectedSites).map((key) =>
|
|
||||||
extractHostname(key.slice(0, key.lastIndexOf(":"))),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings removed this site and has already dropped its remembered
|
|
||||||
// entries. Its connections approved without "Remember" end here, under
|
|
||||||
// every address, and its open tabs are told it has no account.
|
|
||||||
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
||||||
for (const key of Object.keys(connectedSites)) {
|
// Popup already saved state; nothing else needed
|
||||||
const origin = key.slice(0, key.lastIndexOf(":"));
|
|
||||||
if (extractHostname(origin) === msg.hostname) {
|
|
||||||
delete connectedSites[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
broadcastSiteRemoved(msg.hostname);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,12 +31,11 @@
|
|||||||
// an error instead of accepting the refusal.
|
// an error instead of accepting the refusal.
|
||||||
//
|
//
|
||||||
// Whatever code arrived is passed through verbatim rather than being
|
// Whatever code arrived is passed through verbatim rather than being
|
||||||
// matched against a list: the extension emits codes such as 4001, 4100,
|
// matched against a list: the extension emits 4001, 4100 and 4902 today,
|
||||||
// 4200 and 4902, and a code this file has never heard of is still the
|
// and a code this file has never heard of is still the truth about what
|
||||||
// truth about what happened. An error reported with no code at all stays
|
// happened. An error reported with no code at all stays a plain Error —
|
||||||
// a plain Error — a ProviderRpcError whose `code` is undefined would
|
// a ProviderRpcError whose `code` is undefined would advertise a
|
||||||
// advertise a conformance it does not have. `message` is untouched in
|
// conformance it does not have. `message` is untouched in every case.
|
||||||
// every case.
|
|
||||||
function toPageError(error) {
|
function toPageError(error) {
|
||||||
const message = (error && error.message) || "Request failed";
|
const message = (error && error.message) || "Request failed";
|
||||||
if (error && error.code !== undefined && error.code !== null) {
|
if (error && error.code !== undefined && error.code !== null) {
|
||||||
|
|||||||
@@ -19,9 +19,13 @@
|
|||||||
// that the user did not type — the same silent substitution the visible
|
// that the user did not type — the same silent substitution the visible
|
||||||
// rejection message exists to end.
|
// rejection message exists to end.
|
||||||
|
|
||||||
// Must render on ONE line of #flash-msg; see showFlash() in
|
// Must render on ONE line of #flash-msg, whose reserved height
|
||||||
// src/popup/views/helpers.js for how long that is.
|
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
|
||||||
const DUST_THRESHOLD_MESSAGE = "Enter a whole number of gwei, zero or greater.";
|
// wrap to two lines pushes the settings view down, which the No Layout Shift
|
||||||
|
// policy forbids. Do not lengthen this without re-running the layout test in
|
||||||
|
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
|
||||||
|
const DUST_THRESHOLD_MESSAGE =
|
||||||
|
"Please enter a whole number of gwei, zero or greater.";
|
||||||
|
|
||||||
// Returns the threshold in gwei, or null if the input is not one.
|
// Returns the threshold in gwei, or null if the input is not one.
|
||||||
function parseDustThresholdGwei(raw) {
|
function parseDustThresholdGwei(raw) {
|
||||||
|
|||||||
+8
-15
@@ -33,7 +33,7 @@
|
|||||||
<!-- ============ FLASH MESSAGE AREA ============ -->
|
<!-- ============ FLASH MESSAGE AREA ============ -->
|
||||||
<div
|
<div
|
||||||
id="flash-msg"
|
id="flash-msg"
|
||||||
class="text-xs text-muted min-h-[1.25rem] mb-1 truncate"
|
class="text-xs text-muted min-h-[1.25rem] mb-1"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- ============ WELCOME / FIRST USE ============ -->
|
<!-- ============ WELCOME / FIRST USE ============ -->
|
||||||
@@ -698,13 +698,15 @@
|
|||||||
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
|
||||||
@@ -1062,15 +1064,6 @@
|
|||||||
<div id="settings-allowed-sites"></div>
|
<div id="settings-allowed-sites"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
|
||||||
<h3 class="font-bold mb-1">Connected Sites</h3>
|
|
||||||
<p class="text-xs text-muted mb-2">
|
|
||||||
Sites you allowed without "Remember my choice".
|
|
||||||
Switching address disconnects them.
|
|
||||||
</p>
|
|
||||||
<div id="settings-connected-sites"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Denied Sites</h3>
|
<h3 class="font-bold mb-1">Denied Sites</h3>
|
||||||
<p class="text-xs text-muted mb-2">
|
<p class="text-xs text-muted mb-2">
|
||||||
@@ -1631,7 +1624,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.875rem]"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -1708,7 +1701,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.875rem]"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
||||||
style="visibility: hidden"
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ function init(ctx) {
|
|||||||
$("btn-add-token-confirm").addEventListener("click", async () => {
|
$("btn-add-token-confirm").addEventListener("click", async () => {
|
||||||
const contractAddr = $("add-token-address").value.trim();
|
const contractAddr = $("add-token-address").value.trim();
|
||||||
if (!contractAddr || !contractAddr.startsWith("0x")) {
|
if (!contractAddr || !contractAddr.startsWith("0x")) {
|
||||||
showFlash("Enter a valid contract address starting with 0x.");
|
showFlash(
|
||||||
|
"Please enter a valid contract address starting with 0x.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const already = state.trackedTokens.find(
|
const already = state.trackedTokens.find(
|
||||||
@@ -69,15 +71,8 @@ function init(ctx) {
|
|||||||
require("./addressDetail").show();
|
require("./addressDetail").show();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail = e.shortMessage || e.message || String(e);
|
const detail = e.shortMessage || e.message || String(e);
|
||||||
log.errorf("Adding token failed for", contractAddr, detail);
|
log.errorf("Token lookup failed for", contractAddr, detail);
|
||||||
// lookupTokenInfo() rejects a contract with a one-line message
|
showFlash(detail);
|
||||||
// starting "Not a valid ERC-20 token". Any other error, such as a
|
|
||||||
// failed save, can be far longer, so it is only logged.
|
|
||||||
showFlash(
|
|
||||||
detail.startsWith("Not a valid ERC-20 token")
|
|
||||||
? detail
|
|
||||||
: "Could not add the token.",
|
|
||||||
);
|
|
||||||
infoEl.textContent = "";
|
infoEl.textContent = "";
|
||||||
infoEl.style.visibility = "hidden";
|
infoEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,13 +142,15 @@ function validatePassword() {
|
|||||||
async function importMnemonic(ctx) {
|
async function importMnemonic(ctx) {
|
||||||
const mnemonic = $("wallet-mnemonic").value.trim();
|
const mnemonic = $("wallet-mnemonic").value.trim();
|
||||||
if (!mnemonic) {
|
if (!mnemonic) {
|
||||||
showFlash("Enter a recovery phrase, or press the die.");
|
showFlash("Enter a recovery phrase or press the die to generate one.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const words = mnemonic.split(/\s+/);
|
const words = mnemonic.split(/\s+/);
|
||||||
if (words.length !== 12 && words.length !== 24) {
|
if (words.length !== 12 && words.length !== 24) {
|
||||||
showFlash(
|
showFlash(
|
||||||
"Recovery phrase must be 12 or 24 words, not " + words.length + ".",
|
"Recovery phrase must be 12 or 24 words. You entered " +
|
||||||
|
words.length +
|
||||||
|
".",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,12 +163,14 @@ async function importMnemonic(ctx) {
|
|||||||
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
||||||
const xpubDup = findWalletByXpub(xpub);
|
const xpubDup = findWalletByXpub(xpub);
|
||||||
if (xpubDup) {
|
if (xpubDup) {
|
||||||
showFlash("This recovery phrase is already added.");
|
showFlash(
|
||||||
|
"This recovery phrase is already added (" + xpubDup.name + ").",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addrDup = findWalletByAddress(firstAddress);
|
const addrDup = findWalletByAddress(firstAddress);
|
||||||
if (addrDup) {
|
if (addrDup) {
|
||||||
showFlash("Address already exists in a wallet.");
|
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const encrypted = await encryptWithPassword(mnemonic, pw);
|
const encrypted = await encryptWithPassword(mnemonic, pw);
|
||||||
@@ -225,7 +229,9 @@ async function importPrivateKey(ctx) {
|
|||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const duplicate = findWalletByAddress(addr);
|
const duplicate = findWalletByAddress(addr);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
showFlash("This address already exists in a wallet.");
|
showFlash(
|
||||||
|
"This address already exists in wallet (" + duplicate.name + ").",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const encrypted = await encryptWithPassword(key, pw);
|
const encrypted = await encryptWithPassword(key, pw);
|
||||||
@@ -252,29 +258,36 @@ async function importXprvKey(ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidXprv(xprv)) {
|
if (!isValidXprv(xprv)) {
|
||||||
showFlash("That extended private key is not valid.");
|
showFlash(
|
||||||
|
"That extended private key is not valid. Please check it and try again.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isMasterExtendedKey(xprv)) {
|
if (!isMasterExtendedKey(xprv)) {
|
||||||
showFlash("Please paste the master key, not a child key.");
|
showFlash(
|
||||||
|
"That is an account-level or child key, which cannot be imported. " +
|
||||||
|
"Please paste the master extended private key for the wallet.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = hdWalletFromXprv(xprv);
|
result = hdWalletFromXprv(xprv);
|
||||||
} catch {
|
} catch {
|
||||||
showFlash("That extended private key is not valid.");
|
showFlash(
|
||||||
|
"That extended private key is not valid. Please check it and try again.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { xpub, firstAddress } = result;
|
const { xpub, firstAddress } = result;
|
||||||
const xpubDup = findWalletByXpub(xpub);
|
const xpubDup = findWalletByXpub(xpub);
|
||||||
if (xpubDup) {
|
if (xpubDup) {
|
||||||
showFlash("This key is already added.");
|
showFlash("This key is already added (" + xpubDup.name + ").");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addrDup = findWalletByAddress(firstAddress);
|
const addrDup = findWalletByAddress(firstAddress);
|
||||||
if (addrDup) {
|
if (addrDup) {
|
||||||
showFlash("Address already exists in a wallet.");
|
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pw = validatePassword();
|
const pw = validatePassword();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
txCounterpartyHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -233,13 +233,16 @@ function renderTransactions(txs) {
|
|||||||
// is shown whole; the title or ENS name, where there is one, names
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
// it on the line above rather than replacing it.
|
// it on the line above rather than replacing it.
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
const nameStr = escapeHtml(title || ensName || "");
|
||||||
|
const addrStr = escapeHtml(counterparty);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
txCounterpartyHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -309,13 +309,16 @@ function renderTransactions(txs) {
|
|||||||
// is shown whole; the title or ENS name, where there is one, names
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
// it on the line above rather than replacing it.
|
// it on the line above rather than replacing it.
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
const nameStr = escapeHtml(title || ensName || "");
|
||||||
|
const addrStr = escapeHtml(counterparty);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
showView,
|
showView,
|
||||||
showError,
|
showError,
|
||||||
@@ -9,7 +8,6 @@ const {
|
|||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { networkByChainId } = require("../../shared/networks");
|
const { networkByChainId } = require("../../shared/networks");
|
||||||
@@ -209,7 +207,7 @@ function showPhishingWarning(elementId, isPhishing) {
|
|||||||
// and the nonce. The background compares every one of them against the signed
|
// and the nonce. The background compares every one of them against the signed
|
||||||
// artifact, so every one of them has to be on the screen — a number that is
|
// artifact, so every one of them has to be on the screen — a number that is
|
||||||
// verified but never displayed is verified against nothing the user agreed to.
|
// verified but never displayed is verified against nothing the user agreed to.
|
||||||
function showTxFee(approvedTx) {
|
function showTxFee(approvedTx, ethPrice) {
|
||||||
const network = networkByChainId(approvedTx.chainId);
|
const network = networkByChainId(approvedTx.chainId);
|
||||||
$("approve-tx-network").textContent = network
|
$("approve-tx-network").textContent = network
|
||||||
? network.name
|
? network.name
|
||||||
@@ -217,9 +215,12 @@ function showTxFee(approvedTx) {
|
|||||||
|
|
||||||
const gasLimit = BigInt(approvedTx.gasLimit);
|
const gasLimit = BigInt(approvedTx.gasLimit);
|
||||||
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
||||||
// Through formatFee(), as the confirmation screen's fee is, so the same
|
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
|
||||||
// fee reads the same on both.
|
const usdStr = formatUsd(
|
||||||
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
|
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
|
||||||
|
);
|
||||||
|
$("approve-tx-fee").textContent =
|
||||||
|
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||||
|
|
||||||
let detail =
|
let detail =
|
||||||
gasLimit.toString() +
|
gasLimit.toString() +
|
||||||
@@ -319,7 +320,7 @@ function showTxApproval(details) {
|
|||||||
toHtml += approvalAddressHtml(toAddr);
|
toHtml += approvalAddressHtml(toAddr);
|
||||||
$("approve-tx-to").innerHTML = toHtml;
|
$("approve-tx-to").innerHTML = toHtml;
|
||||||
} else {
|
} else {
|
||||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ethValueFormatted = formatTxValue(
|
const ethValueFormatted = formatTxValue(
|
||||||
@@ -331,7 +332,7 @@ function showTxApproval(details) {
|
|||||||
$("approve-tx-value").textContent =
|
$("approve-tx-value").textContent =
|
||||||
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||||
|
|
||||||
showTxFee(approvedTx);
|
showTxFee(approvedTx, ethPrice);
|
||||||
|
|
||||||
// Decode calldata (reuse decoded from above)
|
// Decode calldata (reuse decoded from above)
|
||||||
const decodedEl = $("approve-tx-decoded");
|
const decodedEl = $("approve-tx-decoded");
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const {
|
|||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
@@ -32,9 +31,6 @@ const {
|
|||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
} = require("../../shared/transferAmount");
|
} = require("../../shared/transferAmount");
|
||||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
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.
|
|
||||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
|
||||||
const {
|
const {
|
||||||
CODES,
|
CODES,
|
||||||
FEE_PENDING,
|
FEE_PENDING,
|
||||||
@@ -154,17 +150,11 @@ function show(txInfo) {
|
|||||||
$("confirm-balance").textContent =
|
$("confirm-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "unknown (" + symbol + ")"
|
? "unknown (" + symbol + ")"
|
||||||
: valueWithUsd(
|
: valueWithUsd(bal + " " + symbol, balUsd);
|
||||||
truncateAmountNeverZero(bal) + " " + symbol,
|
|
||||||
balUsd,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const bal = txInfo.balance || "0";
|
const bal = txInfo.balance || "0";
|
||||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
||||||
truncateAmountNeverZero(bal) + " ETH",
|
|
||||||
balUsd,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for warnings (synchronous local checks)
|
// Check for warnings (synchronous local checks)
|
||||||
@@ -198,19 +188,6 @@ 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
|
||||||
@@ -257,8 +234,7 @@ 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, except the
|
// sentences live in the reserved elements in index.html.
|
||||||
// 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.");
|
||||||
@@ -273,7 +249,7 @@ function renderValidation(txInfo) {
|
|||||||
: "Insufficient " +
|
: "Insufficient " +
|
||||||
symbol +
|
symbol +
|
||||||
" balance. You have " +
|
" balance. You have " +
|
||||||
truncateAmountNeverZero(txInfo.tokenBalance) +
|
txInfo.tokenBalance +
|
||||||
" " +
|
" " +
|
||||||
symbol +
|
symbol +
|
||||||
" but are trying to send " +
|
" but are trying to send " +
|
||||||
@@ -286,7 +262,7 @@ function renderValidation(txInfo) {
|
|||||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||||
messages.push(
|
messages.push(
|
||||||
"Insufficient balance. You have " +
|
"Insufficient balance. You have " +
|
||||||
truncateAmountNeverZero(txInfo.balance || "0") +
|
txInfo.balance +
|
||||||
" ETH but are trying to send " +
|
" ETH but are trying to send " +
|
||||||
txInfo.amount +
|
txInfo.amount +
|
||||||
" ETH.",
|
" ETH.",
|
||||||
@@ -329,6 +305,14 @@ function setVisible(id, visible) {
|
|||||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
$(id).style.visibility = visible ? "visible" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fee in wei as an ETH string, truncated to 6 decimal places.
|
||||||
|
function formatFeeEth(wei) {
|
||||||
|
const parts = formatEther(wei).split(".");
|
||||||
|
const dec =
|
||||||
|
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
|
||||||
|
return parts[0] + "." + dec + " ETH";
|
||||||
|
}
|
||||||
|
|
||||||
async function estimateGas(txInfo) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
@@ -375,20 +359,26 @@ async function estimateGas(txInfo) {
|
|||||||
// flight; a stale fee must not reach the screen or the balance check.
|
// flight; a stale fee must not reach the screen or the balance check.
|
||||||
if (pendingTx !== txInfo) return;
|
if (pendingTx !== txInfo) return;
|
||||||
|
|
||||||
// The fee line goes through formatFee(), as the approval screen's
|
const ethPrice = getPrice("ETH");
|
||||||
// does, so the same fee reads the same on both.
|
const usd = (wei) =>
|
||||||
|
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
||||||
|
|
||||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||||
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
|
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||||
|
"~" + formatFeeEth(estimateWei),
|
||||||
|
usd(estimateWei),
|
||||||
|
);
|
||||||
$("confirm-fee-reserve").textContent =
|
$("confirm-fee-reserve").textContent =
|
||||||
"up to " +
|
"up to " + formatFeeEth(gasCostWei) + " reserved";
|
||||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
|
||||||
" ETH reserved";
|
|
||||||
setVisible("confirm-fee-reserve", true);
|
setVisible("confirm-fee-reserve", true);
|
||||||
} else {
|
} else {
|
||||||
// No spread to report: either there is no estimate, or the node
|
// No spread to report: either there is no estimate, or the node
|
||||||
// quotes a gas price at or above maxFeePerGas, so the expected
|
// quotes a gas price at or above maxFeePerGas, so the expected
|
||||||
// cost is not below the reserve. Show the reserve alone.
|
// cost is not below the reserve. Show the reserve alone.
|
||||||
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
|
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||||
|
formatFeeEth(gasCostWei),
|
||||||
|
usd(gasCostWei),
|
||||||
|
);
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
}
|
}
|
||||||
feeStatus = FEE_KNOWN;
|
feeStatus = FEE_KNOWN;
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ function recoveryPathText(wallet) {
|
|||||||
// AddressDetail, followed by the USD total when there is one to give — no
|
// AddressDetail, followed by the USD total when there is one to give — no
|
||||||
// total line at all on testnet or before the first price fetch, and no figure
|
// total line at all on testnet or before the first price fetch, and no figure
|
||||||
// when every holding here is one with no price, since "$0.00" directly under
|
// when every holding here is one with no price, since "$0.00" directly under
|
||||||
// "This address holds a balance." is a contradiction. A token holding below
|
// "This address holds a balance." is a contradiction.
|
||||||
// 0.000001 does not count, as the lines below leave it out.
|
|
||||||
function balanceWarningHtml(addr) {
|
function balanceWarningHtml(addr) {
|
||||||
if (!addressHoldsFunds(addr)) return " ";
|
if (!addressHoldsFunds(addr)) return " ";
|
||||||
const line = formatAddressTotal(getAddressValue(addr));
|
const line = formatAddressTotal(getAddressValue(addr));
|
||||||
|
|||||||
@@ -12,11 +12,6 @@
|
|||||||
// escapeHtml lives in src/shared/html.js, where the escape and the
|
// escapeHtml lives in src/shared/html.js, where the escape and the
|
||||||
// reasoning behind it are; it is re-exported below so views keep importing
|
// reasoning behind it are; it is re-exported below so views keep importing
|
||||||
// it from here.
|
// it from here.
|
||||||
const { formatEther } = require("ethers");
|
|
||||||
const {
|
|
||||||
truncateAmountNeverZero,
|
|
||||||
isBelowOneMillionth,
|
|
||||||
} = require("../../shared/amountDisplay");
|
|
||||||
const { DEBUG } = require("../../shared/constants");
|
const { DEBUG } = require("../../shared/constants");
|
||||||
const { escapeHtml } = require("../../shared/html");
|
const { escapeHtml } = require("../../shared/html");
|
||||||
const { isDebug } = require("../../shared/log");
|
const { isDebug } = require("../../shared/log");
|
||||||
@@ -228,19 +223,15 @@ function clearFlash() {
|
|||||||
flashTimer = null;
|
flashTimer = null;
|
||||||
}
|
}
|
||||||
$("flash-msg").textContent = "";
|
$("flash-msg").textContent = "";
|
||||||
$("flash-msg").title = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The flash line reserves exactly one line, and a message that wrapped would
|
|
||||||
// push the screen below it down (README, No Layout Shift). So #flash-msg never
|
|
||||||
// wraps: text too long for the line is cut with an ellipsis, and the whole
|
|
||||||
// message is also put in the line's title. Write messages to fit, at most 50
|
|
||||||
// characters, so none is cut.
|
|
||||||
function showFlash(msg, duration = 2000) {
|
function showFlash(msg, duration = 2000) {
|
||||||
clearFlash();
|
clearFlash();
|
||||||
$("flash-msg").textContent = msg;
|
$("flash-msg").textContent = msg;
|
||||||
$("flash-msg").title = msg;
|
flashTimer = setTimeout(() => {
|
||||||
flashTimer = setTimeout(clearFlash, duration);
|
$("flash-msg").textContent = "";
|
||||||
|
flashTimer = null;
|
||||||
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored token balance as a number, or null when there is no number in it.
|
// A stored token balance as a number, or null when there is no number in it.
|
||||||
@@ -252,19 +243,6 @@ function unknownableAmount(balance) {
|
|||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A network fee in wei as the confirmation and approval screens both show it:
|
|
||||||
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
|
|
||||||
// ETH price is known. The USD value is of the exact fee, not of the truncated
|
|
||||||
// figure.
|
|
||||||
function formatFee(wei) {
|
|
||||||
const eth = formatEther(wei);
|
|
||||||
const ethPrice = getPrice("ETH");
|
|
||||||
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
|
||||||
return (
|
|
||||||
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One row of the balance list: symbol, quantity, fiat value.
|
// One row of the balance list: symbol, quantity, fiat value.
|
||||||
//
|
//
|
||||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||||
@@ -310,9 +288,6 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
);
|
);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
// A holding below 0.000001 is not listed, tracked or not. A tracked
|
|
||||||
// token then gets the zero row below while showZero is on.
|
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
|
||||||
// A null balance is a holding of an unstatable amount, not a holding
|
// A null balance is a holding of an unstatable amount, not a holding
|
||||||
// of zero, so the show-zero setting has no say over it: hiding it
|
// of zero, so the show-zero setting has no say over it: hiding it
|
||||||
// would be asserting the zero nobody established. Anything that does
|
// would be asserting the zero nobody established. Anything that does
|
||||||
@@ -343,16 +318,14 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
||||||
// knows about, except a token holding below 0.000001, which the balance list
|
// knows about. Deliberately unrounded — the rendered lines round to four
|
||||||
// under the remove-address warning leaves out too. Deliberately unrounded —
|
// decimals, so a dust balance displays as 0.0000 while still being real
|
||||||
// the rendered lines round to four decimals, so a dust balance displays as
|
// money at a real address. Callers that warn about holdings must ask this,
|
||||||
// 0.0000 while still being real money at a real address. Callers that warn
|
// not the rendered figure.
|
||||||
// about holdings must ask this, not the rendered figure.
|
|
||||||
function addressHoldsFunds(addr) {
|
function addressHoldsFunds(addr) {
|
||||||
if (!addr) return false;
|
if (!addr) return false;
|
||||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
|
||||||
// A null balance is a holding whose amount could not be stated —
|
// A null balance is a holding whose amount could not be stated —
|
||||||
// balances.js drops a row of zero base units before the scale is
|
// balances.js drops a row of zero base units before the scale is
|
||||||
// consulted, so a row that survived with no quantity is holding
|
// consulted, so a row that survived with no quantity is holding
|
||||||
@@ -431,29 +404,6 @@ function addressTitle(address, wallets) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What every recipient line and history row says for a transaction with no
|
|
||||||
// `to`. Such a transaction creates a contract, so there is no address to show,
|
|
||||||
// and a blank line on these screens reads as a rendering fault.
|
|
||||||
const CONTRACT_CREATION_TEXT =
|
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
|
||||||
|
|
||||||
// The last two lines of a transaction history row: the counterparty's colour
|
|
||||||
// dot and name beside the amount, then its full address. A contract creation
|
|
||||||
// the user sent has no counterparty (its `to` is ""), so its row has the
|
|
||||||
// amount alone and the contract creation sentence in place of the address.
|
|
||||||
function txCounterpartyHtml(address, nameHtml, amountHtml) {
|
|
||||||
if (!address) {
|
|
||||||
return (
|
|
||||||
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
|
|
||||||
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
|
|
||||||
`<div class="am-address">${escapeHtml(address)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render an address with color dot, optional ENS name, optional title,
|
// Render an address with color dot, optional ENS name, optional title,
|
||||||
// and optional truncation. Title and ENS are shown as bold labels above
|
// and optional truncation. Title and ENS are shown as bold labels above
|
||||||
// the full address.
|
// the full address.
|
||||||
@@ -660,14 +610,11 @@ module.exports = {
|
|||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
formatFee,
|
|
||||||
addressColor,
|
addressColor,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
txCounterpartyHtml,
|
|
||||||
formatAddressHtml,
|
formatAddressHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
copyableHtml,
|
copyableHtml,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const {
|
|||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
txCounterpartyHtml,
|
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
@@ -123,13 +122,16 @@ function renderHomeTxList(ctx) {
|
|||||||
// names it on the line above rather than replacing it.
|
// names it on the line above rather than replacing it.
|
||||||
const title = addressTitle(counterparty, state.wallets);
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const titleStr = title ? escapeHtml(title) : "";
|
const titleStr = title ? escapeHtml(title) : "";
|
||||||
|
const addrStr = escapeHtml(counterparty);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-70
@@ -16,10 +16,6 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
|||||||
const { resolveSymbol } = require("../../shared/tokenList");
|
const { resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { isLowHolderCount } = require("../../shared/holders");
|
const { isLowHolderCount } = require("../../shared/holders");
|
||||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
const {
|
|
||||||
truncateAmountNeverZero,
|
|
||||||
isBelowOneMillionth,
|
|
||||||
} = require("../../shared/amountDisplay");
|
|
||||||
const { getAddress } = require("ethers");
|
const { getAddress } = require("ethers");
|
||||||
|
|
||||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||||
@@ -67,13 +63,13 @@ function validateToAddress(value) {
|
|||||||
if (checksummed !== v) {
|
if (checksummed !== v) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "Address checksum is invalid. Check the address.",
|
error: "Address checksum is invalid. Please double-check the address.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "Address checksum is invalid. Check the address.",
|
error: "Address checksum is invalid. Please double-check the address.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,10 +125,6 @@ function renderSendTokenSelect(addr) {
|
|||||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||||
);
|
);
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
// A holding below 0.000001 is left out, as the balance lists leave it
|
|
||||||
// out. Its token's own screen can still send it: there
|
|
||||||
// state.selectedToken picks the token, not this list.
|
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
|
||||||
if (isSpoofedSymbol(t.symbol, t.address)) continue;
|
if (isSpoofedSymbol(t.symbol, t.address)) continue;
|
||||||
if (fraudSet.has(t.address.toLowerCase())) continue;
|
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||||
// An unknown holder count does not withhold a token the user holds:
|
// An unknown holder count does not withhold a token the user holds:
|
||||||
@@ -146,50 +138,6 @@ 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;
|
||||||
@@ -202,27 +150,24 @@ function updateSendBalance() {
|
|||||||
const token = state.selectedToken || $("send-token").value;
|
const token = state.selectedToken || $("send-token").value;
|
||||||
if (token === "ETH") {
|
if (token === "ETH") {
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
"Current balance: " +
|
"Current balance: " + (addr.balance || "0") + " ETH";
|
||||||
truncateAmountNeverZero(addr.balance || "0") +
|
|
||||||
" 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 is unknown. Saying a figure
|
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||||
// for it would be a claim about the amount, so it reads as the
|
// for it would be a claim about the amount; the send itself is
|
||||||
// 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 = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
const bal = tb ? tb.balance : "0";
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "Current balance: unknown (" + symbol + ")"
|
? "Current balance: unknown (" + symbol + ")"
|
||||||
: "Current balance: " +
|
: "Current balance: " + bal + " " + symbol;
|
||||||
truncateAmountNeverZero(bal) +
|
|
||||||
" " +
|
|
||||||
symbol;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +211,7 @@ function init(_ctx) {
|
|||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const resolved = await provider.resolveName(to);
|
const resolved = await provider.resolveName(to);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
showFlash("That ENS name has no address.");
|
showFlash("Could not resolve " + to);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedTo = resolved;
|
resolvedTo = resolved;
|
||||||
@@ -282,17 +227,59 @@ 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,
|
||||||
);
|
);
|
||||||
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
// null carried through rather than flattened to "0": the confirm
|
||||||
addr,
|
// screen states an unknown balance as unknown, and
|
||||||
token,
|
// validateTransfer() treats it as no balance to spend from, which
|
||||||
));
|
// 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({
|
||||||
|
|||||||
+31
-53
@@ -29,63 +29,46 @@ const {
|
|||||||
GITEA_COMMIT_URL,
|
GITEA_COMMIT_URL,
|
||||||
} = require("../../shared/buildInfo");
|
} = require("../../shared/buildInfo");
|
||||||
|
|
||||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
const { notify } = require("../../shared/browserApi");
|
||||||
|
|
||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
|
|
||||||
// One row per hostname, however many addresses or origins it appears under,
|
function renderSiteList(containerId, siteMap, stateKey) {
|
||||||
// each with an [x] that hands it to onRemove.
|
|
||||||
function renderSiteList(containerId, hostnames, onRemove) {
|
|
||||||
const container = $(containerId);
|
const container = $(containerId);
|
||||||
const unique = [...new Set(hostnames)];
|
const hostnames = [...new Set(Object.values(siteMap).flat())];
|
||||||
if (unique.length === 0) {
|
if (hostnames.length === 0) {
|
||||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = "";
|
let html = "";
|
||||||
unique.forEach((hostname) => {
|
hostnames.forEach((hostname) => {
|
||||||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||||||
// A hostname the URL parser produced cannot carry a delimiter, so
|
// A hostname the URL parser produced cannot carry a delimiter, so
|
||||||
// this is escaped for the rule rather than for a known hole — the
|
// this is escaped for the rule rather than for a known hole — the
|
||||||
// rule being that nothing reaches innerHTML unescaped.
|
// rule being that nothing reaches innerHTML unescaped.
|
||||||
html += `<span>${escapeHtml(hostname)}</span>`;
|
html += `<span>${escapeHtml(hostname)}</span>`;
|
||||||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
|
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
|
btn.addEventListener("click", async () => {
|
||||||
|
const key = btn.dataset.key;
|
||||||
|
const host = btn.dataset.hostname;
|
||||||
|
for (const addr of Object.keys(state[key])) {
|
||||||
|
state[key][addr] = state[key][addr].filter((h) => h !== host);
|
||||||
|
if (state[key][addr].length === 0) {
|
||||||
|
delete state[key][addr];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveState();
|
||||||
|
notify({ type: "AUTISTMASK_REMOVE_SITE" });
|
||||||
|
renderSiteList(containerId, state[key], key);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop a hostname from a remembered site list under every address.
|
|
||||||
function forgetHostname(siteMap, hostname) {
|
|
||||||
for (const addr of Object.keys(siteMap)) {
|
|
||||||
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
|
|
||||||
if (siteMap[addr].length === 0) {
|
|
||||||
delete siteMap[addr];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
|
||||||
// no longer allowed under any address, and the background ends its
|
|
||||||
// connections approved without "Remember" and tells its open tabs.
|
|
||||||
async function removeAllowedSite(hostname) {
|
|
||||||
forgetHostname(state.allowedSites, hostname);
|
|
||||||
await saveState();
|
|
||||||
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
|
|
||||||
await renderSiteLists();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing a denied site only forgets the refusal; it connects nothing.
|
|
||||||
async function removeDeniedSite(hostname) {
|
|
||||||
forgetHostname(state.deniedSites, hostname);
|
|
||||||
await saveState();
|
|
||||||
await renderSiteLists();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTrackedTokens() {
|
function renderTrackedTokens() {
|
||||||
const container = $("settings-tracked-tokens");
|
const container = $("settings-tracked-tokens");
|
||||||
if (state.trackedTokens.length === 0) {
|
if (state.trackedTokens.length === 0) {
|
||||||
@@ -219,24 +202,13 @@ function show() {
|
|||||||
showView("settings");
|
showView("settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderSiteLists() {
|
function renderSiteLists() {
|
||||||
renderSiteList(
|
renderSiteList(
|
||||||
"settings-allowed-sites",
|
"settings-allowed-sites",
|
||||||
Object.values(state.allowedSites).flat(),
|
state.allowedSites,
|
||||||
removeAllowedSite,
|
"allowedSites",
|
||||||
);
|
|
||||||
renderSiteList(
|
|
||||||
"settings-denied-sites",
|
|
||||||
Object.values(state.deniedSites).flat(),
|
|
||||||
removeDeniedSite,
|
|
||||||
);
|
|
||||||
// Sites allowed without "Remember" are held only by the background, in
|
|
||||||
// memory, so it is asked for them.
|
|
||||||
renderSiteList(
|
|
||||||
"settings-connected-sites",
|
|
||||||
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
|
||||||
removeAllowedSite,
|
|
||||||
);
|
);
|
||||||
|
renderSiteList("settings-denied-sites", state.deniedSites, "deniedSites");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
@@ -264,12 +236,18 @@ function init(ctx) {
|
|||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
if (json.error) {
|
if (json.error) {
|
||||||
log.errorf("RPC validation error:", json.error);
|
log.errorf("RPC validation error:", json.error);
|
||||||
showFlash("Endpoint returned an error.");
|
showFlash("Endpoint returned error: " + json.error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const net = currentNetwork();
|
const net = currentNetwork();
|
||||||
if (json.result !== net.chainId) {
|
if (json.result !== net.chainId) {
|
||||||
showFlash("Wrong network: expected " + net.name + ".");
|
showFlash(
|
||||||
|
"Wrong network (expected " +
|
||||||
|
net.name +
|
||||||
|
", got chain " +
|
||||||
|
json.result +
|
||||||
|
").",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ function init(_ctx) {
|
|||||||
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
||||||
const addr = $("settings-addtoken-address").value.trim();
|
const addr = $("settings-addtoken-address").value.trim();
|
||||||
if (!addr || !addr.startsWith("0x")) {
|
if (!addr || !addr.startsWith("0x")) {
|
||||||
showFlash("Enter a valid contract address starting with 0x.");
|
showFlash(
|
||||||
|
"Please enter a valid contract address starting with 0x.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTracked(addr)) {
|
if (isTracked(addr)) {
|
||||||
@@ -153,15 +155,8 @@ function init(_ctx) {
|
|||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail = e.shortMessage || e.message || String(e);
|
const detail = e.shortMessage || e.message || String(e);
|
||||||
log.errorf("Adding token failed for", addr, detail);
|
log.errorf("Token lookup failed for", addr, detail);
|
||||||
// lookupTokenInfo() rejects a contract with a one-line message
|
showFlash(detail);
|
||||||
// starting "Not a valid ERC-20 token". Any other error, such as a
|
|
||||||
// failed save, can be far longer, so it is only logged.
|
|
||||||
showFlash(
|
|
||||||
detail.startsWith("Not a valid ERC-20 token")
|
|
||||||
? detail
|
|
||||||
: "Could not add the token.",
|
|
||||||
);
|
|
||||||
infoEl.textContent = "";
|
infoEl.textContent = "";
|
||||||
infoEl.style.visibility = "hidden";
|
infoEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
isoDate,
|
isoDate,
|
||||||
@@ -95,18 +94,13 @@ function render() {
|
|||||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||||
|
|
||||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||||
|
const toTitle = addressTitle(tx.to, state.wallets);
|
||||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||||
tx.from,
|
tx.from,
|
||||||
tx.fromEns,
|
tx.fromEns,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
);
|
);
|
||||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||||
if (tx.to) {
|
|
||||||
const toTitle = addressTitle(tx.to, state.wallets);
|
|
||||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
|
||||||
} else {
|
|
||||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact amount (full precision, copyable)
|
// Exact amount (full precision, copyable)
|
||||||
const detailSym = displaySymbol(tx.symbol);
|
const detailSym = displaySymbol(tx.symbol);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
@@ -59,10 +58,7 @@ function endWait() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
|
||||||
// writes `to: toAddr || ""`).
|
|
||||||
function toAddressHtml(address) {
|
function toAddressHtml(address) {
|
||||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
|
||||||
const title = addressTitle(address, state.wallets);
|
const title = addressTitle(address, state.wallets);
|
||||||
return renderAddressHtml(address, { title });
|
return renderAddressHtml(address, { title });
|
||||||
}
|
}
|
||||||
@@ -206,9 +202,8 @@ function restoreWait() {
|
|||||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||||
// A string is the whole requirement: the empty string is what a
|
// A string is the whole requirement: the empty string is what a
|
||||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||||
// || ""`), an empty `to` renders as a contract creation and an empty
|
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||||
// amount renders harmlessly, so refusing it would abandon a wait the live
|
// would abandon a wait the live path itself created.
|
||||||
// path itself created.
|
|
||||||
if (typeof info.to !== "string") return false;
|
if (typeof info.to !== "string") return false;
|
||||||
if (typeof info.amount !== "string") return false;
|
if (typeof info.amount !== "string") return false;
|
||||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
|
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
|
||||||
// two showing a different number for the same value.
|
// two showing a different number for the same value.
|
||||||
//
|
//
|
||||||
// The two truncation functions below are the two policies, not two
|
// The two functions below are the two policies, not two implementations of
|
||||||
// implementations of one: summary lists truncate, and the screens that state
|
// one: summary lists truncate, and the screens that state what is being
|
||||||
// what is being authorized truncate with a floor. Keeping them adjacent is the
|
// authorized truncate with a floor. Keeping them adjacent is the point, so a
|
||||||
// point, so a change to the rule cannot reach one screen and miss another.
|
// change to the rule cannot reach one screen and miss another.
|
||||||
|
|
||||||
// Truncate to exactly four decimal places. Truncation, never rounding: an
|
// Truncate to exactly four decimal places. Truncation, never rounding: an
|
||||||
// amount must never be displayed as larger than it is, so 0.99999 stays
|
// amount must never be displayed as larger than it is, so 0.99999 stays
|
||||||
@@ -43,18 +43,4 @@ function truncateAmountNeverZero(val) {
|
|||||||
return parts[0] + "." + parts[1].slice(0, sig + 1);
|
return parts[0] + "." + parts[1].slice(0, sig + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a stored token balance is a holding below 0.000001. The balance
|
module.exports = { truncateAmount, truncateAmountNeverZero };
|
||||||
// lists, the send-screen token selector, the address total and the
|
|
||||||
// remove-address warning leave such a holding out; the Send and confirmation
|
|
||||||
// screens show it when its token is the one being sent. Exact, because
|
|
||||||
// src/shared/balances.js stores plain decimal digits: below 0.000001 the
|
|
||||||
// balance reads "0.000000" and then more digits.
|
|
||||||
function isBelowOneMillionth(balance) {
|
|
||||||
return typeof balance === "string" && balance.startsWith("0.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
truncateAmount,
|
|
||||||
truncateAmountNeverZero,
|
|
||||||
isBelowOneMillionth,
|
|
||||||
};
|
|
||||||
|
|||||||
+14
-9
@@ -52,16 +52,19 @@ function requireNetworkId(networkId) {
|
|||||||
return net;
|
return net;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token balance as an exact decimal string, never cut: a cut stores a small
|
function formatBalance(wei) {
|
||||||
// nonzero holding as zero. fetchTokenBalances() stores every nonzero holding of
|
const eth = formatEther(wei);
|
||||||
// a token it admits, however small; the screens that leave out one below
|
const parts = eth.split(".");
|
||||||
// 0.000001 decide that themselves, through isBelowOneMillionth() in
|
if (parts.length === 1) return eth + ".0";
|
||||||
// src/shared/amountDisplay.js.
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||||
|
return parts[0] + "." + dec;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTokenBalance(raw, decimals) {
|
function formatTokenBalance(raw, decimals) {
|
||||||
const val = formatUnits(raw, decimals);
|
const val = formatUnits(raw, decimals);
|
||||||
const parts = val.split(".");
|
const parts = val.split(".");
|
||||||
if (parts.length === 1) return val + ".0";
|
if (parts.length === 1) return val + ".0";
|
||||||
const dec = parts[1].replace(/0+$/, "") || "0";
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||||
return parts[0] + "." + dec;
|
return parts[0] + "." + dec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +149,11 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
const scale = known !== null ? known : decimals;
|
const scale = known !== null ? known : decimals;
|
||||||
// null is a holding of an amount that cannot be stated, which is
|
// null is a holding of an amount that cannot be stated, which is
|
||||||
// not the same as a holding of zero, and must never render as one.
|
// not the same as a holding of zero, and must never render as one.
|
||||||
|
// With a scale, the display filter proper applies: a balance that
|
||||||
|
// rounds to zero at six places is dust and is not listed. Without
|
||||||
|
// one there is no such judgement to make, and the row is kept.
|
||||||
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
||||||
|
if (bal === "0.0") continue;
|
||||||
// null means the explorer reported no count, which is not the
|
// null means the explorer reported no count, which is not the
|
||||||
// same as a count of zero. This gate is not the low-holder
|
// same as a count of zero. This gate is not the low-holder
|
||||||
// display filter: it has no user-facing off switch and governs
|
// display filter: it has no user-facing off switch and governs
|
||||||
@@ -214,9 +221,7 @@ async function refreshBalances(
|
|||||||
provider
|
provider
|
||||||
.getBalance(addr.address)
|
.getBalance(addr.address)
|
||||||
.then((bal) => {
|
.then((bal) => {
|
||||||
// Exact, never cut: a cut here stores a small nonzero
|
addr.balance = formatBalance(bal);
|
||||||
// balance as zero.
|
|
||||||
addr.balance = formatEther(bal);
|
|
||||||
log.debugf("ETH balance", addr.address, addr.balance);
|
log.debugf("ETH balance", addr.address, addr.balance);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
||||||
|
|
||||||
const { getTopTokenPrices } = require("./tokenList");
|
const { getTopTokenPrices } = require("./tokenList");
|
||||||
const { isBelowOneMillionth } = require("./amountDisplay");
|
|
||||||
|
|
||||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
||||||
|
|
||||||
@@ -79,9 +78,6 @@ function getAddressValue(addr) {
|
|||||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||||
let partial = false;
|
let partial = false;
|
||||||
for (const token of addr.tokenBalances || []) {
|
for (const token of addr.tokenBalances || []) {
|
||||||
// A holding below 0.000001 is left out, as the balance lists leave it
|
|
||||||
// out, so the total never counts a holding the list does not show.
|
|
||||||
if (isBelowOneMillionth(token.balance)) continue;
|
|
||||||
// A null balance is a holding whose scale nothing knows, so it has no
|
// A null balance is a holding whose scale nothing knows, so it has no
|
||||||
// quantity to price — but it is still a holding, and a total that
|
// quantity to price — but it is still a holding, and a total that
|
||||||
// silently omits it would read as complete. That is exactly what
|
// silently omits it would read as complete. That is exactly what
|
||||||
|
|||||||
@@ -139,15 +139,7 @@ function validateTransfer({
|
|||||||
const feeFp = known ? feeWei : null;
|
const feeFp = known ? feeWei : null;
|
||||||
|
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
// A token can declare more than 18 decimals, and its balance is
|
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
|
||||||
// stored with all of them. Only the first 18 places (SCALE_DECIMALS)
|
|
||||||
// are read: an amount with more was refused above, so the places
|
|
||||||
// after them cannot decide whether the amount fits.
|
|
||||||
const tokenText =
|
|
||||||
typeof tokenBalance === "string"
|
|
||||||
? tokenBalance.replace(/(\.\d{18})\d+$/, "$1")
|
|
||||||
: tokenBalance;
|
|
||||||
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
|
||||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||||
if (feeFp !== null && feeFp > ethFp) {
|
if (feeFp !== null && feeFp > ethFp) {
|
||||||
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
||||||
|
|||||||
+10
-41
@@ -100,13 +100,6 @@ 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
|
||||||
@@ -205,6 +198,11 @@ 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(
|
||||||
@@ -449,7 +447,6 @@ 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
|
||||||
@@ -524,16 +521,6 @@ 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) {
|
||||||
@@ -572,19 +559,12 @@ 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 turns WETH into ETH, so it makes the output ETH
|
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
||||||
// when the output side is WETH, or when no step set the output side.
|
// decode, and it is answered here rather than left to that rule.
|
||||||
// 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 =
|
const outInfo = hasUnwrapWeth
|
||||||
hasUnwrapWeth && (outputIsWeth || outputUnset)
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
: tokenInfo(outputToken, sources);
|
||||||
: tokenInfo(outputToken, sources);
|
|
||||||
|
|
||||||
const inSymbol = inInfo.symbol;
|
const inSymbol = inInfo.symbol;
|
||||||
const outSymbol = outInfo.symbol;
|
const outSymbol = outInfo.symbol;
|
||||||
@@ -633,17 +613,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ const DEFECTS = {
|
|||||||
"changed or removed, and this wallet stays until you delete " +
|
"changed or removed, and this wallet stays until you delete " +
|
||||||
"it yourself.",
|
"it yourself.",
|
||||||
],
|
],
|
||||||
// One line, for the flash on a blocked Send and the inline error on
|
// One sentence for the places that have room for one: the flash on a
|
||||||
// the approval screens. It must fit on the flash line; see showFlash()
|
// blocked Send, the inline error on the approval screens.
|
||||||
// in src/popup/views/helpers.js.
|
shortMessage:
|
||||||
shortMessage: "This wallet cannot sign. See the wallet list.",
|
"This wallet cannot sign, because it was imported from an " +
|
||||||
|
"extended private key that is not a master key. The wallet list " +
|
||||||
|
"explains what happened.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ function sameAddress(a, b) {
|
|||||||
return String(a).toLowerCase() === String(b).toLowerCase();
|
return String(a).toLowerCase() === String(b).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forget every site permission held against the given addresses: the
|
// Forget every site permission held against the given addresses.
|
||||||
// remembered ones in `state`, and the connections approved without
|
|
||||||
// "Remember", which only the background holds, in memory.
|
|
||||||
function dropSitePermissions(state, addresses) {
|
function dropSitePermissions(state, addresses) {
|
||||||
for (const addr of addresses) {
|
for (const addr of addresses) {
|
||||||
delete state.allowedSites[addr];
|
delete state.allowedSites[addr];
|
||||||
delete state.deniedSites[addr];
|
delete state.deniedSites[addr];
|
||||||
}
|
}
|
||||||
notify({ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ const { Network, Wallet } = require("ethers");
|
|||||||
// what the user is actually shown.
|
// what the user is actually shown.
|
||||||
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
const {
|
|
||||||
removeAddressFromState,
|
|
||||||
removeWalletFromState,
|
|
||||||
} = require("../src/shared/walletDelete");
|
|
||||||
|
|
||||||
const SIGNER_KEY =
|
const SIGNER_KEY =
|
||||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||||
@@ -2100,260 +2096,3 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// What a site is told when it asks which account it may use.
|
|
||||||
async function siteAccounts(bg, origin) {
|
|
||||||
const { sendResponse } = bg.send(
|
|
||||||
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
|
|
||||||
{ origin: origin || FRESH_ORIGIN },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
return sendResponse.mock.calls[0][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site connected without "Remember" is held only in the background's memory,
|
|
||||||
// keyed to the address it was connected to. Removing that address, or the
|
|
||||||
// wallet holding it, must end the connection as part of the removal itself.
|
|
||||||
// The accountsChanged broadcast the views send afterwards also clears it, but
|
|
||||||
// only when the active address moved, and nothing waits for it to arrive.
|
|
||||||
//
|
|
||||||
// Each test asks the background while storage still names the removed address
|
|
||||||
// as active, because the popup has not saved yet, so the answer turns on the
|
|
||||||
// connection alone.
|
|
||||||
describe("removing an address ends a site's connection to it", () => {
|
|
||||||
// FRESH_ORIGIN connected to the active address without "Remember", in a
|
|
||||||
// wallet holding a second address so that one can be removed at all. The
|
|
||||||
// popup's messages reach the background as they would from the popup.
|
|
||||||
async function connectedBackground() {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const stored = bg.storage.read("autistmask");
|
|
||||||
stored.wallets[0].addresses.push({
|
|
||||||
address: other.address,
|
|
||||||
balance: "0",
|
|
||||||
tokenBalances: [],
|
|
||||||
});
|
|
||||||
bg.storage.write("autistmask", stored);
|
|
||||||
|
|
||||||
const pending = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
bg.connectApproval(pending.id()).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
global.chrome.runtime.sendMessage = (msg) => {
|
|
||||||
bg.send(msg, bg.fromPopup);
|
|
||||||
};
|
|
||||||
return bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("removing the connected address ends the connection", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
const popupState = bg.storage.read("autistmask");
|
|
||||||
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deleting the wallet holding the connected address ends the connection", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
const popupState = bg.storage.read("autistmask");
|
|
||||||
removeWalletFromState(popupState, 0);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a different address leaves the connection alone", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
|
|
||||||
const popupState = bg.storage.read("autistmask");
|
|
||||||
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page cannot end the connection", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
|
|
||||||
const spoof = bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_ADDRESSES_REMOVED",
|
|
||||||
addresses: [signer.address],
|
|
||||||
},
|
|
||||||
{ url: FRESH_ORIGIN + "/index.html" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spoof.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: "Unauthorized sender",
|
|
||||||
});
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Settings lists the sites allowed without "Remember", which only the
|
|
||||||
// background holds, and removing a site there, from either list, disconnects
|
|
||||||
// it. These drive the real Settings view against the real background and
|
|
||||||
// click the [x] the user clicks.
|
|
||||||
describe("removing a site in Settings disconnects it", () => {
|
|
||||||
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
|
|
||||||
const FRESH_OTHER_PORT = "https://fresh.example:8443";
|
|
||||||
|
|
||||||
// A site list's container. Its [x] buttons, data attributes and all, are
|
|
||||||
// read back out of the rows the view wrote into it, so clicking one runs
|
|
||||||
// the handler the view attached to it.
|
|
||||||
function fakeSiteList() {
|
|
||||||
const list = {
|
|
||||||
innerHTML: "",
|
|
||||||
buttons: [],
|
|
||||||
querySelectorAll() {
|
|
||||||
const tags = list.innerHTML.match(/<button[^>]*>/g) || [];
|
|
||||||
list.buttons = tags.map((tag) => ({
|
|
||||||
dataset: Object.fromEntries(
|
|
||||||
[...tag.matchAll(/data-(\w+)="([^"]*)"/g)].map(
|
|
||||||
(match) => [match[1], match[2]],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
addEventListener(event, handler) {
|
|
||||||
this[event] = handler;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return list.buttons;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The hostnames a site list shows.
|
|
||||||
function listed(list) {
|
|
||||||
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
|
|
||||||
(match) => match[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site connected the way the user does it, in the approval popup.
|
|
||||||
async function connect(bg, origin, remember) {
|
|
||||||
const pending = bg.requestSite(origin);
|
|
||||||
await settle();
|
|
||||||
bg.connectApproval(pending.id()).decide(true, remember);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings, opened over the background's storage and wired to it the way
|
|
||||||
// the popup is: what Settings sends reaches the background from the
|
|
||||||
// extension's own page, and the answer comes back.
|
|
||||||
async function openSettings(bg) {
|
|
||||||
const lists = {};
|
|
||||||
const element = (id) => (lists[id] ||= fakeSiteList());
|
|
||||||
global.document = { getElementById: element };
|
|
||||||
global.chrome.runtime.sendMessage = (msg, callback) => {
|
|
||||||
const { sendResponse } = bg.send(msg, bg.fromPopup);
|
|
||||||
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
|
|
||||||
};
|
|
||||||
await require("../src/shared/state").loadState();
|
|
||||||
await require("../src/popup/views/settings").renderSiteLists();
|
|
||||||
return {
|
|
||||||
allowed: element("settings-allowed-sites"),
|
|
||||||
connected: element("settings-connected-sites"),
|
|
||||||
// Click the [x] beside a site, and let what it sends run.
|
|
||||||
remove: async (list, hostname) => {
|
|
||||||
expect(listed(list)).toContain(hostname);
|
|
||||||
const button = list.buttons.find(
|
|
||||||
(b) => b.dataset.hostname === hostname,
|
|
||||||
);
|
|
||||||
await button.click();
|
|
||||||
await settle();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.document;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Settings lists a site connected without Remember", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
expect(listed(settings.connected)).toEqual(["fresh.example"]);
|
|
||||||
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
const sentToTabs = [];
|
|
||||||
global.chrome.tabs = {
|
|
||||||
query: (q, cb) =>
|
|
||||||
cb([
|
|
||||||
{ id: 1, url: FRESH_ORIGIN + "/app" },
|
|
||||||
{ id: 2, url: ORIGIN + "/app" },
|
|
||||||
]),
|
|
||||||
sendMessage: (tabId, msg, cb) => {
|
|
||||||
sentToTabs.push({ tabId, msg });
|
|
||||||
cb();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
await settings.remove(settings.connected, "fresh.example");
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
|
||||||
expect(sentToTabs).toEqual([
|
|
||||||
{
|
|
||||||
tabId: 1,
|
|
||||||
msg: {
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(listed(settings.connected)).toEqual([]);
|
|
||||||
// The other site is untouched.
|
|
||||||
expect(await siteAccounts(bg, ORIGIN)).toEqual({
|
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same hostname can hold both kinds of connection: one origin allowed
|
|
||||||
// without Remember, then another, on a different port, allowed with it.
|
|
||||||
test("removing a remembered site also ends its connection made without Remember", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
await settings.remove(settings.allowed, "fresh.example");
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
|
|
||||||
result: [],
|
|
||||||
});
|
|
||||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page can neither remove a site nor list the connected ones", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
const page = { url: FRESH_ORIGIN + "/index.html" };
|
|
||||||
|
|
||||||
const remove = bg.send(
|
|
||||||
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
|
|
||||||
page,
|
|
||||||
);
|
|
||||||
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
|
|
||||||
|
|
||||||
expect(remove.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: "Unauthorized sender",
|
|
||||||
});
|
|
||||||
expect(list.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: "Unauthorized sender",
|
|
||||||
});
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
// The recipient line of a contract creation
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
|
|
||||||
//
|
|
||||||
// A transaction with no `to` creates a contract. The approval screen, the
|
|
||||||
// wait, success and error screens, the transaction detail view and the
|
|
||||||
// transaction history rows each say so in a sentence, where they used to show
|
|
||||||
// a blank line (an empty address, with a colour dot whose colour was
|
|
||||||
// `undefined`) or, on the approval screen, "(contract creation)". A
|
|
||||||
// transaction with a real `to` still shows that address.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/typedDataPermit.test.js uses.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
|
||||||
log: {
|
|
||||||
debugf: () => {},
|
|
||||||
infof: () => {},
|
|
||||||
warnf: () => {},
|
|
||||||
errorf: () => {},
|
|
||||||
},
|
|
||||||
// The transaction detail view fetches on-chain details after drawing; an
|
|
||||||
// answer that is not ok leaves the drawn lines as they are.
|
|
||||||
debugFetch: async () => ({ ok: false }),
|
|
||||||
setRuntimeDebug: () => {},
|
|
||||||
isDebug: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The wait screen polls for a receipt; this one never arrives.
|
|
||||||
jest.mock("../src/shared/balances", () => ({
|
|
||||||
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
|
|
||||||
refreshBalances: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The history lists ask the explorer for their transactions and resolve ENS
|
|
||||||
// names for them; here the explorer answers with mockHistory and no name
|
|
||||||
// resolves.
|
|
||||||
let mockHistory = [];
|
|
||||||
jest.mock("../src/shared/transactions", () => ({
|
|
||||||
...jest.requireActual("../src/shared/transactions"),
|
|
||||||
fetchRecentTransactions: async () => mockHistory,
|
|
||||||
}));
|
|
||||||
jest.mock("../src/shared/ens", () => ({
|
|
||||||
...jest.requireActual("../src/shared/ens"),
|
|
||||||
resolveEnsNames: async () => new Map(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
|
||||||
const home = require("../src/popup/views/home");
|
|
||||||
const addressDetail = require("../src/popup/views/addressDetail");
|
|
||||||
const addressToken = require("../src/popup/views/addressToken");
|
|
||||||
|
|
||||||
const SENTENCE =
|
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
|
||||||
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const TX_HASH =
|
|
||||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
|
||||||
// Init code for a contract creation's data.
|
|
||||||
const INIT_CODE = "0x600160005500";
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
// Views reach for .parentElement to hide whole sections.
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
// The debug banner is created on demand by helpers.js; absent
|
|
||||||
// is the state a non-debug, non-testnet popup is in.
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The line a transaction with a real `to` shows: that address, and nothing
|
|
||||||
// left over from an empty one.
|
|
||||||
function expectAddressLine(html) {
|
|
||||||
expect(html).toContain(RECIPIENT);
|
|
||||||
expect(html).not.toContain(SENTENCE);
|
|
||||||
expect(html).not.toContain("undefined");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
state.wallets = [];
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
txStatus.init({ doRefreshAndRender: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
txStatus.endWait();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open the transaction approval screen the way the popup does: the background
|
|
||||||
// hands over the populated transaction and show() draws it.
|
|
||||||
async function openTxApproval(to, data) {
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
hostname: "dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to,
|
|
||||||
value: "0x0",
|
|
||||||
data,
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the transaction approval screen", () => {
|
|
||||||
test("a contract creation says so instead of naming a contract", async () => {
|
|
||||||
await openTxApproval(null, INIT_CODE);
|
|
||||||
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address", async () => {
|
|
||||||
await openTxApproval(RECIPIENT, "0x");
|
|
||||||
expectAddressLine(node("approve-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// approval.js carries a contract creation to these screens with `to` as "".
|
|
||||||
describe("the wait, success and error screens", () => {
|
|
||||||
const creation = {
|
|
||||||
to: "",
|
|
||||||
amount: "0.0000",
|
|
||||||
token: "ETH",
|
|
||||||
tokenSymbol: null,
|
|
||||||
};
|
|
||||||
const transfer = { ...creation, to: RECIPIENT };
|
|
||||||
|
|
||||||
test("a contract creation says so on the wait screen", () => {
|
|
||||||
txStatus.showWait(creation, TX_HASH);
|
|
||||||
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address on the wait screen", () => {
|
|
||||||
txStatus.showWait(transfer, TX_HASH);
|
|
||||||
expectAddressLine(node("wait-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract creation says so on the success and error screens", () => {
|
|
||||||
state.viewData = {
|
|
||||||
amount: "0.0000",
|
|
||||||
symbol: "ETH",
|
|
||||||
to: "",
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 1,
|
|
||||||
};
|
|
||||||
txStatus.renderSuccess();
|
|
||||||
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
|
|
||||||
txStatus.showError(creation, TX_HASH, "The transaction failed.");
|
|
||||||
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address on the success and error screens", () => {
|
|
||||||
state.viewData = {
|
|
||||||
amount: "0.0050",
|
|
||||||
symbol: "ETH",
|
|
||||||
to: RECIPIENT,
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 1,
|
|
||||||
};
|
|
||||||
txStatus.renderSuccess();
|
|
||||||
expectAddressLine(node("success-tx-to").innerHTML);
|
|
||||||
|
|
||||||
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
|
|
||||||
expectAddressLine(node("error-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A transaction FROM sent, as the history lists hold it. The explorer reports a
|
|
||||||
// contract creation with no `to`, which src/shared/transactions.js turns into
|
|
||||||
// `to: ""`.
|
|
||||||
function historyTx(to) {
|
|
||||||
return {
|
|
||||||
hash: TX_HASH,
|
|
||||||
from: FROM,
|
|
||||||
to,
|
|
||||||
value: "0.0000",
|
|
||||||
exactValue: "0.0",
|
|
||||||
rawAmount: "0",
|
|
||||||
rawUnit: "wei",
|
|
||||||
symbol: "ETH",
|
|
||||||
timestamp: 1790000000,
|
|
||||||
isError: false,
|
|
||||||
directionLabel: "Sent",
|
|
||||||
direction: "sent",
|
|
||||||
contractAddress: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The detail view is opened with the transaction a history row holds.
|
|
||||||
describe("the transaction detail view", () => {
|
|
||||||
test("a contract creation says so", () => {
|
|
||||||
transactionDetail.show(historyTx(""));
|
|
||||||
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
|
|
||||||
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address", () => {
|
|
||||||
transactionDetail.show(historyTx(RECIPIENT));
|
|
||||||
expectAddressLine(node("tx-detail-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH).
|
|
||||||
describe.each([
|
|
||||||
["Home", "home-tx-list", () => home.render({})],
|
|
||||||
["AddressDetail", "tx-list", () => addressDetail.show()],
|
|
||||||
["AddressToken", "address-token-tx-list", () => addressToken.show()],
|
|
||||||
])("the transaction history on %s", (_name, listId, open) => {
|
|
||||||
async function rowsFor(tx) {
|
|
||||||
mockHistory = [tx];
|
|
||||||
open();
|
|
||||||
// The list is drawn once the history has been fetched.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
return node(listId).innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
name: "Main",
|
|
||||||
type: "key",
|
|
||||||
addresses: [{ address: FROM, balance: "0.0000" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
state.selectedToken = "ETH";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
|
||||||
const html = await rowsFor(historyTx(""));
|
|
||||||
expect(html).toContain(SENTENCE);
|
|
||||||
expect(html).not.toContain("background:");
|
|
||||||
expect(html).not.toContain("am-address");
|
|
||||||
expect(html).not.toContain("undefined");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its colour dot and address", async () => {
|
|
||||||
const html = await rowsFor(historyTx(RECIPIENT));
|
|
||||||
expectAddressLine(html);
|
|
||||||
expect(html).toContain("background:#");
|
|
||||||
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -407,9 +407,7 @@ describe("deleting without the password", () => {
|
|||||||
expect(saved.activeAddress).toBe(A0);
|
expect(saved.activeAddress).toBe(A0);
|
||||||
expect(saved.selectedWallet).toBe(0);
|
expect(saved.selectedWallet).toBe(0);
|
||||||
expect(saved.selectedAddress).toBe(0);
|
expect(saved.selectedAddress).toBe(0);
|
||||||
expect(sent).toEqual([
|
expect(sent).toEqual([]);
|
||||||
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [B0] },
|
|
||||||
]);
|
|
||||||
// Settings is stubbed, so this is where the route hands over, not
|
// Settings is stubbed, so this is where the route hands over, not
|
||||||
// where it renders.
|
// where it renders.
|
||||||
expect(mockSettingsShow).toHaveBeenCalled();
|
expect(mockSettingsShow).toHaveBeenCalled();
|
||||||
@@ -428,10 +426,7 @@ describe("deleting without the password", () => {
|
|||||||
"Wallet 3",
|
"Wallet 3",
|
||||||
]);
|
]);
|
||||||
expect(saved.activeAddress).toBe(B0);
|
expect(saved.activeAddress).toBe(B0);
|
||||||
expect(sent).toEqual([
|
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
|
||||||
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [A0, A1] },
|
|
||||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
||||||
|
|||||||
@@ -99,13 +99,12 @@ describe("the flash line the message is shown in", () => {
|
|||||||
// length, including one that wrapped to two lines and pushed the
|
// length, including one that wrapped to two lines and pushed the
|
||||||
// settings view down 12px.
|
// settings view down 12px.
|
||||||
//
|
//
|
||||||
// The line cuts a message too long for it with an ellipsis (see
|
// The assertion that actually measures — empty line vs. the message,
|
||||||
// showFlash() in src/popup/views/helpers.js). The assertions that
|
// real Chromium, documented 360x600 popup — is
|
||||||
// measure that, in a real browser at the documented 360x600 popup, are
|
// "a rejected dust threshold shifts no layout (#233)" in
|
||||||
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
// tests/e2e/run.js, run by make test-e2e. It is not in make check
|
||||||
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
|
||||||
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
// suite does not fit; run it before changing the wording.
|
||||||
// caps make test at 20 seconds and a browser suite does not fit.
|
|
||||||
test("reserves its height in the markup", () => {
|
test("reserves its height in the markup", () => {
|
||||||
const flashLine = POPUP_HTML.match(
|
const flashLine = POPUP_HTML.match(
|
||||||
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
||||||
|
|||||||
+30
-153
@@ -1320,13 +1320,17 @@ async function waitForFilledFlashLine(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// README, No Layout Shift: the rejection message goes into #flash-msg,
|
// README, No Layout Shift: the rejection message goes into #flash-msg,
|
||||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
|
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
|
||||||
// cuts a message too long for that line with an ellipsis rather than wrap
|
// the space is not enough on its own — a message too long for one line
|
||||||
// it. This shows the real message and measures that nothing moves; the
|
// wraps and pushes everything below it down anyway, which is what the
|
||||||
// test after it does the same with a message several lines long. Both
|
// first version of this change shipped: 75 characters, 32px, the settings
|
||||||
// measure rather than inspect markup: the unit suite runs on the node
|
// view and the threshold field 12px lower than with an empty line.
|
||||||
// environment with no layout engine, where every height is zero (see the
|
//
|
||||||
// note in tests/dustThreshold.test.js).
|
// So this measures rather than inspects markup. It is the only assertion
|
||||||
|
// in the repo that can see the wording grow: the unit suite runs on the
|
||||||
|
// node environment with no layout engine, where every height is zero (see
|
||||||
|
// the note in tests/dustThreshold.test.js). Lengthen
|
||||||
|
// DUST_THRESHOLD_MESSAGE past one line and this test goes red.
|
||||||
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||||
const page = await openPopup(env.ctx, env.popupUrl);
|
const page = await openPopup(env.ctx, env.popupUrl);
|
||||||
try {
|
try {
|
||||||
@@ -1373,11 +1377,11 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
after.flashHeight === before.flashHeight,
|
after.flashHeight === before.flashHeight,
|
||||||
"the message does not keep to the reserved line: " +
|
"the message does not fit the reserved line: " +
|
||||||
before.flashHeight +
|
before.flashHeight +
|
||||||
"px empty vs " +
|
"px empty vs " +
|
||||||
after.flashHeight +
|
after.flashHeight +
|
||||||
"px with the message",
|
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
after.settingsTop === before.settingsTop,
|
after.settingsTop === before.settingsTop,
|
||||||
@@ -1396,134 +1400,6 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------ the flash line (#252)
|
|
||||||
|
|
||||||
// #flash-msg never wraps: a message too long for its one line is cut with an
|
|
||||||
// ellipsis (see showFlash() in src/popup/views/helpers.js). This puts a
|
|
||||||
// message several lines long into it and measures that the line and the
|
|
||||||
// screen below it stay where they were.
|
|
||||||
test("an over-long flash message keeps to one line (#252)", async (env) => {
|
|
||||||
const page = await openPopup(env.ctx, env.popupUrl);
|
|
||||||
try {
|
|
||||||
await page.setViewportSize(POPUP_VIEWPORT);
|
|
||||||
await openSettings(page);
|
|
||||||
|
|
||||||
const before = await page.evaluate(measureFlashLine);
|
|
||||||
const overflows = await page.evaluate(() => {
|
|
||||||
const line = document.getElementById("flash-msg");
|
|
||||||
line.textContent =
|
|
||||||
"This message is far too long for one line. ".repeat(5);
|
|
||||||
return line.scrollWidth > line.clientWidth;
|
|
||||||
});
|
|
||||||
const after = await page.evaluate(measureFlashLine);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
after.flashHeight === before.flashHeight,
|
|
||||||
"the flash line is " +
|
|
||||||
before.flashHeight +
|
|
||||||
"px before and " +
|
|
||||||
after.flashHeight +
|
|
||||||
"px with an over-long message, so it wraps",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
after.settingsTop === before.settingsTop,
|
|
||||||
"the settings view moved " +
|
|
||||||
(after.settingsTop - before.settingsTop) +
|
|
||||||
"px when the message appeared",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
after.fieldTop === before.fieldTop,
|
|
||||||
"the dust threshold field moved " +
|
|
||||||
(after.fieldTop - before.fieldTop) +
|
|
||||||
"px when the message appeared",
|
|
||||||
);
|
|
||||||
// Checked last: a line that wraps does not run past its right edge,
|
|
||||||
// so this only shows the message really was cut once nothing moved.
|
|
||||||
assert(
|
|
||||||
overflows,
|
|
||||||
"the message fits on the line, so it proves nothing: " +
|
|
||||||
JSON.stringify(after.text),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------------------------------------- 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
|
||||||
@@ -1538,10 +1414,9 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
|
|||||||
// on opposite sides of the reserve while sitting on the same side of the
|
// on opposite sides of the reserve while sitting on the same side of the
|
||||||
// estimate.
|
// estimate.
|
||||||
|
|
||||||
// The balance the funded fixture serves, as the Send and confirmation screens
|
// The balance the funded fixture serves, and the amounts sent against it.
|
||||||
// show it, and the amounts sent against it.
|
|
||||||
const FUNDED_ETH_WEI = 10n ** 18n;
|
const FUNDED_ETH_WEI = 10n ** 18n;
|
||||||
const FUNDED_ETH_TEXT = "1.0000";
|
const FUNDED_ETH_TEXT = "1.0";
|
||||||
const COMFORTABLE_AMOUNT = "0.1";
|
const COMFORTABLE_AMOUNT = "0.1";
|
||||||
const OVER_BALANCE_AMOUNT = "2.0";
|
const OVER_BALANCE_AMOUNT = "2.0";
|
||||||
|
|
||||||
@@ -1555,7 +1430,7 @@ const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);
|
|||||||
// fee test: it covers the expected cost to the wei and falls short of the
|
// fee test: it covers the expected cost to the wei and falls short of the
|
||||||
// reserve, so the same swap flips this assertion too — through a different
|
// reserve, so the same swap flips this assertion too — through a different
|
||||||
// balance and a different message than the ETH path uses.
|
// balance and a different message than the ETH path uses.
|
||||||
const TOKEN_BALANCE_TEXT = "1.5000";
|
const TOKEN_BALANCE_TEXT = "1.5";
|
||||||
const TOKEN_AMOUNT = "0.25";
|
const TOKEN_AMOUNT = "0.25";
|
||||||
const OVER_TOKEN_AMOUNT = "9.0";
|
const OVER_TOKEN_AMOUNT = "9.0";
|
||||||
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
|
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
|
||||||
@@ -1564,15 +1439,16 @@ function toHexWei(wei) {
|
|||||||
return "0x" + wei.toString(16);
|
return "0x" + wei.toString(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fee in wei as the confirmation screen writes it: truncated to four decimal
|
// A fee in wei as the confirmation screen writes it. Deliberately a second
|
||||||
// places (README.md, Display Consistency). Deliberately a second
|
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
|
||||||
// implementation rather than an import of src/shared/amountDisplay.js:
|
// than an import of it: that module pulls in the whole popup and cannot be
|
||||||
// asserting against an independent rendering is stronger than asserting a
|
// required outside a browser, and asserting against an independent rendering
|
||||||
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
|
// is stronger than asserting a function equals itself.
|
||||||
// nonzero floor never applies here.
|
|
||||||
function feeEth(wei) {
|
function feeEth(wei) {
|
||||||
const [whole, frac = ""] = formatEther(wei).split(".");
|
const parts = formatEther(wei).split(".");
|
||||||
return whole + "." + (frac + "0000").slice(0, 4) + " ETH";
|
const dec =
|
||||||
|
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
|
||||||
|
return parts[0] + "." + dec + " ETH";
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the confirmation screen is showing right now, read out of the DOM in
|
// What the confirmation screen is showing right now, read out of the DOM in
|
||||||
@@ -1629,10 +1505,11 @@ async function backToAddress(page) {
|
|||||||
|
|
||||||
// Drive the popup to the confirmation screen for one send.
|
// Drive the popup to the confirmation screen for one send.
|
||||||
//
|
//
|
||||||
// It waits for the send screen to be showing `balance`, the fixture's balance
|
// It waits for the send screen to be showing `balance` before filling
|
||||||
// as that screen displays it, before filling anything in. Waiting for it —
|
// anything in. That figure is the exact number the spend gate compares
|
||||||
// rather than for a refresh to have probably landed — is what keeps every
|
// against, so waiting for it — rather than for a refresh to have probably
|
||||||
// assertion below deterministic after a fixture change.
|
// landed — is what keeps every assertion below deterministic after a
|
||||||
|
// fixture change.
|
||||||
async function goToConfirm(page, { token, balance, amount }) {
|
async function goToConfirm(page, { token, balance, amount }) {
|
||||||
await backToAddress(page);
|
await backToAddress(page);
|
||||||
await page.click("#btn-send");
|
await page.click("#btn-send");
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
// The flash line (#252). #flash-msg reserves one line and cuts a message too
|
|
||||||
// long for it with an ellipsis; that is measured in a real browser by
|
|
||||||
// tests/e2e/run.js. Here: showFlash() keeps the whole message readable in the
|
|
||||||
// line's title, and the two add-token screens flash a fixed line, not the text
|
|
||||||
// of whatever error adding the token threw.
|
|
||||||
|
|
||||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
|
||||||
|
|
||||||
let elements;
|
|
||||||
|
|
||||||
function fakeElement() {
|
|
||||||
return {
|
|
||||||
value: "",
|
|
||||||
textContent: "",
|
|
||||||
title: "",
|
|
||||||
style: {},
|
|
||||||
listeners: {},
|
|
||||||
addEventListener(event, handler) {
|
|
||||||
this.listeners[event] = handler;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stands in for document.getElementById(): one fake element per id.
|
|
||||||
function element(id) {
|
|
||||||
return (elements[id] ||= fakeElement());
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.resetModules();
|
|
||||||
elements = {};
|
|
||||||
globalThis.document = { getElementById: element };
|
|
||||||
// state.js reads chrome.storage.local at load.
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.dontMock("../src/popup/views/helpers");
|
|
||||||
jest.dontMock("../src/shared/state");
|
|
||||||
jest.dontMock("../src/shared/balances");
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
jest.useRealTimers();
|
|
||||||
delete globalThis.document;
|
|
||||||
delete globalThis.chrome;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("showFlash() puts the whole message in the title, and clears both", () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
const { showFlash } = require("../src/popup/views/helpers");
|
|
||||||
|
|
||||||
showFlash("Saved.");
|
|
||||||
expect(element("flash-msg").textContent).toBe("Saved.");
|
|
||||||
expect(element("flash-msg").title).toBe("Saved.");
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(2000);
|
|
||||||
expect(element("flash-msg").textContent).toBe("");
|
|
||||||
expect(element("flash-msg").title).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["addToken", "add-token-address", "btn-add-token-confirm"],
|
|
||||||
[
|
|
||||||
"settingsAddToken",
|
|
||||||
"settings-addtoken-address",
|
|
||||||
"btn-settings-addtoken-manual",
|
|
||||||
],
|
|
||||||
])("adding a token on %s", (view, field, button) => {
|
|
||||||
let flashes;
|
|
||||||
let errors;
|
|
||||||
|
|
||||||
// Clicks the screen's add button with lookupTokenInfo() and saveState()
|
|
||||||
// replaced by the given functions.
|
|
||||||
async function add(lookupTokenInfo, saveState) {
|
|
||||||
flashes = [];
|
|
||||||
errors = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
jest.doMock("../src/shared/balances", () => ({ lookupTokenInfo }));
|
|
||||||
jest.doMock("../src/shared/state", () => ({
|
|
||||||
state: { trackedTokens: [] },
|
|
||||||
saveState,
|
|
||||||
}));
|
|
||||||
jest.doMock("../src/popup/views/helpers", () => ({
|
|
||||||
$: element,
|
|
||||||
showView: () => {},
|
|
||||||
showFlash: (msg) => flashes.push(msg),
|
|
||||||
escapeHtml: (s) => s,
|
|
||||||
goBack: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
require("../src/popup/views/" + view).init({
|
|
||||||
doRefreshAndRender: () => {},
|
|
||||||
});
|
|
||||||
element(field).value = ADDRESS;
|
|
||||||
await element(button).listeners.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a failed save flashes a fixed line and logs the error", async () => {
|
|
||||||
const detail = "A sentence about the stored record. ".repeat(4);
|
|
||||||
|
|
||||||
await add(
|
|
||||||
async () => ({ symbol: "TKN", decimals: 18, name: "Token" }),
|
|
||||||
async () => {
|
|
||||||
throw new Error(detail);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(flashes).toEqual(["Could not add the token."]);
|
|
||||||
expect(errors).toHaveBeenCalledWith(
|
|
||||||
"[AutistMask]",
|
|
||||||
"Adding token failed for",
|
|
||||||
ADDRESS,
|
|
||||||
detail,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract that is not a token flashes the lookup message", async () => {
|
|
||||||
const detail = "Not a valid ERC-20 token (symbol() failed).";
|
|
||||||
|
|
||||||
await add(
|
|
||||||
async () => {
|
|
||||||
throw new Error(detail);
|
|
||||||
},
|
|
||||||
async () => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(flashes).toEqual([detail]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -49,12 +49,11 @@ class StubCustomEvent extends StubEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Examples of codes the background emits on the RPC path, read out of
|
// Every code the background emits on the RPC path today, read out of
|
||||||
// src/background/index.js. The provider must not know any list of codes — it
|
// src/background/index.js. The provider must not know this list — it passes
|
||||||
// passes through whatever arrived — but the cases below are real ones.
|
// through whatever arrived — but the cases below are the real ones.
|
||||||
const REJECTED = 4001; // user rejected the request
|
const REJECTED = 4001; // user rejected the request
|
||||||
const UNAUTHORIZED = 4100; // site not connected / wrong address
|
const UNAUTHORIZED = 4100; // site not connected / wrong address
|
||||||
const UNSUPPORTED_METHOD = 4200; // a method the wallet does not implement
|
|
||||||
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
|
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
|
||||||
|
|
||||||
// A stub window with the four things inpage.js touches: message listeners,
|
// A stub window with the four things inpage.js touches: message listeners,
|
||||||
@@ -116,41 +115,6 @@ async function rejectionFrom(start, response) {
|
|||||||
return outcome.error;
|
return outcome.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reply the real background worker (src/background/index.js) sends for
|
|
||||||
// `method`, loaded against just enough of the extension API to receive one
|
|
||||||
// RPC message. Same shape as tests/coldWorkerChainId.test.js.
|
|
||||||
function backgroundReply(method) {
|
|
||||||
jest.resetModules();
|
|
||||||
jest.doMock("../src/shared/alarms", () => ({
|
|
||||||
BALANCE_REFRESH_ALARM: "balance",
|
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
|
||||||
ensureRecurringAlarms: async () => {},
|
|
||||||
registerAlarmHandlers: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
let messageListener = null;
|
|
||||||
global.chrome = {
|
|
||||||
runtime: {
|
|
||||||
onMessage: {
|
|
||||||
addListener: (fn) => {
|
|
||||||
messageListener = fn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onConnect: { addListener: () => {} },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
require("../src/background/index");
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
messageListener(
|
|
||||||
{ type: "AUTISTMASK_RPC", method, params: [] },
|
|
||||||
{ origin: "https://dapp.example" },
|
|
||||||
resolve,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("an EIP-1193 code reaches the page", () => {
|
describe("an EIP-1193 code reaches the page", () => {
|
||||||
test("a user rejection arrives as code 4001", async () => {
|
test("a user rejection arrives as code 4001", async () => {
|
||||||
const err = await rejectionFrom(
|
const err = await rejectionFrom(
|
||||||
@@ -200,9 +164,8 @@ describe("an EIP-1193 code reaches the page", () => {
|
|||||||
expect(err.message).toBe(message);
|
expect(err.message).toBe(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The provider is not allowed to know the codes above: any other code,
|
// The provider is not allowed to know the list above: a code added to the
|
||||||
// including one added to the background later, must reach the page
|
// background later must reach the page without this file being edited.
|
||||||
// without inpage.js being edited.
|
|
||||||
test("a code the provider has never heard of is passed through", async () => {
|
test("a code the provider has never heard of is passed through", async () => {
|
||||||
const err = await rejectionFrom(
|
const err = await rejectionFrom(
|
||||||
(p) => p.request({ method: "eth_accounts" }),
|
(p) => p.request({ method: "eth_accounts" }),
|
||||||
@@ -240,26 +203,6 @@ describe("an EIP-1193 code reaches the page", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reply here is the background's own, not one written in this file: it
|
|
||||||
// used to carry no code for a method the wallet does not implement
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/279), so a site probing for an
|
|
||||||
// optional method could not tell "not implemented" from "the call failed".
|
|
||||||
describe("a method the wallet does not implement", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.chrome;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reaches the page as code 4200", async () => {
|
|
||||||
const method = "wallet_noSuchMethod";
|
|
||||||
const err = await rejectionFrom(
|
|
||||||
(p) => p.request({ method }),
|
|
||||||
await backgroundReply(method),
|
|
||||||
);
|
|
||||||
expect(err.code).toBe(UNSUPPORTED_METHOD);
|
|
||||||
expect(err.message).toBe("Unsupported method: " + method);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the message is untouched", () => {
|
describe("the message is untouched", () => {
|
||||||
test("a coded error keeps the message byte for byte", async () => {
|
test("a coded error keeps the message byte for byte", async () => {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -1,452 +0,0 @@
|
|||||||
// The balance and fee lines of the Send and confirmation screens, and the fee
|
|
||||||
// line they must share with the approval screen.
|
|
||||||
//
|
|
||||||
// An ETH balance, a token balance or a fee below 0.000001 rendered as zero on
|
|
||||||
// these screens (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored
|
|
||||||
// balances and the fee were each cut to six decimal places, a rule of their
|
|
||||||
// own, and a token holding cut to zero was dropped, while the approval screen
|
|
||||||
// showed the same fee through src/shared/amountDisplay.js with the nonzero
|
|
||||||
// floor. The balances are now stored exactly, every nonzero token holding
|
|
||||||
// kept, and the screens show them and the fee through that helper.
|
|
||||||
//
|
|
||||||
// Driven through the real refreshBalances(), Send screen, confirmation screen
|
|
||||||
// and approval screen, with only the node, the explorer and the DOM stubbed: a
|
|
||||||
// balance written onto state by hand would skip the place the cut happened.
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// What the stub node answers. Each test sets what it needs.
|
|
||||||
const mockNode = {
|
|
||||||
balanceWei: 0n,
|
|
||||||
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
|
|
||||||
};
|
|
||||||
|
|
||||||
// The token rows the stub explorer reports for the address.
|
|
||||||
const mockExplorer = { items: [] };
|
|
||||||
|
|
||||||
jest.mock("ethers", () => {
|
|
||||||
const actual = jest.requireActual("ethers");
|
|
||||||
class StubProvider {
|
|
||||||
async getBalance() {
|
|
||||||
return mockNode.balanceWei;
|
|
||||||
}
|
|
||||||
async lookupAddress() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
async getFeeData() {
|
|
||||||
return mockNode.feeData;
|
|
||||||
}
|
|
||||||
async estimateGas() {
|
|
||||||
return 21000n;
|
|
||||||
}
|
|
||||||
async getCode() {
|
|
||||||
return "0x";
|
|
||||||
}
|
|
||||||
async getTransactionCount() {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
JsonRpcProvider: StubProvider,
|
|
||||||
Network: { from: () => ({}) },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
|
||||||
log: {
|
|
||||||
debugf: () => {},
|
|
||||||
infof: () => {},
|
|
||||||
warnf: () => {},
|
|
||||||
errorf: () => {},
|
|
||||||
},
|
|
||||||
// The explorer's token list, which refreshBalances() also fetches.
|
|
||||||
debugFetch: jest.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => mockExplorer.items,
|
|
||||||
})),
|
|
||||||
setRuntimeDebug: () => {},
|
|
||||||
isDebug: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The confirmation screen's Etherscan label lookup is the only fetch() these
|
|
||||||
// screens make; it fails, as it does offline.
|
|
||||||
global.fetch = jest.fn(() => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The approval the background hands the approval screen. Set per test.
|
|
||||||
let approvalDetails = null;
|
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
global.chrome = {
|
|
||||||
storage: makeStorageStub(),
|
|
||||||
runtime: {
|
|
||||||
connect: () => ({
|
|
||||||
postMessage() {},
|
|
||||||
disconnect() {},
|
|
||||||
onDisconnect: { addListener() {} },
|
|
||||||
}),
|
|
||||||
sendMessage(message, callback) {
|
|
||||||
callback(
|
|
||||||
message.type === "AUTISTMASK_GET_APPROVAL"
|
|
||||||
? approvalDetails
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// A stub DOM: every id resolves to a recording element.
|
|
||||||
const elements = new Map();
|
|
||||||
|
|
||||||
function makeEl(id) {
|
|
||||||
const handlers = new Map();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
innerHTML: "",
|
|
||||||
value: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add() {},
|
|
||||||
remove() {},
|
|
||||||
toggle() {},
|
|
||||||
contains: () => false,
|
|
||||||
},
|
|
||||||
handlers,
|
|
||||||
children: [],
|
|
||||||
addEventListener(name, fn) {
|
|
||||||
handlers.set(name, fn);
|
|
||||||
},
|
|
||||||
appendChild(child) {
|
|
||||||
this.children.push(child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
querySelector: () => null,
|
|
||||||
remove() {},
|
|
||||||
focus() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.document = {
|
|
||||||
getElementById(id) {
|
|
||||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
|
||||||
return elements.get(id);
|
|
||||||
},
|
|
||||||
createElement: (tag) => makeEl(tag),
|
|
||||||
body: { prepend() {}, appendChild() {} },
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
global.navigator = { clipboard: { writeText() {} } };
|
|
||||||
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const {
|
|
||||||
prices,
|
|
||||||
clearPrices,
|
|
||||||
formatAddressTotal,
|
|
||||||
getAddressValue,
|
|
||||||
} = require("../src/shared/prices");
|
|
||||||
const send = require("../src/popup/views/send");
|
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const {
|
|
||||||
addressHoldsFunds,
|
|
||||||
balanceLinesForAddress,
|
|
||||||
} = require("../src/popup/views/helpers");
|
|
||||||
|
|
||||||
const HOLDER = "0x" + "a".repeat(40);
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
|
||||||
|
|
||||||
// 0.0000005 ETH, or 0.0000005 of an 18-decimal token.
|
|
||||||
const HALF_MICRO_ETH = 500000000000n;
|
|
||||||
|
|
||||||
// A token the bundled list does not know.
|
|
||||||
const TOKEN = "0x" + "d".repeat(40);
|
|
||||||
|
|
||||||
// The explorer's row for TOKEN, holding `value` base units. With only five
|
|
||||||
// holders, it is listed only when the user tracks the token.
|
|
||||||
function tokenRow(value, token = {}) {
|
|
||||||
return {
|
|
||||||
value: String(value),
|
|
||||||
token: {
|
|
||||||
type: "ERC-20",
|
|
||||||
address_hash: TOKEN,
|
|
||||||
symbol: "TOK",
|
|
||||||
name: "Token",
|
|
||||||
decimals: "18",
|
|
||||||
holders_count: "5",
|
|
||||||
...token,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(id) {
|
|
||||||
return global.document.getElementById(id).textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errors() {
|
|
||||||
return global.document.getElementById("confirm-errors").innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The ETH balance the node reports and the token rows the explorer reports,
|
|
||||||
// fetched and stored exactly where the popup stores them.
|
|
||||||
async function refreshWith(balanceWei, tokenItems = []) {
|
|
||||||
mockNode.balanceWei = balanceWei;
|
|
||||||
mockExplorer.items = tokenItems;
|
|
||||||
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
await refreshBalances(
|
|
||||||
state.wallets,
|
|
||||||
"https://rpc.example.invalid",
|
|
||||||
"https://blockscout.example/api/v2",
|
|
||||||
state.trackedTokens,
|
|
||||||
"mainnet",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Press Review on the Send screen for a send of `token` ("ETH" or a token
|
|
||||||
// address), and show the confirmation screen it leads to with its fee estimate
|
|
||||||
// settled.
|
|
||||||
async function confirmSend(amount, token = "ETH") {
|
|
||||||
let txInfo = null;
|
|
||||||
send.init({ showConfirmTx: (info) => (txInfo = info) });
|
|
||||||
state.selectedToken = token;
|
|
||||||
global.document.getElementById("send-to").value = RECIPIENT;
|
|
||||||
global.document.getElementById("send-amount").value = amount;
|
|
||||||
await global.document
|
|
||||||
.getElementById("btn-send-review")
|
|
||||||
.handlers.get("click")();
|
|
||||||
confirmTx.show(txInfo);
|
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The approval screen for a dApp transaction of 21000 gas, the gas the stub
|
|
||||||
// node estimates for the send above.
|
|
||||||
async function approveTxWithFeePerGas(maxFeePerGas) {
|
|
||||||
approvalDetails = {
|
|
||||||
type: "tx",
|
|
||||||
hostname: "dapp.example",
|
|
||||||
approvedFrom: HOLDER,
|
|
||||||
approvedTx: {
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0",
|
|
||||||
data: "0x",
|
|
||||||
chainId: "0x1",
|
|
||||||
gasLimit: "21000",
|
|
||||||
maxFeePerGas: String(maxFeePerGas),
|
|
||||||
nonce: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await approval.show("1");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
elements.clear();
|
|
||||||
state.selectedToken = null;
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.fraudContracts = [];
|
|
||||||
state.currentView = null;
|
|
||||||
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("an ETH balance below 0.000001 never renders as zero", () => {
|
|
||||||
test("on the Send screen", async () => {
|
|
||||||
await refreshWith(HALF_MICRO_ETH);
|
|
||||||
state.selectedToken = "ETH";
|
|
||||||
send.updateSendBalance();
|
|
||||||
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on the confirmation screen", async () => {
|
|
||||||
await refreshWith(HALF_MICRO_ETH);
|
|
||||||
await confirmSend("0.0000001");
|
|
||||||
expect(text("confirm-balance")).toBe("0.0000005 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("while a balance above the floor keeps four decimals", async () => {
|
|
||||||
await refreshWith(1234567890000000000n);
|
|
||||||
await confirmSend("0.1");
|
|
||||||
expect(text("confirm-balance")).toBe("1.2345 ETH");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A token the user tracks stays on the balance list when the explorer's row is
|
|
||||||
// dropped, so a holding of it below 0.000001 reached these screens as zero, and
|
|
||||||
// the send was checked against zero.
|
|
||||||
describe("a tracked token holding below 0.000001 never renders as zero", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on the Send screen", async () => {
|
|
||||||
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
|
|
||||||
state.selectedToken = TOKEN;
|
|
||||||
send.updateSendBalance();
|
|
||||||
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on the confirmation screen, which checks the send against it", async () => {
|
|
||||||
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
|
|
||||||
await confirmSend("0.0000005", TOKEN);
|
|
||||||
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
await confirmSend("0.0000006", TOKEN);
|
|
||||||
expect(errors()).toContain(
|
|
||||||
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The stored balance keeps all 24 places, and the balance check reads the
|
|
||||||
// first 18 of them rather than refusing it as no balance at all.
|
|
||||||
test("with more than 18 decimals, the send is checked against 18 of them", async () => {
|
|
||||||
state.trackedTokens[0].decimals = 24;
|
|
||||||
// 1.5 plus one base unit.
|
|
||||||
const value = 15n * 10n ** 23n + 1n;
|
|
||||||
await refreshWith(10n ** 18n, [tokenRow(value, { decimals: "24" })]);
|
|
||||||
await confirmSend("1.5", TOKEN);
|
|
||||||
expect(text("confirm-balance")).toBe("1.5000 TOK");
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("with more than 18 decimals and a holding below 10^-18", async () => {
|
|
||||||
state.trackedTokens[0].decimals = 24;
|
|
||||||
// One base unit, 0.000000000000000000000001 TOK.
|
|
||||||
await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
|
|
||||||
state.selectedToken = TOKEN;
|
|
||||||
send.updateSendBalance();
|
|
||||||
expect(text("send-balance")).toBe(
|
|
||||||
"Current balance: 0.000000000000000000000001 TOK",
|
|
||||||
);
|
|
||||||
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.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A token the user does not track, with enough holders to be admitted. The
|
|
||||||
// balance fetch dropped a holding of it below 0.000001, but the token stays
|
|
||||||
// selected while its own screen is open: after sending 2 of a 2.0000003
|
|
||||||
// holding, the user is back on that screen, and Send read the missing row as
|
|
||||||
// zero.
|
|
||||||
describe("an untracked token holding below 0.000001 never renders as zero", () => {
|
|
||||||
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
|
|
||||||
|
|
||||||
test("on the Send screen", async () => {
|
|
||||||
await refreshWith(10n ** 18n, [row()]);
|
|
||||||
state.selectedToken = TOKEN;
|
|
||||||
send.updateSendBalance();
|
|
||||||
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("on the confirmation screen, which checks the send against it", async () => {
|
|
||||||
await refreshWith(10n ** 18n, [row()]);
|
|
||||||
await confirmSend("0.0000005", TOKEN);
|
|
||||||
expect(text("confirm-balance")).toBe("0.0000005 TOK");
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
await confirmSend("0.0000006", TOKEN);
|
|
||||||
expect(errors()).toContain(
|
|
||||||
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The fetch keeps every holding, so the screens that showed only what it kept
|
|
||||||
// leave out a holding below 0.000001 themselves, and look as they did.
|
|
||||||
describe("a token holding below 0.000001 is still not listed", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
clearPrices();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("for a token the user does not track", async () => {
|
|
||||||
prices.ETH = 3000;
|
|
||||||
await refreshWith(0n, [
|
|
||||||
tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }),
|
|
||||||
]);
|
|
||||||
const addr = state.wallets[0].addresses[0];
|
|
||||||
expect(balanceLinesForAddress(addr, [], true)).not.toContain(TOKEN);
|
|
||||||
expect(balanceLinesForAddress(addr, [], false)).not.toContain(TOKEN);
|
|
||||||
send.renderSendTokenSelect(addr);
|
|
||||||
const options = global.document.getElementById("send-token").children;
|
|
||||||
expect(options.map((o) => o.value)).toEqual([]);
|
|
||||||
// Not an unpriced token in the total, and not funds on the
|
|
||||||
// remove-address warning.
|
|
||||||
expect(formatAddressTotal(getAddressValue(addr))).toBe("Total: $0.00");
|
|
||||||
expect(addressHoldsFunds(addr)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// As a tracked token holding nothing: listed only while zero balances are
|
|
||||||
// shown.
|
|
||||||
test("for a tracked token, unless zero balances are shown", async () => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
|
|
||||||
];
|
|
||||||
await refreshWith(0n, [tokenRow(HALF_MICRO_ETH)]);
|
|
||||||
const addr = state.wallets[0].addresses[0];
|
|
||||||
expect(
|
|
||||||
balanceLinesForAddress(addr, state.trackedTokens, false),
|
|
||||||
).not.toContain(TOKEN);
|
|
||||||
expect(
|
|
||||||
balanceLinesForAddress(addr, state.trackedTokens, true),
|
|
||||||
).toContain(`data-token="${TOKEN}"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a fee below 0.000001 ETH never renders as zero", () => {
|
|
||||||
test("when the estimate and the reserve are the same", async () => {
|
|
||||||
await refreshWith(10n ** 18n);
|
|
||||||
await confirmSend("0.1");
|
|
||||||
// 21000 gas at 1 wei is 0.000000000000021 ETH, shown to its first
|
|
||||||
// significant digit.
|
|
||||||
expect(text("confirm-fee-amount")).toBe("0.00000000000002 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("when they differ, on both lines", async () => {
|
|
||||||
mockNode.feeData = { maxFeePerGas: 2n, gasPrice: 1n };
|
|
||||||
await refreshWith(10n ** 18n);
|
|
||||||
await confirmSend("0.1");
|
|
||||||
expect(text("confirm-fee-amount")).toBe("~0.00000000000002 ETH");
|
|
||||||
expect(text("confirm-fee-reserve")).toBe(
|
|
||||||
"up to 0.00000000000004 ETH reserved",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The confirmation screen shows the reserve alone when the node quotes no
|
|
||||||
// cheaper estimate, and that reserve is the same gas limit times maximum fee
|
|
||||||
// per gas that the approval screen calls the max fee. An ETH price is set, as
|
|
||||||
// it is on mainnet, so the USD value has to match too.
|
|
||||||
describe("the same fee reads the same on the confirmation and approval screens", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
prices.ETH = 3000;
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
clearPrices();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
// 21000 gas at 1 wei.
|
|
||||||
["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"],
|
|
||||||
// 0.001235294117631 ETH, which is $3.71. Pricing the truncated
|
|
||||||
// 0.0012 instead would read $3.60.
|
|
||||||
["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"],
|
|
||||||
])("%s", async (_label, feePerGas, expected) => {
|
|
||||||
mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas };
|
|
||||||
await refreshWith(10n ** 18n);
|
|
||||||
await confirmSend("0.1");
|
|
||||||
expect(text("confirm-fee-amount")).toBe(expected);
|
|
||||||
await approveTxWithFeePerGas(feePerGas);
|
|
||||||
expect(text("approve-tx-fee")).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,26 +20,6 @@ const path = require("path");
|
|||||||
|
|
||||||
const { makeStorageStub } = require("./storageStub");
|
const { makeStorageStub } = require("./storageStub");
|
||||||
|
|
||||||
// The four libraries the popup loads from node_modules, loaded once per test
|
|
||||||
// file and handed to every boot. jest.resetModules() in bootPopup() empties the
|
|
||||||
// module cache but keeps what jest.doMock() registered, so these registrations
|
|
||||||
// hold for every boot in the file and the libraries are not loaded again. None
|
|
||||||
// of them holds popup state; everything under src/ is still loaded fresh on
|
|
||||||
// each boot.
|
|
||||||
//
|
|
||||||
// A test's own mock of one of them: a jest.doMock() made inside the test
|
|
||||||
// replaces the registration here, as it would for any module. A top-of-file
|
|
||||||
// jest.mock() is what the require() below gets, so it is kept, but its factory
|
|
||||||
// runs once per file and every boot shares the same mock object.
|
|
||||||
const ethers = require("ethers");
|
|
||||||
const sodium = require("libsodium-wrappers-sumo");
|
|
||||||
const QRCode = require("qrcode");
|
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
|
||||||
jest.doMock("ethers", () => ethers);
|
|
||||||
jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
|
||||||
jest.doMock("qrcode", () => QRCode);
|
|
||||||
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
|
||||||
|
|
||||||
const POPUP_HTML = fs.readFileSync(
|
const POPUP_HTML = fs.readFileSync(
|
||||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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.
|
||||||
@@ -77,14 +75,6 @@ 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) ...
|
||||||
@@ -233,233 +223,6 @@ 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,62 +382,14 @@ 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");
|
||||||
confirmTx.show(txInfo);
|
confirmTx.show(txInfo);
|
||||||
await settle();
|
await settle();
|
||||||
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
|
expect(text("confirm-balance")).toBe("5.0 NOVEL");
|
||||||
expect(errors()).toBe("");
|
expect(errors()).toBe("");
|
||||||
expect(sendDisabled()).toBe(false);
|
expect(sendDisabled()).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -479,7 +431,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
|
|||||||
const zero = await render("0.0");
|
const zero = await render("0.0");
|
||||||
expect(unknown.balance).not.toBe(zero.balance);
|
expect(unknown.balance).not.toBe(zero.balance);
|
||||||
expect(unknown.balance).toBe("unknown (NOVEL)");
|
expect(unknown.balance).toBe("unknown (NOVEL)");
|
||||||
expect(zero.balance).toBe("0.0000 NOVEL");
|
expect(zero.balance).toBe("0.0 NOVEL");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
|
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
|
||||||
@@ -491,7 +443,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
|
|||||||
expect(unknown.errors).not.toBe(zero.errors);
|
expect(unknown.errors).not.toBe(zero.errors);
|
||||||
expect(unknown.errors).toContain("This token's balance is unknown");
|
expect(unknown.errors).toContain("This token's balance is unknown");
|
||||||
expect(unknown.errors).not.toContain("You have");
|
expect(unknown.errors).not.toContain("You have");
|
||||||
expect(zero.errors).toContain("You have 0.0000 NOVEL");
|
expect(zero.errors).toContain("You have 0.0 NOVEL");
|
||||||
expect(zero.errors).not.toContain("balance is unknown");
|
expect(zero.errors).not.toContain("balance is unknown");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user