Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4ae645695 | ||
|
|
8ac2c87c2c | ||
|
|
d1751beb32 | ||
|
|
de3f7a9a11 | ||
|
|
1144fdb71b | ||
|
|
f24b5bca19 | ||
|
|
9f0e88e963 | ||
|
|
45f11ee920 | ||
|
|
a68f30c480 | ||
|
|
43c236451f | ||
|
|
1247c24c4d | ||
|
|
bec20aa2bb | ||
|
|
467b849a13 | ||
|
|
5bf8b5ff1f | ||
|
|
4b62e31e80 | ||
|
|
49a7da87e8 | ||
|
|
5f54fcbb24 | ||
|
|
00d6193ee7 | ||
|
|
6c70a82de8 | ||
|
|
add11e57de | ||
|
|
598de3ff1a | ||
|
|
ae61792aee | ||
|
|
a1f082d686 | ||
|
|
33fa25adca | ||
|
|
2fe6447625 | ||
|
|
2da790fbe9 | ||
|
|
9ac7df0128 | ||
|
|
99292b9188 | ||
|
|
1197d2171b |
+5
-6
@@ -8,12 +8,11 @@ WORKDIR /app
|
||||
# image sets it.
|
||||
ENV AUTISTMASK_LINT_NATIVE=1
|
||||
|
||||
# script/test's default 30s bound is the host figure, against a suite that
|
||||
# runs in about 8s there. In here the same suite starts on a cold jest cache
|
||||
# and shares the runner with the rest of the build, so 30s is marginal rather
|
||||
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
||||
# still catches a hang in three minutes and cannot be tripped by a suite that
|
||||
# is merely running on contended hardware.
|
||||
# script/test's default 30s bound is the host figure. In here the same suite
|
||||
# starts on a cold jest cache and shares the runner with the rest of the build,
|
||||
# so 30s is too tight — it killed a healthy suite at 30.6s on a cold CI cache.
|
||||
# 180s still catches a hang in three minutes and cannot be tripped by a suite
|
||||
# that is merely running on contended hardware.
|
||||
ENV AUTISTMASK_TEST_TIMEOUT=180
|
||||
|
||||
# script/bootstrap installs all prerequisites (make via apt here; node
|
||||
|
||||
@@ -64,7 +64,9 @@ release/SHA256SUMS
|
||||
```
|
||||
|
||||
Nothing is published by this. Tagging, CRX packing and any upload are
|
||||
outward-facing acts and are the owner's alone.
|
||||
outward-facing acts and are the owner's alone. The full procedure that turns a
|
||||
green `main` into a tagged, packaged release — the order of steps, who performs
|
||||
each, and how to check it worked — is in [docs/RELEASE.md](docs/RELEASE.md).
|
||||
|
||||
The archives are deterministic — entries sorted, timestamps fixed, compression
|
||||
level fixed — so two builds of one commit produce byte-identical files and the
|
||||
@@ -800,7 +802,12 @@ discoverable.
|
||||
addresses visually, as a security feature.
|
||||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||||
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
||||
the styling co-located with the markup and eliminates CSS file management.
|
||||
the styling co-located with the markup and eliminates CSS file management. The
|
||||
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
|
||||
carries the copy feedback animation, and `.am-address` carries the rule that
|
||||
an address never wraps. Both are invariants that hold in every place they
|
||||
appear, and spelling either out as repeated utilities is how one of those
|
||||
places drifts away from the rest.
|
||||
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
||||
enough that vanilla JS with simple view switching is sufficient. A framework
|
||||
would add bundle size, build complexity, and attack surface for no benefit at
|
||||
@@ -849,6 +856,12 @@ that the portions still displayed will be more than adequate for the user to
|
||||
verify addresses even in the case of address spoofing attacks. Clicking an
|
||||
address will always copy the full, untruncated value.
|
||||
|
||||
As of the address-row layout change, no view invokes that exception: every
|
||||
address in the popup is rendered on a row of its own, wide enough for all 42
|
||||
characters, and no screen truncates one to fit. The cap is still enforced in
|
||||
`truncateMiddle()` and the 32-character floor in `renderAddressHtml()`, so the
|
||||
guarantee holds for any future caller; there simply are none today.
|
||||
|
||||
**Specific Exception — Transaction Detail view:** The transaction detail screen
|
||||
is the authoritative record of a specific transaction and shows the exact,
|
||||
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
|
||||
@@ -869,19 +882,31 @@ 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
|
||||
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` —
|
||||
the exception only fires where the entire displayed figure would read as zero. A
|
||||
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
|
||||
shows as `0.9999`, never rounded up.
|
||||
the exception only fires where the entire displayed figure would read as zero.
|
||||
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
|
||||
rule still renders a genuine zero as `0.0000`. Two lines of a swap 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.
|
||||
|
||||
The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
||||
confirmation screens display goes through the floored one — the ERC-20 amount,
|
||||
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
|
||||
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
|
||||
balance lists (`src/shared/transactions.js`) use the unfloored one: the
|
||||
transaction detail view is the authoritative record and already shows exact
|
||||
precision. The 4-decimal rule is unchanged everywhere else, including for
|
||||
amounts at or above the floor on the approval screens.
|
||||
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
|
||||
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
|
||||
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
|
||||
on the confirmation screen for the wallet's own send
|
||||
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
|
||||
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
|
||||
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
|
||||
approval screen states base units instead of a quantity — see Unknown token
|
||||
@@ -898,9 +923,10 @@ approximation but a different number — 1,000 units of a 6-decimal token
|
||||
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
|
||||
to state what is being authorized. Both amount paths of that screen take this
|
||||
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
|
||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
|
||||
unbounded allowance or permit needs no scale to describe and is still shown as
|
||||
`Unlimited`.
|
||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
||||
token permission warning on the signature screen takes the same rule for its
|
||||
amounts. An unbounded allowance or permit needs no scale to describe and is
|
||||
still shown as `Unlimited`.
|
||||
|
||||
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
||||
sources are read as authoritative, so a value written into one of them cannot be
|
||||
@@ -923,6 +949,62 @@ 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
|
||||
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
|
||||
|
||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||
@@ -1003,8 +1085,13 @@ 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
|
||||
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||||
and the send-screen token selector, not this list. Tracked tokens with a zero
|
||||
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
||||
and the send-screen token selector, not this list. `fetchTokenBalances()` stores
|
||||
every nonzero holding of a token it admits, however small, but a holding below
|
||||
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
|
||||
|
||||
@@ -1070,7 +1157,7 @@ each caught only by a reviewer re-deriving thirty fields by hand.
|
||||
The `allowedSites` case is why the entry check is not optional. A stored
|
||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||
passed the gate, rendered a completely healthy popup, and then threw inside
|
||||
`saveState()`'s per-hostname merge, so every save from that moment on failed and
|
||||
`saveState()`'s per-origin merge, so every save from that moment on failed and
|
||||
the user went on operating a wallet that was persisting nothing
|
||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
||||
now also reported rather than swallowed: `onSaveFailure()` in
|
||||
@@ -1182,13 +1269,20 @@ view would leave a wallet one click from deletion.
|
||||
- Send / Receive quick-action buttons, both acting on the active address
|
||||
- ETH/USD price display
|
||||
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
||||
button for HD and xprv wallets, then one block per address with "Address
|
||||
N" (bold when active), the ENS name if resolved, the full address, an
|
||||
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
|
||||
than one address), the address USD total, and a balance line for ETH and
|
||||
for each token shown for that address
|
||||
button for HD and xprv wallets, then one block per address. The block
|
||||
opens with a row carrying the colour dot, "Address N" (bold when active),
|
||||
an `[info]` button and an `[x]` button (only on HD and xprv wallets
|
||||
holding more than one address); the ENS name, if resolved, is below it;
|
||||
then the full address on a row of its own, followed by the address USD
|
||||
total and a balance line for ETH and for each token shown for that address
|
||||
- "Recent Transactions": up to 25 transactions merged across every address
|
||||
of every wallet, deduplicated by hash and filtered
|
||||
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
|
||||
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
|
||||
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
|
||||
- **Transitions**:
|
||||
- Tap address row → sets the active address and broadcasts
|
||||
@@ -1322,7 +1416,9 @@ view would leave a wallet one click from deletion.
|
||||
- What to send: token dropdown (or static display with contract address when
|
||||
locked from AddressToken)
|
||||
- To: address or ENS name input, with an inline validation message
|
||||
- Amount input with current balance display
|
||||
- Amount input with current balance display, which reads
|
||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||
ConfirmTx's balance line does (see Unknown token scale)
|
||||
- "Review" button, disabled until the recipient validates
|
||||
- **Transitions**:
|
||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||
@@ -1340,7 +1436,8 @@ view would leave a wallet one click from deletion.
|
||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses)
|
||||
- Your balance: value + symbol (USD in parentheses)
|
||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
||||
for a token whose scale is unknown
|
||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||
fetched async. The first line is what the transfer is expected to cost,
|
||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||
@@ -1356,7 +1453,11 @@ view would leave a wallet one click from deletion.
|
||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
||||
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
||||
estimated. The first two are mutually exclusive per transfer type, so only
|
||||
the applicable one holds space
|
||||
the applicable one holds space. The last names its cause: for a token
|
||||
whose scale is unknown the fee can never be estimated, and it says the
|
||||
wallet does not know how many decimal places the token uses and that the
|
||||
transaction cannot be sent; for any other failure it asks the user to go
|
||||
back and try again
|
||||
- Password: an inline field on this screen, not a modal, with its own error
|
||||
line
|
||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||
@@ -1381,7 +1482,9 @@ view would leave a wallet one click from deletion.
|
||||
- **Elements**:
|
||||
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- To: color dot + full address + etherscan link; for a contract creation,
|
||||
which has no recipient, "This transaction creates a new contract. It has
|
||||
no recipient." instead
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- Count-up timer: "Waiting for confirmation... Ns"
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||
@@ -1410,7 +1513,8 @@ view would leave a wallet one click from deletion.
|
||||
- Decoded action well (shown when the transaction carried recognized
|
||||
calldata; the top-level Amount and To are hidden in that case)
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- To: color dot + full address + etherscan link, or for a contract creation
|
||||
the same sentence as on WaitTx
|
||||
- Block number
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- "Done" button
|
||||
@@ -1425,7 +1529,8 @@ view would leave a wallet one click from deletion.
|
||||
- **Elements**:
|
||||
- "Transaction Failed" heading
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- To: color dot + full address + etherscan link, or for a contract creation
|
||||
the same sentence as on WaitTx
|
||||
- Error message (dashed border box)
|
||||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||||
full hash (tap to copy) + etherscan link
|
||||
@@ -1463,7 +1568,7 @@ view would leave a wallet one click from deletion.
|
||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||
ENS name if available
|
||||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||||
name if available
|
||||
name if available. For a contract creation, the same sentence as on WaitTx
|
||||
- Time: ISO datetime + relative age in parentheses
|
||||
- Block: block number (tap to copy) + etherscan block link
|
||||
- Amount: value + symbol (bold)
|
||||
@@ -1526,8 +1631,14 @@ view would leave a wallet one click from deletion.
|
||||
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
|
||||
threshold, so a number the user did not type is never stored.
|
||||
- Allowed Sites: list with remove buttons
|
||||
- Denied Sites: list with remove buttons
|
||||
- Allowed Sites: the origins (scheme, host and port) remembered as allowed,
|
||||
under any address, with remove buttons
|
||||
- Connected Sites: the origins 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 origins remembered as denied, under any address, with
|
||||
remove buttons
|
||||
- About: project link, license, author, version, release date, and the
|
||||
commit, which links to the commit in the repository
|
||||
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
||||
@@ -1538,8 +1649,16 @@ view would leave a wallet one click from deletion.
|
||||
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
|
||||
- `[x]` on a wallet → **DeleteWallet**
|
||||
- Tap wallet name → inline rename field (no screen change)
|
||||
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||||
change)
|
||||
- `[x]` on a tracked token → removes it in place (no screen change)
|
||||
- `[x]` on an allowed or connected site → disconnects that site, in place:
|
||||
its origin is dropped from Allowed Sites under every address, and
|
||||
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
||||
without "Remember" from that origin, under any address, and send
|
||||
`accountsChanged` with an empty list to the open tabs of that origin. The
|
||||
same host under another scheme or port is another site and is left alone.
|
||||
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)
|
||||
- "Back" (or Settings gear again) → previous screen (Home)
|
||||
|
||||
@@ -1590,6 +1709,10 @@ view would leave a wallet one click from deletion.
|
||||
- Either way, the active address moves only if it belonged to the deleted
|
||||
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
||||
(`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
|
||||
try again." on the error line, nothing deleted
|
||||
- "I have lost my password" → **DeleteWalletLostPassword**
|
||||
@@ -1686,6 +1809,10 @@ view would leave a wallet one click from deletion.
|
||||
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;
|
||||
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
|
||||
fresh address rather than handing back the one just removed.
|
||||
|
||||
@@ -1712,21 +1839,30 @@ view would leave a wallet one click from deletion.
|
||||
|
||||
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||||
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
||||
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||||
falls back to a separate popup window (`src/background/index.js`,
|
||||
`requestApproval()`).
|
||||
A site is its full origin, `scheme://host[:port]`, on both lists and for a
|
||||
connection allowed without "Remember": a choice for `https://dapp.example`
|
||||
says nothing about `http://dapp.example` or another port of that host. The
|
||||
background script prefers the toolbar popup (`action.openPopup()`) and falls
|
||||
back to a separate popup window (`src/background/index.js`,
|
||||
`requestApproval()`). Only one exists per site at a time: a further connection
|
||||
request from a site whose prompt is still unanswered is refused with EIP-1193
|
||||
code `-32002` and opens no new prompt. If that prompt was in a toolbar popup
|
||||
that closed before it connected, and the toolbar popup has since been set to
|
||||
open something else, the refused request shows that prompt again.
|
||||
- **Elements**:
|
||||
- "Connection Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
blocklist)
|
||||
- Site hostname (bold) + "wants to connect to your wallet"
|
||||
- Site origin (bold, scheme and port included) + "wants to connect to your
|
||||
wallet"
|
||||
- Address that will be shared (color dot + full address + etherscan link)
|
||||
- "Remember my choice for this site" checkbox
|
||||
- "Allow" / "Deny" buttons
|
||||
- **Transitions**:
|
||||
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||||
choice is persisted to the allowed or denied list when "Remember" is
|
||||
checked)
|
||||
checked, and an "Allow" without it is listed under Connected Sites in
|
||||
**Settings**)
|
||||
- Popup closed without answering → treated as a denial
|
||||
|
||||
#### TxApproval (`approve-tx`)
|
||||
@@ -1737,23 +1873,27 @@ view would leave a wallet one click from deletion.
|
||||
programmatically rather than by a user gesture. The background populates the
|
||||
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
||||
opening the window, so the screen shows a complete transaction and the signed
|
||||
artifact can be compared with it field for field. A request that cannot be
|
||||
populated — unreachable node, reverting gas estimate — opens no window and is
|
||||
failed back to the site. Only one transaction approval exists at a time:
|
||||
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
|
||||
one is unanswered is refused with EIP-1193 code `-32002` rather than being
|
||||
populated at the same nonce. It opens no window and takes no nonce, and the
|
||||
site can send it again once the pending one is answered.
|
||||
artifact can be compared with it field for field. A nonce the site supplies is
|
||||
ignored: the nonce is always the account's next nonce from the node, so a site
|
||||
cannot replace one of the user's pending transactions or leave this one stuck
|
||||
behind a gap. A request that cannot be populated — unreachable node, reverting
|
||||
gas estimate — opens no window and is failed back to the site. Only one
|
||||
transaction approval exists at a time: populating fixes the nonce, so a second
|
||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
||||
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
|
||||
no window and takes no nonce, and the site can send it again once the pending
|
||||
one is answered.
|
||||
- **Elements**:
|
||||
- "Transaction Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
blocklist)
|
||||
- Site hostname (bold) + "wants to send a transaction"
|
||||
- Site origin (bold, scheme and port included) + "wants to send a
|
||||
transaction"
|
||||
- Decoded action (if calldata is recognized): action name, token details,
|
||||
amounts, steps, deadline (see Transaction Decoding)
|
||||
- From: color dot + full address + etherscan link
|
||||
- Contract: color dot + full address + etherscan link (or "contract
|
||||
creation"), token symbol label if known
|
||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
||||
known; for a contract creation, the same sentence as on WaitTx
|
||||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||
- 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
|
||||
@@ -1774,20 +1914,57 @@ view would leave a wallet one click from deletion.
|
||||
|
||||
- **When**: A connected website requests a message signature via
|
||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
||||
TxApproval, in a separate popup window.
|
||||
TxApproval, in a separate popup window. Only one exists per site at a time: a
|
||||
further signature request, by any of these methods, from a site whose
|
||||
signature request is still unanswered is refused with EIP-1193 code `-32002`
|
||||
and opens no window.
|
||||
- **Elements**:
|
||||
- "Signature Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
blocklist)
|
||||
- Site hostname (bold) + "wants you to sign a message"
|
||||
- Site origin (bold, scheme and port included) + "wants you to sign a
|
||||
message"
|
||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||
- From: color dot + full address + etherscan link
|
||||
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||
message fields (EIP-712 typed data)
|
||||
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
||||
decode to as UTF-8, laid out left to right in the order of the bytes that
|
||||
are signed, right-to-left characters included. Each control character,
|
||||
each line or paragraph separator (U+2028, U+2029; left in the text, a
|
||||
paragraph separator would end that layout for the text after it), and each
|
||||
character that paints nothing (format characters such as zero-width and
|
||||
bidirectional ones, default-ignorable characters such as variation
|
||||
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
|
||||
mark instead of acting on the text; a line feed is shown as a line break.
|
||||
Bytes that are not UTF-8 are shown as "This message is not text." For
|
||||
typed data, formatted domain/type/message fields (EIP-712). The primary
|
||||
type shown is the one ethers signs, derived from the typed data's `types`,
|
||||
not the type the site states.
|
||||
- Token permission warning, at the top of the message (typed data whose
|
||||
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
||||
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
||||
tokens listed here from your address, without asking you again.", then the
|
||||
spender's full address and, for each token, its symbol, full address and
|
||||
amount (`Unlimited` for the largest amount the field holds). These are
|
||||
read only from the fields the signed type declares, never from other keys
|
||||
the site puts in the message, except a `Permit`'s token, which is the
|
||||
domain's `verifyingContract`; any those fields do not give is shown as
|
||||
`Unknown`, and the domain, type and message lines still follow. Only typed
|
||||
data that cannot be read at all is shown as raw text.
|
||||
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
|
||||
the site sent it. The bytes it encodes are what is signed, as an EIP-191
|
||||
personal message.
|
||||
- Password input and an error line
|
||||
- "Sign" / "Reject" buttons
|
||||
- **Transitions**:
|
||||
- Typed data that states no primary type, or one other than the type it
|
||||
would be signed as, or that cannot be read → shown with the error line
|
||||
saying so and "Sign" disabled; only "Reject" remains
|
||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
||||
signing) → shown as plain text, with the error line "This message is plain
|
||||
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
|
||||
the bytes from the hex, so such a message has none to sign
|
||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||
signature)
|
||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||
@@ -2008,6 +2185,17 @@ the log level and turns the banner on, and that is all it may ever do: it feeds
|
||||
constant directly, so no runtime toggle in a release build can reach the
|
||||
hardcoded test phrase.
|
||||
|
||||
At the raised log level the console also shows the wallet's addresses with their
|
||||
balances and ENS names, the token contracts looked up, and a line for each
|
||||
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
|
||||
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
|
||||
for its response. A request is logged by its HTTP method, the origin of its URL
|
||||
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
|
||||
refresh, the token lookup and a failed endpoint check in settings name the
|
||||
endpoint by its origin too. The URL's path and query string, where RPC providers
|
||||
put API keys, any user name and password in it, and the request body are never
|
||||
logged.
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
||||
|
||||
@@ -45,6 +45,356 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
||||
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
||||
showed only the decoded text, with bidirectional and zero-width characters
|
||||
acting on it, so a site could make the message read differently from what is
|
||||
signed, and a message that was not hex was shown as NUL characters. The hex is
|
||||
now shown as "Raw data" alongside the decoded text, the text is laid out left
|
||||
to right in byte order, control characters, line and paragraph separators and
|
||||
characters that paint nothing are shown as `U+XXXX` marks, and a message that
|
||||
is not hex by the rule signing reads it with is shown as plain text with
|
||||
"Sign" disabled, since such a message has no bytes to sign.
|
||||
|
||||
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
|
||||
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
|
||||
every request's full URL and body, so an RPC endpoint with a key in its path
|
||||
or query string printed that key on every request. It now logs the HTTP
|
||||
method, the URL's origin and, for a JSON-RPC call, the method name. The
|
||||
balance refresh and token lookup log the RPC endpoint by its origin too. A
|
||||
failed RPC call's error line prints the error's short message, which names the
|
||||
HTTP status, not its full message, which carries the request URL. A failed
|
||||
endpoint check in settings names the endpoint by its origin, not the `fetch`
|
||||
error's message, which carries the whole URL, password included, for a URL
|
||||
with a user name and password. The README's DEBUG Mode Policy says what debug
|
||||
mode logs.
|
||||
|
||||
- 2026-10-04: A site has at most one connection prompt and one signature prompt
|
||||
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
|
||||
`eth_requestAccounts` or `personal_sign` call opened another approval window,
|
||||
so a page calling in a loop could cover the screen with identical prompts. A
|
||||
further request of the same kind from a site whose prompt is still unanswered
|
||||
is now refused with EIP-1193 `-32002`, the code a second transaction already
|
||||
gets, and opens no window. Signing by `personal_sign`, `eth_sign` and
|
||||
`eth_signTypedData_v4` counts as one kind. Other sites are not affected, and
|
||||
the site may ask again once the user has answered. A connection prompt whose
|
||||
toolbar popup closed before it connected, and which nothing shows any more, is
|
||||
shown again when the site asks again.
|
||||
|
||||
- 2026-10-04: A nonce the site supplies with `eth_sendTransaction` is ignored
|
||||
([#404](https://git.eeqj.de/sneak/AutistMask/issues/404)). It was passed on to
|
||||
the transaction, so a site could replace one of the user's pending
|
||||
transactions (same nonce, higher fee) or leave the new one stuck behind a gap,
|
||||
and the approval screen showed it as a bare number. `nonce` is no longer one
|
||||
of the fields taken from the request in `src/shared/approvalTx.js`, so the
|
||||
transaction always gets the account's next nonce from the node, and that is
|
||||
the nonce the approval screen shows and the popup signs.
|
||||
|
||||
- 2026-10-04: Remembered site permissions are held by full origin
|
||||
([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and
|
||||
`deniedSites` stored the hostname alone, so a grant to `https://dapp.example`
|
||||
also authorised `http://dapp.example` and every port on that host, and the
|
||||
prompts named only the hostname. Both lists now store and match the origin
|
||||
(`scheme://host[:port]`), the key the connections approved without "Remember"
|
||||
already used, in `src/background/index.js` and in Settings, whose site lists
|
||||
and `AUTISTMASK_REMOVE_SITE` carry the origin too. The connection, transaction
|
||||
and signature prompts show the origin. Entries saved by hostname before this
|
||||
change are not migrated (pre-1.0): they match no site, and Settings lists them
|
||||
until they are removed.
|
||||
|
||||
- 2026-10-04: A page's request is credited only to the site the browser says
|
||||
sent it ([#407](https://git.eeqj.de/sneak/AutistMask/issues/407)). Where the
|
||||
browser does not give the sender's origin (Firefox before 126), the background
|
||||
used the tab's page, so a frame from another site would have been treated as
|
||||
the site embedding it, and with no tab it used an origin the page wrote into
|
||||
the message. It now uses the URL of the frame that sent the message, and
|
||||
refuses the request with code 4100 when the browser gives neither. The content
|
||||
script no longer writes an origin into the message.
|
||||
|
||||
- 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
|
||||
names the primary type ethers signs
|
||||
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
|
||||
signature lets its spender take tokens from the signer's address, and the
|
||||
screen listed it as plain key/value lines, exactly like a sign-in message. For
|
||||
typed data signed as `Permit` (EIP-2612's, DAI's older one, or any other of
|
||||
that name) or as one of Permit2's six signature types,
|
||||
`src/popup/views/approval.js` now shows a red warning at the top of the
|
||||
message naming the spender and each token and amount, read only from the
|
||||
fields the signed type declares (a `Permit`'s token is the domain's
|
||||
`verifyingContract`), with `Unlimited` for the largest amount the field holds,
|
||||
the existing unknown-scale wording otherwise, and `Unknown` for whatever those
|
||||
fields do not give. The screen printed the page's `primaryType`, but ethers
|
||||
signs the type it derives from `types`; the screen now shows the derived type,
|
||||
and typed data whose stated type is missing or differs, or that cannot be
|
||||
read, is shown with an error line and Sign disabled, and is refused again
|
||||
where signing starts. The warning names no deadline or expiry: those fields
|
||||
mean different things across the shapes, and a date could read as the
|
||||
permission ending when it does not.
|
||||
- 2026-09-21: The network fee a transaction can commit is bounded by the product
|
||||
of the gas limit and the fee per gas, not by each field alone, and the
|
||||
wallet's own send is bounded the same way
|
||||
([#399](https://git.eeqj.de/sneak/AutistMask/issues/399)). The two per-field
|
||||
ceilings in `src/shared/approvalVerify.js` were checked independently, so a
|
||||
gas limit and a fee that were each under their own ceiling still multiplied to
|
||||
thousands of ETH — a fee a gas-consuming contract really collects — while the
|
||||
comment claimed the ceiling caught exactly that. `assertWithinCeilings` now
|
||||
also refuses a transaction whose gas limit times its fee per gas
|
||||
(`maxFeePerGas` for a type-2 transaction, `gasPrice` for a legacy or type-1
|
||||
one) exceeds `MAX_TOTAL_FEE`, a new constant of 1 ETH beside the existing
|
||||
ceilings, so both callers — where the dApp transaction is populated and where
|
||||
the signed artifact is verified — reject it with a full sentence naming the
|
||||
fee and the limit. The wallet's own send in `src/popup/views/confirmTx.js`
|
||||
pinned no fee fields, so ethers filled them from whatever the configured node
|
||||
answered with nothing bounding them; it now populates the transaction and runs
|
||||
the same check before signing, showing the same error in the confirmation
|
||||
screen's reserved errors box so nothing on screen moves. Deliberately out of
|
||||
scope: comparing a supplied fee against the node's own suggested fee, which
|
||||
the absolute bound already makes unnecessary for the balance-draining case. 1
|
||||
ETH is a plain constant, one line to change; the owner may prefer another
|
||||
figure.
|
||||
- 2026-09-21: The test recovery phrase no longer survives in a release bundle,
|
||||
and the committed-key guard matches by content
|
||||
([#351](https://git.eeqj.de/sneak/AutistMask/issues/351)). `DEBUG_MNEMONIC` in
|
||||
`src/shared/constants.js` is now behind the `__BUILD_DEBUG__` define, so a
|
||||
release build folds the phrase to `null` and no emitted bundle carries it; it
|
||||
used to survive as dead text because `module.exports` keeps the const alive.
|
||||
`script/verify-build` now fails a release build if the phrase appears in any
|
||||
emitted file, so the fold cannot silently regress. `tests/extensionId.test.js`
|
||||
scans the content of every tracked file for a PEM private-key header instead
|
||||
of matching filename extensions alone.
|
||||
- 2026-09-21: A transaction response is honoured only for a transaction
|
||||
approval, and the three remaining approval-settlement paths are pinned
|
||||
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
|
||||
the issue asks for — settle `4001` on release when the window it would be
|
||||
retried in is gone — already landed with
|
||||
[#271](https://git.eeqj.de/sneak/AutistMask/issues/271); this closes the rest.
|
||||
`AUTISTMASK_TX_RESPONSE` now refuses any approval that is not a transaction
|
||||
approval, so a reject no longer retires a sign or connection approval and a
|
||||
signed artifact never runs the broadcast path against one, which before only
|
||||
failed closed by throwing deeper in. Tests pin the site-connection port's
|
||||
approve, reject and disconnect paths against a transaction approval
|
||||
broadcasting behind them: each is declined and the dApp still receives its
|
||||
broadcast result.
|
||||
- 2026-09-21: `docs/RELEASE.md`, linked from `README.md`, states the release
|
||||
procedure as a numbered list a newcomer can follow: confirm `main` is green in
|
||||
CI, confirm the one version in the three files matches the intended tag,
|
||||
`make package` from a clean checkout, verify `SHA256SUMS`, tag `vX.Y.Z`, then
|
||||
distribute per browser. Each step names who performs it (owner-only steps
|
||||
marked) and the check that it worked. The distribution step is written as
|
||||
pending the owner's choice on
|
||||
[#386](https://git.eeqj.de/sneak/AutistMask/issues/386), with the Firefox and
|
||||
Chrome options named but none settled. Docs only
|
||||
([#387](https://git.eeqj.de/sneak/AutistMask/issues/387)).
|
||||
|
||||
- 2026-09-21: Adding a second wallet no longer accepts a different password with
|
||||
nothing saying it is a separate one
|
||||
([#374](https://git.eeqj.de/sneak/AutistMask/issues/374)). Each wallet has its
|
||||
own encrypted secret, so per-wallet passwords are by design; the add-wallet
|
||||
screen said only "Choose a password". A note now appears on that screen when
|
||||
the profile already holds a wallet, stating that each wallet has its own
|
||||
password and this one need not match any already in use. It is shown only
|
||||
then, since the first wallet has no other password to differ from, and it
|
||||
stays consistent with the no-reset reality of
|
||||
[#312](https://git.eeqj.de/sneak/AutistMask/issues/312) by promising no
|
||||
recovery or reset.
|
||||
|
||||
- 2026-09-21: The dApp approval and transaction-status screens resolve a token's
|
||||
symbol from the bundled list, then the tokens the user tracks, then the block
|
||||
explorer's report — the same sources and precedence the amount line already
|
||||
used for the token's scale
|
||||
([#323](https://git.eeqj.de/sneak/AutistMask/issues/323), folding in
|
||||
[#354](https://git.eeqj.de/sneak/AutistMask/issues/354)). A token the user
|
||||
added by hand, or holds a balance of, is now named rather than labelled
|
||||
`Unknown token`, and a non-bundled ERC-20 is no longer carried onto the wait
|
||||
screen as `ETH`. A tracked or explorer-reported name stays subject to the
|
||||
spoof rule, so resolving a symbol is not a new way to wear a known ticker.
|
||||
- 2026-09-21: The debug/testnet banner no longer shows the internal view id to
|
||||
the user in a release build
|
||||
([#375](https://git.eeqj.de/sneak/AutistMask/issues/375)). The banner appended
|
||||
the active view's id (e.g. `[TESTNET] (approve-tx)`), which is developer
|
||||
vocabulary sitting directly above the approval screen's carefully worded
|
||||
authorization text. The suffix is now gated on the compile-time `DEBUG`
|
||||
constant rather than `isDebug()`, so it survives only in a debug build; a
|
||||
testnet or the runtime debug toggle still raises the banner but without the
|
||||
view id.
|
||||
|
||||
- 2026-09-21: The Confirm Delete button on the delete-wallet screen no longer
|
||||
stays dead after a successful delete
|
||||
([#335](https://git.eeqj.de/sneak/AutistMask/issues/335)). The password route
|
||||
disabled the button before the decrypt and never re-enabled it, so a second
|
||||
delete in the same popup session needed a reopen; the lost-password route
|
||||
re-enabled its own button in its leave hook, so the two screens behaved
|
||||
differently. Both now reset through the shared `finishDelete()`, the one path
|
||||
both routes take, so they behave the same and the button is live for the next
|
||||
delete.
|
||||
|
||||
- 2026-09-21: The EIP-6963 provider UUID is generated fresh on each page load
|
||||
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
|
||||
It was created once and stored, then announced verbatim to every page on every
|
||||
load and across restarts, so any site — connected or not — could read it as a
|
||||
stable cross-site, cross-session identifier for the install, contradicting the
|
||||
"no tracking" promise. inpage.js now announces a per-load
|
||||
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
|
||||
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
|
||||
standalone storage entry, never part of the versioned `autistmask` profile, so
|
||||
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
|
||||
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
|
||||
`[info]` and `[x]` controls and folded onto a second line, which turns one
|
||||
42-character string the user is meant to compare into two shorter ones — the
|
||||
shape an address-poisoning attack wants. The fix is layout, not CSS: every
|
||||
address in the popup now sits alone on a full-width row, with the colour dot,
|
||||
the wallet title, the ENS name and the explorer link moved onto a strip above
|
||||
it, and the transaction rows carry the counterparty's whole address instead of
|
||||
a `truncateMiddle()`d one squeezed in beside the amount. `truncateMiddle()`
|
||||
keeps its 10-character cap and its 32-character floor moved into
|
||||
`renderAddressHtml()`, so the guarantee outlives having no callers. The e2e
|
||||
suite measures every rendered address in a real Chromium — whole, one line
|
||||
box, inside its row and inside the popup — across Home, the address, token,
|
||||
receive, send and transaction detail screens, the confirmation screen and the
|
||||
dApp transaction prompt.
|
||||
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
|
||||
16/32/48/128 ship inside both archives
|
||||
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
|
||||
|
||||
+5
-3
@@ -285,11 +285,13 @@ not appear and may be permanently lost.
|
||||
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
||||
pages. When a site requests access to your wallet:
|
||||
|
||||
1. A popup appears showing the site's hostname and the address that will be
|
||||
shared.
|
||||
1. A popup appears showing the site's origin (its scheme, host and port, for
|
||||
example `https://app.example`) and the address that will be shared.
|
||||
2. Click "Allow" to connect or "Deny" to reject.
|
||||
3. Optionally check "Remember my choice for this site" to skip the prompt next
|
||||
time.
|
||||
time. The choice applies to that exact origin only: a choice remembered for
|
||||
`https://app.example` does not cover `http://app.example` or another port of
|
||||
the same host, which ask again.
|
||||
|
||||
When a connected site requests a transaction, a separate approval popup appears
|
||||
showing the transaction details (from, to, value, data, network fee, network and
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Releasing AutistMask
|
||||
|
||||
This is the procedure that turns a green `main` into a tagged, packaged release.
|
||||
It gathers into one place what is otherwise spread across the `Makefile` and
|
||||
three `README.md` sections, so the person cutting a release does not have to
|
||||
reconstruct the order from them.
|
||||
|
||||
There is one version, declared in three files (`package.json`,
|
||||
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
|
||||
packages but publishes nothing. `make build` and `make package` can be run by
|
||||
anyone; tagging, signing, packing a CRX and any upload need credentials only the
|
||||
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
|
||||
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
|
||||
so the "release commit" throughout is the `main` commit the milestone PR merged.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Confirm `main` is green in CI.** The `check` workflow
|
||||
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`,
|
||||
and the `Dockerfile` runs `make check` as a build step, so a green `check`
|
||||
run is a green `make check`. Find the run for the exact release commit on the
|
||||
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
|
||||
`make check` on a clean checkout of the commit reproduces it and exits 0.
|
||||
|
||||
2. **Confirm the version matches the intended tag.** `package.json`,
|
||||
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
|
||||
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
|
||||
equal the tag you mean to create — that is this manual step. _Check:_ all
|
||||
three files read the same `X.Y.Z`, and it is the version you intend to tag
|
||||
`vX.Y.Z`.
|
||||
|
||||
3. **Build and package from a clean checkout of that commit.** From a fresh
|
||||
clone, or a working tree with no local modifications (`git status` clean),
|
||||
checked out at the release commit: run `make setup`, then `make package`.
|
||||
`make package` runs `make build` first, so the archives can only be made from
|
||||
a `dist/` verified against that build's own receipt as a release (not debug)
|
||||
build. It writes three files into `release/`:
|
||||
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
|
||||
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
|
||||
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
|
||||
(see step 6 and "Installing on Firefox" in `README.md`).
|
||||
|
||||
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
|
||||
fixed timestamps, fixed compression — so a second `make package` from another
|
||||
clean checkout of the same commit produces byte-identical files. Verify the
|
||||
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
|
||||
`release/`. To confirm reproducibility, run `make package` again on a
|
||||
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
|
||||
reports `OK` for every file, and an independent build's digests match.
|
||||
|
||||
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
|
||||
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
|
||||
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
|
||||
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
|
||||
|
||||
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
|
||||
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
|
||||
each browser is not yet decided; it is the open question on that issue, and
|
||||
the concrete steps cannot be written until the owner records a choice there.
|
||||
These steps need credentials only the owner holds. The options under
|
||||
consideration are:
|
||||
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
|
||||
refuse an unsigned add-on:
|
||||
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
|
||||
with the owner's credentials; AMO returns a signed `.xpi` installable
|
||||
on every Firefox, with nothing listed publicly.
|
||||
- (b) AMO listed: as (a), plus a public AMO listing and review.
|
||||
- (c) Ship the unsigned `.xpi` and state that Firefox support means
|
||||
Developer Edition, Nightly, or an Unbranded build with
|
||||
`xpinstall.signatures.required` set to `false`.
|
||||
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
|
||||
is fixed by the `key` in `manifest/chrome.json`:
|
||||
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
|
||||
developer account; the store delivers installs and updates.
|
||||
- (b) Self-hosted CRX signed with the private key the owner holds
|
||||
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
|
||||
installable only via enterprise policy on Windows and macOS, so
|
||||
realistically Linux-only.
|
||||
- (c) "Load unpacked" from `dist/chrome/` only, as today.
|
||||
|
||||
Once the owner decides, the chosen steps — including which credentials they
|
||||
need and who holds them — are written into this section and `README.md`'s
|
||||
installation sections are updated to match, which is part of the definition
|
||||
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
|
||||
store or AMO route, the artifact installs from the store or AMO on a clean
|
||||
browser profile; for the CRX or unpacked route, the documented load succeeds
|
||||
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --forceExit",
|
||||
"test:verbose": "jest --forceExit --verbose",
|
||||
"test": "jest --forceExit --maxWorkers=3",
|
||||
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
|
||||
"build": "node build.js",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fmt": "prettier --write .",
|
||||
|
||||
+8
-5
@@ -1,11 +1,14 @@
|
||||
#!/bin/sh
|
||||
# script/test: run the test suite.
|
||||
#
|
||||
# The timeout bounds a hung suite; it is not a performance budget. On a
|
||||
# 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
|
||||
# shares the runner with the rest of the build, which is not what that budget
|
||||
# describes, so the Dockerfile raises the bound through
|
||||
# jest runs three worker processes (package.json), not one per CPU core: on a
|
||||
# many-core shared host one per core took gigabytes of RAM per run.
|
||||
#
|
||||
# The timeout bounds a hung suite; it is not a performance budget. On the busy
|
||||
# shared build host the suite takes 8-13s with three workers, inside
|
||||
# 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
|
||||
# produces a red that means nothing, and teaches "just run it again".
|
||||
set -eu
|
||||
|
||||
@@ -37,6 +37,10 @@ DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
|
||||
MARKER_ON="autistmask-build-debug=on"
|
||||
MARKER_OFF="autistmask-build-debug=off"
|
||||
|
||||
# The same test recovery phrase verify-build searches release bundles for. Held
|
||||
# here too, the way the markers above are, so a case can plant it in a bundle.
|
||||
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
|
||||
|
||||
RECEIPT_HEADER="autistmask-build-receipt v1"
|
||||
|
||||
NEWLINE='
|
||||
@@ -516,6 +520,24 @@ c_debug_build() {
|
||||
write_receipt
|
||||
}
|
||||
|
||||
# A release bundle that still carries the test recovery phrase — the regression
|
||||
# verify-build guards against, and the reason DEBUG_MNEMONIC is behind the
|
||||
# __BUILD_DEBUG__ define in src/shared/constants.js. The receipt is regenerated
|
||||
# so the phrase is caught as bundle content, not incidentally as a stale digest.
|
||||
c_release_bundle_with_mnemonic() {
|
||||
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
|
||||
write_receipt
|
||||
}
|
||||
|
||||
# The same phrase in a debug build is expected: make build-debug ships it on
|
||||
# purpose, so the phrase check must stay quiet under --expect debug.
|
||||
c_debug_bundle_with_mnemonic() {
|
||||
write_bundle dist/chrome/src/popup/index.js "$MARKER_ON"
|
||||
write_bundle dist/firefox/src/popup/index.js "$MARKER_ON"
|
||||
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
|
||||
write_receipt
|
||||
}
|
||||
|
||||
c_no_dist() { rm -rf dist; }
|
||||
|
||||
# --- dist discard -----------------------------------------------------------
|
||||
@@ -753,6 +775,13 @@ run_cases() {
|
||||
check_case "debug bundles under --expect debug pass" \
|
||||
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
|
||||
|
||||
check_case "release bundle carrying the test recovery phrase fails" \
|
||||
no release 1 \
|
||||
"carries the BIP-39 test recovery phrase" c_release_bundle_with_mnemonic
|
||||
|
||||
check_case "debug bundle carrying the test recovery phrase passes" \
|
||||
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_bundle_with_mnemonic
|
||||
|
||||
check_case "no --expect argument" \
|
||||
no no-expect 1 "no --expect argument." c_control
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
# fallback branch; the property only exists in the emitted output, so it has to
|
||||
# be asserted against the emitted output.
|
||||
#
|
||||
# The DEBUG half also checks the phrase directly: a release build must not carry
|
||||
# the test recovery phrase in any emitted file. The phrase is behind the
|
||||
# __BUILD_DEBUG__ define in src/shared/constants.js and folds away in a release
|
||||
# build, but the marker only proves DEBUG compiled off, not that the fold
|
||||
# removed the string; the phrase grep is the assertion that it did.
|
||||
#
|
||||
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never
|
||||
# taken from this script's environment. It used to be read from
|
||||
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
|
||||
@@ -67,6 +73,15 @@ TAB=' '
|
||||
MARKER_ON="autistmask-build-debug=on"
|
||||
MARKER_OFF="autistmask-build-debug=off"
|
||||
|
||||
# The 12-word BIP-39 test recovery phrase from src/shared/constants.js. It is
|
||||
# behind the __BUILD_DEBUG__ define there, so a release build folds it out of
|
||||
# every bundle; this is the assertion that it stayed out. The phrase is a
|
||||
# publicly committed test value rather than a secret, but a BIP-39 phrase in a
|
||||
# distributed wallet artifact is exactly the string a scanner or auditor has to
|
||||
# stop and reason about, so a release build must not ship it. A debug build
|
||||
# ships it on purpose, so this is checked only when release is expected.
|
||||
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
|
||||
|
||||
RECEIPT_HEADER="autistmask-build-receipt v1"
|
||||
|
||||
# Set by the arguments.
|
||||
@@ -283,6 +298,18 @@ check_entry() {
|
||||
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
|
||||
to dist/ after the build, so this artifact is not the one that was built."
|
||||
|
||||
# No emitted file of a release build may carry the test recovery phrase.
|
||||
# Checked on every file, not only the audited bundles, so a copy that
|
||||
# reached some other emitted file fails here too. A debug build ships the
|
||||
# phrase deliberately, so this runs only when release was expected.
|
||||
if [ "$EXPECT" = "$MARKER_OFF" ] && has_marker "$TEST_MNEMONIC" "$ENTRY_PATH"; then
|
||||
fail "$ENTRY_PATH carries the BIP-39 test recovery phrase, which a
|
||||
release build must fold out. The __BUILD_DEBUG__ define in build.js is what
|
||||
drops it from src/shared/constants.js; check that DEBUG_MNEMONIC is still
|
||||
behind that flag. A recovery phrase in a distributed bundle is exactly the
|
||||
string an auditor or scanner has to stop on, so this is a hard failure."
|
||||
fi
|
||||
|
||||
if [ "$ENTRY_FLAG" = A ]; then
|
||||
read_marker "$ENTRY_PATH"
|
||||
[ "$MARKER" = "$EXPECT" ] ||
|
||||
|
||||
+191
-55
@@ -57,9 +57,13 @@ const windowsNs = windowsApi();
|
||||
const actionNs = actionApi();
|
||||
|
||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
||||
//
|
||||
// A site is its full origin (scheme://host[:port]), here and in the
|
||||
// remembered allowedSites/deniedSites lists alike: a grant to
|
||||
// https://dapp.example says nothing about http://dapp.example or another port.
|
||||
const connectedSites = {};
|
||||
|
||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||
// Pending approval requests: { id: { origin, resolve } }
|
||||
const pendingApprovals = {};
|
||||
|
||||
// One transaction approval at a time, wallet-wide.
|
||||
@@ -83,7 +87,8 @@ const pendingApprovals = {};
|
||||
// authority on a nonce the network has not accepted, which an abandoned
|
||||
// approval then leaves a hole in.
|
||||
//
|
||||
// Sign approvals are not gated: a signature consumes no nonce.
|
||||
// Sign approvals do not take this slot: a signature consumes no nonce. They are
|
||||
// limited per site instead; see findPendingApproval().
|
||||
//
|
||||
// The slot is null when free, and otherwise the handle of the request holding
|
||||
// it. Once that request has raised its approval the handle carries the
|
||||
@@ -95,7 +100,7 @@ let txApprovalSlot = null;
|
||||
|
||||
// EIP-1474 "resource unavailable": the standard code for a request that is
|
||||
// refused because another one is already pending.
|
||||
const TX_APPROVAL_PENDING_CODE = -32002;
|
||||
const APPROVAL_PENDING_CODE = -32002;
|
||||
|
||||
// True at every moment this can be sent: the slot is taken immediately before
|
||||
// the transaction is populated, so the other request is either being prepared
|
||||
@@ -132,6 +137,22 @@ function releaseTxApprovalSlotFor(approvalId) {
|
||||
}
|
||||
}
|
||||
|
||||
// One site-connection approval and one sign approval per site at a time: a
|
||||
// page that asks again before the user has answered is refused with the code
|
||||
// above instead of opening another window, so it cannot bury the user in
|
||||
// prompts. The pending approval itself holds the place, so a caller must test
|
||||
// this and raise its approval with nothing awaited in between.
|
||||
function findPendingApproval(origin, type) {
|
||||
return Object.values(pendingApprovals).find(
|
||||
(approval) => approval.origin === origin && approval.type === type,
|
||||
);
|
||||
}
|
||||
|
||||
const APPROVAL_PENDING_MESSAGE =
|
||||
"AutistMask is already waiting for your answer to a request of this kind" +
|
||||
" from this site, so this one was not shown. Please answer that one," +
|
||||
" then send this one again.";
|
||||
|
||||
// Nonces this worker has already handed to the node, per chain and address.
|
||||
// This is the wallet's own knowledge that a nonce is spent, and it is checked
|
||||
// before a broadcast rather than after: a node's pending count can lag a
|
||||
@@ -284,7 +305,12 @@ async function proxyRpc(method, params) {
|
||||
return json.result;
|
||||
}
|
||||
|
||||
// The site-connection approval the toolbar popup is set to open, or null while
|
||||
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
|
||||
let toolbarPopupApprovalId = null;
|
||||
|
||||
function resetPopupUrl() {
|
||||
toolbarPopupApprovalId = null;
|
||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||
}
|
||||
@@ -459,12 +485,23 @@ async function openApprovalWindow(id) {
|
||||
|
||||
// Open an approval popup and return a promise that resolves with the user decision.
|
||||
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
||||
function requestApproval(origin, hostname) {
|
||||
function requestApproval(origin) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||
pendingApprovals[id] = { id, origin, resolve, type: "site" };
|
||||
|
||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||
showInToolbarPopup(id);
|
||||
} else {
|
||||
openApprovalWindow(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show a site-connection approval in the toolbar popup, or in a separate popup
|
||||
// window when the browser will not open the toolbar popup.
|
||||
function showInToolbarPopup(id) {
|
||||
toolbarPopupApprovalId = id;
|
||||
actionNs.setPopup({
|
||||
popup: "src/popup/index.html?approval=" + id,
|
||||
});
|
||||
@@ -476,10 +513,6 @@ function requestApproval(origin, hostname) {
|
||||
} catch {
|
||||
openApprovalWindow(id);
|
||||
}
|
||||
} else {
|
||||
openApprovalWindow(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
||||
@@ -495,13 +528,12 @@ function requestApproval(origin, hostname) {
|
||||
// screen never named.
|
||||
// `slot` is the transaction-approval slot its caller holds. Handing the
|
||||
// approval's id to it is what makes retiring the approval free the slot.
|
||||
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
|
||||
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
id,
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
approvedFrom,
|
||||
resolve,
|
||||
@@ -517,13 +549,12 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
|
||||
// Uses windows.create() directly because sign approvals are triggered programmatically
|
||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||
// unreliable in this context.
|
||||
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||
function requestSignApproval(origin, signParams, approvedFrom) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
id,
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
approvedFrom,
|
||||
resolve,
|
||||
@@ -601,11 +632,11 @@ runtime.onConnect.addListener((port) => {
|
||||
// in the worker — a balance refresh in flight, another site's approval — has
|
||||
// gone on running the whole time. Loading here used to replace the very
|
||||
// objects that work was holding.
|
||||
async function rememberSiteChoice(field, address, hostname) {
|
||||
async function rememberSiteChoice(field, address, origin) {
|
||||
await updateState((s) => {
|
||||
if (!s[field][address]) s[field][address] = [];
|
||||
if (!s[field][address].includes(hostname)) {
|
||||
s[field][address].push(hostname);
|
||||
if (!s[field][address].includes(origin)) {
|
||||
s[field][address].push(origin);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -618,12 +649,11 @@ async function handleConnectionRequest(origin) {
|
||||
return { error: { message: "No accounts available" } };
|
||||
}
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
const denied = s.deniedSites[activeAddress] || [];
|
||||
|
||||
// Check denied list
|
||||
if (denied.includes(hostname)) {
|
||||
if (denied.includes(origin)) {
|
||||
return {
|
||||
error: {
|
||||
code: 4001,
|
||||
@@ -634,25 +664,50 @@ async function handleConnectionRequest(origin) {
|
||||
|
||||
// Check allowed list or in-memory connected
|
||||
if (
|
||||
allowed.includes(hostname) ||
|
||||
allowed.includes(origin) ||
|
||||
connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { result: [activeAddress] };
|
||||
}
|
||||
|
||||
const pending = findPendingApproval(origin, "site");
|
||||
if (pending) {
|
||||
// A toolbar popup that closed before it connected leaves its prompt
|
||||
// pending, and once the toolbar popup is set to open something else
|
||||
// nothing shows that prompt: the site would be refused until the
|
||||
// address changed. Show it again. A prompt in a window or in a
|
||||
// connected popup is settled when that closes, and one the toolbar
|
||||
// popup is still set to open is a click away, so those are left alone.
|
||||
if (
|
||||
actionNs &&
|
||||
typeof actionNs.openPopup === "function" &&
|
||||
!pending.windowId &&
|
||||
!pending.portConnected &&
|
||||
toolbarPopupApprovalId !== pending.id
|
||||
) {
|
||||
showInToolbarPopup(pending.id);
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: APPROVAL_PENDING_CODE,
|
||||
message: APPROVAL_PENDING_MESSAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Open approval popup
|
||||
const decision = await requestApproval(origin, hostname);
|
||||
const decision = await requestApproval(origin);
|
||||
|
||||
if (decision.approved) {
|
||||
if (decision.remember) {
|
||||
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
||||
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
||||
} else {
|
||||
connectedSites[origin + ":" + activeAddress] = true;
|
||||
}
|
||||
return { result: [activeAddress] };
|
||||
} else {
|
||||
if (decision.remember) {
|
||||
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
||||
await rememberSiteChoice("deniedSites", activeAddress, origin);
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
@@ -698,10 +753,9 @@ async function handleRpc(method, params, origin) {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress) return { result: [] };
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
allowed.includes(hostname) ||
|
||||
allowed.includes(origin) ||
|
||||
connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { result: [activeAddress] };
|
||||
@@ -731,10 +785,9 @@ async function handleRpc(method, params, origin) {
|
||||
// [TESTNET] banner under a user who believed they were on Sepolia.
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!allowed.includes(origin) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
@@ -806,10 +859,9 @@ async function handleRpc(method, params, origin) {
|
||||
if (method === "wallet_getPermissions") {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
const isConnected =
|
||||
allowed.includes(hostname) ||
|
||||
allowed.includes(origin) ||
|
||||
connectedSites[origin + ":" + activeAddress];
|
||||
if (!isConnected || !activeAddress) {
|
||||
return { result: [] };
|
||||
@@ -835,10 +887,9 @@ async function handleRpc(method, params, origin) {
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!allowed.includes(origin) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
@@ -868,9 +919,16 @@ async function handleRpc(method, params, origin) {
|
||||
"Only proceed if you fully understand what you are signing.";
|
||||
}
|
||||
|
||||
if (findPendingApproval(origin, "sign")) {
|
||||
return {
|
||||
error: {
|
||||
code: APPROVAL_PENDING_CODE,
|
||||
message: APPROVAL_PENDING_MESSAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
const decision = await requestSignApproval(
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
activeAddress,
|
||||
);
|
||||
@@ -884,10 +942,9 @@ async function handleRpc(method, params, origin) {
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!allowed.includes(origin) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
@@ -903,9 +960,16 @@ async function handleRpc(method, params, origin) {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (findPendingApproval(origin, "sign")) {
|
||||
return {
|
||||
error: {
|
||||
code: APPROVAL_PENDING_CODE,
|
||||
message: APPROVAL_PENDING_MESSAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
const decision = await requestSignApproval(
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
activeAddress,
|
||||
);
|
||||
@@ -932,7 +996,9 @@ async function handleRpc(method, params, origin) {
|
||||
}
|
||||
}
|
||||
|
||||
return { error: { message: "Unsupported method: " + method } };
|
||||
// EIP-1193 4200 lets a site tell "this wallet does not implement that"
|
||||
// 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
|
||||
@@ -944,10 +1010,9 @@ async function handleSendTransaction(params, origin) {
|
||||
const activeAddress = activeAddressOf(s);
|
||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!allowed.includes(origin) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
@@ -974,7 +1039,7 @@ async function handleSendTransaction(params, origin) {
|
||||
if (!slot) {
|
||||
return {
|
||||
error: {
|
||||
code: TX_APPROVAL_PENDING_CODE,
|
||||
code: APPROVAL_PENDING_CODE,
|
||||
message: TX_APPROVAL_PENDING_MESSAGE,
|
||||
},
|
||||
};
|
||||
@@ -1021,7 +1086,6 @@ async function handleSendTransaction(params, origin) {
|
||||
|
||||
const decision = await requestTxApproval(
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
activeAddress,
|
||||
slot,
|
||||
@@ -1095,10 +1159,9 @@ async function broadcastAccountsChanged() {
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||
const hostname = extractHostname(origin);
|
||||
const hasPermission =
|
||||
activeAddress &&
|
||||
(allowed.includes(hostname) ||
|
||||
(allowed.includes(origin) ||
|
||||
connectedSites[origin + ":" + activeAddress]);
|
||||
// Same as chainChanged above: a tab without our content script
|
||||
// rejects, and that is expected rather than a fault.
|
||||
@@ -1110,6 +1173,24 @@ async function broadcastAccountsChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tell every open tab of a site Settings removed that it has no account.
|
||||
async function broadcastSiteRemoved(origin) {
|
||||
let tabs;
|
||||
try {
|
||||
tabs = await tabsQuery({});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
if (!tab.url || new URL(tab.url).origin !== origin) continue;
|
||||
tabsSendMessage(tab.id, {
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "accountsChanged",
|
||||
data: [],
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
||||
// fresh, so this naturally skips.
|
||||
@@ -1268,18 +1349,29 @@ if (windowsNs && windowsNs.onRemoved) {
|
||||
// Listen for messages from content scripts and popup
|
||||
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === "AUTISTMASK_RPC") {
|
||||
// Derive origin from trusted sender info to prevent origin spoofing.
|
||||
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
|
||||
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
|
||||
if (sender.origin) {
|
||||
trustedOrigin = sender.origin;
|
||||
} else if (sender.tab && sender.tab.url) {
|
||||
// The origin is the one the browser reports for the sender, never one
|
||||
// the message carries. Firefox before 126 gives no sender.origin, so
|
||||
// the origin of sender.url is used: the frame that sent the message,
|
||||
// not the tab's page, which may be another site embedding that
|
||||
// frame. With neither, the request is refused.
|
||||
let trustedOrigin = sender.origin;
|
||||
if (!trustedOrigin && sender.url) {
|
||||
try {
|
||||
trustedOrigin = new URL(sender.tab.url).origin;
|
||||
trustedOrigin = new URL(sender.url).origin;
|
||||
} catch {
|
||||
// keep fallback
|
||||
// an unparseable URL leaves the origin unknown
|
||||
}
|
||||
}
|
||||
if (!trustedOrigin) {
|
||||
sendResponse({
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"The wallet could not tell which site sent this request.",
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||
.then((response) => {
|
||||
sendResponse(response);
|
||||
@@ -1305,6 +1397,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
"AUTISTMASK_GET_APPROVAL",
|
||||
"AUTISTMASK_TX_RESPONSE",
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
||||
"AUTISTMASK_REMOVE_SITE",
|
||||
];
|
||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
@@ -1314,10 +1409,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
if (approval) {
|
||||
const resp = {
|
||||
hostname: approval.hostname,
|
||||
origin: approval.origin,
|
||||
};
|
||||
const resp = { origin: approval.origin };
|
||||
if (approval.type === "tx") {
|
||||
resp.type = "tx";
|
||||
// The populated transaction, and the address it was raised
|
||||
@@ -1332,7 +1424,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
resp.approvedFrom = approval.approvedFrom;
|
||||
}
|
||||
// Flag if the requesting domain is on the phishing blocklist.
|
||||
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
|
||||
resp.isPhishingDomain = isPhishingDomain(
|
||||
extractHostname(approval.origin),
|
||||
);
|
||||
sendResponse(resp);
|
||||
} else {
|
||||
sendResponse(null);
|
||||
@@ -1344,6 +1438,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
if (!approval) return false;
|
||||
|
||||
// This message signs and broadcasts a transaction, so it is honoured
|
||||
// only for a transaction approval. A sign or connection approval
|
||||
// carries no approvedTx, and reaching the broadcast path with one used
|
||||
// to fail closed by throwing deeper in; refusing here keeps a future
|
||||
// refactor from turning that incidental throw into a live path, and
|
||||
// keeps a reject on this message from retiring an approval of another
|
||||
// kind.
|
||||
if (approval.type !== "tx") return false;
|
||||
|
||||
// A reject arriving while an attempt holds the approval is refused,
|
||||
// not honoured: the attempt is on its way to broadcasting the
|
||||
// transaction, and resolving 4001 here would tell the page the request
|
||||
@@ -1638,8 +1741,41 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
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) =>
|
||||
key.slice(0, key.lastIndexOf(":")),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Settings removed this site (msg.origin) 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") {
|
||||
// Popup already saved state; nothing else needed
|
||||
for (const key of Object.keys(connectedSites)) {
|
||||
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
|
||||
delete connectedSites[key];
|
||||
}
|
||||
}
|
||||
broadcastSiteRemoved(msg.origin);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,8 +5,6 @@ const {
|
||||
hasBrowserNamespace,
|
||||
runtimeApi,
|
||||
sendMessage,
|
||||
storageGet,
|
||||
storageSet,
|
||||
} = require("../shared/browserApi");
|
||||
|
||||
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
||||
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
}
|
||||
|
||||
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
||||
// Generated once at install time and stored in extension storage.
|
||||
(async function sendProviderUuid() {
|
||||
let uuid = null;
|
||||
try {
|
||||
const items = await storageGet("eip6963Uuid");
|
||||
uuid = items?.eip6963Uuid;
|
||||
if (!uuid) {
|
||||
uuid = crypto.randomUUID();
|
||||
await storageSet({ eip6963Uuid: uuid });
|
||||
}
|
||||
} catch {
|
||||
// Storage was unavailable or refused the write. The announcement
|
||||
// still has to go out — a provider that never announces is invisible
|
||||
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
|
||||
// page load will not outlive.
|
||||
if (!uuid) uuid = crypto.randomUUID();
|
||||
}
|
||||
window.postMessage(
|
||||
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
||||
location.origin,
|
||||
);
|
||||
})();
|
||||
|
||||
// Relay requests from the page to the background script
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
@@ -56,7 +30,6 @@ window.addEventListener("message", (event) => {
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
origin: location.origin,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
|
||||
+15
-18
@@ -31,11 +31,12 @@
|
||||
// an error instead of accepting the refusal.
|
||||
//
|
||||
// Whatever code arrived is passed through verbatim rather than being
|
||||
// matched against a list: the extension emits 4001, 4100 and 4902 today,
|
||||
// and a code this file has never heard of is still the truth about what
|
||||
// happened. An error reported with no code at all stays a plain Error —
|
||||
// a ProviderRpcError whose `code` is undefined would advertise a
|
||||
// conformance it does not have. `message` is untouched in every case.
|
||||
// matched against a list: the extension emits codes such as 4001, 4100,
|
||||
// 4200 and 4902, and a code this file has never heard of is still the
|
||||
// truth about what happened. An error reported with no code at all stays
|
||||
// a plain Error — a ProviderRpcError whose `code` is undefined would
|
||||
// advertise a conformance it does not have. `message` is untouched in
|
||||
// every case.
|
||||
function toPageError(error) {
|
||||
const message = (error && error.message) || "Request failed";
|
||||
if (error && error.code !== undefined && error.code !== null) {
|
||||
@@ -45,7 +46,7 @@
|
||||
}
|
||||
|
||||
// Listen for responses from the content script
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
||||
const { id, result, error } = event.data;
|
||||
@@ -60,7 +61,7 @@
|
||||
});
|
||||
|
||||
// Listen for events pushed from the extension
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
||||
const { eventName, data } = event.data;
|
||||
@@ -204,7 +205,13 @@
|
||||
"</svg>",
|
||||
);
|
||||
|
||||
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
|
||||
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
|
||||
// per page load, shared by every announcement in that load. It is
|
||||
// generated here and never stored: announcing one persisted value to every
|
||||
// site, on every load and across restarts, turned it into a stable
|
||||
// cross-site, cross-session tracking identifier any page could read
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
|
||||
const providerUuid = crypto.randomUUID();
|
||||
|
||||
function buildProviderInfo() {
|
||||
return {
|
||||
@@ -226,16 +233,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for the persisted UUID from the content script
|
||||
function onProviderUuid(event) {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
|
||||
window.removeEventListener("message", onProviderUuid);
|
||||
providerUuid = event.data.uuid;
|
||||
announceProvider();
|
||||
}
|
||||
window.addEventListener("message", onProviderUuid);
|
||||
|
||||
window.addEventListener("eip6963:requestProvider", announceProvider);
|
||||
announceProvider();
|
||||
|
||||
|
||||
@@ -19,13 +19,9 @@
|
||||
// that the user did not type — the same silent substitution the visible
|
||||
// rejection message exists to end.
|
||||
|
||||
// Must render on ONE line of #flash-msg, whose reserved height
|
||||
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
|
||||
// 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.";
|
||||
// Must render on ONE line of #flash-msg; see showFlash() in
|
||||
// src/popup/views/helpers.js for how long that is.
|
||||
const DUST_THRESHOLD_MESSAGE = "Enter a whole number of gwei, zero or greater.";
|
||||
|
||||
// Returns the threshold in gwei, or null if the input is not one.
|
||||
function parseDustThresholdGwei(raw) {
|
||||
|
||||
+62
-40
@@ -33,7 +33,7 @@
|
||||
<!-- ============ FLASH MESSAGE AREA ============ -->
|
||||
<div
|
||||
id="flash-msg"
|
||||
class="text-xs text-muted min-h-[1.25rem] mb-1"
|
||||
class="text-xs text-muted min-h-[1.25rem] mb-1 truncate"
|
||||
></div>
|
||||
|
||||
<!-- ============ WELCOME / FIRST USE ============ -->
|
||||
@@ -152,6 +152,21 @@
|
||||
|
||||
<!-- Shared password fields -->
|
||||
<div class="mb-2" id="add-wallet-password-section">
|
||||
<!-- Shown only when the profile already holds a wallet:
|
||||
each wallet has its own password (its own
|
||||
encryptedSecret), so a second wallet does not reuse
|
||||
the first one's. addWallet.js toggles this on screen
|
||||
entry from state.wallets.length, so it is constant
|
||||
while the screen is up and moves nothing. -->
|
||||
<p
|
||||
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
|
||||
id="add-wallet-separate-password-note"
|
||||
>
|
||||
You already have a wallet. Each wallet has its own
|
||||
password: the one you choose here is only for this new
|
||||
wallet, and it need not match any password you already
|
||||
use.
|
||||
</p>
|
||||
<label class="block mb-1">Choose a password</label>
|
||||
<!-- The hint is swapped in place when the import tab
|
||||
changes, and it sits directly above the password
|
||||
@@ -213,10 +228,7 @@
|
||||
</div>
|
||||
|
||||
<!-- active address display -->
|
||||
<div
|
||||
id="active-address-display"
|
||||
class="text-xs break-all mb-3"
|
||||
></div>
|
||||
<div id="active-address-display" class="text-xs mb-3"></div>
|
||||
|
||||
<!-- quick actions for active address -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
@@ -292,7 +304,7 @@
|
||||
class="font-bold mb-1 hidden flex items-center"
|
||||
></div>
|
||||
<div
|
||||
class="text-xs mb-1 cursor-pointer break-all"
|
||||
class="text-xs mb-1 cursor-pointer"
|
||||
title="Click to copy"
|
||||
id="address-line"
|
||||
>
|
||||
@@ -380,14 +392,14 @@
|
||||
></div>
|
||||
<h2 class="font-bold mb-1">Export Private Key</h2>
|
||||
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
||||
<p class="text-xs mb-3">
|
||||
<div class="text-xs mb-3">
|
||||
<span id="export-privkey-dot"></span>
|
||||
<span
|
||||
id="export-privkey-address"
|
||||
class="cursor-pointer"
|
||||
title="Click to copy"
|
||||
></span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs mb-3 text-muted">
|
||||
Warning: anyone with this private key can access and
|
||||
transfer all funds from this address. Never share it.
|
||||
@@ -440,7 +452,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs mb-1 cursor-pointer break-all"
|
||||
class="text-xs mb-1 cursor-pointer"
|
||||
title="Click to copy"
|
||||
id="address-token-line"
|
||||
>
|
||||
@@ -573,19 +585,16 @@
|
||||
<!-- ERC-20 token contract (hidden for ETH) -->
|
||||
<div id="confirm-token-section" class="mb-3 hidden">
|
||||
<div class="text-xs text-muted mb-1">Token contract</div>
|
||||
<div
|
||||
id="confirm-token-contract"
|
||||
class="text-xs break-all"
|
||||
></div>
|
||||
<div id="confirm-token-contract" class="text-xs"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">From</div>
|
||||
<div id="confirm-from" class="text-xs break-all"></div>
|
||||
<div id="confirm-from" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
<div id="confirm-to" class="text-xs break-all"></div>
|
||||
<div id="confirm-to" class="text-xs"></div>
|
||||
<div
|
||||
id="confirm-to-ens"
|
||||
class="text-xs text-muted hidden"
|
||||
@@ -689,15 +698,13 @@
|
||||
You do not have enough ETH to pay the network fee for this
|
||||
transfer. Please add ETH to this address and try again.
|
||||
</div>
|
||||
<!-- Its sentence names why the fee could not be estimated,
|
||||
so show() in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-fee-unknown-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
>
|
||||
The network fee could not be estimated, so this transaction
|
||||
cannot be checked against your balance. Please go back and
|
||||
try again.
|
||||
</div>
|
||||
></div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
@@ -728,7 +735,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
<div id="wait-tx-to" class="text-xs break-all"></div>
|
||||
<div id="wait-tx-to" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
||||
@@ -747,7 +754,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
<div id="success-tx-to" class="text-xs break-all"></div>
|
||||
<div id="success-tx-to" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">Block</div>
|
||||
@@ -774,7 +781,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
<div id="error-tx-to" class="text-xs break-all"></div>
|
||||
<div id="error-tx-to" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div
|
||||
@@ -811,9 +818,9 @@
|
||||
<canvas id="receive-qr"></canvas>
|
||||
</div>
|
||||
<div
|
||||
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
|
||||
class="border border-border p-2 mb-3 text-xs cursor-pointer"
|
||||
>
|
||||
<span id="receive-address-block" class="select-all"></span>
|
||||
<div id="receive-address-block" class="select-all"></div>
|
||||
<span id="receive-etherscan-link"></span>
|
||||
</div>
|
||||
<button
|
||||
@@ -1055,6 +1062,15 @@
|
||||
<div id="settings-allowed-sites"></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">
|
||||
<h3 class="font-bold mb-1">Denied Sites</h3>
|
||||
<p class="text-xs text-muted mb-2">
|
||||
@@ -1239,7 +1255,7 @@
|
||||
</p>
|
||||
<div
|
||||
id="delete-address-value"
|
||||
class="text-xs mb-2 break-all min-h-[1rem]"
|
||||
class="text-xs mb-2 min-h-[1rem]"
|
||||
></div>
|
||||
<div
|
||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||
@@ -1429,14 +1445,11 @@
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="text-xs text-muted mb-1">From</div>
|
||||
<div
|
||||
id="tx-detail-from"
|
||||
class="text-xs break-all"
|
||||
></div>
|
||||
<div id="tx-detail-from" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
<div id="tx-detail-to" class="text-xs break-all"></div>
|
||||
<div id="tx-detail-to" class="text-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1473,7 +1486,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="tx-detail-token-contract"
|
||||
class="text-xs break-all"
|
||||
class="text-xs"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1548,7 +1561,7 @@
|
||||
with extreme caution.
|
||||
</div>
|
||||
<p class="mb-2">
|
||||
<span id="approve-tx-hostname" class="font-bold"></span>
|
||||
<span id="approve-tx-origin" class="font-bold"></span>
|
||||
wants to send a transaction.
|
||||
</p>
|
||||
|
||||
@@ -1567,11 +1580,11 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">From</div>
|
||||
<div id="approve-tx-from" class="text-xs break-all"></div>
|
||||
<div id="approve-tx-from" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">Contract</div>
|
||||
<div id="approve-tx-to" class="text-xs break-all"></div>
|
||||
<div id="approve-tx-to" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">Value</div>
|
||||
@@ -1618,7 +1631,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="approve-tx-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
||||
style="visibility: hidden"
|
||||
></div>
|
||||
<div class="flex justify-between">
|
||||
@@ -1649,7 +1662,7 @@
|
||||
funds. Proceed with extreme caution.
|
||||
</div>
|
||||
<p class="mb-2">
|
||||
<span id="approve-sign-hostname" class="font-bold"></span>
|
||||
<span id="approve-sign-origin" class="font-bold"></span>
|
||||
wants you to sign a message.
|
||||
</p>
|
||||
|
||||
@@ -1673,7 +1686,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">From</div>
|
||||
<div id="approve-sign-from" class="text-xs break-all"></div>
|
||||
<div id="approve-sign-from" class="text-xs"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -1685,6 +1698,15 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div id="approve-sign-hex-section" class="mb-3 hidden">
|
||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||
<div
|
||||
id="approve-sign-hex"
|
||||
class="text-xs break-all"
|
||||
style="max-height: 6rem; overflow-y: auto"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
@@ -1695,7 +1717,7 @@
|
||||
</div>
|
||||
<div
|
||||
id="approve-sign-error"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
||||
style="visibility: hidden"
|
||||
></div>
|
||||
<div class="flex justify-between">
|
||||
@@ -1727,7 +1749,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<p class="mb-2">
|
||||
<span id="approve-hostname" class="font-bold"></span>
|
||||
<span id="approve-origin" class="font-bold"></span>
|
||||
wants to connect to your wallet.
|
||||
</p>
|
||||
<div class="text-xs text-muted mb-1">
|
||||
|
||||
@@ -44,3 +44,33 @@ body {
|
||||
background-color 225ms ease-out,
|
||||
color 225ms ease-out;
|
||||
}
|
||||
|
||||
/* An address is one atomic string, so it gets a row of its own and never
|
||||
* breaks across lines. A wrapped address reads as two shorter strings, and
|
||||
* two shorter strings are exactly what an address-poisoning attack needs
|
||||
* the user to compare instead of the whole thing. Every view that shows an
|
||||
* address puts it in one of these, alone: the colour dot, the wallet title,
|
||||
* the ENS name and the explorer link all live on their own line above, so
|
||||
* nothing competes with the 42 characters for width.
|
||||
*
|
||||
* overflow-x is the escape hatch, not the mechanism. The row is wide enough
|
||||
* for a full address at every nesting depth the popup uses; if that ever
|
||||
* stops being true — a font with wider glyphs, a browser zoom — the row
|
||||
* scrolls and the user can still reach the last character, rather than the
|
||||
* tail being clipped away by #app's overflow-x-hidden with nothing to say
|
||||
* it happened. tests/e2e asserts the scroll is never actually needed. */
|
||||
.am-address {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* A personal message on the signature screen is laid out left to right in
|
||||
* the order of its bytes. Without this, right-to-left characters in it move
|
||||
* the characters around them: `5`, U+05C3, `00` would read as `500`
|
||||
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
|
||||
* the text after it, so src/popup/views/approval.js shows one as a mark. */
|
||||
.am-byte-order {
|
||||
direction: ltr;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ function init(ctx) {
|
||||
$("btn-add-token-confirm").addEventListener("click", async () => {
|
||||
const contractAddr = $("add-token-address").value.trim();
|
||||
if (!contractAddr || !contractAddr.startsWith("0x")) {
|
||||
showFlash(
|
||||
"Please enter a valid contract address starting with 0x.",
|
||||
);
|
||||
showFlash("Enter a valid contract address starting with 0x.");
|
||||
return;
|
||||
}
|
||||
const already = state.trackedTokens.find(
|
||||
@@ -71,8 +69,15 @@ function init(ctx) {
|
||||
require("./addressDetail").show();
|
||||
} catch (e) {
|
||||
const detail = e.shortMessage || e.message || String(e);
|
||||
log.errorf("Token lookup failed for", contractAddr, detail);
|
||||
showFlash(detail);
|
||||
log.errorf("Adding token failed for", contractAddr, detail);
|
||||
// lookupTokenInfo() rejects a contract with a one-line message
|
||||
// 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.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
@@ -100,9 +100,24 @@ function clear() {
|
||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||
}
|
||||
|
||||
// Each wallet has its own password (its own encryptedSecret), so adding a
|
||||
// second wallet does not reuse the first one's. The note that says so is
|
||||
// only meaningful once a wallet exists — on the first wallet there is no
|
||||
// other password to be separate from — so it is shown only then. This is
|
||||
// decided on entry and stays put while the screen is up, so it does not
|
||||
// move the password fields the way a per-tab hint would.
|
||||
function updateSeparatePasswordNote() {
|
||||
const hasExistingWallet = state.wallets.length > 0;
|
||||
$("add-wallet-separate-password-note").classList.toggle(
|
||||
"hidden",
|
||||
!hasExistingWallet,
|
||||
);
|
||||
}
|
||||
|
||||
function show() {
|
||||
clear();
|
||||
switchMode("mnemonic");
|
||||
updateSeparatePasswordNote();
|
||||
showView("add-wallet");
|
||||
}
|
||||
|
||||
@@ -127,15 +142,13 @@ function validatePassword() {
|
||||
async function importMnemonic(ctx) {
|
||||
const mnemonic = $("wallet-mnemonic").value.trim();
|
||||
if (!mnemonic) {
|
||||
showFlash("Enter a recovery phrase or press the die to generate one.");
|
||||
showFlash("Enter a recovery phrase, or press the die.");
|
||||
return;
|
||||
}
|
||||
const words = mnemonic.split(/\s+/);
|
||||
if (words.length !== 12 && words.length !== 24) {
|
||||
showFlash(
|
||||
"Recovery phrase must be 12 or 24 words. You entered " +
|
||||
words.length +
|
||||
".",
|
||||
"Recovery phrase must be 12 or 24 words, not " + words.length + ".",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -148,14 +161,12 @@ async function importMnemonic(ctx) {
|
||||
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
||||
const xpubDup = findWalletByXpub(xpub);
|
||||
if (xpubDup) {
|
||||
showFlash(
|
||||
"This recovery phrase is already added (" + xpubDup.name + ").",
|
||||
);
|
||||
showFlash("This recovery phrase is already added.");
|
||||
return;
|
||||
}
|
||||
const addrDup = findWalletByAddress(firstAddress);
|
||||
if (addrDup) {
|
||||
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||
showFlash("Address already exists in a wallet.");
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(mnemonic, pw);
|
||||
@@ -214,9 +225,7 @@ async function importPrivateKey(ctx) {
|
||||
if (!pw) return;
|
||||
const duplicate = findWalletByAddress(addr);
|
||||
if (duplicate) {
|
||||
showFlash(
|
||||
"This address already exists in wallet (" + duplicate.name + ").",
|
||||
);
|
||||
showFlash("This address already exists in a wallet.");
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(key, pw);
|
||||
@@ -243,36 +252,29 @@ async function importXprvKey(ctx) {
|
||||
return;
|
||||
}
|
||||
if (!isValidXprv(xprv)) {
|
||||
showFlash(
|
||||
"That extended private key is not valid. Please check it and try again.",
|
||||
);
|
||||
showFlash("That extended private key is not valid.");
|
||||
return;
|
||||
}
|
||||
if (!isMasterExtendedKey(xprv)) {
|
||||
showFlash(
|
||||
"That is an account-level or child key, which cannot be imported. " +
|
||||
"Please paste the master extended private key for the wallet.",
|
||||
);
|
||||
showFlash("Please paste the master key, not a child key.");
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = hdWalletFromXprv(xprv);
|
||||
} catch {
|
||||
showFlash(
|
||||
"That extended private key is not valid. Please check it and try again.",
|
||||
);
|
||||
showFlash("That extended private key is not valid.");
|
||||
return;
|
||||
}
|
||||
const { xpub, firstAddress } = result;
|
||||
const xpubDup = findWalletByXpub(xpub);
|
||||
if (xpubDup) {
|
||||
showFlash("This key is already added (" + xpubDup.name + ").");
|
||||
showFlash("This key is already added.");
|
||||
return;
|
||||
}
|
||||
const addrDup = findWalletByAddress(firstAddress);
|
||||
if (addrDup) {
|
||||
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||
showFlash("Address already exists in a wallet.");
|
||||
return;
|
||||
}
|
||||
const pw = validatePassword();
|
||||
|
||||
@@ -3,11 +3,10 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
balanceLinesForAddress,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -229,18 +228,18 @@ function renderTransactions(txs) {
|
||||
const amountStr = tx.value
|
||||
? escapeHtml(tx.value + " " + sym)
|
||||
: escapeHtml(sym);
|
||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||
const displayAddr =
|
||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||
const addrStr = escapeHtml(displayAddr);
|
||||
const dot = addressDotHtml(counterparty);
|
||||
// The counterparty used to be squeezed in beside the amount and
|
||||
// truncated to whatever was left over. It gets its own row now and
|
||||
// is shown whole; the title or ENS name, where there is one, names
|
||||
// it on the line above rather than replacing it.
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const ago = escapeHtml(timeAgo(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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
balanceLine,
|
||||
unknownableAmount,
|
||||
renderAddressHtml,
|
||||
@@ -305,18 +304,18 @@ function renderTransactions(txs) {
|
||||
const amountStr = tx.value
|
||||
? escapeHtml(tx.value + " " + sym)
|
||||
: escapeHtml(sym);
|
||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||
const displayAddr =
|
||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||
const addrStr = escapeHtml(displayAddr);
|
||||
const dot = addressDotHtml(counterparty);
|
||||
// The counterparty used to be squeezed in beside the amount and
|
||||
// truncated to whatever was left over. It gets its own row now and
|
||||
// is shown whole; the title or ENS name, where there is one, names
|
||||
// it on the line above rather than replacing it.
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const ago = escapeHtml(timeAgo(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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
}
|
||||
|
||||
+347
-57
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
$,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
escapeHtml,
|
||||
showView,
|
||||
showError,
|
||||
@@ -8,21 +9,27 @@ const {
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { networkByChainId } = require("../../shared/networks");
|
||||
const {
|
||||
formatEther,
|
||||
formatUnits,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
Interface,
|
||||
MaxUint256,
|
||||
toUtf8String,
|
||||
TypedDataEncoder,
|
||||
} = require("ethers");
|
||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||
const { ERC20_ABI } = require("../../shared/constants");
|
||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||
const {
|
||||
resolveTokenDecimals,
|
||||
resolveTokenSymbol,
|
||||
unknownDecimalsAmount,
|
||||
} = require("../../shared/approvalAmount");
|
||||
// Four decimals, with the nonzero floor these screens hold: every amount this
|
||||
@@ -63,9 +70,15 @@ function tokenAmountText(rawAmount, decimals, symbol) {
|
||||
};
|
||||
}
|
||||
|
||||
// The symbol shown for a token line, resolved from the bundled list, the
|
||||
// tokens the user tracks, and the explorer's report — the same chain the
|
||||
// amount line's scale comes from. Null when no source names one, so the token
|
||||
// lines keep saying `Unknown token` for a token nothing knows.
|
||||
function tokenLabel(address) {
|
||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||
return t ? t.symbol : null;
|
||||
return resolveTokenSymbol(address, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to decode calldata using known ABIs.
|
||||
@@ -85,8 +98,7 @@ function decodeCalldata(data, toAddress) {
|
||||
try {
|
||||
const parsed = erc20Iface.parseTransaction({ data });
|
||||
if (parsed) {
|
||||
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
||||
const tokenSymbol = token ? token.symbol : null;
|
||||
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
|
||||
// null when no source knows this token's scale. It is not
|
||||
// defaulted to 18: an amount formatted with a guessed scale is
|
||||
// the wrong number, and for a token with fewer decimals than the
|
||||
@@ -198,7 +210,7 @@ function showPhishingWarning(elementId, isPhishing) {
|
||||
// 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
|
||||
// verified but never displayed is verified against nothing the user agreed to.
|
||||
function showTxFee(approvedTx, ethPrice) {
|
||||
function showTxFee(approvedTx) {
|
||||
const network = networkByChainId(approvedTx.chainId);
|
||||
$("approve-tx-network").textContent = network
|
||||
? network.name
|
||||
@@ -206,12 +218,9 @@ function showTxFee(approvedTx, ethPrice) {
|
||||
|
||||
const gasLimit = BigInt(approvedTx.gasLimit);
|
||||
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
||||
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
|
||||
const usdStr = formatUsd(
|
||||
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
|
||||
);
|
||||
$("approve-tx-fee").textContent =
|
||||
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||
// Through formatFee(), as the confirmation screen's fee is, so the same
|
||||
// fee reads the same on both.
|
||||
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
|
||||
|
||||
let detail =
|
||||
gasLimit.toString() +
|
||||
@@ -242,8 +251,11 @@ function showTxApproval(details) {
|
||||
const approvedTx = details.approvedTx;
|
||||
|
||||
const toAddr = approvedTx.to;
|
||||
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
|
||||
const ethValue = formatEther(approvedTx.value || "0");
|
||||
const sources = {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
};
|
||||
|
||||
// Build txInfo for status screens
|
||||
pendingTxDetails = {
|
||||
@@ -251,14 +263,17 @@ function showTxApproval(details) {
|
||||
to: toAddr || "",
|
||||
amount: formatTxValue(ethValue),
|
||||
token: "ETH",
|
||||
tokenSymbol: token ? token.symbol : null,
|
||||
tokenSymbol: null,
|
||||
};
|
||||
|
||||
// If this is an ERC-20 call, try to extract the real recipient and amount
|
||||
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
|
||||
if (decoded && decoded.details) {
|
||||
let decodedTokenAddr = null;
|
||||
let decodedTokenSymbol = null;
|
||||
// The asset the status summary is counted in: an ERC-20 call's Token
|
||||
// contract, or a swap's input token. Its symbol is resolved from the
|
||||
// same sources as the approval screen, so a non-bundled token the
|
||||
// wallet knows is not carried onto the wait and success screens as ETH.
|
||||
let assetAddr = null;
|
||||
for (const d of decoded.details) {
|
||||
if (d.label === "Recipient" && d.address) {
|
||||
pendingTxDetails.to = d.address;
|
||||
@@ -266,20 +281,20 @@ function showTxApproval(details) {
|
||||
if (d.label === "Amount") {
|
||||
pendingTxDetails.amount = d.rawValue || d.value;
|
||||
}
|
||||
if (d.label === "Token In" && d.isToken && d.address) {
|
||||
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
||||
if (t) {
|
||||
decodedTokenAddr = d.address;
|
||||
decodedTokenSymbol = t.symbol;
|
||||
if (
|
||||
(d.label === "Token" || d.label === "Token In") &&
|
||||
d.isToken &&
|
||||
d.address
|
||||
) {
|
||||
assetAddr = d.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
pendingTxDetails.token = toAddr;
|
||||
pendingTxDetails.tokenSymbol = token.symbol;
|
||||
} else if (decodedTokenAddr) {
|
||||
pendingTxDetails.token = decodedTokenAddr;
|
||||
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
|
||||
if (assetAddr) {
|
||||
pendingTxDetails.token = assetAddr;
|
||||
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
|
||||
assetAddr,
|
||||
sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +307,7 @@ function showTxApproval(details) {
|
||||
};
|
||||
}
|
||||
|
||||
$("approve-tx-hostname").textContent = details.hostname;
|
||||
$("approve-tx-origin").textContent = details.origin;
|
||||
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
||||
|
||||
// Show token symbol next to contract address if known
|
||||
@@ -305,7 +320,7 @@ function showTxApproval(details) {
|
||||
toHtml += approvalAddressHtml(toAddr);
|
||||
$("approve-tx-to").innerHTML = toHtml;
|
||||
} else {
|
||||
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
|
||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
}
|
||||
|
||||
const ethValueFormatted = formatTxValue(
|
||||
@@ -317,7 +332,7 @@ function showTxApproval(details) {
|
||||
$("approve-tx-value").textContent =
|
||||
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||
|
||||
showTxFee(approvedTx, ethPrice);
|
||||
showTxFee(approvedTx);
|
||||
|
||||
// Decode calldata (reuse decoded from above)
|
||||
const decodedEl = $("approve-tx-decoded");
|
||||
@@ -366,52 +381,287 @@ function showTxApproval(details) {
|
||||
);
|
||||
}
|
||||
|
||||
// Whether a personal message is hex by the rule signing reads it with:
|
||||
// signing takes getBytes(message), which throws on anything else.
|
||||
function isHexMessage(message) {
|
||||
try {
|
||||
getBytes(message);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The text the hex message's bytes decode to as UTF-8, or null when they are
|
||||
// not UTF-8. The caller has checked that the message is hex.
|
||||
function decodeHexMessage(hex) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(
|
||||
hex
|
||||
.slice(2)
|
||||
.match(/.{1,2}/g)
|
||||
.map((b) => parseInt(b, 16)),
|
||||
);
|
||||
return toUtf8String(bytes);
|
||||
return toUtf8String(getBytes(hex));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
// A character shown as a bordered U+XXXX mark.
|
||||
function codePointMark(c) {
|
||||
const code = c.codePointAt(0).toString(16).toUpperCase();
|
||||
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
|
||||
}
|
||||
|
||||
// The text as HTML, with each character that paints nothing (zero-width and
|
||||
// bidirectional characters, variation selectors and Hangul fillers among
|
||||
// them), each control character and each line or paragraph separator
|
||||
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
|
||||
// Left in the text, a paragraph separator would end the byte-order layout
|
||||
// for everything after it. The marks are plain ASCII, so the second pass
|
||||
// leaves them be.
|
||||
function markInvisibleCharacters(text) {
|
||||
return escapeHtml(text)
|
||||
.replace(INVISIBLE_CHARACTERS, codePointMark)
|
||||
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
|
||||
c === "\n" ? "<br>" : codePointMark(c),
|
||||
);
|
||||
}
|
||||
|
||||
// The type ethers will sign typed data as. ethers does not read the page's
|
||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||
// refers to. Throws when the types name no such single struct, which ethers
|
||||
// would refuse to sign as well.
|
||||
function signedPrimaryType(types) {
|
||||
const structs = { ...types };
|
||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
||||
delete structs.EIP712Domain;
|
||||
return TypedDataEncoder.getPrimaryType(structs);
|
||||
}
|
||||
|
||||
// Why a signature request cannot be signed, as a sentence for the error line,
|
||||
// or null when it can. Only typed data is refused: the `primaryType` the page
|
||||
// states has to be the type ethers will sign, or this screen would name one
|
||||
// message while another is signed.
|
||||
function typedDataRefusal(sp) {
|
||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
||||
let data;
|
||||
let signed;
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
let html = "";
|
||||
data = JSON.parse(sp.typedData);
|
||||
signed = signedPrimaryType(data.types);
|
||||
} catch {
|
||||
return "This typed data cannot be read, so it cannot be signed.";
|
||||
}
|
||||
if (!data.primaryType) {
|
||||
return "This typed data does not name its primary type, so it cannot be signed.";
|
||||
}
|
||||
if (data.primaryType !== signed) {
|
||||
return (
|
||||
"This typed data names its primary type as " +
|
||||
data.primaryType +
|
||||
", but it would be signed as " +
|
||||
signed +
|
||||
", so it cannot be signed."
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
||||
// it as an allowance that is never used up.
|
||||
const MAX_UINT160 = (1n << 160n) - 1n;
|
||||
|
||||
// One field of a struct as typed data signs it: the field's declared type and
|
||||
// the struct's value for it, or null when the struct's type declares no field
|
||||
// of that name. ethers signs only the fields a type declares and drops every
|
||||
// other key, so a key the page adds beside them is never read here.
|
||||
function declaredField(types, typeName, struct, name) {
|
||||
const field = (types[typeName] || []).find((f) => f.name === name);
|
||||
if (!field || !struct || typeof struct !== "object") return null;
|
||||
return { type: field.type, value: struct[name] };
|
||||
}
|
||||
|
||||
// The tokens and amounts a Permit2 message grants, from its `details` or
|
||||
// `permitted` field: one struct of `token` and `amount`, or a list of them,
|
||||
// as the field's declared type says. When the field cannot be read the one
|
||||
// grant returned has no token or amount, so the warning still lists it.
|
||||
function permit2Grants(types, primaryType, message, name, max) {
|
||||
const field = declaredField(types, primaryType, message, name);
|
||||
if (!field) return [{ max }];
|
||||
// `PermitDetails` is one grant, `PermitDetails[]` a list of them.
|
||||
const itemType = field.type.replace(/\[\d*\]$/, "");
|
||||
const items = itemType === field.type ? [field.value] : field.value;
|
||||
if (!Array.isArray(items)) return [{ max }];
|
||||
return items.map((item) => ({
|
||||
token: declaredField(types, itemType, item, "token")?.value,
|
||||
amount: declaredField(types, itemType, item, "amount")?.value,
|
||||
max,
|
||||
}));
|
||||
}
|
||||
|
||||
// The address or number a permission field holds, or null when it holds
|
||||
// none; the warning then shows `Unknown` rather than failing.
|
||||
function addressOrNull(value) {
|
||||
try {
|
||||
return getAddress(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function amountOrNull(value) {
|
||||
try {
|
||||
return getBigInt(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A warning for typed data that lets a spender take your tokens, naming the
|
||||
// spender and each token and amount, or "" for any other typed data. These
|
||||
// signatures are how most wallet drains are done, and as plain key/value
|
||||
// lines they read exactly like a sign-in message. They are recognised by the
|
||||
// type ethers signs, `Permit` or one of Permit2's six signature types: a
|
||||
// contract checks the type's exact name, so a renamed copy of one of these
|
||||
// would not be honoured. Everything named is read only from the fields that
|
||||
// type declares, except a `Permit`'s token, which is the domain's
|
||||
// `verifyingContract`; whatever they do not give is shown as `Unknown`.
|
||||
function permitWarningHtml(types, primaryType, domain, message) {
|
||||
// Each entry is a token, the amount, and the largest value its amount
|
||||
// field holds, which the contract treats as unlimited.
|
||||
let grants;
|
||||
switch (primaryType) {
|
||||
case "Permit": {
|
||||
// EIP-2612 declares a `value`. DAI's older permit, signed under
|
||||
// the same name, declares only `allowed`: unlimited, or nothing.
|
||||
// Others, such as the permit for a Uniswap v3 position, declare
|
||||
// neither, and their amount is unknown.
|
||||
const value = declaredField(types, primaryType, message, "value");
|
||||
const allowed = declaredField(
|
||||
types,
|
||||
primaryType,
|
||||
message,
|
||||
"allowed",
|
||||
);
|
||||
let amount;
|
||||
if (value) amount = value.value;
|
||||
else if (allowed) amount = allowed.value ? MaxUint256 : 0n;
|
||||
grants = [
|
||||
{ token: domain?.verifyingContract, amount, max: MaxUint256 },
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "PermitSingle":
|
||||
case "PermitBatch":
|
||||
grants = permit2Grants(
|
||||
types,
|
||||
primaryType,
|
||||
message,
|
||||
"details",
|
||||
MAX_UINT160,
|
||||
);
|
||||
break;
|
||||
case "PermitTransferFrom":
|
||||
case "PermitWitnessTransferFrom":
|
||||
case "PermitBatchTransferFrom":
|
||||
case "PermitBatchWitnessTransferFrom":
|
||||
grants = permit2Grants(
|
||||
types,
|
||||
primaryType,
|
||||
message,
|
||||
"permitted",
|
||||
MaxUint256,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
const sources = {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
};
|
||||
const spender = addressOrNull(
|
||||
declaredField(types, primaryType, message, "spender")?.value,
|
||||
);
|
||||
let html = `<div class="mb-2 p-2 font-bold bg-red-100 text-red-800 border-2 border-red-600 rounded-md">`;
|
||||
html += `<div class="mb-2">⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens listed here from your address, without asking you again.</div>`;
|
||||
html += `<div class="mb-2"><div>Spender</div>`;
|
||||
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
|
||||
html += `</div>`;
|
||||
for (const grant of grants) {
|
||||
const token = addressOrNull(grant.token);
|
||||
const amount = amountOrNull(grant.amount);
|
||||
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
|
||||
// or base units when nothing knows the token's scale.
|
||||
let amountText = "Unknown";
|
||||
if (amount === grant.max) {
|
||||
amountText = "Unlimited";
|
||||
} else if (amount !== null && token === null) {
|
||||
amountText = unknownDecimalsAmount(amount);
|
||||
} else if (amount !== null) {
|
||||
amountText = tokenAmountText(
|
||||
amount,
|
||||
resolveTokenDecimals(token, sources),
|
||||
tokenLabel(token),
|
||||
).display;
|
||||
}
|
||||
html += `<div class="mb-2"><div>Token</div>`;
|
||||
if (token) {
|
||||
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
||||
html += approvalAddressHtml(token);
|
||||
} else {
|
||||
html += `<div>Unknown</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// The typed data as the screen shows it. The primary type shown is the one
|
||||
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
|
||||
// from differing on anything that can be signed. Only typed data that cannot
|
||||
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
let data;
|
||||
let primaryType;
|
||||
try {
|
||||
data = JSON.parse(jsonStr);
|
||||
primaryType = signedPrimaryType(data.types);
|
||||
} catch {
|
||||
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
||||
}
|
||||
|
||||
let html = permitWarningHtml(
|
||||
data.types,
|
||||
primaryType,
|
||||
data.domain,
|
||||
data.message,
|
||||
);
|
||||
|
||||
// A value that is an object is shown as JSON: String() of it says
|
||||
// nothing, and throws for some objects a page can send.
|
||||
const display = (val) =>
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
|
||||
if (data.domain) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
|
||||
for (const [key, val] of Object.entries(data.domain)) {
|
||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
|
||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(display(val))}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
if (data.primaryType) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
|
||||
}
|
||||
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
||||
|
||||
if (data.message) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||
for (const [key, val] of Object.entries(data.message)) {
|
||||
const display =
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
|
||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
} catch {
|
||||
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showSignApproval(details) {
|
||||
@@ -424,7 +674,7 @@ function showSignApproval(details) {
|
||||
pendingSignParams = sp;
|
||||
pendingSignFrom = details.approvedFrom;
|
||||
|
||||
$("approve-sign-hostname").textContent = details.hostname;
|
||||
$("approve-sign-origin").textContent = details.origin;
|
||||
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
||||
details.approvedFrom,
|
||||
);
|
||||
@@ -436,15 +686,33 @@ function showSignApproval(details) {
|
||||
? "Typed data (EIP-712)"
|
||||
: "Personal message";
|
||||
|
||||
// A personal message is signed as the bytes its hex encodes, so the hex
|
||||
// is shown as well as any text it decodes to, and that text is laid out
|
||||
// left to right in the order of its bytes. Signing reads the bytes from
|
||||
// the hex, so a message that is not hex cannot be signed: it is shown as
|
||||
// the text it is, and refused.
|
||||
let refusal = null;
|
||||
$("approve-sign-hex-section").classList.add("hidden");
|
||||
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
|
||||
if (isTyped) {
|
||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||
} else {
|
||||
refusal = typedDataRefusal(sp);
|
||||
} else if (isHexMessage(sp.message)) {
|
||||
const decoded = decodeHexMessage(sp.message);
|
||||
if (decoded !== null) {
|
||||
$("approve-sign-message").textContent = decoded;
|
||||
$("approve-sign-message").innerHTML =
|
||||
markInvisibleCharacters(decoded);
|
||||
} else {
|
||||
$("approve-sign-message").textContent = sp.message;
|
||||
$("approve-sign-message").textContent = "This message is not text.";
|
||||
}
|
||||
$("approve-sign-hex").textContent = sp.message;
|
||||
$("approve-sign-hex-section").classList.remove("hidden");
|
||||
} else {
|
||||
$("approve-sign-message").innerHTML = markInvisibleCharacters(
|
||||
sp.message,
|
||||
);
|
||||
refusal =
|
||||
"This message is plain text, not hex, so it cannot be signed.";
|
||||
}
|
||||
|
||||
// Display danger warning for eth_sign (raw hash signing)
|
||||
@@ -466,6 +734,12 @@ function showSignApproval(details) {
|
||||
|
||||
showView("approve-sign");
|
||||
attachCopyHandlers("view-approve-sign");
|
||||
if (refusal) {
|
||||
showError("approve-sign-error", refusal);
|
||||
$("btn-approve-sign").disabled = true;
|
||||
$("btn-approve-sign").classList.add("text-muted");
|
||||
return;
|
||||
}
|
||||
gateOnWalletDefect(
|
||||
"approve-sign-error",
|
||||
"btn-approve-sign",
|
||||
@@ -504,7 +778,7 @@ async function show(id) {
|
||||
"approve-site-phishing-warning",
|
||||
details.isPhishingDomain,
|
||||
);
|
||||
$("approve-hostname").textContent = details.hostname;
|
||||
$("approve-origin").textContent = details.origin;
|
||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||
attachCopyHandlers("view-approve-site");
|
||||
$("approve-remember").checked = state.rememberSiteChoice;
|
||||
@@ -771,6 +1045,16 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Checked again where the signing starts, not only when the screen
|
||||
// was drawn, and the button stays disabled: this request can never be
|
||||
// signed.
|
||||
const refusal = typedDataRefusal(pendingSignParams);
|
||||
if (refusal) {
|
||||
password = null;
|
||||
showError("approve-sign-error", refusal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt here, in the popup. The password must never cross the
|
||||
// extension messaging boundary; only the signature does.
|
||||
let decryptedSecret;
|
||||
@@ -862,4 +1146,10 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, decodeCalldata };
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
decodeCalldata,
|
||||
formatTypedDataHtml,
|
||||
typedDataRefusal,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
@@ -30,6 +31,10 @@ const {
|
||||
displayedDecimals,
|
||||
transferAmountUnits,
|
||||
} = require("../../shared/transferAmount");
|
||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||
// The balance lines, the fee reserve and the insufficient-balance messages go
|
||||
// through it, as the approval screen's amounts do.
|
||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||
const {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
@@ -149,11 +154,17 @@ function show(txInfo) {
|
||||
$("confirm-balance").textContent =
|
||||
bal == null
|
||||
? "unknown (" + symbol + ")"
|
||||
: valueWithUsd(bal + " " + symbol, balUsd);
|
||||
: valueWithUsd(
|
||||
truncateAmountNeverZero(bal) + " " + symbol,
|
||||
balUsd,
|
||||
);
|
||||
} else {
|
||||
const bal = txInfo.balance || "0";
|
||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
||||
$("confirm-balance").textContent = valueWithUsd(
|
||||
truncateAmountNeverZero(bal) + " ETH",
|
||||
balUsd,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for warnings (synchronous local checks)
|
||||
@@ -187,6 +198,19 @@ function show(txInfo) {
|
||||
$("confirm-amount-fee-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);
|
||||
|
||||
// Reset password field and error
|
||||
@@ -233,7 +257,8 @@ function renderValidation(txInfo) {
|
||||
});
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html.
|
||||
// sentences live in the reserved elements in index.html, except the
|
||||
// fee-unknown one, which show() sets.
|
||||
const messages = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
messages.push("Please enter a valid amount to send.");
|
||||
@@ -248,7 +273,7 @@ function renderValidation(txInfo) {
|
||||
: "Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
truncateAmountNeverZero(txInfo.tokenBalance) +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
@@ -261,7 +286,7 @@ function renderValidation(txInfo) {
|
||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||
messages.push(
|
||||
"Insufficient balance. You have " +
|
||||
txInfo.balance +
|
||||
truncateAmountNeverZero(txInfo.balance || "0") +
|
||||
" ETH but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
@@ -304,14 +329,6 @@ function setVisible(id, visible) {
|
||||
$(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) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
@@ -358,33 +375,27 @@ async function estimateGas(txInfo) {
|
||||
// flight; a stale fee must not reach the screen or the balance check.
|
||||
if (pendingTx !== txInfo) return;
|
||||
|
||||
const ethPrice = getPrice("ETH");
|
||||
const usd = (wei) =>
|
||||
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
||||
|
||||
// The fee line goes through formatFee(), as the approval screen's
|
||||
// does, so the same fee reads the same on both.
|
||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
"~" + formatFeeEth(estimateWei),
|
||||
usd(estimateWei),
|
||||
);
|
||||
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
|
||||
$("confirm-fee-reserve").textContent =
|
||||
"up to " + formatFeeEth(gasCostWei) + " reserved";
|
||||
"up to " +
|
||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
||||
" ETH reserved";
|
||||
setVisible("confirm-fee-reserve", true);
|
||||
} else {
|
||||
// No spread to report: either there is no estimate, or the node
|
||||
// quotes a gas price at or above maxFeePerGas, so the expected
|
||||
// cost is not below the reserve. Show the reserve alone.
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
formatFeeEth(gasCostWei),
|
||||
usd(gasCostWei),
|
||||
);
|
||||
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
}
|
||||
feeStatus = FEE_KNOWN;
|
||||
feeWei = gasCostWei;
|
||||
renderValidation(txInfo);
|
||||
} catch (e) {
|
||||
log.errorf("gas estimation failed:", e.message);
|
||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
||||
if (pendingTx !== txInfo) return;
|
||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
@@ -394,6 +405,46 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the transaction this send describes, enforce the fee bound against
|
||||
// the fees that were actually filled in, then sign and broadcast it. The send
|
||||
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
|
||||
// the configured RPC node answers, with nothing otherwise bounding what a
|
||||
// hostile node can set — the dApp path's ceilings never reached this one.
|
||||
// Populating before the check is what makes assertWithinCeilings() see the
|
||||
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
||||
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
||||
// shows in the reserved error area rather than sending.
|
||||
async function populateVerifyAndSend(connectedSigner, tx) {
|
||||
let request;
|
||||
if (tx.token === "ETH") {
|
||||
request = { to: tx.to, value: parseEther(tx.amount) };
|
||||
} else {
|
||||
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
||||
// The contract's decimals() is read to be COMPARED with the scale the
|
||||
// screen rendered this amount at, not to encode with: encoding from it
|
||||
// signs whatever the contract answers now, which is not what the user
|
||||
// read. A disagreement throws. See transferAmount.js.
|
||||
const amount = transferAmountUnits(
|
||||
tx.amount,
|
||||
tx.tokenDecimals,
|
||||
await contract.decimals(),
|
||||
);
|
||||
request = await contract.transfer.populateTransaction(tx.to, amount);
|
||||
}
|
||||
const populated = await connectedSigner.populateTransaction(request);
|
||||
assertWithinCeilings(populated);
|
||||
return connectedSigner.sendTransaction(populated);
|
||||
}
|
||||
|
||||
// Show a full-sentence send failure in the reserved errors box, the same
|
||||
// element and markup renderValidation() uses for messages carrying the user's
|
||||
// own numbers, so it never moves anything on the screen.
|
||||
function showSendError(message) {
|
||||
const el = $("confirm-errors");
|
||||
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
|
||||
el.style.visibility = "visible";
|
||||
}
|
||||
|
||||
async function checkRecipientHistory(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
@@ -467,29 +518,7 @@ function init(_ctx) {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const connectedSigner = signer.connect(provider);
|
||||
|
||||
if (pendingTx.token === "ETH") {
|
||||
tx = await connectedSigner.sendTransaction({
|
||||
to: pendingTx.to,
|
||||
value: parseEther(pendingTx.amount),
|
||||
});
|
||||
} else {
|
||||
const contract = new Contract(
|
||||
pendingTx.token,
|
||||
ERC20_ABI,
|
||||
connectedSigner,
|
||||
);
|
||||
// The contract's decimals() is read to be COMPARED with the
|
||||
// scale the screen rendered this amount at, not to encode with:
|
||||
// encoding from it signs whatever the contract answers now,
|
||||
// which is not what the user read. A disagreement throws and is
|
||||
// reported on the error screen. See transferAmount.js.
|
||||
const amount = transferAmountUnits(
|
||||
pendingTx.amount,
|
||||
pendingTx.tokenDecimals,
|
||||
await contract.decimals(),
|
||||
);
|
||||
tx = await contract.transfer(pendingTx.to, amount);
|
||||
}
|
||||
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
|
||||
|
||||
// Best-effort: clear decrypted secret after use.
|
||||
// Note: JS strings are immutable; this nulls the reference but
|
||||
@@ -498,6 +527,14 @@ function init(_ctx) {
|
||||
txStatus.showWait(pendingTx, tx.hash);
|
||||
} catch (e) {
|
||||
decryptedSecret = null;
|
||||
// A fee over the bound is refused before anything is broadcast, so
|
||||
// there is no transaction that may have reached the network to warn
|
||||
// about: the message stays on the confirmation screen where the
|
||||
// user can go back, rather than routing to the sent/failed screen.
|
||||
if (e && e.approvalMismatch) {
|
||||
showSendError(e.message);
|
||||
return;
|
||||
}
|
||||
const hash = tx ? tx.hash : null;
|
||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||
} finally {
|
||||
@@ -511,4 +548,4 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, restore };
|
||||
module.exports = { init, show, restore, populateVerifyAndSend };
|
||||
|
||||
@@ -87,7 +87,8 @@ function recoveryPathText(wallet) {
|
||||
// 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
|
||||
// when every holding here is one with no price, since "$0.00" directly under
|
||||
// "This address holds a balance." is a contradiction.
|
||||
// "This address holds a balance." is a contradiction. A token holding below
|
||||
// 0.000001 does not count, as the lines below leave it out.
|
||||
function balanceWarningHtml(addr) {
|
||||
if (!addressHoldsFunds(addr)) return " ";
|
||||
const line = formatAddressTotal(getAddressValue(addr));
|
||||
|
||||
@@ -51,16 +51,12 @@ function clear() {
|
||||
// The lost-password screen holds no secret — a wallet name is not one —
|
||||
// but it is wiped on leave for the neighbouring reason: a typed
|
||||
// confirmation left standing in a hidden view is one click away from
|
||||
// destroying a wallet the user has since navigated off. The button is
|
||||
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
|
||||
// destroying a wallet the user has since navigated off.
|
||||
function clearLostPassword() {
|
||||
lostPasswordIndex = null;
|
||||
$("delete-wallet-lost-name-input").value = "";
|
||||
$("delete-wallet-lost-flash").textContent = "";
|
||||
$("delete-wallet-lost-flash").style.visibility = "hidden";
|
||||
const btn = $("btn-delete-wallet-lost-confirm");
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
}
|
||||
|
||||
function show(walletIdx) {
|
||||
@@ -98,6 +94,17 @@ function showLostPassword() {
|
||||
// cleanup and the accountsChanged broadcast cannot drift apart between
|
||||
// them.
|
||||
async function finishDelete(walletIdx) {
|
||||
// Each route's confirm button was disabled by its own click handler
|
||||
// before the delete ran. Re-enable both here, on the one path they
|
||||
// share, so the two routes reset the same way and a second delete in
|
||||
// the same popup session finds a live button instead of a dead one.
|
||||
const passwordBtn = $("btn-delete-wallet-confirm");
|
||||
passwordBtn.disabled = false;
|
||||
passwordBtn.classList.remove("text-muted");
|
||||
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
|
||||
lostPasswordBtn.disabled = false;
|
||||
lostPasswordBtn.classList.remove("text-muted");
|
||||
|
||||
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
|
||||
|
||||
deleteWalletIndex = null;
|
||||
@@ -187,8 +194,8 @@ function init(_ctx) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
|
||||
// finishDelete() navigates, and the leave hook re-enables the
|
||||
// button and wipes the typed name on the way out.
|
||||
// finishDelete() re-enables the button; navigating away then runs
|
||||
// the leave hook that wipes the typed name.
|
||||
await finishDelete(lostPasswordIndex);
|
||||
});
|
||||
|
||||
|
||||
+100
-24
@@ -12,6 +12,12 @@
|
||||
// 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
|
||||
// it from here.
|
||||
const { formatEther } = require("ethers");
|
||||
const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const { DEBUG } = require("../../shared/constants");
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
@@ -119,7 +125,11 @@ function updateDebugBanner(viewName) {
|
||||
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
const suffix = viewName ? " (" + viewName + ")" : "";
|
||||
// The view id is internal vocabulary; it helps while developing but
|
||||
// means nothing to a user. Only a debug build appends it, gated on the
|
||||
// compile-time DEBUG constant so a release build never shows it — not
|
||||
// isDebug(), which is also true for a testnet or the runtime toggle.
|
||||
const suffix = DEBUG && viewName ? " (" + viewName + ")" : "";
|
||||
if (debug && net.isTestnet) {
|
||||
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
|
||||
} else if (net.isTestnet) {
|
||||
@@ -218,15 +228,19 @@ function clearFlash() {
|
||||
flashTimer = null;
|
||||
}
|
||||
$("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) {
|
||||
clearFlash();
|
||||
$("flash-msg").textContent = msg;
|
||||
flashTimer = setTimeout(() => {
|
||||
$("flash-msg").textContent = "";
|
||||
flashTimer = null;
|
||||
}, duration);
|
||||
$("flash-msg").title = msg;
|
||||
flashTimer = setTimeout(clearFlash, duration);
|
||||
}
|
||||
|
||||
// A stored token balance as a number, or null when there is no number in it.
|
||||
@@ -238,6 +252,19 @@ function unknownableAmount(balance) {
|
||||
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.
|
||||
//
|
||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||
@@ -283,6 +310,9 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
);
|
||||
const seen = new Set();
|
||||
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
|
||||
// of zero, so the show-zero setting has no say over it: hiding it
|
||||
// would be asserting the zero nobody established. Anything that does
|
||||
@@ -313,14 +343,16 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
}
|
||||
|
||||
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
||||
// knows about. Deliberately unrounded — the rendered lines round to four
|
||||
// decimals, so a dust balance displays as 0.0000 while still being real
|
||||
// money at a real address. Callers that warn about holdings must ask this,
|
||||
// not the rendered figure.
|
||||
// knows about, except a token holding below 0.000001, which the balance list
|
||||
// under the remove-address warning leaves out too. Deliberately unrounded —
|
||||
// the rendered lines round to four decimals, so a dust balance displays as
|
||||
// 0.0000 while still being real money at a real address. Callers that warn
|
||||
// about holdings must ask this, not the rendered figure.
|
||||
function addressHoldsFunds(addr) {
|
||||
if (!addr) return false;
|
||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (isBelowOneMillionth(t.balance)) continue;
|
||||
// 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
|
||||
// consulted, so a row that survived with no quantity is holding
|
||||
@@ -331,6 +363,12 @@ function addressHoldsFunds(addr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The fewest characters of an address any caller may ask to display. The
|
||||
// 10-character cap inside truncateMiddle() is the other half of the same
|
||||
// guarantee; this is the half that used to be spelled out at each call
|
||||
// site, and is now enforced once in renderAddressHtml().
|
||||
const ADDRESS_MIN_DISPLAY_LEN = 32;
|
||||
|
||||
// Truncate the middle of a string, replacing removed characters with "…".
|
||||
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
||||
// that still prevents address spoofing attacks (see Display Consistency in
|
||||
@@ -393,6 +431,29 @@ function addressTitle(address, wallets) {
|
||||
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,
|
||||
// and optional truncation. Title and ENS are shown as bold labels above
|
||||
// the full address.
|
||||
@@ -518,17 +579,29 @@ function attachCopyHandlers(container) {
|
||||
|
||||
// Unified address rendering.
|
||||
//
|
||||
// Produces consistent HTML for any Ethereum address:
|
||||
// • Color dot
|
||||
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
||||
// • Optional ENS name shown bold above address
|
||||
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
||||
// • Etherscan external link icon
|
||||
// Two stacked rows, in this order:
|
||||
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
|
||||
// Address 2") and the explorer link icon. Optional ENS name below it.
|
||||
// 2. The address itself, alone on a full-width row that never wraps
|
||||
// (see .am-address in styles/main.css).
|
||||
//
|
||||
// The split is the point. Everything used to sit on one line: dot, address
|
||||
// and link together, with `break-all` to let the address fold when the line
|
||||
// ran out. In the wallet list, where the row also carried [info] and [x],
|
||||
// it ran out every time — the bug in #380 — and a folded address is a
|
||||
// spoofing hazard, not a cosmetic one. Nothing shares the address's row
|
||||
// now, so all 42 characters fit at every nesting depth the popup uses and
|
||||
// nothing has to be dropped or folded to make room.
|
||||
//
|
||||
// Options object:
|
||||
// title — wallet title string (from addressTitle)
|
||||
// ensName — ENS name string
|
||||
// maxLen — if set, truncate address display (min 32 chars enforced)
|
||||
// maxLen — if set, truncate address display. Floored at 32 characters
|
||||
// here rather than by the caller: no view passes it any more
|
||||
// (every address row is wide enough for all 42 characters),
|
||||
// so a floor that lived in the callers would have gone away
|
||||
// with them, and the "at least 32 characters" guarantee has
|
||||
// to survive having no current callers to be a guarantee.
|
||||
// noLink — if true, omit etherscan link
|
||||
//
|
||||
// After inserting the returned HTML into the DOM, call
|
||||
@@ -536,22 +609,22 @@ function attachCopyHandlers(container) {
|
||||
function renderAddressHtml(address, opts) {
|
||||
const { title, ensName, maxLen, noLink } = opts || {};
|
||||
const dot = addressDotHtml(address);
|
||||
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
|
||||
const displayAddr = maxLen
|
||||
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
|
||||
: address;
|
||||
const link = etherscanAddressUrl(address);
|
||||
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
||||
|
||||
let html = "";
|
||||
html += `<div class="flex items-center">${dot}`;
|
||||
if (title) {
|
||||
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
||||
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
|
||||
}
|
||||
html += `${extLink}</div>`;
|
||||
if (ensName) {
|
||||
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
||||
}
|
||||
if (title || ensName) {
|
||||
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
||||
} else {
|
||||
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
|
||||
}
|
||||
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -587,11 +660,14 @@ module.exports = {
|
||||
balanceLinesForAddress,
|
||||
addressHoldsFunds,
|
||||
unknownableAmount,
|
||||
formatFee,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
txCounterpartyHtml,
|
||||
formatAddressHtml,
|
||||
renderAddressHtml,
|
||||
copyableHtml,
|
||||
|
||||
+18
-12
@@ -6,10 +6,10 @@ const {
|
||||
isoDate,
|
||||
timeAgo,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
pushCurrentView,
|
||||
@@ -117,18 +117,19 @@ function renderHomeTxList(ctx) {
|
||||
const amountStr = tx.value
|
||||
? escapeHtml(tx.value + " " + sym)
|
||||
: escapeHtml(sym);
|
||||
// The counterparty used to be squeezed in beside the amount and
|
||||
// truncated to whatever was left over. It gets its own row now and
|
||||
// is shown whole; the title, when it is one of our own addresses,
|
||||
// names it on the line above rather than replacing it.
|
||||
const title = addressTitle(counterparty, state.wallets);
|
||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
||||
const addrStr = escapeHtml(displayAddr);
|
||||
const dot = addressDotHtml(counterparty);
|
||||
const titleStr = title ? escapeHtml(title) : "";
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const ago = escapeHtml(timeAgo(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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
}
|
||||
@@ -252,17 +253,22 @@ function walletListHtml() {
|
||||
: "";
|
||||
const dot = addressDotHtml(addr.address);
|
||||
const titleBold = isActive ? "font-bold" : "";
|
||||
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
||||
// [info] and [x] ride on the "Address N" line, which was empty
|
||||
// to its right, so the address below gets the row to itself.
|
||||
// They used to sit beside the address and take about a third of
|
||||
// the width off it, which is what made a 42-character address
|
||||
// fold onto a second line here and nowhere else (#380).
|
||||
html += `<div class="flex text-xs items-center justify-between">`;
|
||||
html += `<span class="flex items-center ${titleBold}">${dot}Address ${ai + 1}</span>`;
|
||||
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
||||
html += `</div>`;
|
||||
if (addr.ensName) {
|
||||
// An ENS reverse record is whatever the name owner set it
|
||||
// to; renderAddressHtml() escapes its own copy of this and
|
||||
// this list was the one that did not.
|
||||
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
|
||||
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
|
||||
}
|
||||
html += `<div class="flex text-xs items-center justify-between">`;
|
||||
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
|
||||
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
|
||||
+70
-57
@@ -16,6 +16,10 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||
const { resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const { getAddress } = require("ethers");
|
||||
|
||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||
@@ -63,13 +67,13 @@ function validateToAddress(value) {
|
||||
if (checksummed !== v) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Address checksum is invalid. Please double-check the address.",
|
||||
error: "Address checksum is invalid. Check the address.",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Address checksum is invalid. Please double-check the address.",
|
||||
error: "Address checksum is invalid. Check the address.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -125,6 +129,10 @@ function renderSendTokenSelect(addr) {
|
||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||
);
|
||||
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 (fraudSet.has(t.address.toLowerCase())) continue;
|
||||
// An unknown holder count does not withhold a token the user holds:
|
||||
@@ -138,6 +146,50 @@ function renderSendTokenSelect(addr) {
|
||||
}
|
||||
}
|
||||
|
||||
// The token balance and scale the Send screen states and hands the
|
||||
// confirmation screen, so the two screens describe the holding the same way.
|
||||
//
|
||||
// The scale is resolved the same way balances.js resolved the scale it
|
||||
// DISPLAYED this token's balance at: bundled list, then the user's tracked
|
||||
// tokens, then the explorer. The stored tokenBalances[].decimals is the
|
||||
// explorer's own answer alone, so reading it raw carries a null forward for a
|
||||
// token the wallet does know the scale of — and displayedDecimals() then throws
|
||||
// inside estimateGas(), which the confirmation screen reports as an unestimable
|
||||
// fee. Unsendable, over a scale that was never in doubt
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
|
||||
// knows: no fallback.
|
||||
//
|
||||
// Resolved WITH `wallets`, which balances.js does not pass: that adds
|
||||
// explorerDecimals()'s cross-address check, so a contract two addresses report
|
||||
// different scales for answers null rather than picking one. That check has to
|
||||
// apply here, because this scale encodes the transfer — it is carried forward
|
||||
// so the transfer is encoded with the number the user read rather than with
|
||||
// whatever the contract answers at signing time (see
|
||||
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
||||
// fetch time and cannot consult a state it is in the middle of replacing.
|
||||
//
|
||||
// The two resolutions can therefore differ, and where they do, the stored
|
||||
// `balance` is a quantity computed at a scale this screen has just declined to
|
||||
// stand behind. Stating it would leave validateTransfer() checking the amount
|
||||
// against a number the wallet does not vouch for, so it is withdrawn: unknown
|
||||
// scale means unknown balance. It is null rather than "0": both screens state
|
||||
// an unknown balance as unknown, and validateTransfer() treats it as no balance
|
||||
// to spend from, which is the fail-closed side of an amount nobody can check.
|
||||
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
|
||||
// all is an absence of holdings, which is true at every scale.
|
||||
function tokenBalanceAndDecimals(addr, token) {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const tokenDecimals = resolveTokenDecimals(token, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||
}
|
||||
|
||||
function updateSendBalance() {
|
||||
const addr = currentAddress();
|
||||
if (!addr) return;
|
||||
@@ -150,24 +202,27 @@ function updateSendBalance() {
|
||||
const token = state.selectedToken || $("send-token").value;
|
||||
if (token === "ETH") {
|
||||
$("send-balance").textContent =
|
||||
"Current balance: " + (addr.balance || "0") + " ETH";
|
||||
"Current balance: " +
|
||||
truncateAmountNeverZero(addr.balance || "0") +
|
||||
" ETH";
|
||||
} else {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const symbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||
// for it would be a claim about the amount; the send itself is
|
||||
// A null balance is a holding whose scale is unknown. Saying a figure
|
||||
// for it would be a claim about the amount, so it reads as the
|
||||
// confirmation screen's balance line reads it; the send itself is
|
||||
// refused later by transferAmountUnits() for the same missing scale.
|
||||
const bal = tb ? tb.balance : "0";
|
||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||
$("send-balance").textContent =
|
||||
bal == null
|
||||
? "Current balance: unknown (" + symbol + ")"
|
||||
: "Current balance: " + bal + " " + symbol;
|
||||
: "Current balance: " +
|
||||
truncateAmountNeverZero(bal) +
|
||||
" " +
|
||||
symbol;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +266,7 @@ function init(_ctx) {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const resolved = await provider.resolveName(to);
|
||||
if (!resolved) {
|
||||
showFlash("Could not resolve " + to);
|
||||
showFlash("That ENS name has no address.");
|
||||
return;
|
||||
}
|
||||
resolvedTo = resolved;
|
||||
@@ -227,59 +282,17 @@ function init(_ctx) {
|
||||
|
||||
let tokenSymbol = 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;
|
||||
if (token !== "ETH") {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
tokenSymbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// null carried through rather than flattened to "0": the confirm
|
||||
// screen states 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.
|
||||
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;
|
||||
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
||||
addr,
|
||||
token,
|
||||
));
|
||||
}
|
||||
|
||||
ctx.showConfirmTx({
|
||||
|
||||
+67
-36
@@ -16,7 +16,12 @@ const {
|
||||
} = require("../dustThreshold");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||
const {
|
||||
log,
|
||||
debugFetch,
|
||||
urlOrigin,
|
||||
setRuntimeDebug,
|
||||
} = require("../../shared/log");
|
||||
const deleteWallet = require("./deleteWallet");
|
||||
const showPhrase = require("./showPhrase");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
@@ -29,44 +34,61 @@ const {
|
||||
GITEA_COMMIT_URL,
|
||||
} = require("../../shared/buildInfo");
|
||||
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
||||
|
||||
let versionClickCount = 0;
|
||||
let versionClickTimer = null;
|
||||
|
||||
function renderSiteList(containerId, siteMap, stateKey) {
|
||||
// One row per site origin, however many addresses it appears under, each with
|
||||
// an [x] that hands it to onRemove.
|
||||
function renderSiteList(containerId, origins, onRemove) {
|
||||
const container = $(containerId);
|
||||
const hostnames = [...new Set(Object.values(siteMap).flat())];
|
||||
if (hostnames.length === 0) {
|
||||
const unique = [...new Set(origins)];
|
||||
if (unique.length === 0) {
|
||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||
return;
|
||||
}
|
||||
let html = "";
|
||||
hostnames.forEach((hostname) => {
|
||||
unique.forEach((origin) => {
|
||||
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
|
||||
// An origin the URL parser produced cannot carry a delimiter, so
|
||||
// this is escaped for the rule rather than for a known hole — the
|
||||
// rule being that nothing reaches innerHTML unescaped.
|
||||
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-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
|
||||
html += `<span>${escapeHtml(origin)}</span>`;
|
||||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||||
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];
|
||||
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
|
||||
});
|
||||
}
|
||||
|
||||
// Drop a site origin from a remembered site list under every address.
|
||||
function forgetOrigin(siteMap, origin) {
|
||||
for (const addr of Object.keys(siteMap)) {
|
||||
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
|
||||
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(origin) {
|
||||
forgetOrigin(state.allowedSites, origin);
|
||||
await saveState();
|
||||
notify({ type: "AUTISTMASK_REMOVE_SITE" });
|
||||
renderSiteList(containerId, state[key], key);
|
||||
});
|
||||
});
|
||||
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
||||
await renderSiteLists();
|
||||
}
|
||||
|
||||
// Removing a denied site only forgets the refusal; it connects nothing.
|
||||
async function removeDeniedSite(origin) {
|
||||
forgetOrigin(state.deniedSites, origin);
|
||||
await saveState();
|
||||
await renderSiteLists();
|
||||
}
|
||||
|
||||
function renderTrackedTokens() {
|
||||
@@ -202,13 +224,24 @@ function show() {
|
||||
showView("settings");
|
||||
}
|
||||
|
||||
function renderSiteLists() {
|
||||
async function renderSiteLists() {
|
||||
renderSiteList(
|
||||
"settings-allowed-sites",
|
||||
state.allowedSites,
|
||||
"allowedSites",
|
||||
Object.values(state.allowedSites).flat(),
|
||||
removeAllowedSite,
|
||||
);
|
||||
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) {
|
||||
@@ -236,22 +269,19 @@ function init(ctx) {
|
||||
const json = await resp.json();
|
||||
if (json.error) {
|
||||
log.errorf("RPC validation error:", json.error);
|
||||
showFlash("Endpoint returned error: " + json.error.message);
|
||||
showFlash("Endpoint returned an error.");
|
||||
return;
|
||||
}
|
||||
const net = currentNetwork();
|
||||
if (json.result !== net.chainId) {
|
||||
showFlash(
|
||||
"Wrong network (expected " +
|
||||
net.name +
|
||||
", got chain " +
|
||||
json.result +
|
||||
").",
|
||||
);
|
||||
showFlash("Wrong network: expected " + net.name + ".");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("RPC validation fetch failed:", e.message);
|
||||
} catch {
|
||||
// Not the error's message: fetch puts the whole URL, password and
|
||||
// key included, in the message of the error it throws for a URL
|
||||
// with a user name and password or one it cannot parse.
|
||||
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
@@ -273,8 +303,9 @@ function init(ctx) {
|
||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("Blockscout validation failed:", e.message);
|
||||
} catch {
|
||||
// Not the error's message, as for the RPC check above.
|
||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,9 +115,7 @@ function init(_ctx) {
|
||||
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
||||
const addr = $("settings-addtoken-address").value.trim();
|
||||
if (!addr || !addr.startsWith("0x")) {
|
||||
showFlash(
|
||||
"Please enter a valid contract address starting with 0x.",
|
||||
);
|
||||
showFlash("Enter a valid contract address starting with 0x.");
|
||||
return;
|
||||
}
|
||||
if (isTracked(addr)) {
|
||||
@@ -155,8 +153,15 @@ function init(_ctx) {
|
||||
ctx.doRefreshAndRender();
|
||||
} catch (e) {
|
||||
const detail = e.shortMessage || e.message || String(e);
|
||||
log.errorf("Token lookup failed for", addr, detail);
|
||||
showFlash(detail);
|
||||
log.errorf("Adding token failed for", addr, detail);
|
||||
// lookupTokenInfo() rejects a contract with a one-line message
|
||||
// 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.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
isoDate,
|
||||
@@ -94,13 +95,18 @@ function render() {
|
||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||
|
||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||
const toTitle = addressTitle(tx.to, state.wallets);
|
||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||
tx.from,
|
||||
tx.fromEns,
|
||||
fromTitle,
|
||||
);
|
||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
||||
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)
|
||||
const detailSym = displaySymbol(tx.symbol);
|
||||
@@ -137,10 +143,16 @@ function render() {
|
||||
if (tx.contractAddress) {
|
||||
const dot = addressDotHtml(tx.contractAddress);
|
||||
const link = explorerUrl("token", tx.contractAddress);
|
||||
// Hand-rolled rather than renderAddressHtml() because the
|
||||
// link goes to the explorer's /token/ page, not /address/.
|
||||
// Same two-row shape though: dot and link on the strip, the
|
||||
// contract address alone on the row below it.
|
||||
tokenContractEl.innerHTML =
|
||||
`<div class="flex items-center">${dot}` +
|
||||
copyableHtml(tx.contractAddress, "break-all") +
|
||||
etherscanLinkHtml(link) +
|
||||
`</div>` +
|
||||
`<div class="am-address">` +
|
||||
copyableHtml(tx.contractAddress) +
|
||||
`</div>`;
|
||||
tokenContractSection.classList.remove("hidden");
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ const {
|
||||
$,
|
||||
showView,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
escapeHtml,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
@@ -13,7 +14,7 @@ const {
|
||||
displaySymbol,
|
||||
clearViewStack,
|
||||
} = require("./helpers");
|
||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
const { state } = require("../../shared/state");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { log } = require("../../shared/log");
|
||||
@@ -58,7 +59,10 @@ function endWait() {
|
||||
}
|
||||
}
|
||||
|
||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
||||
// writes `to: toAddr || ""`).
|
||||
function toAddressHtml(address) {
|
||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
const title = addressTitle(address, state.wallets);
|
||||
return renderAddressHtml(address, { title });
|
||||
}
|
||||
@@ -129,7 +133,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
// failed — which matters most on a resumed wait, where the
|
||||
// first poll is already past the deadline.
|
||||
answered = false;
|
||||
log.errorf("poll receipt failed:", e.message);
|
||||
log.errorf("poll receipt failed:", e.shortMessage || e.message);
|
||||
}
|
||||
// The lookup is async: the wait may have ended while it was in
|
||||
// flight, in which case this result must not touch the view.
|
||||
@@ -202,8 +206,9 @@ function restoreWait() {
|
||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||
// A string is the whole requirement: the empty string is what a
|
||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||
// would abandon a wait the live path itself created.
|
||||
// || ""`), an empty `to` renders as a contract creation and an empty
|
||||
// amount renders harmlessly, so refusing it would abandon a wait the live
|
||||
// path itself created.
|
||||
if (typeof info.to !== "string") return false;
|
||||
if (typeof info.amount !== "string") return false;
|
||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||
@@ -232,9 +237,15 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
// The symbol shown for a decoded token line, resolved from the bundled list,
|
||||
// the tokens the user tracks, and the explorer's report — the same chain the
|
||||
// approval screen uses. Null when no source names one, so the line keeps
|
||||
// saying `Unknown token`.
|
||||
function tokenLabel(address) {
|
||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||
return t ? t.symbol : null;
|
||||
return resolveTokenSymbol(address, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
}
|
||||
|
||||
function decodedDetailsHtml(decoded) {
|
||||
|
||||
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("contract check failed:", e.message);
|
||||
log.errorf("contract check failed:", e.shortMessage || e.message);
|
||||
}
|
||||
|
||||
// Skip tx count check for contracts — they may legitimately have
|
||||
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("tx count check failed:", e.message);
|
||||
log.errorf("tx count check failed:", e.shortMessage || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
|
||||
// two showing a different number for the same value.
|
||||
//
|
||||
// The two functions below are the two policies, not two implementations of
|
||||
// one: summary lists truncate, and the screens that state what is being
|
||||
// authorized truncate with a floor. Keeping them adjacent is the point, so a
|
||||
// change to the rule cannot reach one screen and miss another.
|
||||
// The two truncation functions below are the two policies, not two
|
||||
// implementations of one: summary lists truncate, and the screens that state
|
||||
// what is being authorized truncate with a floor. Keeping them adjacent is the
|
||||
// point, so a change to the rule cannot reach one screen and miss another.
|
||||
|
||||
// Truncate to exactly four decimal places. Truncation, never rounding: an
|
||||
// amount must never be displayed as larger than it is, so 0.99999 stays
|
||||
@@ -43,4 +43,18 @@ function truncateAmountNeverZero(val) {
|
||||
return parts[0] + "." + parts[1].slice(0, sig + 1);
|
||||
}
|
||||
|
||||
module.exports = { truncateAmount, truncateAmountNeverZero };
|
||||
// Whether a stored token balance is a holding below 0.000001. The balance
|
||||
// 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,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
// enumerated rather than coerced.
|
||||
const { toDecimals } = require("./transferAmount");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
|
||||
// Every decimals the explorer reported for this contract, across all the
|
||||
// addresses whose balances have been fetched. They describe one contract, so
|
||||
@@ -74,6 +75,59 @@ function resolveTokenDecimals(tokenAddress, sources) {
|
||||
return explorerDecimals(lower, sources && sources.wallets);
|
||||
}
|
||||
|
||||
// Every symbol the explorer reported for this contract, across the addresses
|
||||
// whose balances have been fetched. The counterpart to explorerDecimals(): one
|
||||
// contract, so the reports should agree, and a set that does not agree is a
|
||||
// name this screen has no way to choose between.
|
||||
function explorerSymbol(lower, wallets) {
|
||||
let found = null;
|
||||
for (const wallet of wallets || []) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
for (const tb of addr.tokenBalances || []) {
|
||||
if ((tb.address || "").toLowerCase() !== lower) continue;
|
||||
if (!tb.symbol) continue;
|
||||
if (found !== null && found !== tb.symbol) return null;
|
||||
found = tb.symbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// The symbol to label a token with, or null when no source the wallet trusts
|
||||
// names one — in which case the screen keeps saying `Unknown token` rather than
|
||||
// guessing. The bundled list, then the tokens the user tracks, then what the
|
||||
// explorer reported: the same sources and the same precedence
|
||||
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
|
||||
// the same place and the two can no longer disagree about which sources they
|
||||
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
|
||||
//
|
||||
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
|
||||
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
|
||||
// native ticker from a contract not entitled to it is refused and the next
|
||||
// source tried, so resolving a symbol never becomes a new way to claim a known
|
||||
// ticker. The bundled list is the wallet's own data and is trusted as it is.
|
||||
function resolveTokenSymbol(tokenAddress, sources) {
|
||||
const lower = (tokenAddress || "").toLowerCase();
|
||||
if (!lower) return null;
|
||||
|
||||
const bundled = TOKEN_BY_ADDRESS.get(lower);
|
||||
if (bundled && bundled.symbol) return bundled.symbol;
|
||||
|
||||
const tracked = ((sources && sources.trackedTokens) || []).find(
|
||||
(t) => (t.address || "").toLowerCase() === lower,
|
||||
);
|
||||
const candidates = [];
|
||||
if (tracked && tracked.symbol) candidates.push(tracked.symbol);
|
||||
const reported = explorerSymbol(lower, sources && sources.wallets);
|
||||
if (reported) candidates.push(reported);
|
||||
|
||||
for (const symbol of candidates) {
|
||||
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// What the amount line reads when the scale is unknown. The base units are
|
||||
// exact and the caveat is part of the same string, so the number on the screen
|
||||
// cannot be mistaken for a token quantity, and it can never read as zero for a
|
||||
@@ -84,5 +138,6 @@ function unknownDecimalsAmount(rawAmount) {
|
||||
|
||||
module.exports = {
|
||||
resolveTokenDecimals,
|
||||
resolveTokenSymbol,
|
||||
unknownDecimalsAmount,
|
||||
};
|
||||
|
||||
@@ -51,11 +51,15 @@ const POPULATE_TIMEOUT_MS = 20000;
|
||||
// passed to ethers: the object is page-controlled, and a future ethers that
|
||||
// learns to carry a new transaction field must not start picking one up out of
|
||||
// it without this module knowing.
|
||||
//
|
||||
// The nonce is not taken from the page; it is always the account's next nonce
|
||||
// from the network. A page that chose it could replace one of the user's
|
||||
// pending transactions (the same nonce at a higher fee) or leave this one stuck
|
||||
// behind a gap (a nonce above the next one).
|
||||
const REQUEST_FIELDS = [
|
||||
"to",
|
||||
"value",
|
||||
"data",
|
||||
"nonce",
|
||||
"gasLimit",
|
||||
"gasPrice",
|
||||
"maxFeePerGas",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
const {
|
||||
Transaction,
|
||||
accessListify,
|
||||
formatEther,
|
||||
getAddress,
|
||||
getBytes,
|
||||
verifyMessage,
|
||||
@@ -134,10 +135,19 @@ const FORBIDDEN_FIELDS = [
|
||||
const MAX_GAS_LIMIT = 100000000n;
|
||||
|
||||
// 100,000 gwei per gas: orders of magnitude above the highest fee either
|
||||
// supported network has produced, and low enough to catch a fee that would
|
||||
// hand the validator the balance.
|
||||
// supported network has produced.
|
||||
const MAX_FEE_PER_GAS = 100000000000000n;
|
||||
|
||||
// The largest total fee this wallet will sign, in wei. The two ceilings above
|
||||
// bound the gas limit and the price per gas each on its own, but the fee a
|
||||
// validator is actually paid is their product, and a gas limit and a price
|
||||
// that are each under their own ceiling still multiply to thousands of ETH —
|
||||
// 30,000,000 gas at 100,000 gwei is about 3,000 ETH. Bounding the product is
|
||||
// what catches a fee that would hand the validator the balance; the per-field
|
||||
// ceilings alone do not. A full 30,000,000-gas block at 33 gwei reaches this,
|
||||
// which no ordinary wallet transaction approaches.
|
||||
const MAX_TOTAL_FEE = 1000000000000000000n; // 1 ETH
|
||||
|
||||
// A refusal to act on an artifact: it is not the thing that was approved, so
|
||||
// the approval it was offered against is spent and must not be retried. Every
|
||||
// throw in this module is one of these; the background distinguishes them from
|
||||
@@ -384,6 +394,28 @@ function assertWithinCeilings(tx) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// The product: gasLimit × the most this transaction could pay per gas —
|
||||
// maxFeePerGas for a type-2 transaction, gasPrice for a legacy or type-1
|
||||
// one. This is the fee a gas-consuming contract can really extract, and it
|
||||
// is the bound the two per-field ceilings above cannot express.
|
||||
if (present(tx.gasLimit)) {
|
||||
const gasLimit = normalizeQuantity(tx.gasLimit, "gas limit");
|
||||
let price = null;
|
||||
if (present(tx.maxFeePerGas)) {
|
||||
price = normalizeQuantity(tx.maxFeePerGas, "maximum fee per gas");
|
||||
} else if (present(tx.gasPrice)) {
|
||||
price = normalizeQuantity(tx.gasPrice, "gas price");
|
||||
}
|
||||
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
|
||||
throw refuse(
|
||||
"This transaction would allow a network fee of up to " +
|
||||
formatEther(gasLimit * price) +
|
||||
" ETH, which is more than the " +
|
||||
formatEther(MAX_TOTAL_FEE) +
|
||||
" ETH this wallet will sign for.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse a field only a transaction type this wallet does not sign can carry.
|
||||
@@ -775,4 +807,5 @@ module.exports = {
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
MAX_TOTAL_FEE,
|
||||
};
|
||||
|
||||
+17
-19
@@ -10,7 +10,7 @@ const {
|
||||
} = require("ethers");
|
||||
const { ERC20_ABI } = require("./constants");
|
||||
const { NETWORKS } = require("./networks");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { log, debugFetch, urlOrigin } = require("./log");
|
||||
const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||
@@ -52,19 +52,16 @@ function requireNetworkId(networkId) {
|
||||
return net;
|
||||
}
|
||||
|
||||
function formatBalance(wei) {
|
||||
const eth = formatEther(wei);
|
||||
const parts = eth.split(".");
|
||||
if (parts.length === 1) return eth + ".0";
|
||||
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||
return parts[0] + "." + dec;
|
||||
}
|
||||
|
||||
// A token balance as an exact decimal string, never cut: a cut stores a small
|
||||
// nonzero holding as zero. fetchTokenBalances() stores every nonzero holding of
|
||||
// a token it admits, however small; the screens that leave out one below
|
||||
// 0.000001 decide that themselves, through isBelowOneMillionth() in
|
||||
// src/shared/amountDisplay.js.
|
||||
function formatTokenBalance(raw, decimals) {
|
||||
const val = formatUnits(raw, decimals);
|
||||
const parts = val.split(".");
|
||||
if (parts.length === 1) return val + ".0";
|
||||
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||
const dec = parts[1].replace(/0+$/, "") || "0";
|
||||
return parts[0] + "." + dec;
|
||||
}
|
||||
|
||||
@@ -149,11 +146,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
const scale = known !== null ? known : decimals;
|
||||
// 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.
|
||||
// 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);
|
||||
if (bal === "0.0") continue;
|
||||
// null means the explorer reported no count, which is not the
|
||||
// same as a count of zero. This gate is not the low-holder
|
||||
// display filter: it has no user-facing off switch and governs
|
||||
@@ -210,7 +203,7 @@ async function refreshBalances(
|
||||
trackedTokens,
|
||||
networkId,
|
||||
) {
|
||||
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const updates = [];
|
||||
|
||||
@@ -221,7 +214,9 @@ async function refreshBalances(
|
||||
provider
|
||||
.getBalance(addr.address)
|
||||
.then((bal) => {
|
||||
addr.balance = formatBalance(bal);
|
||||
// Exact, never cut: a cut here stores a small nonzero
|
||||
// balance as zero.
|
||||
addr.balance = formatEther(bal);
|
||||
log.debugf("ETH balance", addr.address, addr.balance);
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -251,7 +246,7 @@ async function refreshBalances(
|
||||
log.errorf(
|
||||
"ENS reverse failed",
|
||||
addr.address,
|
||||
e.message,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
// Keep existing addr.ensName if we had one
|
||||
}),
|
||||
@@ -285,7 +280,7 @@ async function refreshBalances(
|
||||
// Look up token metadata from its contract.
|
||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||
|
||||
@@ -310,7 +305,10 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
name = await contract.name();
|
||||
log.debugf("name() =", name);
|
||||
} catch (e) {
|
||||
log.warnf("name() failed, using symbol as name:", e.message);
|
||||
log.warnf(
|
||||
"name() failed, using symbol as name:",
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
name = symbol;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,15 @@ const BUILD_DEBUG_MARKER = DEBUG
|
||||
? "autistmask-build-debug=on"
|
||||
: "autistmask-build-debug=off";
|
||||
|
||||
const DEBUG_MNEMONIC =
|
||||
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
|
||||
// Behind DEBUG for the same reason BUILD_DEBUG_MARKER is above: in a release
|
||||
// build __BUILD_DEBUG__ is a compile-time false, esbuild drops this branch, and
|
||||
// the phrase never reaches a distributed bundle. The literal used to survive as
|
||||
// dead text because module.exports keeps this const live even though wallet.js's
|
||||
// only use of it is folded away; making the value itself fold to null removes
|
||||
// it. script/verify-build fails a release build if the phrase appears anyway.
|
||||
const DEBUG_MNEMONIC = DEBUG
|
||||
? "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
|
||||
: null;
|
||||
|
||||
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
|
||||
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
|
||||
|
||||
+5
-1
@@ -42,7 +42,11 @@ async function resolveEnsName(address, rpcUrl, networkId) {
|
||||
setCache(address, name);
|
||||
return name;
|
||||
} catch (e) {
|
||||
log.errorf("ENS reverse lookup failed", address, e.message);
|
||||
log.errorf(
|
||||
"ENS reverse lookup failed",
|
||||
address,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
// Don't cache failures — let subsequent lookups retry
|
||||
return null;
|
||||
}
|
||||
|
||||
+25
-5
@@ -42,14 +42,34 @@ const log = {
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch wrapper that debug-logs every request and response.
|
||||
// The origin (scheme, host and port) of a URL, for logging in place of the
|
||||
// URL: RPC providers put API keys in the path or the query string, and a URL
|
||||
// can carry a user name and password, which the origin leaves out. A URL that
|
||||
// does not parse gives "", so logging never stops a request.
|
||||
function urlOrigin(url) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch wrapper that debug-logs every request and response. It logs the
|
||||
// URL's origin and, for a JSON-RPC body, the method name: never the full URL
|
||||
// or body, which can carry an API key or a signed transaction.
|
||||
async function debugFetch(url, opts) {
|
||||
const method = (opts && opts.method) || "GET";
|
||||
const body = opts && opts.body;
|
||||
log.debugf("fetch →", method, url, body || "");
|
||||
const origin = urlOrigin(url);
|
||||
let rpcMethod = "";
|
||||
try {
|
||||
rpcMethod = JSON.parse(opts.body).method || "";
|
||||
} catch {
|
||||
// no body, or a body that is not JSON
|
||||
}
|
||||
log.debugf("fetch →", method, origin, rpcMethod);
|
||||
const resp = await fetch(url, opts);
|
||||
log.debugf("fetch ←", resp.status, url);
|
||||
log.debugf("fetch ←", resp.status, origin);
|
||||
return resp;
|
||||
}
|
||||
|
||||
module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
|
||||
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
|
||||
|
||||
@@ -102,11 +102,11 @@ function tokenRefs(value) {
|
||||
|
||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
||||
// src/shared/transactions.js) and each address's hostname list in the site maps
|
||||
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
||||
// src/shared/transactions.js) and each address's origin list in the site maps
|
||||
// below (`o !== origin` filters, `list.includes(origin)` in the background).
|
||||
//
|
||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
||||
// with a malformed entry DROPPED rather than repaired. A number in a hostname
|
||||
// with a malformed entry DROPPED rather than repaired. A number in an origin
|
||||
// list names no site and a number in fraudContracts names no contract, so there
|
||||
// is nothing to repair either to, and the empty list is a legitimate value that
|
||||
// survives. The result is a fresh array of primitives, so it shares no
|
||||
@@ -116,21 +116,22 @@ function textList(value) {
|
||||
return value.filter((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
||||
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
|
||||
// full scheme://host[:port] of a site.
|
||||
//
|
||||
// The container check these had (truthy and not an array) is not the floor:
|
||||
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
||||
// level below it. saveState() merges these maps per key and then per hostname
|
||||
// level below it. saveState() merges these maps per key and then per origin
|
||||
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
||||
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
||||
// rendered, which is why every save from then on failed while the UI looked
|
||||
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
||||
// revoke button (`list.filter()`), and the background's
|
||||
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
|
||||
// `allowed.includes(origin)` gate, dereference it the same way; on that last
|
||||
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
||||
// could widen a site permission rather than merely throw.
|
||||
//
|
||||
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||
// An address key whose value is not a list of origins is dropped entirely: it
|
||||
// grants and denies nothing, and dropping it fails closed. A stored own
|
||||
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
||||
@@ -143,9 +144,9 @@ function siteMap(value) {
|
||||
if (!isRecord(value)) return out;
|
||||
for (const address of Object.keys(value)) {
|
||||
if (address === "__proto__") continue;
|
||||
const hostnames = textList(value[address]);
|
||||
if (hostnames.length === 0) continue;
|
||||
defineOwn(out, address, hostnames);
|
||||
const origins = textList(value[address]);
|
||||
if (origins.length === 0) continue;
|
||||
defineOwn(out, address, origins);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
||||
|
||||
const { getTopTokenPrices } = require("./tokenList");
|
||||
const { isBelowOneMillionth } = require("./amountDisplay");
|
||||
|
||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
||||
|
||||
@@ -78,6 +79,9 @@ function getAddressValue(addr) {
|
||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||
let partial = false;
|
||||
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
|
||||
// quantity to price — but it is still a holding, and a total that
|
||||
// silently omits it would read as complete. That is exactly what
|
||||
|
||||
+10
-10
@@ -304,7 +304,7 @@ function mergeAddress(base, ours, theirs) {
|
||||
}
|
||||
|
||||
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
||||
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
||||
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
||||
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
||||
// object diff — so a key one page added or removed applies independently of
|
||||
// a key another page edited. Unlike an array's identity function, an object
|
||||
@@ -353,25 +353,25 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. The origin
|
||||
// list is itself membership, not a leaf — the background appends a newly
|
||||
// approved/denied hostname to it, and the Settings "revoke" button
|
||||
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
|
||||
// approved/denied origin to it, and the Settings "revoke" button
|
||||
// (src/popup/views/settings.js) filters an origin out of it in place, from a
|
||||
// different page. Merge it the same way wallets are merged: identity is the
|
||||
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
|
||||
// origin itself, so a merged pair is always equal and mergeItem is a no-op
|
||||
// pick.
|
||||
function mergeHostnameList(base, ours, theirs) {
|
||||
function mergeOriginList(base, ours, theirs) {
|
||||
return mergeListByIdentity(
|
||||
base,
|
||||
ours,
|
||||
theirs,
|
||||
(hostname) => hostname,
|
||||
(origin) => origin,
|
||||
(b, o, t) => t,
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSiteMap(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||
return mergeMapByKey(base, ours, theirs, mergeOriginList);
|
||||
}
|
||||
|
||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||
@@ -422,8 +422,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// address) apply independently instead of colliding as the same field.
|
||||
//
|
||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||
// by address key and then by hostname within each address's list), for the
|
||||
// identical reason: the background appends a newly approved/denied hostname
|
||||
// by address key and then by origin within each address's list), for the
|
||||
// identical reason: the background appends a newly approved/denied origin
|
||||
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
||||
// filters one out in place, from a different page. A whole-field diff here
|
||||
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||
|
||||
@@ -34,6 +34,11 @@ function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
// The characters that paint nothing; normalizeSymbol below says which they
|
||||
// are. The signature screen marks them in a personal message
|
||||
// (src/popup/views/approval.js).
|
||||
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
||||
|
||||
// Fold a symbol onto what a user actually sees, and no further:
|
||||
//
|
||||
// NFKC collapses compatibility variants that render as the ASCII
|
||||
@@ -82,7 +87,7 @@ function normalizeAddress(addr) {
|
||||
function normalizeSymbol(symbol) {
|
||||
return String(symbol || "")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||
.replace(INVISIBLE_CHARACTERS, "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
@@ -104,5 +109,6 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVISIBLE_CHARACTERS,
|
||||
isSpoofedSymbol,
|
||||
};
|
||||
|
||||
@@ -139,7 +139,15 @@ function validateTransfer({
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
|
||||
// A token can declare more than 18 decimals, and its balance is
|
||||
// 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 (feeFp !== null && feeFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
||||
|
||||
+41
-11
@@ -2,10 +2,10 @@
|
||||
// swap details. Designed to be extended with other DEX decoders later.
|
||||
|
||||
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { truncateAmountNeverZero } = require("./amountDisplay");
|
||||
const {
|
||||
resolveTokenDecimals,
|
||||
resolveTokenSymbol,
|
||||
unknownDecimalsAmount,
|
||||
} = require("./approvalAmount");
|
||||
|
||||
@@ -100,6 +100,13 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
||||
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
|
||||
// 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
|
||||
@@ -123,9 +130,8 @@ function tokenInfo(address, sources) {
|
||||
if (address === "0x0000000000000000000000000000000000000000") {
|
||||
return { symbol: "ETH", decimals: 18, address: null };
|
||||
}
|
||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||
return {
|
||||
symbol: t ? t.symbol : null,
|
||||
symbol: resolveTokenSymbol(address, sources),
|
||||
decimals: resolveTokenDecimals(address, sources),
|
||||
address,
|
||||
};
|
||||
@@ -199,11 +205,6 @@ function decodeV2SwapExactIn(input) {
|
||||
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||
// 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) {
|
||||
try {
|
||||
const d = coder.decode(
|
||||
@@ -448,6 +449,7 @@ function decode(data, toAddress, sources) {
|
||||
let outputToken = null;
|
||||
let minOutput = null;
|
||||
let hasUnwrapWeth = false;
|
||||
let hasV2ExactOut = false;
|
||||
const commandNames = [];
|
||||
|
||||
// THE INVARIANT: an amount and the token it is counted in always come
|
||||
@@ -522,6 +524,16 @@ function decode(data, toAddress, sources) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x09) {
|
||||
// Buys exactly amountOut and spends at most amountInMax.
|
||||
hasV2ExactOut = true;
|
||||
const s = decodeV2SwapExactOut(inputs[i]);
|
||||
if (s) {
|
||||
setInputOnce(s.tokenIn, s.amountInMax);
|
||||
setOutput(s.tokenOut, s.amountOut);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x0b) {
|
||||
const w = decodeWrapEth(inputs[i]);
|
||||
if (w) {
|
||||
@@ -560,10 +572,17 @@ function decode(data, toAddress, sources) {
|
||||
|
||||
// Resolve token info. A null token on either side means the calldata
|
||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
||||
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
||||
// decode, and it is answered here rather than left to that rule.
|
||||
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
|
||||
// when the output side is WETH, or when no step set the output side.
|
||||
// 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 outInfo = hasUnwrapWeth
|
||||
const outInfo =
|
||||
hasUnwrapWeth && (outputIsWeth || outputUnset)
|
||||
? { symbol: "ETH", decimals: 18, address: null }
|
||||
: tokenInfo(outputToken, sources);
|
||||
|
||||
@@ -614,6 +633,17 @@ function decode(data, toAddress, sources) {
|
||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||
} else if (inputAmount >= MAX_UINT160) {
|
||||
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 {
|
||||
amount = amountText(inputAmount, inInfo);
|
||||
}
|
||||
|
||||
@@ -41,12 +41,10 @@ const DEFECTS = {
|
||||
"changed or removed, and this wallet stays until you delete " +
|
||||
"it yourself.",
|
||||
],
|
||||
// One sentence for the places that have room for one: the flash on a
|
||||
// blocked Send, the inline error on the approval screens.
|
||||
shortMessage:
|
||||
"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.",
|
||||
// One line, for the flash on a blocked Send and the inline error on
|
||||
// the approval screens. It must fit on the flash line; see showFlash()
|
||||
// in src/popup/views/helpers.js.
|
||||
shortMessage: "This wallet cannot sign. See the wallet list.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@ function sameAddress(a, b) {
|
||||
return String(a).toLowerCase() === String(b).toLowerCase();
|
||||
}
|
||||
|
||||
// Forget every site permission held against the given addresses.
|
||||
// Forget every site permission held against the given addresses: the
|
||||
// remembered ones in `state`, and the connections approved without
|
||||
// "Remember", which only the background holds, in memory.
|
||||
function dropSitePermissions(state, addresses) {
|
||||
for (const addr of addresses) {
|
||||
delete state.allowedSites[addr];
|
||||
delete state.deniedSites[addr];
|
||||
}
|
||||
notify({ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses });
|
||||
}
|
||||
|
||||
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Adding a second wallet accepts a password different from the first one's
|
||||
// with nothing on screen saying the two are separate — each wallet has its
|
||||
// own encryptedSecret, so per-wallet passwords are by design, but the add
|
||||
// screen said only "Choose a password"
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/374).
|
||||
//
|
||||
// The fix is copy: a note on the password screen that says each wallet has
|
||||
// its own password and this one need not match. It is only meaningful once
|
||||
// a wallet exists — on the very first wallet there is no other password to
|
||||
// be separate from — so it is shown then and hidden otherwise. These boot
|
||||
// the real popup and reach the add-wallet screen through the same button a
|
||||
// user presses, so the note's visibility is decided by the real show().
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
POPUP_HTML,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
const NOTE = "add-wallet-separate-password-note";
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
describe("second-wallet password note", () => {
|
||||
test("hidden while onboarding the first wallet", async () => {
|
||||
const page = await bootPopup(undefined);
|
||||
expect(page.pageErrors).toEqual([]);
|
||||
await page.click("btn-welcome-add");
|
||||
expect(page.visibleViews()).toContain("add-wallet");
|
||||
expect(page.hidden(NOTE)).toBe(true);
|
||||
});
|
||||
|
||||
test("shown when a wallet already exists", async () => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
expect(page.pageErrors).toEqual([]);
|
||||
await page.click("btn-main-add-wallet");
|
||||
expect(page.visibleViews()).toContain("add-wallet");
|
||||
expect(page.hidden(NOTE)).toBe(false);
|
||||
});
|
||||
|
||||
// The copy states the two facts the definition of done asks for — each
|
||||
// wallet has its own password, and this one need not match — and stays
|
||||
// consistent with the no-password-reset reality of
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/312 by not promising any
|
||||
// recovery or reset here.
|
||||
test("the note says the password is per-wallet and need not match", () => {
|
||||
const note = /id="add-wallet-separate-password-note"[^>]*>([^]*?)<\/p>/
|
||||
.exec(POPUP_HTML)[1]
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
expect(note).toContain("its own");
|
||||
expect(note).toContain("need not match");
|
||||
expect(note).not.toMatch(/recover|reset/i);
|
||||
});
|
||||
});
|
||||
@@ -143,16 +143,18 @@ describe("decodeCalldata amount", () => {
|
||||
state.trackedTokens = [
|
||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
// The tracked entry supplies both: the scale (5000.0000) and, since
|
||||
// issue #323, the symbol that the scale is counted in.
|
||||
expect(
|
||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||
).toBe("5000.0000");
|
||||
).toBe("5000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("transfer priced off the explorer's decimals shows the true quantity", () => {
|
||||
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
|
||||
expect(
|
||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||
).toBe("5000.0000");
|
||||
).toBe("5000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("transfer of an unknown-decimals token shows base units, not a number", () => {
|
||||
@@ -172,7 +174,7 @@ describe("decodeCalldata amount", () => {
|
||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
||||
"5000.0000",
|
||||
"5000.0000 NOVEL",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// A nonce the page supplies is not used
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could
|
||||
// replace one of the user's pending transactions (the same nonce at a higher
|
||||
// fee) or leave the new one stuck behind a gap, so the transaction is always
|
||||
// given the account's next nonce from the network.
|
||||
//
|
||||
// Driven through the preparation the background runs on a page's
|
||||
// eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen,
|
||||
// password and Confirm included, against a minimal DOM stub in the shape
|
||||
// tests/approvalOrigin.test.js uses. The vault is mocked so that no password
|
||||
// has to be hashed.
|
||||
|
||||
jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { Network, Transaction } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { decryptWithPassword } = require("../src/shared/vault");
|
||||
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
|
||||
// A well-known test phrase, and its first address.
|
||||
const PHRASE = "test test test test test test test test test test test junk";
|
||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
// The account's next nonce, as the network reports it.
|
||||
const NETWORK_NONCE = 7;
|
||||
|
||||
const network = {
|
||||
getNetwork: async () => Network.from(1),
|
||||
getTransactionCount: async () => NETWORK_NONCE,
|
||||
estimateGas: async () => 21000n,
|
||||
getFeeData: async () => ({
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
};
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
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: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
function click(id) {
|
||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
||||
}
|
||||
|
||||
// Open the transaction approval screen the way the popup does: the background
|
||||
// hands over the populated transaction and show() draws it. Returns every
|
||||
// message the screen sends to the background. The background's answer to the
|
||||
// signed transaction does not matter here; a retryable refusal keeps the
|
||||
// screen where it is.
|
||||
async function openTxApproval(approvedTx) {
|
||||
const sent = [];
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
sent.push(msg);
|
||||
if (!reply) return;
|
||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
|
||||
return reply({ error: "Not sent.", retryable: true });
|
||||
}
|
||||
reply({
|
||||
type: "tx",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: FROM,
|
||||
approvedTx,
|
||||
});
|
||||
},
|
||||
};
|
||||
state.activeAddress = FROM;
|
||||
state.wallets = [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
|
||||
},
|
||||
];
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
return sent;
|
||||
}
|
||||
|
||||
test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => {
|
||||
// What the background does with the page's request before it opens the
|
||||
// approval window.
|
||||
const approvedTx = await prepareApprovalTx(network, FROM, {
|
||||
from: FROM,
|
||||
to: RECIPIENT,
|
||||
value: "0x0",
|
||||
data: "0x",
|
||||
nonce: "0x2",
|
||||
});
|
||||
|
||||
const sent = await openTxApproval(approvedTx);
|
||||
expect(node("approve-tx-nonce").textContent).toBe("7");
|
||||
|
||||
decryptWithPassword.mockResolvedValue(PHRASE);
|
||||
node("approve-tx-password").value = "any password";
|
||||
await click("btn-approve-tx");
|
||||
|
||||
const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
|
||||
expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// The connection, transaction and signature prompts name the site by its full
|
||||
// origin, scheme and port included, not by its bare hostname
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
|
||||
// or on another port, of a host the user trusts over https must not raise a
|
||||
// prompt that reads as that trusted site.
|
||||
//
|
||||
// Driven against a minimal DOM stub in the shape
|
||||
// tests/contractCreation.test.js uses.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
|
||||
// The site asking, in cleartext and on a port, which is what the hostname
|
||||
// alone, dapp.example, used to hide.
|
||||
const ORIGIN = "http://dapp.example:8080";
|
||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Open the prompt the background describes with `details`, the way the popup
|
||||
// does: it asks for the approval and show() draws it.
|
||||
async function openApproval(details) {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" } };
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
if (!reply) return;
|
||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||
reply({
|
||||
origin: ORIGIN,
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: FROM,
|
||||
...details,
|
||||
});
|
||||
},
|
||||
};
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.wallets = [];
|
||||
state.activeAddress = FROM;
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
});
|
||||
|
||||
test("the connection prompt shows the origin", async () => {
|
||||
await openApproval({});
|
||||
expect(node("approve-origin").textContent).toBe(ORIGIN);
|
||||
});
|
||||
|
||||
test("the transaction prompt shows the origin", async () => {
|
||||
await openApproval({
|
||||
type: "tx",
|
||||
approvedTx: {
|
||||
type: 2,
|
||||
from: FROM,
|
||||
chainId: "0x1",
|
||||
nonce: "0x7",
|
||||
gasLimit: "0x5208",
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
maxFeePerGas: "0x77359400",
|
||||
to: RECIPIENT,
|
||||
value: "0x0",
|
||||
data: "0x",
|
||||
accessList: [],
|
||||
},
|
||||
});
|
||||
expect(node("approve-tx-origin").textContent).toBe(ORIGIN);
|
||||
});
|
||||
|
||||
test("the signature prompt shows the origin", async () => {
|
||||
await openApproval({
|
||||
type: "sign",
|
||||
// "Hello" as the hex a dApp passes to personal_sign.
|
||||
signParams: {
|
||||
method: "personal_sign",
|
||||
message: "0x48656c6c6f",
|
||||
from: FROM,
|
||||
},
|
||||
});
|
||||
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
|
||||
});
|
||||
@@ -121,7 +121,7 @@ describe("prepareApprovalTx", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a nonce, gas limit and fee the request did fix", async () => {
|
||||
test("keeps a gas limit and fee the request did fix, but not its nonce", async () => {
|
||||
const approved = await prepareApprovalTx(
|
||||
providerWith(),
|
||||
signer.address,
|
||||
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
},
|
||||
);
|
||||
expect(approved.nonce).toBe("0x2");
|
||||
expect(approved.nonce).toBe("0x7");
|
||||
expect(approved.gasLimit).toBe("0x30d40");
|
||||
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
||||
});
|
||||
@@ -212,6 +212,24 @@ describe("prepareApprovalTx", () => {
|
||||
).rejects.toThrow(/gas limit no network this wallet supports/);
|
||||
});
|
||||
|
||||
// The combined bound at population: a gas limit and a fee that are each
|
||||
// under their own ceiling but multiply to thousands of ETH is refused
|
||||
// before the approval window opens, so the user is never shown a
|
||||
// balance-draining fee to click past.
|
||||
test("refuses a fee whose product with the gas limit is over the bound", async () => {
|
||||
const gouging = providerWith({
|
||||
estimateGas: async () => 30000000n,
|
||||
getFeeData: async () => ({
|
||||
gasPrice: MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
|
||||
).rejects.toThrow(/network fee of up to/);
|
||||
});
|
||||
|
||||
// No approval and no window: the failure goes back to the page the click
|
||||
// came from, in a sentence.
|
||||
test("reports a failed estimate as a full sentence", async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
MAX_TOTAL_FEE,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||
@@ -475,18 +476,131 @@ describe("verifySignedTx field comparison", () => {
|
||||
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
|
||||
).toThrow(/fee per gas far above any plausible value/);
|
||||
}
|
||||
// Each field at its own ceiling multiplies to about 10,000 ETH, which
|
||||
// is exactly the combination the per-field ceilings cannot see and the
|
||||
// product bound is for: it is refused, not accepted.
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
gasLimit: MAX_GAS_LIMIT,
|
||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
|
||||
}),
|
||||
).toThrow(/network fee of up to/);
|
||||
// An ordinary transaction — a modest gas limit and a modest fee, each
|
||||
// far under its ceiling and their product far under the bound — passes.
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
gasLimit: 21000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
).not.toThrow();
|
||||
// Nothing to bound is not a failure: a type 2 approval carries no gas
|
||||
// price, and a bare object must not be refused for lacking one.
|
||||
expect(() => assertWithinCeilings({})).not.toThrow();
|
||||
});
|
||||
|
||||
// The defect this issue closes: gasLimit and maxFeePerGas each under their
|
||||
// own ceiling, but their product — the fee a gas-consuming contract can
|
||||
// really extract — thousands of ETH. The per-field ceilings accept it; the
|
||||
// product bound refuses it, on either side of the screen.
|
||||
describe("the combined fee bound", () => {
|
||||
// A gas limit and a fee that are each comfortably under their own
|
||||
// ceiling but multiply to well over 1 ETH: 30,000,000 gas at 100,000
|
||||
// gwei is about 3,000 ETH.
|
||||
const OVER = { gasLimit: 30000000n, maxFeePerGas: 100000000000000n };
|
||||
|
||||
test("each field is under its own ceiling", () => {
|
||||
expect(OVER.gasLimit).toBeLessThan(MAX_GAS_LIMIT);
|
||||
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
|
||||
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
|
||||
MAX_TOTAL_FEE,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertWithinCeilings refuses the product over the bound", () => {
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
...OVER,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
).toThrow(/network fee of up to 3000\.0 ETH/);
|
||||
});
|
||||
|
||||
test("assertWithinCeilings bounds a legacy gasPrice the same way", () => {
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
gasLimit: OVER.gasLimit,
|
||||
gasPrice: OVER.maxFeePerGas,
|
||||
}),
|
||||
).toThrow(/network fee of up to/);
|
||||
});
|
||||
|
||||
// The boundary itself, pinned rather than only some value well past
|
||||
// it. Both fields stay under their own ceilings, so it is the product
|
||||
// and nothing else that decides these two cases: a gas limit of 10,000
|
||||
// at the per-gas ceiling is exactly 1 ETH.
|
||||
test("assertWithinCeilings accepts a product exactly at the bound and refuses one wei over", () => {
|
||||
expect(MAX_FEE_PER_GAS * 10000n).toBe(MAX_TOTAL_FEE);
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
gasLimit: 10000n,
|
||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
gasLimit: 10001n,
|
||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
||||
}),
|
||||
).toThrow(/network fee of up to/);
|
||||
});
|
||||
|
||||
// The dApp path: an artifact whose fee is within each field's ceiling
|
||||
// but over the product bound, both displayed and signed, is refused at
|
||||
// verification just as it is at population.
|
||||
test("verifySignedTx refuses an over-bound product even when displayed", async () => {
|
||||
const raw = await signedWith(OVER);
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
approvedFor(TX_PARAMS, OVER),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/network fee of up to/);
|
||||
});
|
||||
|
||||
test("verifySignedTx accepts a product just under the bound", async () => {
|
||||
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
|
||||
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
|
||||
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(
|
||||
MAX_TOTAL_FEE,
|
||||
);
|
||||
const raw = await signedWith(under);
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
approvedFor(TX_PARAMS, under),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("the refusal names the fee and the limit in a full sentence", () => {
|
||||
try {
|
||||
assertWithinCeilings(OVER);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.approvalMismatch).toBe(true);
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
expect(e.message).toContain("3000.0 ETH");
|
||||
expect(e.message).toContain("1.0 ETH");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("every field mismatch is a refusal, not a warning", async () => {
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,10 @@ const { Network, Wallet } = require("ethers");
|
||||
// what the user is actually shown.
|
||||
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
const {
|
||||
removeAddressFromState,
|
||||
removeWalletFromState,
|
||||
} = require("../src/shared/walletDelete");
|
||||
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
@@ -35,7 +39,6 @@ const other = new Wallet(OTHER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const ORIGIN = "https://dapp.example";
|
||||
const HOSTNAME = "dapp.example";
|
||||
// A page the wallet has never been connected to, whose requests are refused.
|
||||
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
@@ -71,6 +74,22 @@ const NONCE = 7;
|
||||
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
||||
|
||||
// An EIP-712 document as a dApp passes it to eth_signTypedData_v4. The
|
||||
// background only carries it to the approval screen, so a small one does.
|
||||
const TYPED_DATA = JSON.stringify({
|
||||
domain: { name: "AutistMask Test", version: "1", chainId: 1 },
|
||||
primaryType: "Note",
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "version", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
],
|
||||
Note: [{ name: "contents", type: "string" }],
|
||||
},
|
||||
message: { contents: "Hello AutistMask" },
|
||||
});
|
||||
|
||||
// The transaction the background populates and the approval screen displays.
|
||||
// The nonce is a parameter because the duplicate case turns on two artifacts
|
||||
// differing in a field the dApp fixed nothing for.
|
||||
@@ -195,7 +214,7 @@ function loadBackground(options) {
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://rpc.invalid",
|
||||
activeAddress: signer.address,
|
||||
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||
allowedSites: { [signer.address]: [ORIGIN] },
|
||||
deniedSites: {},
|
||||
};
|
||||
|
||||
@@ -226,6 +245,7 @@ function loadBackground(options) {
|
||||
// raised through action.openPopup() opens no window at all, so this is
|
||||
// the only place its id appears.
|
||||
const actionPopups = [];
|
||||
const openPopup = jest.fn(() => Promise.resolve());
|
||||
|
||||
global.chrome = {
|
||||
storage,
|
||||
@@ -280,7 +300,7 @@ function loadBackground(options) {
|
||||
// popup: no window is created, so windows.onRemoved can never
|
||||
// fire for it and the port disconnect is the only close signal
|
||||
// that exists.
|
||||
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
|
||||
...(opts.actionPopup ? { openPopup } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -327,7 +347,7 @@ function loadBackground(options) {
|
||||
|
||||
// The same for a message-signing approval, which pins the signing address
|
||||
// at approval time in exactly the same way.
|
||||
function requestSign(from) {
|
||||
function requestSign(from, origin) {
|
||||
let rpcResult = null;
|
||||
messageListener(
|
||||
{
|
||||
@@ -335,7 +355,7 @@ function loadBackground(options) {
|
||||
method: "personal_sign",
|
||||
params: [MESSAGE, from || signer.address],
|
||||
},
|
||||
{ origin: ORIGIN },
|
||||
{ origin: origin || ORIGIN },
|
||||
(r) => {
|
||||
rpcResult = r;
|
||||
},
|
||||
@@ -349,6 +369,24 @@ function loadBackground(options) {
|
||||
};
|
||||
}
|
||||
|
||||
// The same through eth_signTypedData_v4, whose params name the address
|
||||
// first and the typed data second.
|
||||
function requestTypedData() {
|
||||
let rpcResult = null;
|
||||
messageListener(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_signTypedData_v4",
|
||||
params: [signer.address, TYPED_DATA],
|
||||
},
|
||||
{ origin: ORIGIN },
|
||||
(r) => {
|
||||
rpcResult = r;
|
||||
},
|
||||
);
|
||||
return { result: () => rpcResult };
|
||||
}
|
||||
|
||||
// A dApp asking to connect. The origin defaults to one the persisted
|
||||
// state has never allowed, so the request really does raise a prompt
|
||||
// instead of being answered from allowedSites.
|
||||
@@ -423,12 +461,15 @@ function loadBackground(options) {
|
||||
send,
|
||||
requestTx,
|
||||
requestSign,
|
||||
requestTypedData,
|
||||
requestSite,
|
||||
connectApproval,
|
||||
closeWindow,
|
||||
broadcastTransaction,
|
||||
created,
|
||||
removed,
|
||||
actionPopups,
|
||||
openPopup,
|
||||
storage,
|
||||
// The user switching account in the toolbar popup, as the background
|
||||
// sees it: the persisted active address changes underneath a pending
|
||||
@@ -818,6 +859,238 @@ describe("one transaction approval at a time", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A page that asks again before the user has answered its last connection or
|
||||
// signature request is refused, instead of opening one more window per call.
|
||||
describe("one connection and one signature approval per site at a time", () => {
|
||||
const PENDING_REFUSAL = {
|
||||
error: {
|
||||
code: -32002,
|
||||
message: expect.stringMatching(/already waiting for your answer/),
|
||||
},
|
||||
};
|
||||
|
||||
test("a loop of eth_requestAccounts opens one approval and refuses the rest", async () => {
|
||||
const bg = loadBackground();
|
||||
const requests = [];
|
||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
|
||||
await settle();
|
||||
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(requests[0].result()).toBeNull();
|
||||
for (const extra of requests.slice(1)) {
|
||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
||||
}
|
||||
|
||||
// Once the user has answered, the site may ask again.
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
expect(requests[0].result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
const again = bg.requestSite();
|
||||
await settle();
|
||||
expect(again.result()).toBeNull();
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a loop of personal_sign opens one approval and refuses the rest", async () => {
|
||||
const bg = loadBackground();
|
||||
const requests = [];
|
||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSign());
|
||||
await settle();
|
||||
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(requests[0].result()).toBeNull();
|
||||
for (const extra of requests.slice(1)) {
|
||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
||||
}
|
||||
});
|
||||
|
||||
test("a loop of eth_signTypedData_v4 opens one approval and refuses the rest", async () => {
|
||||
const bg = loadBackground();
|
||||
const requests = [];
|
||||
for (let i = 0; i < 5; i++) requests.push(bg.requestTypedData());
|
||||
await settle();
|
||||
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(requests[0].result()).toBeNull();
|
||||
for (const extra of requests.slice(1)) {
|
||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
||||
}
|
||||
});
|
||||
|
||||
test("a pending personal_sign also refuses eth_signTypedData_v4", async () => {
|
||||
const bg = loadBackground();
|
||||
bg.requestSign();
|
||||
const typed = bg.requestTypedData();
|
||||
await settle();
|
||||
|
||||
expect(typed.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(bg.created).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("in the toolbar popup, a loop of eth_requestAccounts opens it once", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const requests = [];
|
||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
|
||||
await settle();
|
||||
|
||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
||||
for (const extra of requests.slice(1)) {
|
||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
||||
}
|
||||
});
|
||||
|
||||
// A toolbar popup that closes before it connects tells the background
|
||||
// nothing, so its prompt stays pending. Once the toolbar popup has been set
|
||||
// to open something else, nothing shows that prompt, and the site asking
|
||||
// again must show it again rather than be refused for good.
|
||||
test("a toolbar prompt nothing shows any more is shown again when the site asks again", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const first = bg.requestSite();
|
||||
await settle();
|
||||
const id = first.id();
|
||||
|
||||
// Its popup closed without connecting. Another site's prompt takes the
|
||||
// toolbar popup and is answered, which sets it back to the wallet.
|
||||
const other = bg.requestSite(UNCONNECTED_ORIGIN);
|
||||
await settle();
|
||||
const otherPort = bg.connectApproval(other.id());
|
||||
otherPort.decide(false, false);
|
||||
otherPort.disconnect();
|
||||
await settle();
|
||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||
"src/popup/index.html",
|
||||
);
|
||||
|
||||
const repeat = bg.requestSite();
|
||||
await settle();
|
||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(bg.openPopup).toHaveBeenCalledTimes(3);
|
||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||
"src/popup/index.html?approval=" + id,
|
||||
);
|
||||
|
||||
// The user answers it, and the first request gets that answer.
|
||||
bg.connectApproval(id).decide(true, false);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({ result: [signer.address] });
|
||||
});
|
||||
|
||||
// The user rejects a signature request from another site, which is
|
||||
// connected already. Answering any approval sets the toolbar popup back to
|
||||
// the wallet.
|
||||
async function rejectSignatureFromAnotherSite(bg) {
|
||||
const sign = bg.requestSign(undefined, ORIGIN);
|
||||
await settle();
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id: sign.id(),
|
||||
approved: false,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(sign.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||
"src/popup/index.html",
|
||||
);
|
||||
}
|
||||
|
||||
test("a toolbar prompt is shown again after another site's signature request is answered", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const first = bg.requestSite();
|
||||
await settle();
|
||||
const id = first.id();
|
||||
|
||||
// Its popup closed without connecting.
|
||||
await rejectSignatureFromAnotherSite(bg);
|
||||
|
||||
const repeat = bg.requestSite();
|
||||
await settle();
|
||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(bg.openPopup).toHaveBeenCalledTimes(2);
|
||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||
"src/popup/index.html?approval=" + id,
|
||||
);
|
||||
});
|
||||
|
||||
test("a prompt in a window is not opened again when the site asks again", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
// The browser will not open the toolbar popup, so the prompt goes to a
|
||||
// window of its own.
|
||||
bg.openPopup.mockImplementation(() =>
|
||||
Promise.reject(new Error("no toolbar popup")),
|
||||
);
|
||||
bg.requestSite();
|
||||
await settle();
|
||||
expect(bg.created).toHaveLength(1);
|
||||
|
||||
await rejectSignatureFromAnotherSite(bg);
|
||||
expect(bg.created).toHaveLength(2);
|
||||
|
||||
const repeat = bg.requestSite();
|
||||
await settle();
|
||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a prompt in a connected toolbar popup is not opened again when the site asks again", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const first = bg.requestSite();
|
||||
await settle();
|
||||
// The popup is open and showing the prompt.
|
||||
bg.connectApproval(first.id());
|
||||
|
||||
await rejectSignatureFromAnotherSite(bg);
|
||||
|
||||
const repeat = bg.requestSite();
|
||||
await settle();
|
||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||
"src/popup/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
test("another site's connection request is not held up", async () => {
|
||||
const bg = loadBackground();
|
||||
bg.requestSite();
|
||||
const repeat = bg.requestSite();
|
||||
const other = bg.requestSite(UNCONNECTED_ORIGIN);
|
||||
await settle();
|
||||
|
||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(other.result()).toBeNull();
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("another site's signature request is not held up", async () => {
|
||||
const bg = loadBackground();
|
||||
// Connect a second site, so that it may ask for a signature at all.
|
||||
const connecting = bg.requestSite();
|
||||
await settle();
|
||||
const port = bg.connectApproval(connecting.id());
|
||||
port.decide(true, false);
|
||||
port.disconnect();
|
||||
await settle();
|
||||
expect(connecting.result()).toEqual({ result: [signer.address] });
|
||||
|
||||
bg.requestSign();
|
||||
const repeat = bg.requestSign();
|
||||
const other = bg.requestSign(signer.address, FRESH_ORIGIN);
|
||||
await settle();
|
||||
|
||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
||||
expect(other.result()).toBeNull();
|
||||
expect(bg.created).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// A nonce collision found before the transaction reaches the network is the
|
||||
// one send failure the wallet can speak about with certainty. The user is told
|
||||
// it did not go out and to send it again, rather than being warned it might
|
||||
@@ -1541,6 +1814,149 @@ describe("a claimed approval outlives every other retirement path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The approval popup connects a port named for its approval whatever the
|
||||
// approval's kind, so a decision or a disconnect on that port can reach a
|
||||
// transaction approval. Both must be declined: the port decides only
|
||||
// site-connection approvals, and settling a transaction approval it does not
|
||||
// own — while an attempt is broadcasting behind it — is the round-3 fund-loss
|
||||
// bug, where the page is told the request was rejected as the transaction goes
|
||||
// out. These three paths route through settleApproval() and, before this
|
||||
// suite, were exercised only against site approvals.
|
||||
describe("the site-connection port never retires a transaction approval", () => {
|
||||
async function txMidBroadcast() {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
return { bg, pending, id, inFlight, first };
|
||||
}
|
||||
|
||||
test("an approve on the port does not settle it", async () => {
|
||||
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
||||
|
||||
bg.connectApproval(id).decide(true, false);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
});
|
||||
|
||||
test("a reject on the port does not settle it", async () => {
|
||||
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
||||
|
||||
bg.connectApproval(id).decide(false, false);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
});
|
||||
|
||||
test("a port disconnect does not settle it", async () => {
|
||||
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
||||
|
||||
bg.connectApproval(id).disconnect();
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
});
|
||||
});
|
||||
|
||||
// AUTISTMASK_TX_RESPONSE signs and broadcasts a transaction, so it is honoured
|
||||
// only for a transaction approval. A reject shaped as this message used to
|
||||
// retire a sign or connection approval outright, and an approve carrying a
|
||||
// signed artifact used to run the broadcast path against an approval that names
|
||||
// no transaction, failing closed only by throwing deeper in.
|
||||
describe("a transaction response is honoured only for a transaction approval", () => {
|
||||
test("a reject does not retire a sign approval", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestSign();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
// Still live: its own reject settles it.
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
});
|
||||
|
||||
test("a reject does not retire a connection approval", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const pending = bg.requestSite();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
// Still live: the port that owns it connects the site.
|
||||
const port = bg.connectApproval(id);
|
||||
port.decide(true, false);
|
||||
port.disconnect();
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||
});
|
||||
|
||||
test("an approve carrying a signed transaction never broadcasts against a sign approval", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestSign();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(pending.result()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A handler that throws must still answer. `sendResponse` is the only thing
|
||||
// that settles the page's window.ethereum.request() promise, so a throw that
|
||||
// escapes a handler leaves that promise pending forever — no error, no
|
||||
@@ -1953,3 +2369,329 @@ 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] });
|
||||
});
|
||||
});
|
||||
|
||||
// A remembered permission belongs to the origin it was granted to, scheme and
|
||||
// port included (https://git.eeqj.de/sneak/AutistMask/issues/402). The stored
|
||||
// state allows ORIGIN, https://dapp.example. A cleartext page on the same host,
|
||||
// which a network attacker can serve, and another port on it are other sites.
|
||||
describe("a remembered permission is held by the full origin", () => {
|
||||
for (const origin of ["http://dapp.example", "https://dapp.example:8443"]) {
|
||||
test(`an https grant does not authorise ${origin}`, async () => {
|
||||
const bg = loadBackground();
|
||||
expect(await siteAccounts(bg, ORIGIN)).toEqual({
|
||||
result: [signer.address],
|
||||
});
|
||||
|
||||
expect(await siteAccounts(bg, origin)).toEqual({ result: [] });
|
||||
const send = bg.requestTx(TX_PARAMS, origin);
|
||||
await settle();
|
||||
expect(send.result()).toEqual({
|
||||
error: { code: 4100, message: "Unauthorized" },
|
||||
});
|
||||
expect(send.id()).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test("Remember stores the origin, so the cleartext page on that host is asked again", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
const granted = bg.requestSite(FRESH_ORIGIN);
|
||||
await settle();
|
||||
const grantedId = granted.id();
|
||||
bg.connectApproval(grantedId).decide(true, true);
|
||||
await settle();
|
||||
expect(granted.result()).toEqual({ result: [signer.address] });
|
||||
expect(
|
||||
bg.storage.read("autistmask").allowedSites[signer.address],
|
||||
).toEqual([ORIGIN, FRESH_ORIGIN]);
|
||||
|
||||
const cleartext = bg.requestSite("http://fresh.example");
|
||||
await settle();
|
||||
// Unanswered: it is waiting on a prompt of its own.
|
||||
expect(cleartext.result()).toBeNull();
|
||||
expect(cleartext.id()).not.toBe(grantedId);
|
||||
});
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
// The host of FRESH_ORIGIN on another port, which makes it another site.
|
||||
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 origins a site list shows.
|
||||
function listed(list) {
|
||||
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/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, origin) => {
|
||||
expect(listed(list)).toContain(origin);
|
||||
const button = list.buttons.find(
|
||||
(b) => b.dataset.origin === origin,
|
||||
);
|
||||
await button.click();
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
test("Settings lists each site by its origin", async () => {
|
||||
const bg = loadBackground({ actionPopup: true });
|
||||
await connect(bg, FRESH_ORIGIN, false);
|
||||
await connect(bg, FRESH_OTHER_PORT, true);
|
||||
|
||||
const settings = await openSettings(bg);
|
||||
|
||||
expect(listed(settings.connected)).toEqual([FRESH_ORIGIN]);
|
||||
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
|
||||
});
|
||||
|
||||
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" },
|
||||
{ id: 3, url: FRESH_OTHER_PORT + "/app" },
|
||||
]),
|
||||
sendMessage: (tabId, msg, cb) => {
|
||||
sentToTabs.push({ tabId, msg });
|
||||
cb();
|
||||
},
|
||||
};
|
||||
const settings = await openSettings(bg);
|
||||
|
||||
await settings.remove(settings.connected, FRESH_ORIGIN);
|
||||
|
||||
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],
|
||||
});
|
||||
});
|
||||
|
||||
// One origin can hold both kinds of connection under two addresses:
|
||||
// remembered for one, allowed without Remember for the other.
|
||||
test("removing a remembered site also ends its connection made without Remember", async () => {
|
||||
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);
|
||||
await connect(bg, FRESH_ORIGIN, true);
|
||||
bg.setActiveAddress(other.address);
|
||||
const pending = bg.requestSite(FRESH_ORIGIN);
|
||||
await settle();
|
||||
bg.connectApproval(pending.id()).decide(true, false);
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: [other.address] });
|
||||
const settings = await openSettings(bg);
|
||||
|
||||
await settings.remove(settings.allowed, FRESH_ORIGIN);
|
||||
|
||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
|
||||
});
|
||||
|
||||
test("removing a remembered site leaves the same host on another port connected", 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_OTHER_PORT);
|
||||
|
||||
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
|
||||
result: [],
|
||||
});
|
||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
|
||||
result: [signer.address],
|
||||
});
|
||||
});
|
||||
|
||||
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", origin: FRESH_ORIGIN },
|
||||
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] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ const signer = new Wallet(SIGNER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
|
||||
const MAINNET = networkById("mainnet");
|
||||
@@ -81,7 +80,7 @@ function storedProfile(networkId) {
|
||||
networkId,
|
||||
rpcUrl: net.defaultRpcUrl,
|
||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
lastBalanceRefresh: 0,
|
||||
|
||||
@@ -19,7 +19,6 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
// The site the persisted state has connected, and one it has never heard of.
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
const STRANGER_ORIGIN = "https://stranger.example";
|
||||
|
||||
const MAINNET = networkById("mainnet");
|
||||
@@ -86,7 +85,7 @@ function loadBackground() {
|
||||
tokenHolderCache: {},
|
||||
fraudContracts: [],
|
||||
activeAddress: ADDRESS,
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
||||
deniedSites: {},
|
||||
};
|
||||
const storage = makeStorageStub({ autistmask: persisted });
|
||||
|
||||
@@ -17,7 +17,6 @@ const { networkById } = require("../src/shared/networks");
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
const UNKNOWN_ORIGIN = "https://stranger.example";
|
||||
|
||||
const MAINNET = networkById("mainnet");
|
||||
@@ -41,7 +40,7 @@ function storedProfile(networkId) {
|
||||
networkId,
|
||||
rpcUrl: networkById(networkId).defaultRpcUrl,
|
||||
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ const { makeStorageStub } = require("./support/storageStub");
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
|
||||
const MAINNET = networkById("mainnet");
|
||||
const SEPOLIA = networkById("sepolia");
|
||||
@@ -50,7 +49,7 @@ function storedProfile(networkId) {
|
||||
networkId,
|
||||
rpcUrl: CUSTOM_RPC,
|
||||
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
||||
theme: "dark",
|
||||
@@ -168,7 +167,7 @@ describe("a chain switch on a worker that never loaded state", () => {
|
||||
expect(after.wallets).toEqual(walletFixture());
|
||||
expect(after.hasWallet).toBe(true);
|
||||
expect(after.activeAddress).toBe(ADDRESS);
|
||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
|
||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
||||
expect(after.trackedTokens).toEqual([
|
||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||
]);
|
||||
|
||||
@@ -29,7 +29,6 @@ const signer = new Wallet(SIGNER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const CONNECTED_HOSTNAME = "dapp.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
|
||||
const SEPOLIA = networkById("sepolia");
|
||||
@@ -67,7 +66,7 @@ function storedProfile(networkId) {
|
||||
networkId,
|
||||
rpcUrl: net.defaultRpcUrl,
|
||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// The wallet's OWN send path enforces the same combined fee bound the dApp
|
||||
// path does (https://git.eeqj.de/sneak/AutistMask/issues/399).
|
||||
//
|
||||
// The send in src/popup/views/confirmTx.js pins no fee fields, so ethers fills
|
||||
// maxFeePerGas and the gas limit from whatever the configured RPC node
|
||||
// answers. Nothing bounded that: a hostile node could report a fee whose
|
||||
// product with the gas limit is thousands of ETH, and it would be both
|
||||
// displayed and signed. populateVerifyAndSend() populates the transaction and
|
||||
// runs assertWithinCeilings() on the populated fees before signing, so an
|
||||
// over-bound send is refused before anything is broadcast.
|
||||
//
|
||||
// The check is driven here with a fake connected signer rather than a real
|
||||
// one: populateTransaction() returns the fees the node would have produced,
|
||||
// and sendTransaction() records whether the send actually happened. The real
|
||||
// DOM path around it — reading the fee error into the reserved errors box — is
|
||||
// covered by the Chrome e2e suite.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
|
||||
const {
|
||||
MAX_FEE_PER_GAS,
|
||||
MAX_TOTAL_FEE,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
// A signer whose populateTransaction() fills in the fees a node quoted and
|
||||
// whose sendTransaction() records the call, so a test can assert whether the
|
||||
// send was reached at all.
|
||||
function fakeSigner(fees) {
|
||||
const sent = [];
|
||||
return {
|
||||
sent,
|
||||
populateTransaction: async (request) => ({
|
||||
...request,
|
||||
from: RECIPIENT,
|
||||
nonce: 0,
|
||||
type: 2,
|
||||
chainId: 1n,
|
||||
gasLimit: fees.gasLimit,
|
||||
maxFeePerGas: fees.maxFeePerGas,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
}),
|
||||
sendTransaction: async (tx) => {
|
||||
sent.push(tx);
|
||||
return { hash: "0xabc" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ETH_SEND = { token: "ETH", to: RECIPIENT, amount: "1.0" };
|
||||
|
||||
describe("populateVerifyAndSend enforces the combined fee bound", () => {
|
||||
// A gas limit and a fee that are each under their own field ceiling, but
|
||||
// multiply to about 3,000 ETH — the combination the per-field ceilings
|
||||
// cannot see.
|
||||
const OVER = { gasLimit: 30000000n, maxFeePerGas: MAX_FEE_PER_GAS };
|
||||
|
||||
test("each field is under its ceiling but the product is over the bound", () => {
|
||||
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
|
||||
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
|
||||
MAX_TOTAL_FEE,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses an over-bound send without broadcasting it", async () => {
|
||||
const signer = fakeSigner(OVER);
|
||||
let thrown;
|
||||
try {
|
||||
await populateVerifyAndSend(signer, ETH_SEND);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeDefined();
|
||||
expect(thrown.approvalMismatch).toBe(true);
|
||||
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
||||
expect(thrown.message).toContain("3000.0 ETH");
|
||||
expect(thrown.message).toContain("1.0 ETH");
|
||||
// The one guarantee that matters: nothing was signed or sent.
|
||||
expect(signer.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("broadcasts a send whose product is just under the bound", async () => {
|
||||
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
|
||||
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
|
||||
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(MAX_TOTAL_FEE);
|
||||
const signer = fakeSigner(under);
|
||||
const tx = await populateVerifyAndSend(signer, ETH_SEND);
|
||||
expect(tx.hash).toBe("0xabc");
|
||||
expect(signer.sent).toHaveLength(1);
|
||||
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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",
|
||||
origin: "https://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>`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Tests for the debug/testnet banner (issue #375).
|
||||
//
|
||||
// On a testnet the banner is raised even in a release build, but it must not
|
||||
// append the active view's internal id: the user should see "[TESTNET]", never
|
||||
// "[TESTNET] (approve-tx)". The suffix is gated on the compile-time DEBUG
|
||||
// constant, which is false in a plain test load, so this drives exactly the
|
||||
// text a shipped build renders. Revert the gate to the old unconditional
|
||||
// suffix and this fails.
|
||||
//
|
||||
// The banner is created on demand by updateDebugBanner(); the document stub
|
||||
// records what it prepends so the assertion can read the resulting text.
|
||||
|
||||
function makeBanner() {
|
||||
return {
|
||||
id: "",
|
||||
textContent: "",
|
||||
style: { cssText: "" },
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
let banner = null;
|
||||
return {
|
||||
getElementById(id) {
|
||||
return id === "debug-banner" ? banner : null;
|
||||
},
|
||||
createElement: () => makeBanner(),
|
||||
body: {
|
||||
prepend(node) {
|
||||
banner = node;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
jest.resetModules();
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
globalThis.document = makeDocument();
|
||||
const helpers = require("../src/popup/views/helpers");
|
||||
const { state } = require("../src/shared/state");
|
||||
return { helpers, state };
|
||||
}
|
||||
|
||||
describe("the release banner on a testnet", () => {
|
||||
test("carries no internal view id", () => {
|
||||
const { helpers, state } = load();
|
||||
state.networkId = "sepolia";
|
||||
|
||||
helpers.updateDebugBanner("approve-tx");
|
||||
|
||||
expect(
|
||||
globalThis.document.getElementById("debug-banner").textContent,
|
||||
).toBe("[TESTNET]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// What debugFetch writes to the console in debug mode.
|
||||
//
|
||||
// RPC providers put the API key in the URL's path or query string, and the
|
||||
// debug log used to print the whole URL and request body, so turning debug
|
||||
// mode on wrote the key to the console
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
|
||||
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
|
||||
// the request.
|
||||
|
||||
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
setRuntimeDebug(false);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
|
||||
setRuntimeDebug(true);
|
||||
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
|
||||
|
||||
await debugFetch(
|
||||
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_chainId",
|
||||
params: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const logged = consoleLog.mock.calls.flat().join(" ");
|
||||
expect(logged).not.toContain("PATHKEY123");
|
||||
expect(logged).not.toContain("QUERYTOKEN456");
|
||||
expect(logged).toContain("https://rpc.example.invalid");
|
||||
expect(logged).toContain("eth_chainId");
|
||||
});
|
||||
|
||||
test("the origin leaves out a user name and password in the URL", () => {
|
||||
expect(
|
||||
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
|
||||
).toBe("https://rpc.example.invalid");
|
||||
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
|
||||
"wss://rpc.example.invalid:8546",
|
||||
);
|
||||
});
|
||||
@@ -140,8 +140,14 @@ function load() {
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.activeAddress = A0;
|
||||
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
|
||||
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
|
||||
state.allowedSites = {
|
||||
[A0]: ["https://a.example"],
|
||||
[B0]: ["https://b.example"],
|
||||
};
|
||||
state.deniedSites = {
|
||||
[B0]: ["https://c.example"],
|
||||
[C0]: ["https://d.example"],
|
||||
};
|
||||
state.viewStack = ["main", "settings"];
|
||||
state.currentView = "settings";
|
||||
|
||||
@@ -172,6 +178,15 @@ async function openLostPassword(deleteWallet, walletIdx) {
|
||||
await click("btn-delete-wallet-lost-password");
|
||||
}
|
||||
|
||||
// Delete a wallet through the password route: open its confirm screen,
|
||||
// enter the password, and confirm. The vault is mocked, so the password
|
||||
// text itself is irrelevant — decryptWithPassword decides pass or fail.
|
||||
async function deleteWithPassword(deleteWallet, walletIdx) {
|
||||
deleteWallet.show(walletIdx);
|
||||
node("delete-wallet-password").value = "any password";
|
||||
await click("btn-delete-wallet-confirm");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ tests
|
||||
|
||||
// The stub is what every persistence assertion below rests on, so its one
|
||||
@@ -379,8 +394,8 @@ describe("deleting without the password", () => {
|
||||
await click("btn-delete-wallet-lost-confirm");
|
||||
|
||||
const saved = (await storage.get("autistmask")).autistmask;
|
||||
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
|
||||
expect(saved.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
||||
expect(saved.deniedSites).toEqual({ [C0]: ["https://d.example"] });
|
||||
});
|
||||
|
||||
// The route shares finishDelete() with the password route, so the
|
||||
@@ -398,7 +413,9 @@ describe("deleting without the password", () => {
|
||||
expect(saved.activeAddress).toBe(A0);
|
||||
expect(saved.selectedWallet).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
|
||||
// where it renders.
|
||||
expect(mockSettingsShow).toHaveBeenCalled();
|
||||
@@ -417,13 +434,16 @@ describe("deleting without the password", () => {
|
||||
"Wallet 3",
|
||||
]);
|
||||
expect(saved.activeAddress).toBe(B0);
|
||||
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
|
||||
expect(sent).toEqual([
|
||||
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [A0, A1] },
|
||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
||||
const { deleteWallet, state, storage } = load();
|
||||
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
||||
state.allowedSites = { [A0]: ["a.example"] };
|
||||
state.allowedSites = { [A0]: ["https://a.example"] };
|
||||
state.deniedSites = {};
|
||||
|
||||
await openLostPassword(deleteWallet, 0);
|
||||
@@ -456,15 +476,21 @@ describe("what the screen leaves behind", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Left mid-delete, the screen has to come back usable.
|
||||
test("the confirm button is re-enabled on the way out", async () => {
|
||||
const { helpers, deleteWallet } = load();
|
||||
// Both routes now re-enable through finishDelete(), not their leave
|
||||
// hooks, so the button comes back live once a delete completes.
|
||||
test("the confirm button is re-enabled after a delete", async () => {
|
||||
const { deleteWallet } = load();
|
||||
await openLostPassword(deleteWallet, 1);
|
||||
|
||||
node("btn-delete-wallet-lost-confirm").disabled = true;
|
||||
helpers.showView("settings");
|
||||
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
||||
await click("btn-delete-wallet-lost-confirm");
|
||||
|
||||
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
|
||||
expect(
|
||||
node("btn-delete-wallet-lost-confirm").classList.contains(
|
||||
"text-muted",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// A wallet name is not a secret, so the screen is excluded for the
|
||||
@@ -475,3 +501,48 @@ describe("what the screen leaves behind", () => {
|
||||
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The password route is the pre-existing bug this file's fix addresses:
|
||||
// its Confirm Delete button was disabled before the decrypt and never
|
||||
// re-enabled on success, so a second delete in the same popup session
|
||||
// found a dead button. Now both routes re-enable through finishDelete().
|
||||
//
|
||||
// Against head these tests fail: with the re-enable absent, the button
|
||||
// stays disabled after the first delete, so the disabled assertions read
|
||||
// true where they expect false.
|
||||
describe("the password route's confirm button", () => {
|
||||
test("is re-enabled after a successful delete", async () => {
|
||||
const { deleteWallet, vault } = load();
|
||||
vault.decryptWithPassword.mockResolvedValue();
|
||||
|
||||
await deleteWithPassword(deleteWallet, 1);
|
||||
|
||||
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
|
||||
expect(
|
||||
node("btn-delete-wallet-confirm").classList.contains("text-muted"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// The reported symptom: delete one wallet, then open Delete Wallet for
|
||||
// a second one without reopening the popup. The button must be live on
|
||||
// that second visit, and the second delete must actually persist.
|
||||
test("a second delete works in the same popup session", async () => {
|
||||
const { deleteWallet, vault, storage } = load();
|
||||
vault.decryptWithPassword.mockResolvedValue();
|
||||
|
||||
await deleteWithPassword(deleteWallet, 1);
|
||||
|
||||
// Wallet 2 is gone; the list is now [Wallet 1, Wallet 3]. Opening
|
||||
// the confirm screen for the wallet now at index 1 (Wallet 3) must
|
||||
// find its button live, not the dead one the first delete left.
|
||||
deleteWallet.show(1);
|
||||
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
|
||||
|
||||
node("delete-wallet-password").value = "any password";
|
||||
await click("btn-delete-wallet-confirm");
|
||||
|
||||
expect((await persistedWallets(storage)).map((w) => w.name)).toEqual([
|
||||
"Wallet 1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,12 +99,13 @@ describe("the flash line the message is shown in", () => {
|
||||
// length, including one that wrapped to two lines and pushed the
|
||||
// settings view down 12px.
|
||||
//
|
||||
// The assertion that actually measures — empty line vs. the message,
|
||||
// real Chromium, documented 360x600 popup — is
|
||||
// "a rejected dust threshold shifts no layout (#233)" in
|
||||
// tests/e2e/run.js, run by make test-e2e. It is not in make check
|
||||
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
|
||||
// suite does not fit; run it before changing the wording.
|
||||
// The line cuts a message too long for it with an ellipsis (see
|
||||
// showFlash() in src/popup/views/helpers.js). The assertions that
|
||||
// measure that, in a real browser at the documented 360x600 popup, are
|
||||
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
||||
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
||||
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
||||
// caps make test at 20 seconds and a browser suite does not fit.
|
||||
test("reserves its height in the markup", () => {
|
||||
const flashLine = POPUP_HTML.match(
|
||||
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
||||
|
||||
+14
-15
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||
STEP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// EIP-6963, asked of the provider itself. The announcement carries the
|
||||
// uuid src/content/index.js reads out of extension storage — call site 1
|
||||
// in the issue — and it has to name this extension and hand back the very
|
||||
// object on window.ethereum.
|
||||
// EIP-6963, asked of the provider itself. The announcement carries a
|
||||
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
|
||||
// it — see issue #398) and has to name this extension and hand back the
|
||||
// very object on window.ethereum.
|
||||
const announced = await d.executeAsync(
|
||||
`const done = arguments[arguments.length - 1];
|
||||
const onAnnounce = (e) => {
|
||||
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||
);
|
||||
assert(
|
||||
typeof announced.uuid === "string" && announced.uuid.length === 36,
|
||||
"the announcement carries no stored provider uuid: " +
|
||||
"the announcement carries no provider uuid: " +
|
||||
JSON.stringify(announced.uuid),
|
||||
);
|
||||
|
||||
@@ -438,11 +438,10 @@ step(
|
||||
await d.switchToWindow(popup);
|
||||
await d.waitVisible("#view-approve-site");
|
||||
|
||||
const hostname = await d.text("#approve-hostname");
|
||||
const origin = await d.text("#approve-origin");
|
||||
assert(
|
||||
hostname === "127.0.0.1",
|
||||
"the site prompt names the wrong origin: " +
|
||||
JSON.stringify(hostname),
|
||||
origin === env.server.origin,
|
||||
"the site prompt names the wrong origin: " + JSON.stringify(origin),
|
||||
);
|
||||
const shown = await d.text("#approve-address");
|
||||
assert(
|
||||
@@ -494,16 +493,16 @@ step(
|
||||
|
||||
const screen = await d.execute(
|
||||
`return {
|
||||
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||
origin: document.getElementById("approve-sign-origin").textContent,
|
||||
type: document.getElementById("approve-sign-type").textContent,
|
||||
message: document.getElementById("approve-sign-message").textContent,
|
||||
from: document.getElementById("approve-sign-from").textContent,
|
||||
};`,
|
||||
);
|
||||
assert(
|
||||
screen.hostname === "127.0.0.1",
|
||||
screen.origin === env.server.origin,
|
||||
"the sign prompt names the wrong origin: " +
|
||||
JSON.stringify(screen.hostname),
|
||||
JSON.stringify(screen.origin),
|
||||
);
|
||||
assert(
|
||||
screen.type === "Personal message",
|
||||
@@ -571,7 +570,7 @@ step(
|
||||
|
||||
const screen = await d.execute(
|
||||
`return {
|
||||
hostname: document.getElementById("approve-tx-hostname").textContent,
|
||||
origin: document.getElementById("approve-tx-origin").textContent,
|
||||
from: document.getElementById("approve-tx-from").textContent,
|
||||
to: document.getElementById("approve-tx-to").textContent,
|
||||
value: document.getElementById("approve-tx-value").textContent,
|
||||
@@ -582,9 +581,9 @@ step(
|
||||
};`,
|
||||
);
|
||||
assert(
|
||||
screen.hostname === "127.0.0.1",
|
||||
screen.origin === env.server.origin,
|
||||
"the transaction prompt names the wrong origin: " +
|
||||
JSON.stringify(screen.hostname),
|
||||
JSON.stringify(screen.origin),
|
||||
);
|
||||
assert(
|
||||
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
||||
|
||||
+430
-49
@@ -35,6 +35,7 @@ const {
|
||||
const {
|
||||
DAPP_ORIGIN,
|
||||
DAPP_URL,
|
||||
PHISHING_DAPP_ORIGIN,
|
||||
PHISHING_DAPP_URL,
|
||||
FEE_ESTIMATE_WEI,
|
||||
FEE_RESERVE_WEI,
|
||||
@@ -1320,17 +1321,13 @@ async function waitForFilledFlashLine(page) {
|
||||
}
|
||||
|
||||
// README, No Layout Shift: the rejection message goes into #flash-msg,
|
||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
|
||||
// the space is not enough on its own — a message too long for one line
|
||||
// wraps and pushes everything below it down anyway, which is what the
|
||||
// first version of this change shipped: 75 characters, 32px, the settings
|
||||
// view and the threshold field 12px lower than with an empty line.
|
||||
//
|
||||
// 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.
|
||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
|
||||
// cuts a message too long for that line with an ellipsis rather than wrap
|
||||
// it. This shows the real message and measures that nothing moves; the
|
||||
// test after it does the same with a message several lines long. Both
|
||||
// measure rather than inspect markup: 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).
|
||||
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
const page = await openPopup(env.ctx, env.popupUrl);
|
||||
try {
|
||||
@@ -1377,11 +1374,11 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
);
|
||||
assert(
|
||||
after.flashHeight === before.flashHeight,
|
||||
"the message does not fit the reserved line: " +
|
||||
"the message does not keep to the reserved line: " +
|
||||
before.flashHeight +
|
||||
"px empty vs " +
|
||||
after.flashHeight +
|
||||
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
|
||||
"px with the message",
|
||||
);
|
||||
assert(
|
||||
after.settingsTop === before.settingsTop,
|
||||
@@ -1400,6 +1397,134 @@ 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)
|
||||
//
|
||||
// The screen that decides what gets signed. The arithmetic underneath it
|
||||
@@ -1414,9 +1539,10 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
// on opposite sides of the reserve while sitting on the same side of the
|
||||
// estimate.
|
||||
|
||||
// The balance the funded fixture serves, and the amounts sent against it.
|
||||
// The balance the funded fixture serves, as the Send and confirmation screens
|
||||
// show it, and the amounts sent against it.
|
||||
const FUNDED_ETH_WEI = 10n ** 18n;
|
||||
const FUNDED_ETH_TEXT = "1.0";
|
||||
const FUNDED_ETH_TEXT = "1.0000";
|
||||
const COMFORTABLE_AMOUNT = "0.1";
|
||||
const OVER_BALANCE_AMOUNT = "2.0";
|
||||
|
||||
@@ -1430,7 +1556,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
|
||||
// reserve, so the same swap flips this assertion too — through a different
|
||||
// balance and a different message than the ETH path uses.
|
||||
const TOKEN_BALANCE_TEXT = "1.5";
|
||||
const TOKEN_BALANCE_TEXT = "1.5000";
|
||||
const TOKEN_AMOUNT = "0.25";
|
||||
const OVER_TOKEN_AMOUNT = "9.0";
|
||||
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
|
||||
@@ -1439,16 +1565,15 @@ function toHexWei(wei) {
|
||||
return "0x" + wei.toString(16);
|
||||
}
|
||||
|
||||
// A fee in wei as the confirmation screen writes it. Deliberately a second
|
||||
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
|
||||
// than an import of it: that module pulls in the whole popup and cannot be
|
||||
// required outside a browser, and asserting against an independent rendering
|
||||
// is stronger than asserting a function equals itself.
|
||||
// A fee in wei as the confirmation screen writes it: truncated to four decimal
|
||||
// places (README.md, Display Consistency). Deliberately a second
|
||||
// implementation rather than an import of src/shared/amountDisplay.js:
|
||||
// asserting against an independent rendering is stronger than asserting a
|
||||
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
|
||||
// nonzero floor never applies here.
|
||||
function feeEth(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";
|
||||
const [whole, frac = ""] = formatEther(wei).split(".");
|
||||
return whole + "." + (frac + "0000").slice(0, 4) + " ETH";
|
||||
}
|
||||
|
||||
// What the confirmation screen is showing right now, read out of the DOM in
|
||||
@@ -1505,11 +1630,10 @@ async function backToAddress(page) {
|
||||
|
||||
// Drive the popup to the confirmation screen for one send.
|
||||
//
|
||||
// It waits for the send screen to be showing `balance` before filling
|
||||
// anything in. That figure is the exact number the spend gate compares
|
||||
// against, so waiting for it — rather than for a refresh to have probably
|
||||
// landed — is what keeps every assertion below deterministic after a
|
||||
// fixture change.
|
||||
// It waits for the send screen to be showing `balance`, the fixture's balance
|
||||
// as that screen displays it, before filling anything in. Waiting for it —
|
||||
// rather than for a refresh to have probably landed — is what keeps every
|
||||
// assertion below deterministic after a fixture change.
|
||||
async function goToConfirm(page, { token, balance, amount }) {
|
||||
await backToAddress(page);
|
||||
await page.click("#btn-send");
|
||||
@@ -1525,6 +1649,7 @@ async function goToConfirm(page, { token, balance, amount }) {
|
||||
await page.fill("#send-amount", amount);
|
||||
await page.click("#btn-send-review");
|
||||
await visible(page, "#view-confirm-tx");
|
||||
await assertAddressesFit(page, "the confirmation screen");
|
||||
}
|
||||
|
||||
// A balance as the main view renders it: balanceLinesForAddress() writes
|
||||
@@ -2347,8 +2472,6 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
|
||||
// dApp, with real funds, against a real network. The RPC is stubbed
|
||||
// throughout. That pass stays on the human list before 1.0.0.
|
||||
|
||||
const DAPP_HOSTNAME = new URL(DAPP_URL).hostname;
|
||||
|
||||
// The personal_sign payload. Sent as hex, which is what dApps send and what
|
||||
// the popup requires — it calls getBytes() on the message — and displayed on
|
||||
// the approval screen as the decoded text, which is what the user is agreeing
|
||||
@@ -2892,11 +3015,10 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
|
||||
try {
|
||||
await visible(popup, "#view-approve-site");
|
||||
|
||||
const hostname = await popup.locator("#approve-hostname").innerText();
|
||||
const origin = await popup.locator("#approve-origin").innerText();
|
||||
assert(
|
||||
hostname === DAPP_HOSTNAME,
|
||||
"the site prompt names the wrong origin: " +
|
||||
JSON.stringify(hostname),
|
||||
origin === DAPP_ORIGIN,
|
||||
"the site prompt names the wrong origin: " + JSON.stringify(origin),
|
||||
);
|
||||
|
||||
// The control for the phishing test below: this origin is not on the
|
||||
@@ -2977,7 +3099,6 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
|
||||
// check and the real approval screen. Nothing about the list is stubbed —
|
||||
// there is nothing left to stub, since the extension no longer fetches it.
|
||||
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
|
||||
const hostname = new URL(PHISHING_DAPP_URL).hostname;
|
||||
try {
|
||||
await reserveApprovalTab(env);
|
||||
await startRequest(
|
||||
@@ -2990,15 +3111,15 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
|
||||
try {
|
||||
await visible(popup, "#view-approve-site");
|
||||
|
||||
const shown = await popup.locator("#approve-hostname").innerText();
|
||||
const shown = await popup.locator("#approve-origin").innerText();
|
||||
assert(
|
||||
shown === hostname,
|
||||
shown === PHISHING_DAPP_ORIGIN,
|
||||
"the site prompt names the wrong origin: " +
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
|
||||
await visible(popup, "#approve-site-phishing-warning");
|
||||
console.log("# phishing warning shown for " + hostname);
|
||||
console.log("# phishing warning shown for " + PHISHING_DAPP_ORIGIN);
|
||||
|
||||
// Not remembered: a remembered decision for this origin would
|
||||
// outlive the test.
|
||||
@@ -3028,15 +3149,15 @@ test("personal_sign signs, and the signature recovers to the address (#183)", as
|
||||
const boundary = await watchApprovalBoundary(popup, env);
|
||||
|
||||
const screen = await popup.evaluate(() => ({
|
||||
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||
origin: document.getElementById("approve-sign-origin").textContent,
|
||||
type: document.getElementById("approve-sign-type").textContent,
|
||||
message: document.getElementById("approve-sign-message").textContent,
|
||||
from: document.getElementById("approve-sign-from").textContent,
|
||||
}));
|
||||
assert(
|
||||
screen.hostname === DAPP_HOSTNAME,
|
||||
screen.origin === DAPP_ORIGIN,
|
||||
"the sign prompt names the wrong origin: " +
|
||||
JSON.stringify(screen.hostname),
|
||||
JSON.stringify(screen.origin),
|
||||
);
|
||||
assert(
|
||||
screen.type === "Personal message",
|
||||
@@ -3111,6 +3232,64 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
||||
);
|
||||
});
|
||||
|
||||
// A right-to-left character must not move the characters around it: U+05C3
|
||||
// between "5" and "00" would otherwise put "500" on screen before it. A
|
||||
// paragraph separator (U+2029) before it, left in the text, would end the
|
||||
// byte-order layout and bring that back
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403).
|
||||
test("a personal message is laid out in the order of its bytes (#403)", async (env) => {
|
||||
const rightToLeft = String.fromCodePoint(0x05c3);
|
||||
const text =
|
||||
"Sign in" +
|
||||
String.fromCodePoint(0x2029) +
|
||||
"Pay 5" +
|
||||
rightToLeft +
|
||||
"00 ETH";
|
||||
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
|
||||
hexlify(toUtf8Bytes(text)),
|
||||
env.expectedAddress,
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-sign");
|
||||
|
||||
// The text on screen, marks included, and the left edge of each of its
|
||||
// characters, in byte order. A character the browser's fonts draw with
|
||||
// no width shares its neighbour's edge.
|
||||
const shown = await popup.evaluate(() => {
|
||||
const message = document.getElementById("approve-sign-message");
|
||||
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
|
||||
const range = document.createRange();
|
||||
let text = "";
|
||||
const lefts = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
for (let i = 0; i < node.length; i++) {
|
||||
range.setStart(node, i);
|
||||
range.setEnd(node, i + 1);
|
||||
lefts.push(range.getBoundingClientRect().left);
|
||||
}
|
||||
text += node.data;
|
||||
}
|
||||
return { text, lefts };
|
||||
});
|
||||
await clickAndClose(popup, "#btn-reject-sign");
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
"sign-bidi",
|
||||
"the byte-order personal_sign rejection",
|
||||
);
|
||||
|
||||
assert(
|
||||
shown.text === "Sign inU+2029Pay 5" + rightToLeft + "00 ETH",
|
||||
"the paragraph separator is not shown as a mark: " +
|
||||
JSON.stringify(shown.text),
|
||||
);
|
||||
assert(
|
||||
shown.lefts.every((left, i) => i === 0 || left >= shown.lefts[i - 1]),
|
||||
"the personal message is not laid out in byte order: " +
|
||||
JSON.stringify(shown.lefts),
|
||||
);
|
||||
});
|
||||
|
||||
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
||||
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
||||
env.expectedAddress,
|
||||
@@ -3121,15 +3300,15 @@ test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env
|
||||
const boundary = await watchApprovalBoundary(popup, env);
|
||||
|
||||
const screen = await popup.evaluate(() => ({
|
||||
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||
origin: document.getElementById("approve-sign-origin").textContent,
|
||||
type: document.getElementById("approve-sign-type").textContent,
|
||||
message: document.getElementById("approve-sign-message").innerText,
|
||||
from: document.getElementById("approve-sign-from").textContent,
|
||||
}));
|
||||
assert(
|
||||
screen.hostname === DAPP_HOSTNAME,
|
||||
screen.origin === DAPP_ORIGIN,
|
||||
"the typed data prompt names the wrong origin: " +
|
||||
JSON.stringify(screen.hostname),
|
||||
JSON.stringify(screen.origin),
|
||||
);
|
||||
assert(
|
||||
screen.type === "Typed data (EIP-712)",
|
||||
@@ -3227,7 +3406,7 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
|
||||
const boundary = await watchApprovalBoundary(popup, env);
|
||||
|
||||
const screen = await popup.evaluate(() => ({
|
||||
hostname: document.getElementById("approve-tx-hostname").textContent,
|
||||
origin: document.getElementById("approve-tx-origin").textContent,
|
||||
from: document.getElementById("approve-tx-from").textContent,
|
||||
to: document.getElementById("approve-tx-to").textContent,
|
||||
value: document.getElementById("approve-tx-value").textContent,
|
||||
@@ -3237,9 +3416,9 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
|
||||
.classList.contains("hidden"),
|
||||
}));
|
||||
assert(
|
||||
screen.hostname === DAPP_HOSTNAME,
|
||||
screen.origin === DAPP_ORIGIN,
|
||||
"the transaction prompt names the wrong origin: " +
|
||||
JSON.stringify(screen.hostname),
|
||||
JSON.stringify(screen.origin),
|
||||
);
|
||||
assert(
|
||||
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
|
||||
@@ -3262,6 +3441,8 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
|
||||
JSON.stringify(screen.data),
|
||||
);
|
||||
|
||||
await assertAddressesFit(popup, "the dApp transaction prompt");
|
||||
|
||||
const broadcastBefore = env.routeOpts.broadcastTransactions.length;
|
||||
await popup.fill("#approve-tx-password", PASSWORD);
|
||||
await popup.click("#btn-approve-tx");
|
||||
@@ -3425,6 +3606,206 @@ test("the password never crossed either boundary in this section (#183)", async
|
||||
await env.dapp.close();
|
||||
});
|
||||
|
||||
// ------------------------------------------- address layout (#380)
|
||||
//
|
||||
// "addresses should never wrap in the common views. this doesn't mean to
|
||||
// just change the css, but update the layout itself so the untruncated
|
||||
// addresses are shown in full and don't mess up the layout."
|
||||
//
|
||||
// Every one of these questions is about glyph advances and the width of
|
||||
// the box an address landed in, and nothing in the markup answers any of
|
||||
// them: a row can hold `white-space: nowrap` and still be too narrow, and
|
||||
// the popup's own `overflow-x-hidden` would then hide the evidence by
|
||||
// clipping the tail. So they are measured in a real Chromium, on the real
|
||||
// rendered views, one assertion per property #380 names:
|
||||
//
|
||||
// - the whole address is there (42 characters, no ellipsis)
|
||||
// - it occupies exactly one line box
|
||||
// - it fits its row, so the overflow-x escape hatch never engages
|
||||
// - its row ends inside the popup's content box
|
||||
// - and the document itself does not scroll sideways
|
||||
//
|
||||
// The narrowest containers the popup has are covered here — the
|
||||
// transaction detail wells (`bg-well p-3 mx-1`) and the token contract
|
||||
// well — so the wider ones cannot fail while these pass.
|
||||
|
||||
// Everything on screen that carries an address, measured in one pass.
|
||||
// Views other than the current one are display:none and measure zero, so
|
||||
// filtering on width leaves exactly what a user can see right now.
|
||||
function addressRowReport(page) {
|
||||
return page.evaluate(() => {
|
||||
const app = document.getElementById("app");
|
||||
const appRight = app.getBoundingClientRect().right;
|
||||
const rows = [];
|
||||
for (const el of document.querySelectorAll(".am-address")) {
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width === 0) continue;
|
||||
// Line boxes are counted off the inline content, because the
|
||||
// element's own rect is one box whether the text inside it
|
||||
// wrapped or not. A Range yields a rect per contained node as
|
||||
// well as per line, so it is the distinct tops that count:
|
||||
// a copyable span and the text inside it share one.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const tops = new Set(
|
||||
Array.from(range.getClientRects()).map((r) =>
|
||||
Math.round(r.top),
|
||||
),
|
||||
);
|
||||
rows.push({
|
||||
text: el.innerText.trim(),
|
||||
lineBoxes: tops.size,
|
||||
overflow: el.scrollWidth - el.clientWidth,
|
||||
overhang: Math.round(box.right - appRight),
|
||||
});
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
pageOverflow:
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAddressesFit(page, where) {
|
||||
const report = await addressRowReport(page);
|
||||
assert(
|
||||
report.rows.length > 0,
|
||||
where + ": no address rows were rendered, so nothing was measured",
|
||||
);
|
||||
for (const row of report.rows) {
|
||||
assert(
|
||||
/^0x[0-9a-fA-F]{40}$/.test(row.text),
|
||||
where +
|
||||
": the address is not shown whole: " +
|
||||
JSON.stringify(row.text),
|
||||
);
|
||||
assert(
|
||||
row.lineBoxes === 1,
|
||||
where +
|
||||
": " +
|
||||
row.text +
|
||||
" wrapped onto " +
|
||||
row.lineBoxes +
|
||||
" lines",
|
||||
);
|
||||
assert(
|
||||
row.overflow <= 1,
|
||||
where +
|
||||
": " +
|
||||
row.text +
|
||||
" is " +
|
||||
row.overflow +
|
||||
"px wider than the row holding it",
|
||||
);
|
||||
assert(
|
||||
row.overhang <= 1,
|
||||
where +
|
||||
": " +
|
||||
row.text +
|
||||
" reaches " +
|
||||
row.overhang +
|
||||
"px past the popup's content box",
|
||||
);
|
||||
}
|
||||
assert(
|
||||
report.pageOverflow <= 0,
|
||||
where + ": the popup scrolls sideways by " + report.pageOverflow + "px",
|
||||
);
|
||||
return report.rows.length;
|
||||
}
|
||||
|
||||
// Back to Home from wherever the suite above finished, without assuming
|
||||
// which screen that was. Every screen the popup can rest on has a Back
|
||||
// button, and Home has none, so unwinding until Home shows is the one
|
||||
// route that does not depend on the order of the tests before this point.
|
||||
async function unwindToHome(page) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (await page.isVisible("#view-main")) return;
|
||||
const back = page
|
||||
.locator(".view:not(.hidden) button", { hasText: "Back" })
|
||||
.first();
|
||||
if ((await back.count()) === 0) break;
|
||||
await back.click();
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await visible(page, "#view-main");
|
||||
}
|
||||
|
||||
// The reproduction from the issue: a wallet holding more than one address.
|
||||
// Every address in the list is a full 42 characters competing with the
|
||||
// [info] and [x] controls for one row's width, which is the state the
|
||||
// wallet view was reported wrapping in.
|
||||
test("a wallet with two addresses lists both in full, unwrapped (#380)", async (env) => {
|
||||
await unwindToHome(env.page);
|
||||
|
||||
const before = await env.page
|
||||
.locator("#wallet-list .btn-addr-info")
|
||||
.count();
|
||||
await env.page.locator("#wallet-list .btn-add-address").first().click();
|
||||
await env.page.waitForFunction(
|
||||
(n) =>
|
||||
document.querySelectorAll("#wallet-list .btn-addr-info").length > n,
|
||||
before,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
const shown = await assertAddressesFit(env.page, "the wallet list");
|
||||
assert(
|
||||
shown >= before + 1,
|
||||
"the wallet list measured " +
|
||||
shown +
|
||||
" addresses, fewer than the " +
|
||||
(before + 1) +
|
||||
" it now holds",
|
||||
);
|
||||
|
||||
// The [x] control only exists on a wallet holding more than one
|
||||
// address, so its presence is also the proof the second one landed.
|
||||
const removable = await env.page
|
||||
.locator("#wallet-list .btn-remove-address")
|
||||
.count();
|
||||
assert(removable > 0, "the second address did not reach the wallet list");
|
||||
});
|
||||
|
||||
test("every common view shows its addresses in full on one line (#380)", async (env) => {
|
||||
await unwindToHome(env.page);
|
||||
await assertAddressesFit(env.page, "Home");
|
||||
|
||||
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
||||
await visible(env.page, "#view-address");
|
||||
await visible(env.page, "#tx-list .tx-row");
|
||||
await assertAddressesFit(env.page, "the address screen");
|
||||
|
||||
await env.page.click("#btn-receive");
|
||||
await visible(env.page, "#view-receive");
|
||||
await assertAddressesFit(env.page, "the receive screen");
|
||||
await env.page.click("#btn-receive-back");
|
||||
await visible(env.page, "#view-address");
|
||||
|
||||
await env.page.click("#btn-send");
|
||||
await visible(env.page, "#view-send");
|
||||
await assertAddressesFit(env.page, "the send screen");
|
||||
await env.page.click("#btn-send-back");
|
||||
await visible(env.page, "#view-address");
|
||||
|
||||
// The transaction detail screen carries the narrowest address rows in
|
||||
// the popup: its fields sit inside a well that takes another 24px of
|
||||
// padding and 8px of margin off the content width, and the token
|
||||
// contract row there is narrower still.
|
||||
await env.page.locator("#address-balances .balance-row").first().click();
|
||||
await visible(env.page, "#view-address-token");
|
||||
await assertAddressesFit(env.page, "the token screen");
|
||||
await env.page.click("#btn-address-token-back");
|
||||
await visible(env.page, "#view-address");
|
||||
|
||||
await env.page.locator("#tx-list .tx-row").first().click();
|
||||
await visible(env.page, "#view-transaction");
|
||||
await visible(env.page, "#tx-detail-token-contract-section");
|
||||
await assertAddressesFit(env.page, "the transaction detail screen");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- runner
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -89,19 +89,28 @@ describe("chrome extension identity", () => {
|
||||
|
||||
// The private half is a credential. It has never been in this repo and no
|
||||
// target generates one into the working tree; this fails loudly if that
|
||||
// ever changes, because a committed .pem is a key anyone can sign a CRX
|
||||
// with under this extension's id.
|
||||
// ever changes, because a committed private key is one anyone can sign a
|
||||
// CRX with under this extension's id.
|
||||
//
|
||||
// Matched by CONTENT, not by filename: a key committed as notes.txt or with
|
||||
// no extension carries the same risk as one named key.pem, and a
|
||||
// filename-only check waves it through. The PEM header a private key opens
|
||||
// with is the signature searched for. The pattern does not trip on its own
|
||||
// source: the bracket-expression characters between the two anchors are not
|
||||
// in the character class, so this file is not a match for it.
|
||||
const PRIVATE_KEY_HEADER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
||||
test("no private key is committed anywhere in the tree", () => {
|
||||
const root = path.join(__dirname, "..");
|
||||
const tracked = require("child_process")
|
||||
.execSync("git ls-files", {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
})
|
||||
.execSync("git ls-files", { cwd: root, encoding: "utf8" })
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
expect(tracked.filter((f) => /\.(pem|key|p12|pfx)$/i.test(f))).toEqual(
|
||||
[],
|
||||
const offenders = tracked.filter((f) =>
|
||||
PRIVATE_KEY_HEADER.test(
|
||||
fs.readFileSync(path.join(root, f), "latin1"),
|
||||
),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -253,8 +253,9 @@ describe("the ERC-20 approval line reaches its refusal", () => {
|
||||
|
||||
test("a scale the explorer did report still formats", async () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
// The same explorer entry now also names the token (issue #323).
|
||||
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
|
||||
"1000.0000",
|
||||
"1000.0000 NOVEL",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -273,7 +274,7 @@ describe("the swap approval line reaches its refusal", () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
expect(
|
||||
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
|
||||
).toBe("1000.0000");
|
||||
).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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,11 +49,12 @@ class StubCustomEvent extends StubEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// Every code the background emits on the RPC path today, read out of
|
||||
// src/background/index.js. The provider must not know this list — it passes
|
||||
// through whatever arrived — but the cases below are the real ones.
|
||||
// Examples of codes the background emits on the RPC path, read out of
|
||||
// src/background/index.js. The provider must not know any list of codes — it
|
||||
// passes through whatever arrived — but the cases below are real ones.
|
||||
const REJECTED = 4001; // user rejected the request
|
||||
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
|
||||
|
||||
// A stub window with the four things inpage.js touches: message listeners,
|
||||
@@ -115,6 +116,41 @@ async function rejectionFrom(start, response) {
|
||||
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", () => {
|
||||
test("a user rejection arrives as code 4001", async () => {
|
||||
const err = await rejectionFrom(
|
||||
@@ -164,8 +200,9 @@ describe("an EIP-1193 code reaches the page", () => {
|
||||
expect(err.message).toBe(message);
|
||||
});
|
||||
|
||||
// The provider is not allowed to know the list above: a code added to the
|
||||
// background later must reach the page without this file being edited.
|
||||
// The provider is not allowed to know the codes above: any other code,
|
||||
// including one added to the background later, must reach the page
|
||||
// without inpage.js being edited.
|
||||
test("a code the provider has never heard of is passed through", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_accounts" }),
|
||||
@@ -203,6 +240,26 @@ 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", () => {
|
||||
test("a coded error keeps the message byte for byte", async () => {
|
||||
const message =
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// The EIP-6963 provider UUID inpage.js announces (src/content/inpage.js).
|
||||
//
|
||||
// The bug this pins down (issue #398): the UUID used to be generated once,
|
||||
// persisted in extension storage, and announced verbatim to every page on
|
||||
// every load and across browser restarts, so any site — connected or not —
|
||||
// could read a stable cross-site, cross-session identifier for the install.
|
||||
// EIP-6963 asks for one UUIDv4 per page load, shared by every announcement in
|
||||
// that load. The fix generates it per page load and stores nothing.
|
||||
//
|
||||
// The stored UUID reached inpage.js as an AUTISTMASK_PROVIDER_UUID page
|
||||
// message from the content script, and inpage.js then announced it. Each load
|
||||
// below is posted the same stored UUID that way, so on the old code both loads
|
||||
// announce it and the last two tests fail.
|
||||
//
|
||||
// inpage.js is a bare IIFE injected into the page's JS context, not a module;
|
||||
// see tests/inpageErrors.test.js for why it is evaluated against a stub window
|
||||
// rather than imported. Here the stub captures the CustomEvent that carries
|
||||
// the announcement, so the UUID this file reads is the one a real dApp's
|
||||
// eip6963:announceProvider listener would see.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { webcrypto } = require("crypto");
|
||||
|
||||
const SOURCE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "content", "inpage.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const loadInto = new Function(
|
||||
"window",
|
||||
"self",
|
||||
"crypto",
|
||||
"Event",
|
||||
"CustomEvent",
|
||||
SOURCE,
|
||||
);
|
||||
|
||||
class StubEvent {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
class StubCustomEvent extends StubEvent {
|
||||
constructor(type, init) {
|
||||
super(type);
|
||||
this.detail = init && init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
// What the old content script read out of extension storage and posted to
|
||||
// every page load.
|
||||
const STORED_UUID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
// Evaluate inpage.js once against a fresh stub window, post it STORED_UUID the
|
||||
// way the old content script did, then dispatch a `requestProvider` event so a
|
||||
// re-announcement is observed as well as the announcement at load. Returns
|
||||
// every UUID the load announced, in order.
|
||||
function announcedUuids() {
|
||||
const listeners = {};
|
||||
const uuids = [];
|
||||
|
||||
const win = {
|
||||
addEventListener(type, fn) {
|
||||
(listeners[type] || (listeners[type] = [])).push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
const fns = listeners[type];
|
||||
if (!fns) return;
|
||||
const i = fns.indexOf(fn);
|
||||
if (i !== -1) fns.splice(i, 1);
|
||||
},
|
||||
postMessage() {},
|
||||
dispatchEvent(event) {
|
||||
if (event.type === "eip6963:announceProvider") {
|
||||
uuids.push(event.detail.info.uuid);
|
||||
}
|
||||
for (const fn of (listeners[event.type] || []).slice()) fn(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
win.window = win;
|
||||
|
||||
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
|
||||
win.dispatchEvent({
|
||||
type: "message",
|
||||
source: win,
|
||||
data: { type: "AUTISTMASK_PROVIDER_UUID", uuid: STORED_UUID },
|
||||
});
|
||||
win.dispatchEvent(new StubEvent("eip6963:requestProvider"));
|
||||
return uuids;
|
||||
}
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("the EIP-6963 provider UUID is fresh per page load", () => {
|
||||
test("a load announces a UUIDv4", () => {
|
||||
const uuids = announcedUuids();
|
||||
expect(uuids.length).toBeGreaterThan(0);
|
||||
expect(uuids[0]).toMatch(UUID_V4);
|
||||
});
|
||||
|
||||
test("every announcement within one load carries the same UUID", () => {
|
||||
const uuids = announcedUuids();
|
||||
expect(uuids.length).toBeGreaterThan(1);
|
||||
expect(new Set(uuids).size).toBe(1);
|
||||
});
|
||||
|
||||
test("two page loads announce different UUIDs, neither the stored one", () => {
|
||||
const first = announcedUuids();
|
||||
const second = announcedUuids();
|
||||
expect(first).not.toContain(STORED_UUID);
|
||||
expect(second).not.toContain(STORED_UUID);
|
||||
expect(second[0]).not.toBe(first[0]);
|
||||
});
|
||||
});
|
||||
@@ -59,24 +59,32 @@ describe("the floor under allowedSites and deniedSites", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
|
||||
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
|
||||
test(`an ${field} entry whose value is not an origin list is dropped`, () => {
|
||||
for (const bad of [
|
||||
"https://dapp.example",
|
||||
42,
|
||||
null,
|
||||
{ a: 1 },
|
||||
true,
|
||||
]) {
|
||||
expect(
|
||||
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
||||
).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
|
||||
test(`an origin that is not text is dropped from an ${field} entry`, () => {
|
||||
expect(
|
||||
normalizePersisted({
|
||||
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
|
||||
[field]: {
|
||||
[ADDRESS]: [42, null, "https://dapp.example", {}],
|
||||
},
|
||||
})[field],
|
||||
).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
||||
});
|
||||
|
||||
test(`a real ${field} map survives, copied not shared`, () => {
|
||||
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
|
||||
const saved = { [field]: { [ADDRESS]: ["https://dapp.example"] } };
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
@@ -87,10 +95,13 @@ describe("the floor under allowedSites and deniedSites", () => {
|
||||
|
||||
test(`a good ${field} entry beside a malformed one survives`, () => {
|
||||
const out = normalizePersisted({
|
||||
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
|
||||
[field]: {
|
||||
[ADDRESS]: ["https://dapp.example"],
|
||||
[TOKEN_ADDRESS]: 42,
|
||||
},
|
||||
});
|
||||
|
||||
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
expect(out[field]).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
||||
});
|
||||
|
||||
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
||||
@@ -100,7 +111,7 @@ describe("the floor under allowedSites and deniedSites", () => {
|
||||
// saveState()'s merge hands to the prototype setter on the next
|
||||
// write.
|
||||
const saved = JSON.parse(
|
||||
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
||||
'{"' + field + '":{"__proto__":["https://evil.invalid"]}}',
|
||||
);
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
@@ -197,7 +208,7 @@ describe("a malformed allowedSites entry", () => {
|
||||
const MALFORMED = [
|
||||
{ name: "a string", value: "notalist" },
|
||||
{ name: "a number", value: 42 },
|
||||
{ name: "a record", value: { hostnames: ["dapp.example"] } },
|
||||
{ name: "a record", value: { origins: ["https://dapp.example"] } },
|
||||
];
|
||||
|
||||
for (const { name, value } of MALFORMED) {
|
||||
@@ -231,7 +242,7 @@ describe("a malformed allowedSites entry", () => {
|
||||
const env = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
allowedSites: {
|
||||
[ADDRESS]: ["dapp.example"],
|
||||
[ADDRESS]: ["https://dapp.example"],
|
||||
[TOKEN_ADDRESS]: "notalist",
|
||||
},
|
||||
}),
|
||||
@@ -239,7 +250,7 @@ describe("a malformed allowedSites entry", () => {
|
||||
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
||||
[ADDRESS]: ["dapp.example"],
|
||||
[ADDRESS]: ["https://dapp.example"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ const CONTRACT = [
|
||||
[ADDRESS],
|
||||
{ [ADDRESS]: 42 },
|
||||
{ [ADDRESS]: [42, null, {}] },
|
||||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
|
||||
],
|
||||
holds: siteMapHolds,
|
||||
},
|
||||
@@ -177,7 +177,7 @@ const CONTRACT = [
|
||||
[ADDRESS],
|
||||
{ [ADDRESS]: 42 },
|
||||
{ [ADDRESS]: [42, null, {}] },
|
||||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
|
||||
],
|
||||
holds: siteMapHolds,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// The signature prompt shows a personal message as the bytes that are signed
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
|
||||
// text it decodes to with control characters, line and paragraph separators
|
||||
// and characters that paint nothing marked rather than obeyed, markup shown as
|
||||
// text, laid out in byte order, and a message that is not hex as plain text
|
||||
// that cannot be signed.
|
||||
//
|
||||
// Driven against a minimal DOM stub in the shape
|
||||
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
|
||||
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { hexlify, toUtf8Bytes } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
|
||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
||||
|
||||
// Built from their code points so that this file holds none of them.
|
||||
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
|
||||
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
|
||||
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
||||
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
|
||||
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
|
||||
const HANGUL_FILLER = String.fromCodePoint(0x3164);
|
||||
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
|
||||
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
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: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
const els = new Map();
|
||||
return {
|
||||
getElementById(id) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Open the signature prompt for a personal_sign of `message`, the way the
|
||||
// popup does: it asks the background for the approval and show() draws it.
|
||||
async function openPersonalSign(message) {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" } };
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
if (!reply) return;
|
||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||
reply({
|
||||
type: "sign",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: FROM,
|
||||
signParams: { method: "personal_sign", message, from: FROM },
|
||||
});
|
||||
},
|
||||
};
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
}
|
||||
|
||||
// The message box's markup as the text a reader sees: tags dropped.
|
||||
function shownMessage() {
|
||||
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.wallets = [];
|
||||
state.activeAddress = FROM;
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
});
|
||||
|
||||
test("a right-to-left override is marked, so the text reads in byte order", async () => {
|
||||
// Obeyed, the override shows "0001" as "1000".
|
||||
const text =
|
||||
"Pay " +
|
||||
RIGHT_TO_LEFT_OVERRIDE +
|
||||
"0001" +
|
||||
POP_DIRECTIONAL_FORMATTING +
|
||||
" ETH";
|
||||
await openPersonalSign(hexlify(toUtf8Bytes(text)));
|
||||
const html = node("approve-sign-message").innerHTML;
|
||||
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
|
||||
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
|
||||
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
|
||||
});
|
||||
|
||||
test("a zero-width character is marked", async () => {
|
||||
await openPersonalSign(
|
||||
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
|
||||
);
|
||||
expect(node("approve-sign-message").innerHTML).not.toContain(
|
||||
ZERO_WIDTH_SPACE,
|
||||
);
|
||||
expect(shownMessage()).toBe("payU+200Bpal.com");
|
||||
});
|
||||
|
||||
test("variation selectors and a Hangul filler are marked", async () => {
|
||||
// Each paints nothing, so a page could hide bytes after "Sign in".
|
||||
await openPersonalSign(
|
||||
hexlify(
|
||||
toUtf8Bytes(
|
||||
"Sign in" +
|
||||
VARIATION_SELECTOR_1 +
|
||||
VARIATION_SELECTOR_17 +
|
||||
HANGUL_FILLER,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
|
||||
});
|
||||
|
||||
test("the message is laid out in byte order", async () => {
|
||||
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
|
||||
expect(
|
||||
node("approve-sign-message").classList.contains("am-byte-order"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a control character other than a line feed is marked", async () => {
|
||||
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
|
||||
expect(shownMessage()).toBe("aU+0000bU+0009c");
|
||||
});
|
||||
|
||||
test("line and paragraph separators are marked", async () => {
|
||||
// Left in the text, a paragraph separator would end the byte-order
|
||||
// layout for everything after it.
|
||||
await openPersonalSign(
|
||||
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
|
||||
);
|
||||
const html = node("approve-sign-message").innerHTML;
|
||||
expect(html).not.toContain(LINE_SEPARATOR);
|
||||
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
|
||||
expect(shownMessage()).toBe("aU+2028bU+2029");
|
||||
});
|
||||
|
||||
test("a line feed is shown as a line break", async () => {
|
||||
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
|
||||
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
|
||||
});
|
||||
|
||||
// The message box is written as HTML, so a site's markup has to arrive there
|
||||
// escaped, as the text it is.
|
||||
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
|
||||
|
||||
test.each([
|
||||
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
|
||||
["a message that is not hex", MARKUP],
|
||||
])("markup in %s is shown as text, not as markup", async (_, message) => {
|
||||
await openPersonalSign(message);
|
||||
expect(node("approve-sign-message").innerHTML).toBe(
|
||||
"<b>x</b><img src=x onerror=alert(1)>",
|
||||
);
|
||||
});
|
||||
|
||||
test("the raw hex is shown alongside the text", async () => {
|
||||
await openPersonalSign("0x48656c6c6f");
|
||||
expect(shownMessage()).toBe("Hello");
|
||||
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
|
||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
|
||||
await openPersonalSign("0X48656C6C6F");
|
||||
expect(shownMessage()).toBe("Hello");
|
||||
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("bytes that are not text are shown only as hex", async () => {
|
||||
await openPersonalSign("0xff00");
|
||||
expect(node("approve-sign-message").textContent).toBe(
|
||||
"This message is not text.",
|
||||
);
|
||||
expect(node("approve-sign-hex").textContent).toBe("0xff00");
|
||||
});
|
||||
|
||||
test("a message that is not hex is shown as text and cannot be signed", async () => {
|
||||
await openPersonalSign("Hello world");
|
||||
expect(shownMessage()).toBe("Hello world");
|
||||
expect(node("approve-sign-error").textContent).toBe(
|
||||
"This message is plain text, not hex, so it cannot be signed.",
|
||||
);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// The symbol the dApp approval and status screens label a token with.
|
||||
//
|
||||
// Issue #323: the approval screen labelled anything outside the bundled list
|
||||
// `Unknown token`, even a token the user tracks or holds a balance of, while
|
||||
// the amount line already read that token's *scale* from those same sources
|
||||
// (issue #306). The name and the scale disagreed about which sources they
|
||||
// trust. resolveTokenSymbol() closes that gap: it draws the symbol from the
|
||||
// bundled list, then the tracked tokens, then the explorer's report — the
|
||||
// precedence resolveTokenDecimals() uses — and returns null, not a guess,
|
||||
// when nothing names it, so the screens keep saying `Unknown token`.
|
||||
//
|
||||
// A tracked or explorer-reported symbol is attacker-influenced text, so it
|
||||
// stays subject to the spoof rule (src/shared/symbolSpoof.js): resolving a
|
||||
// symbol must not become a new way for a stray contract to wear a bundled or
|
||||
// native ticker.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { Interface } = require("ethers");
|
||||
const { ERC20_ABI } = require("../src/shared/constants");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { resolveTokenSymbol } = require("../src/shared/approvalAmount");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
|
||||
const iface = new Interface(ERC20_ABI);
|
||||
|
||||
// Outside the bundled list, as the great majority of ERC-20s are.
|
||||
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
||||
// In the bundled list: USDC at 6 decimals, DAI at 18.
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const FIVE_THOUSAND_AT_SIX = 5000000000n;
|
||||
|
||||
function transferData(amount) {
|
||||
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
|
||||
}
|
||||
|
||||
// A wallet whose block-explorer balance for `token` reports `symbol`, shaped
|
||||
// as balances.js writes it onto state.
|
||||
function walletsReporting(token, symbol) {
|
||||
return [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [
|
||||
{
|
||||
address: "0x" + "a".repeat(40),
|
||||
balance: "1.0",
|
||||
tokenBalances: [
|
||||
{
|
||||
address: token,
|
||||
symbol,
|
||||
decimals: 6,
|
||||
balance: "5000.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
describe("resolveTokenSymbol", () => {
|
||||
test("reads the bundled list", () => {
|
||||
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
|
||||
});
|
||||
|
||||
test("prefers the bundled list over a tracked entry", () => {
|
||||
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
|
||||
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
|
||||
});
|
||||
|
||||
test("reads a token the user tracks", () => {
|
||||
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "NOVEL" }];
|
||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
|
||||
});
|
||||
|
||||
test("reads the symbol the explorer reported", () => {
|
||||
state.wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
|
||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
|
||||
});
|
||||
|
||||
test("is null when no source names the token", () => {
|
||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses a name the explorer's own entries disagree about", () => {
|
||||
const wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
|
||||
wallets[0].addresses.push({
|
||||
address: "0x" + "b".repeat(40),
|
||||
balance: "0.0",
|
||||
tokenBalances: [{ address: NOVEL_TOKEN, symbol: "OTHER" }],
|
||||
});
|
||||
state.wallets = wallets;
|
||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
|
||||
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
|
||||
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
|
||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an explorer entry claiming the native ETH ticker", () => {
|
||||
state.wallets = walletsReporting(NOVEL_TOKEN, "ETH");
|
||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeCalldata symbol", () => {
|
||||
test("a tracked token is named, not called Unknown", () => {
|
||||
state.trackedTokens = [
|
||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
const decoded = decodeCalldata(
|
||||
transferData(FIVE_THOUSAND_AT_SIX),
|
||||
NOVEL_TOKEN,
|
||||
);
|
||||
expect(decoded.description).toBe("Transfer NOVEL");
|
||||
const amount = decoded.details.find((d) => d.label === "Amount");
|
||||
expect(amount.value).toBe("5000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("a token nothing knows keeps a symbol-less label", () => {
|
||||
const decoded = decodeCalldata(
|
||||
transferData(FIVE_THOUSAND_AT_SIX),
|
||||
NOVEL_TOKEN,
|
||||
);
|
||||
expect(decoded.description).toBe("Transfer ERC-20 token");
|
||||
const token = decoded.details.find((d) => d.label === "Token");
|
||||
// The Token line carries the address and is flagged for the screen's
|
||||
// symbol lookup, which resolves to nothing here — so `Unknown token`.
|
||||
expect(token.isToken).toBe(true);
|
||||
expect(token.address).toBe(NOVEL_TOKEN);
|
||||
expect(resolveTokenSymbol(token.address, state)).toBeNull();
|
||||
});
|
||||
|
||||
test("a tracked token spoofing a bundled ticker is not named by it", () => {
|
||||
state.trackedTokens = [
|
||||
{ address: NOVEL_TOKEN, symbol: "USDC", decimals: 6 },
|
||||
];
|
||||
const decoded = decodeCalldata(
|
||||
transferData(FIVE_THOUSAND_AT_SIX),
|
||||
NOVEL_TOKEN,
|
||||
);
|
||||
expect(decoded.description).toBe("Transfer ERC-20 token");
|
||||
const amount = decoded.details.find((d) => d.label === "Amount");
|
||||
expect(amount.value).not.toMatch(/USDC/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// What reaches the console when the RPC endpoint answers with an HTTP error.
|
||||
//
|
||||
// RPC providers put the API key in the endpoint URL's path or query string.
|
||||
// When the endpoint answers with an HTTP error (a wrong or expired key, a rate
|
||||
// limit, a server error), the error ethers throws carries the full request URL
|
||||
// in its message, so a line logging that message printed the key
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). Those lines log the
|
||||
// error's short message, which names the HTTP status and not the URL.
|
||||
//
|
||||
// The real ethers provider runs; only its HTTP transport is replaced, by one
|
||||
// that answers every request with 401 Unauthorized. Debug mode is on, so
|
||||
// every log level is printed.
|
||||
|
||||
const { FetchRequest } = require("ethers");
|
||||
const {
|
||||
getProvider,
|
||||
lookupTokenInfo,
|
||||
refreshBalances,
|
||||
} = require("../src/shared/balances");
|
||||
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
||||
const { resolveEnsName } = require("../src/shared/ens");
|
||||
const { setRuntimeDebug } = require("../src/shared/log");
|
||||
|
||||
const RPC_URL = "https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
|
||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let printed;
|
||||
|
||||
beforeEach(() => {
|
||||
setRuntimeDebug(true);
|
||||
printed = [];
|
||||
for (const method of ["log", "warn", "error"]) {
|
||||
jest.spyOn(console, method).mockImplementation((...args) => {
|
||||
printed.push(args.map(String).join(" "));
|
||||
});
|
||||
}
|
||||
FetchRequest.registerGetUrl(async () => ({
|
||||
statusCode: 401,
|
||||
statusMessage: "Unauthorized",
|
||||
headers: {},
|
||||
body: new Uint8Array(),
|
||||
}));
|
||||
// The explorer requests the balance refresh makes go nowhere.
|
||||
globalThis.fetch = jest.fn(async () => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||
globalThis.fetch = realFetch;
|
||||
setRuntimeDebug(false);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// The line carrying `label` was printed and names the HTTP status, and nothing
|
||||
// printed carries the key.
|
||||
function expectFailureLoggedWithoutKey(label) {
|
||||
const line = printed.find((text) => text.includes(label));
|
||||
expect(line).toContain("401");
|
||||
const all = printed.join("\n");
|
||||
expect(all).not.toContain("PATHKEY123");
|
||||
expect(all).not.toContain("QUERYTOKEN456");
|
||||
}
|
||||
|
||||
test("ethers puts the URL in the error message, not in the short message", async () => {
|
||||
const provider = getProvider(RPC_URL, "mainnet");
|
||||
const error = await provider.getCode(ADDRESS).catch((e) => e);
|
||||
expect(error.message).toContain("PATHKEY123");
|
||||
expect(error.shortMessage).not.toContain("PATHKEY123");
|
||||
});
|
||||
|
||||
test("the recipient checks before a send", async () => {
|
||||
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
|
||||
expectFailureLoggedWithoutKey("contract check failed");
|
||||
expectFailureLoggedWithoutKey("tx count check failed");
|
||||
});
|
||||
|
||||
test("the ENS reverse lookup", async () => {
|
||||
expect(await resolveEnsName(ADDRESS, RPC_URL, "mainnet")).toBeNull();
|
||||
expectFailureLoggedWithoutKey("ENS reverse lookup failed");
|
||||
});
|
||||
|
||||
test("the balance refresh", async () => {
|
||||
const wallets = [{ addresses: [{ address: ADDRESS }] }];
|
||||
await refreshBalances(
|
||||
wallets,
|
||||
RPC_URL,
|
||||
"https://explorer.example.invalid/api/v2",
|
||||
[],
|
||||
"mainnet",
|
||||
);
|
||||
expectFailureLoggedWithoutKey("ETH balance failed");
|
||||
expectFailureLoggedWithoutKey("ENS reverse failed");
|
||||
});
|
||||
|
||||
// The lookup's first line, at debug level, names the RPC endpoint; the check
|
||||
// of everything printed covers it too.
|
||||
test("the token lookup", async () => {
|
||||
await expect(lookupTokenInfo(ADDRESS, RPC_URL, "mainnet")).rejects.toThrow(
|
||||
"Not a valid ERC-20 token",
|
||||
);
|
||||
expectFailureLoggedWithoutKey("symbol() failed:");
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// Which site a page's request is attributed to.
|
||||
//
|
||||
// The background takes a request's origin from what the browser says sent the
|
||||
// message: sender.origin, or on Firefox before 126, which has no
|
||||
// sender.origin, the origin of sender.url — the frame that sent it. It used to
|
||||
// fall back to the tab's page and then to an origin the message itself
|
||||
// carried, so a request from a frame was credited to the site embedding it,
|
||||
// and a request the browser said nothing about was credited to whatever the
|
||||
// page wrote (https://git.eeqj.de/sneak/AutistMask/issues/407).
|
||||
//
|
||||
// Every sender here lacks sender.origin, as on old Firefox. The connection
|
||||
// check on eth_accounts is what shows which site a request was credited to.
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
// The site the persisted state has connected, and one it has never heard of.
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const STRANGER_ORIGIN = "https://stranger.example";
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
function loadBackground() {
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({}),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
}));
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const storage = makeStorageStub({
|
||||
autistmask: {
|
||||
networkId: "mainnet",
|
||||
wallets: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{ address: ADDRESS, balance: "0", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: ADDRESS,
|
||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
||||
deniedSites: {},
|
||||
},
|
||||
});
|
||||
|
||||
let messageListener = null;
|
||||
global.chrome = {
|
||||
storage,
|
||||
runtime: {
|
||||
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
lastError: null,
|
||||
},
|
||||
windows: { onRemoved: { addListener: () => {} } },
|
||||
action: { setPopup: () => {} },
|
||||
};
|
||||
|
||||
require("../src/background/index");
|
||||
|
||||
// Ask for eth_accounts. `claimedOrigin` is an origin written into the
|
||||
// message, as the content script used to send.
|
||||
return async function accounts(sender, claimedOrigin) {
|
||||
let result = null;
|
||||
messageListener(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_accounts",
|
||||
params: [],
|
||||
origin: claimedOrigin,
|
||||
},
|
||||
sender,
|
||||
(r) => {
|
||||
result = r;
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
describe("a request is attributed to the frame that sent it", () => {
|
||||
test("a stranger's frame on a connected site gets no address", async () => {
|
||||
const accounts = loadBackground();
|
||||
|
||||
const result = await accounts({
|
||||
url: STRANGER_ORIGIN + "/frame.html",
|
||||
tab: { url: CONNECTED_ORIGIN + "/" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: [] });
|
||||
});
|
||||
|
||||
test("a connected site's frame on a stranger's page gets the address", async () => {
|
||||
const accounts = loadBackground();
|
||||
|
||||
const result = await accounts({
|
||||
url: CONNECTED_ORIGIN + "/frame.html",
|
||||
tab: { url: STRANGER_ORIGIN + "/" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: [ADDRESS] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a request the browser does not say the sender of", () => {
|
||||
test("is refused, whatever the tab or the message says", async () => {
|
||||
const accounts = loadBackground();
|
||||
|
||||
const result = await accounts(
|
||||
{ tab: { url: CONNECTED_ORIGIN + "/" } },
|
||||
CONNECTED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"The wallet could not tell which site sent this request.",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
// 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,
|
||||
})),
|
||||
urlOrigin: () => "",
|
||||
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",
|
||||
origin: "https://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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// What reaches the console when an endpoint check in Settings fails.
|
||||
//
|
||||
// fetch refuses a URL with a user name and password in it, or one it cannot
|
||||
// parse, with an error whose message carries the whole URL: the password, and
|
||||
// any API key in the path or query string. The checks behind the RPC and
|
||||
// Blockscout Save buttons printed that message
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410); they now name the
|
||||
// endpoint by its origin.
|
||||
//
|
||||
// The real fetch runs; it throws before making any request. Debug mode is on,
|
||||
// so every log level is printed.
|
||||
|
||||
const SECRETS = ["SECRETPASS789", "PATHKEY123", "QUERYTOKEN456"];
|
||||
const RPC_WITH_PASSWORD =
|
||||
"https://user:SECRETPASS789@rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
|
||||
// Port 99999 is out of range, so the URL does not parse.
|
||||
const RPC_UNPARSEABLE =
|
||||
"https://rpc.example.invalid:99999/v3/PATHKEY123?token=QUERYTOKEN456";
|
||||
const BLOCKSCOUT_WITH_PASSWORD =
|
||||
"https://user:SECRETPASS789@explorer.example.invalid/PATHKEY123/api/v2";
|
||||
|
||||
const SAVED_RPC = "https://saved-rpc.example.invalid";
|
||||
const SAVED_BLOCKSCOUT = "https://saved-explorer.example.invalid/api/v2";
|
||||
|
||||
let elements;
|
||||
let flashes;
|
||||
let printed;
|
||||
let state;
|
||||
|
||||
// A stand-in for one DOM node: enough of an element for init() to set
|
||||
// properties on it and hang listeners off it.
|
||||
function fakeElement() {
|
||||
return {
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
href: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
listeners: {},
|
||||
addEventListener(event, handler) {
|
||||
this.listeners[event] = handler;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function element(id) {
|
||||
return (elements[id] ||= fakeElement());
|
||||
}
|
||||
|
||||
function loadSettingsView() {
|
||||
elements = {};
|
||||
flashes = [];
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock("../src/popup/views/helpers", () => ({
|
||||
$: element,
|
||||
showView: () => {},
|
||||
updateDebugBanner: () => {},
|
||||
showFlash: (msg) => flashes.push(msg),
|
||||
escapeHtml: (s) => s,
|
||||
flashCopyFeedback: () => {},
|
||||
goBack: () => {},
|
||||
pushCurrentView: () => {},
|
||||
onViewLeave: () => {},
|
||||
VIEWS: [],
|
||||
}));
|
||||
|
||||
state = require("../src/shared/state").state;
|
||||
state.rpcUrl = SAVED_RPC;
|
||||
state.blockscoutUrl = SAVED_BLOCKSCOUT;
|
||||
require("../src/shared/log").setRuntimeDebug(true);
|
||||
|
||||
require("../src/popup/views/settings").init({});
|
||||
}
|
||||
|
||||
async function save(fieldId, buttonId, typed) {
|
||||
element(fieldId).value = typed;
|
||||
await element(buttonId).listeners.click();
|
||||
}
|
||||
|
||||
// The check failed, nothing was saved, and nothing printed carries the
|
||||
// password or the key.
|
||||
function expectFailedWithoutSecrets(label) {
|
||||
expect(flashes).toContain("Could not reach endpoint.");
|
||||
expect(state.rpcUrl).toBe(SAVED_RPC);
|
||||
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
|
||||
const line = printed.find((text) => text.includes(label));
|
||||
expect(line).toBeDefined();
|
||||
const all = printed.join("\n");
|
||||
for (const secret of SECRETS) {
|
||||
expect(all).not.toContain(secret);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
printed = [];
|
||||
for (const method of ["log", "warn", "error"]) {
|
||||
jest.spyOn(console, method).mockImplementation((...args) => {
|
||||
printed.push(args.map(String).join(" "));
|
||||
});
|
||||
}
|
||||
globalThis.chrome = {
|
||||
runtime: { sendMessage: () => {} },
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.dontMock("../src/popup/views/helpers");
|
||||
delete globalThis.chrome;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("fetch puts the whole URL in the error it throws for such a URL", async () => {
|
||||
for (const url of [RPC_WITH_PASSWORD, RPC_UNPARSEABLE]) {
|
||||
const error = await fetch(url).catch((e) => e);
|
||||
expect(error.message).toContain("PATHKEY123");
|
||||
}
|
||||
});
|
||||
|
||||
test("the RPC check of a URL with a user name and password", async () => {
|
||||
loadSettingsView();
|
||||
await save("settings-rpc", "btn-save-rpc", RPC_WITH_PASSWORD);
|
||||
const line = expectFailedWithoutSecrets("RPC validation fetch failed");
|
||||
expect(line).toContain("https://rpc.example.invalid");
|
||||
});
|
||||
|
||||
test("the RPC check of a URL that does not parse", async () => {
|
||||
loadSettingsView();
|
||||
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
|
||||
expectFailedWithoutSecrets("RPC validation fetch failed");
|
||||
});
|
||||
|
||||
test("the Blockscout check of a URL with a user name and password", async () => {
|
||||
loadSettingsView();
|
||||
await save(
|
||||
"settings-blockscout",
|
||||
"btn-save-blockscout",
|
||||
BLOCKSCOUT_WITH_PASSWORD,
|
||||
);
|
||||
const line = expectFailedWithoutSecrets("Blockscout validation failed");
|
||||
expect(line).toContain("https://explorer.example.invalid");
|
||||
});
|
||||
+33
-31
@@ -270,31 +270,32 @@ describe("background refresh racing a wallet deleted on another page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
|
||||
// from two different contexts — src/background/index.js:592-599 pushes a
|
||||
// newly approved hostname onto state.allowedSites[activeAddress], and the
|
||||
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
|
||||
// hostname out of state[key][addr] in place, deleting the address key
|
||||
// entirely once its list is empty — the exact membership-vs-whole-field
|
||||
// pattern that made the whole-field `wallets` diff unsafe, on a
|
||||
// security-relevant field: a stale whole-field save here can resurrect a
|
||||
// revoked permission or wipe a freshly granted one.
|
||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. Mutated in place
|
||||
// from two different contexts — rememberSiteChoice() in
|
||||
// src/background/index.js pushes a newly approved origin onto
|
||||
// state.allowedSites[activeAddress], and the Settings "revoke" button
|
||||
// (forgetOrigin() in src/popup/views/settings.js) filters an origin out of
|
||||
// state[key][addr] in place, deleting the address key entirely once its list
|
||||
// is empty — the exact membership-vs-whole-field pattern that made the
|
||||
// whole-field `wallets` diff unsafe, on a security-relevant field: a stale
|
||||
// whole-field save here can resurrect a revoked permission or wipe a freshly
|
||||
// granted one.
|
||||
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
function approveSite(pageState, address, hostname) {
|
||||
function approveSite(pageState, address, origin) {
|
||||
if (!pageState.allowedSites[address]) {
|
||||
pageState.allowedSites[address] = [];
|
||||
}
|
||||
if (!pageState.allowedSites[address].includes(hostname)) {
|
||||
pageState.allowedSites[address].push(hostname);
|
||||
if (!pageState.allowedSites[address].includes(origin)) {
|
||||
pageState.allowedSites[address].push(origin);
|
||||
}
|
||||
}
|
||||
|
||||
function revokeSite(pageState, hostname) {
|
||||
function revokeSite(pageState, origin) {
|
||||
for (const addr of Object.keys(pageState.allowedSites)) {
|
||||
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
|
||||
(h) => h !== hostname,
|
||||
(o) => o !== origin,
|
||||
);
|
||||
if (pageState.allowedSites[addr].length === 0) {
|
||||
delete pageState.allowedSites[addr];
|
||||
@@ -308,7 +309,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
|
||||
await storage.set({
|
||||
autistmask: {
|
||||
wallets: [W1],
|
||||
allowedSites: { [ADDR2]: ["other.example"] },
|
||||
allowedSites: { [ADDR2]: ["https://other.example"] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -318,24 +319,24 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
|
||||
await settings.state.loadState();
|
||||
|
||||
// A dApp approval window, opened later, approves a new site for a
|
||||
// different address and saves — the real sequence at
|
||||
// src/background/index.js:592-599.
|
||||
// different address and saves — the real sequence in
|
||||
// rememberSiteChoice(), src/background/index.js.
|
||||
const approval = loadPage(storage);
|
||||
await approval.state.loadState();
|
||||
approveSite(approval.state.state, ADDR1, "dapp.example");
|
||||
approveSite(approval.state.state, ADDR1, "https://dapp.example");
|
||||
await approval.state.saveState();
|
||||
expect(
|
||||
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
||||
).toEqual(["dapp.example"]);
|
||||
).toEqual(["https://dapp.example"]);
|
||||
|
||||
// Settings revokes its own, unrelated site — the real sequence at
|
||||
// src/popup/views/settings.js:55-68 — and saves from state loaded
|
||||
// before the dApp approval ever happened.
|
||||
revokeSite(settings.state.state, "other.example");
|
||||
// Settings revokes its own, unrelated site — the real sequence in
|
||||
// forgetOrigin(), src/popup/views/settings.js — and saves from state
|
||||
// loaded before the dApp approval ever happened.
|
||||
revokeSite(settings.state.state, "https://other.example");
|
||||
await settings.state.saveState();
|
||||
|
||||
const persisted = (await storage.get("autistmask")).autistmask;
|
||||
expect(persisted.allowedSites[ADDR1]).toEqual(["dapp.example"]);
|
||||
expect(persisted.allowedSites[ADDR1]).toEqual(["https://dapp.example"]);
|
||||
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -346,7 +347,7 @@ describe("a revoked site permission against a stale page's later save", () => {
|
||||
await storage.set({
|
||||
autistmask: {
|
||||
wallets: [W1],
|
||||
allowedSites: { [ADDR1]: ["evil.example"] },
|
||||
allowedSites: { [ADDR1]: ["https://evil.example"] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -354,23 +355,24 @@ describe("a revoked site permission against a stale page's later save", () => {
|
||||
const stale = loadPage(storage);
|
||||
await stale.state.loadState();
|
||||
|
||||
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
|
||||
// second page.
|
||||
// Settings revokes it — forgetOrigin(), src/popup/views/settings.js —
|
||||
// from a second page.
|
||||
const settings = loadPage(storage);
|
||||
await settings.state.loadState();
|
||||
revokeSite(settings.state.state, "evil.example");
|
||||
revokeSite(settings.state.state, "https://evil.example");
|
||||
await settings.state.saveState();
|
||||
expect(
|
||||
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
||||
).toBeUndefined();
|
||||
|
||||
// The stale page, unaware of the revoke, approves an unrelated site
|
||||
// for a different address and saves — src/background/index.js:592-599.
|
||||
approveSite(stale.state.state, ADDR2, "good.example");
|
||||
// for a different address and saves — rememberSiteChoice(),
|
||||
// src/background/index.js.
|
||||
approveSite(stale.state.state, ADDR2, "https://good.example");
|
||||
await stale.state.saveState();
|
||||
|
||||
const persisted = (await storage.get("autistmask")).autistmask;
|
||||
expect(persisted.allowedSites[ADDR2]).toEqual(["good.example"]);
|
||||
expect(persisted.allowedSites[ADDR2]).toEqual(["https://good.example"]);
|
||||
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,9 @@ describe("an unversioned profile that is perfectly valid", () => {
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||
expect(stored.activeAddress).toBe(ADDRESS);
|
||||
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
expect(stored.allowedSites).toEqual({
|
||||
[ADDRESS]: ["https://dapp.example"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,26 @@ const path = require("path");
|
||||
|
||||
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(
|
||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
@@ -51,7 +71,7 @@ function unversionedValidProfile(extra) {
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||
allowedSites: { [ADDRESS]: ["https://dapp.example"] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
theme: "system",
|
||||
|
||||
@@ -44,6 +44,7 @@ jest.mock("../src/shared/log", () => ({
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
urlOrigin: () => "",
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
// The typed-data signing screen
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/400).
|
||||
//
|
||||
// A Permit or Permit2 signature lets its spender take tokens from the signer's
|
||||
// address, and it is how most wallet drains are done. Listed as plain
|
||||
// key/value lines it reads exactly like a sign-in message, so the screen has
|
||||
// to warn for it, naming the spender and the amount, and must not warn for a
|
||||
// sign-in message.
|
||||
//
|
||||
// ethers signs only the fields a type declares in `types` and drops any other
|
||||
// key in the message, so the warning reads the spender, tokens and amounts
|
||||
// from those fields alone, except a `Permit`'s token, which is the domain's
|
||||
// `verifyingContract`: a key the page adds beside them must not change what
|
||||
// the warning says.
|
||||
//
|
||||
// ethers signs the type it derives from `types`, not the page's
|
||||
// `primaryType`, so the screen names the derived type, and a request whose
|
||||
// stated type is missing or differs is refused rather than shown under a name
|
||||
// it is not signed as. The refusal is checked on the sign screen itself,
|
||||
// driven against a minimal DOM stub in the shape
|
||||
// tests/deleteWalletLostPassword.test.js uses.
|
||||
|
||||
jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { getAddress, MaxUint256 } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { decryptWithPassword } = require("../src/shared/vault");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
const { formatTypedDataHtml, typedDataRefusal } = approval;
|
||||
|
||||
const SPENDER = getAddress("0xbad000000000000000000000000000000000bad0");
|
||||
const DECOY = getAddress("0xdec0000000000000000000000000000000000dec");
|
||||
const OWNER = getAddress("0x0000000000000000000000000000000000000a11");
|
||||
// Bundled, so the symbol and the 6-decimal scale come from the list.
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
||||
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
||||
|
||||
const WARNING = "TOKEN PERMISSION";
|
||||
|
||||
// Permit2's PermitSingle, granting the largest uint160 allowance there is.
|
||||
const PERMIT_SINGLE = {
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
{ name: "verifyingContract", type: "address" },
|
||||
],
|
||||
PermitSingle: [
|
||||
{ name: "details", type: "PermitDetails" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "sigDeadline", type: "uint256" },
|
||||
],
|
||||
PermitDetails: [
|
||||
{ name: "token", type: "address" },
|
||||
{ name: "amount", type: "uint160" },
|
||||
{ name: "expiration", type: "uint48" },
|
||||
{ name: "nonce", type: "uint48" },
|
||||
],
|
||||
},
|
||||
primaryType: "PermitSingle",
|
||||
domain: { name: "Permit2", chainId: 1, verifyingContract: PERMIT2 },
|
||||
message: {
|
||||
details: {
|
||||
token: USDC,
|
||||
amount: ((1n << 160n) - 1n).toString(),
|
||||
expiration: "1790000000",
|
||||
nonce: "0",
|
||||
},
|
||||
spender: SPENDER,
|
||||
sigDeadline: "1790000000",
|
||||
},
|
||||
};
|
||||
|
||||
// Permit2's PermitBatch: 5 USDC and 7 DAI, a line for each token.
|
||||
const PERMIT_BATCH = {
|
||||
types: {
|
||||
EIP712Domain: PERMIT_SINGLE.types.EIP712Domain,
|
||||
PermitBatch: [
|
||||
{ name: "details", type: "PermitDetails[]" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "sigDeadline", type: "uint256" },
|
||||
],
|
||||
PermitDetails: PERMIT_SINGLE.types.PermitDetails,
|
||||
},
|
||||
primaryType: "PermitBatch",
|
||||
domain: PERMIT_SINGLE.domain,
|
||||
message: {
|
||||
details: [
|
||||
{
|
||||
token: USDC,
|
||||
amount: "5000000",
|
||||
expiration: "1790000000",
|
||||
nonce: "0",
|
||||
},
|
||||
{
|
||||
token: DAI,
|
||||
amount: "7000000000000000000",
|
||||
expiration: "1790000000",
|
||||
nonce: "0",
|
||||
},
|
||||
],
|
||||
spender: SPENDER,
|
||||
sigDeadline: "1790000000",
|
||||
},
|
||||
};
|
||||
|
||||
// One of Permit2's four transfer types, letting SPENDER take 5 USDC. The
|
||||
// batch types take a list of `TokenPermissions`; the witness types add a
|
||||
// struct of the site's own as their last field.
|
||||
function permit2Transfer(primaryType, { batch = false, witness = false }) {
|
||||
const fields = [
|
||||
{
|
||||
name: "permitted",
|
||||
type: batch ? "TokenPermissions[]" : "TokenPermissions",
|
||||
},
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "deadline", type: "uint256" },
|
||||
];
|
||||
const types = {
|
||||
EIP712Domain: PERMIT_SINGLE.types.EIP712Domain,
|
||||
[primaryType]: fields,
|
||||
TokenPermissions: [
|
||||
{ name: "token", type: "address" },
|
||||
{ name: "amount", type: "uint256" },
|
||||
],
|
||||
};
|
||||
const permitted = { token: USDC, amount: "5000000" };
|
||||
const message = {
|
||||
permitted: batch ? [permitted] : permitted,
|
||||
spender: SPENDER,
|
||||
nonce: "0",
|
||||
deadline: "1790000000",
|
||||
};
|
||||
if (witness) {
|
||||
fields.push({ name: "witness", type: "Order" });
|
||||
types.Order = [{ name: "recipient", type: "address" }];
|
||||
message.witness = { recipient: OWNER };
|
||||
}
|
||||
return { types, primaryType, domain: PERMIT_SINGLE.domain, message };
|
||||
}
|
||||
|
||||
// EIP-2612's Permit for 5 USDC; the token is the domain's contract.
|
||||
const PERMIT = {
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "version", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
{ name: "verifyingContract", type: "address" },
|
||||
],
|
||||
Permit: [
|
||||
{ name: "owner", type: "address" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "deadline", type: "uint256" },
|
||||
],
|
||||
},
|
||||
primaryType: "Permit",
|
||||
domain: {
|
||||
name: "USD Coin",
|
||||
version: "2",
|
||||
chainId: 1,
|
||||
verifyingContract: USDC,
|
||||
},
|
||||
message: {
|
||||
owner: OWNER,
|
||||
spender: SPENDER,
|
||||
value: "5000000",
|
||||
nonce: "0",
|
||||
deadline: "1790000000",
|
||||
},
|
||||
};
|
||||
|
||||
// DAI's older permit: the same name, no amount, all or nothing by `allowed`.
|
||||
const DAI_PERMIT = {
|
||||
types: {
|
||||
EIP712Domain: PERMIT.types.EIP712Domain,
|
||||
Permit: [
|
||||
{ name: "holder", type: "address" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "expiry", type: "uint256" },
|
||||
{ name: "allowed", type: "bool" },
|
||||
],
|
||||
},
|
||||
primaryType: "Permit",
|
||||
domain: {
|
||||
name: "Dai Stablecoin",
|
||||
version: "1",
|
||||
chainId: 1,
|
||||
verifyingContract: DAI,
|
||||
},
|
||||
message: {
|
||||
holder: OWNER,
|
||||
spender: SPENDER,
|
||||
nonce: "0",
|
||||
expiry: "0",
|
||||
allowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const LOGIN = {
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "version", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
],
|
||||
Login: [
|
||||
{ name: "contents", type: "string" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
],
|
||||
},
|
||||
primaryType: "Login",
|
||||
domain: { name: "Example", version: "1", chainId: 1 },
|
||||
message: { contents: "Sign in to example.com", nonce: "7" },
|
||||
};
|
||||
|
||||
// The warning box alone. It comes before the key/value lines, which list the
|
||||
// spender and the raw amount too, so an assertion on the whole screen would
|
||||
// pass with no warning at all.
|
||||
function warningOf(data) {
|
||||
const html = formatTypedDataHtml(JSON.stringify(data));
|
||||
return html.slice(0, html.indexOf(">Domain<"));
|
||||
}
|
||||
|
||||
function request(data) {
|
||||
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
describe("a token permission is warned about, naming spender and amount", () => {
|
||||
test("a Permit2 PermitSingle for an unlimited allowance", () => {
|
||||
const warning = warningOf(PERMIT_SINGLE);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain(USDC);
|
||||
expect(warning).toContain("Unlimited");
|
||||
});
|
||||
|
||||
test("a Permit2 PermitBatch names both tokens and both amounts", () => {
|
||||
const warning = warningOf(PERMIT_BATCH);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain(USDC);
|
||||
expect(warning).toContain("5.0000 USDC");
|
||||
expect(warning).toContain(DAI);
|
||||
expect(warning).toContain("7.0000 DAI");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["PermitTransferFrom", {}],
|
||||
["PermitWitnessTransferFrom", { witness: true }],
|
||||
["PermitBatchTransferFrom", { batch: true }],
|
||||
["PermitBatchWitnessTransferFrom", { batch: true, witness: true }],
|
||||
])("a Permit2 %s", (primaryType, shape) => {
|
||||
const warning = warningOf(permit2Transfer(primaryType, shape));
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain(USDC);
|
||||
expect(warning).toContain("5.0000 USDC");
|
||||
});
|
||||
|
||||
test("an EIP-2612 Permit for 5 USDC", () => {
|
||||
const warning = warningOf(PERMIT);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain("5.0000 USDC");
|
||||
});
|
||||
|
||||
test("DAI's older permit, which grants everything", () => {
|
||||
const warning = warningOf(DAI_PERMIT);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain("Unlimited");
|
||||
});
|
||||
|
||||
test("a sign-in message carries no warning, and is still shown", () => {
|
||||
const html = formatTypedDataHtml(JSON.stringify(LOGIN));
|
||||
expect(html).not.toContain(WARNING);
|
||||
expect(html).toContain("Sign in to example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the warning reads only the fields the signed type declares", () => {
|
||||
// An unlimited EIP-2612 permit with DAI's `allowed` added as a key the
|
||||
// type does not declare. ethers signs exactly the unlimited permit.
|
||||
test("an added key does not change the amount", () => {
|
||||
const data = {
|
||||
...PERMIT,
|
||||
message: {
|
||||
...PERMIT.message,
|
||||
value: MaxUint256.toString(),
|
||||
allowed: false,
|
||||
},
|
||||
};
|
||||
expect(warningOf(data)).toContain("Unlimited");
|
||||
});
|
||||
|
||||
// A Permit whose type names its spender `operator`; the page adds a
|
||||
// `spender` key the type does not declare.
|
||||
test("an added key is not named as the spender", () => {
|
||||
const data = {
|
||||
...PERMIT,
|
||||
types: {
|
||||
...PERMIT.types,
|
||||
Permit: [
|
||||
{ name: "owner", type: "address" },
|
||||
{ name: "operator", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "deadline", type: "uint256" },
|
||||
],
|
||||
},
|
||||
message: { ...PERMIT.message, operator: SPENDER, spender: DECOY },
|
||||
};
|
||||
const warning = warningOf(data);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).not.toContain(DECOY);
|
||||
});
|
||||
|
||||
// The permit for a Uniswap v3 position NFT: a `Permit` with no amount
|
||||
// field at all, which ethers signs and its contract accepts.
|
||||
test("a Permit with no amount still warns, above the normal lines", () => {
|
||||
const data = {
|
||||
types: {
|
||||
EIP712Domain: PERMIT.types.EIP712Domain,
|
||||
Permit: [
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "tokenId", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "deadline", type: "uint256" },
|
||||
],
|
||||
},
|
||||
primaryType: "Permit",
|
||||
domain: {
|
||||
name: "Uniswap V3 Positions NFT-V1",
|
||||
version: "1",
|
||||
chainId: 1,
|
||||
verifyingContract: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
|
||||
},
|
||||
message: {
|
||||
spender: SPENDER,
|
||||
tokenId: "12345",
|
||||
nonce: "0",
|
||||
deadline: "1790000000",
|
||||
},
|
||||
};
|
||||
const html = formatTypedDataHtml(JSON.stringify(data));
|
||||
const warning = warningOf(data);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain("<div>Amount</div><div>Unknown</div>");
|
||||
expect(html).toContain(">Domain<");
|
||||
expect(html).toContain(">Primary type<");
|
||||
expect(html).toContain("tokenId:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the primary type shown is the one ethers signs", () => {
|
||||
// A drain stated as a sign-in: the page says Login, the types say
|
||||
// PermitSingle, and ethers would sign PermitSingle.
|
||||
const disguised = { ...PERMIT_SINGLE, primaryType: "Login" };
|
||||
|
||||
test("a stated type that differs from the signed one is refused", () => {
|
||||
expect(typedDataRefusal(request(disguised))).toBe(
|
||||
"This typed data names its primary type as Login, but it would be signed as PermitSingle, so it cannot be signed.",
|
||||
);
|
||||
});
|
||||
|
||||
test("the screen names the signed type, and still warns", () => {
|
||||
const html = formatTypedDataHtml(JSON.stringify(disguised));
|
||||
expect(html).toContain(">PermitSingle<");
|
||||
expect(html).not.toContain(">Login<");
|
||||
expect(html).toContain(WARNING);
|
||||
});
|
||||
|
||||
test("a missing primary type is refused", () => {
|
||||
const unnamed = { ...PERMIT_SINGLE, primaryType: undefined };
|
||||
expect(typedDataRefusal(request(unnamed))).toBe(
|
||||
"This typed data does not name its primary type, so it cannot be signed.",
|
||||
);
|
||||
});
|
||||
|
||||
test("a stated type that is the signed one is not refused", () => {
|
||||
expect(typedDataRefusal(request(PERMIT_SINGLE))).toBeNull();
|
||||
expect(typedDataRefusal(request(LOGIN))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ the sign screen
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
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: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
function click(id) {
|
||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
||||
}
|
||||
|
||||
// Open the sign screen for a typed-data request from OWNER, the way the popup
|
||||
// does: the background hands over the request and show() draws it. Returns
|
||||
// every message the screen sends to the background.
|
||||
async function openSignScreen(data) {
|
||||
const sent = [];
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
sent.push(msg);
|
||||
if (!reply) return;
|
||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||
reply({
|
||||
type: "sign",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: OWNER,
|
||||
signParams: request(data),
|
||||
});
|
||||
},
|
||||
};
|
||||
state.wallets = [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: OWNER, balance: "0.0000", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
return sent;
|
||||
}
|
||||
|
||||
describe("the sign screen refuses a mismatched primary type", () => {
|
||||
const disguised = { ...PERMIT_SINGLE, primaryType: "Login" };
|
||||
const REFUSAL =
|
||||
"This typed data names its primary type as Login, but it would be signed as PermitSingle, so it cannot be signed.";
|
||||
|
||||
beforeEach(() => {
|
||||
decryptWithPassword.mockClear();
|
||||
});
|
||||
|
||||
test("a matching primary type leaves Sign enabled", async () => {
|
||||
await openSignScreen(PERMIT_SINGLE);
|
||||
expect(node("approve-sign-error").textContent).toBe("");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("a mismatched one shows the error, with Sign disabled", async () => {
|
||||
await openSignScreen(disguised);
|
||||
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
|
||||
expect(node("approve-sign-error").style.visibility).toBe("visible");
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
|
||||
// The handler is called directly, as a button re-enabled by some other
|
||||
// path would call it: the refusal must not rest on the button alone.
|
||||
test("Sign clicked anyway decrypts and signs nothing", async () => {
|
||||
const sent = await openSignScreen(disguised);
|
||||
node("approve-sign-password").value = "any password";
|
||||
await click("btn-approve-sign");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
);
|
||||
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
||||
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
||||
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
// AutistMask's first-ever swap, 2026-02-27.
|
||||
@@ -75,6 +77,14 @@ function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09)
|
||||
function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
|
||||
return coder.encode(
|
||||
["address", "uint256", "uint256", "address[]", "bool"],
|
||||
[recipient, amountOut, amountInMax, pathAddrs, true],
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
|
||||
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
|
||||
// V3 path: token(20) + fee(3) + token(20) ...
|
||||
@@ -223,6 +233,233 @@ describe("uniswap decoder", () => {
|
||||
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", () => {
|
||||
const data = buildExecute(
|
||||
"0x00", // V3_SWAP_EXACT_IN
|
||||
|
||||
@@ -78,15 +78,20 @@ describe("a swap to a token absent from the bundled list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("names the address when the scale is known but the symbol is not", () => {
|
||||
test("names the tracked symbol alongside the address (issue #323)", () => {
|
||||
// The tracked entry supplies both halves now: the scale, and the
|
||||
// symbol the output line is named by. Before #323 the symbol was read
|
||||
// from the bundled list alone, so this line fell back to the address.
|
||||
const sources = {
|
||||
trackedTokens: [
|
||||
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
|
||||
],
|
||||
};
|
||||
expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT);
|
||||
expect(detail(data(), "Token Out", sources).value).toBe(
|
||||
"NOVEL (" + NOVEL_OUT + ")",
|
||||
);
|
||||
expect(detail(data(), "Min. received", sources).value).toBe(
|
||||
"1000.0000",
|
||||
"1000.0000 NOVEL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,12 +98,13 @@ describe("a swap of a token outside the bundled list", () => {
|
||||
state.trackedTokens = [
|
||||
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
|
||||
// The tracked entry names the token as well as scaling it (issue #323).
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("shows the true quantity from the explorer's decimals", () => {
|
||||
state.wallets = walletsHolding(NOVEL, "6");
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
|
||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
|
||||
test("refuses to format when nothing knows the scale", () => {
|
||||
@@ -140,7 +141,7 @@ describe("the Min. received line takes the same rule", () => {
|
||||
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
|
||||
];
|
||||
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
|
||||
expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
|
||||
expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -382,14 +382,62 @@ 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 () => {
|
||||
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");
|
||||
expect(txInfo.tokenDecimals).toBe(6);
|
||||
expect(txInfo.tokenBalance).toBe("5.0");
|
||||
confirmTx.show(txInfo);
|
||||
await settle();
|
||||
expect(text("confirm-balance")).toBe("5.0 NOVEL");
|
||||
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
|
||||
expect(errors()).toBe("");
|
||||
expect(sendDisabled()).toBe(false);
|
||||
});
|
||||
@@ -431,7 +479,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
|
||||
const zero = await render("0.0");
|
||||
expect(unknown.balance).not.toBe(zero.balance);
|
||||
expect(unknown.balance).toBe("unknown (NOVEL)");
|
||||
expect(zero.balance).toBe("0.0 NOVEL");
|
||||
expect(zero.balance).toBe("0.0000 NOVEL");
|
||||
});
|
||||
|
||||
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
|
||||
@@ -443,7 +491,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
|
||||
expect(unknown.errors).not.toBe(zero.errors);
|
||||
expect(unknown.errors).toContain("This token's balance is unknown");
|
||||
expect(unknown.errors).not.toContain("You have");
|
||||
expect(zero.errors).toContain("You have 0.0 NOVEL");
|
||||
expect(zero.errors).toContain("You have 0.0000 NOVEL");
|
||||
expect(zero.errors).not.toContain("balance is unknown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,14 @@ describe("generateMnemonic in a release build", () => {
|
||||
expect(constants.DEBUG).toBe(false);
|
||||
});
|
||||
|
||||
test("the test phrase folds away when DEBUG is false", () => {
|
||||
// The release bundle is what must not carry the phrase; here, with the
|
||||
// define absent, DEBUG_MNEMONIC is the null branch the bundler keeps,
|
||||
// and the literal only exists in the branch it drops.
|
||||
const { constants } = loadWallet();
|
||||
expect(constants.DEBUG_MNEMONIC).toBeNull();
|
||||
});
|
||||
|
||||
test("returns fresh, valid 12-word phrases that are not the test phrase", () => {
|
||||
const { constants, wallet } = loadWallet();
|
||||
|
||||
|
||||
+20
-11
@@ -28,11 +28,14 @@ function makeState(overrides = {}) {
|
||||
selectedAddress: 0,
|
||||
activeAddress: A0,
|
||||
allowedSites: {
|
||||
[A0]: ["a.example"],
|
||||
[A1]: ["b.example"],
|
||||
[B0]: ["c.example"],
|
||||
[A0]: ["https://a.example"],
|
||||
[A1]: ["https://b.example"],
|
||||
[B0]: ["https://c.example"],
|
||||
},
|
||||
deniedSites: {
|
||||
[A1]: ["https://d.example"],
|
||||
[C0]: ["https://e.example"],
|
||||
},
|
||||
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -41,7 +44,7 @@ describe("removeWalletFromState", () => {
|
||||
test("deleting the last wallet clears hasWallet", () => {
|
||||
const state = makeState({
|
||||
wallets: [wallet("A", [A0])],
|
||||
allowedSites: { [A0]: ["a.example"] },
|
||||
allowedSites: { [A0]: ["https://a.example"] },
|
||||
deniedSites: {},
|
||||
});
|
||||
|
||||
@@ -109,8 +112,8 @@ describe("removeWalletFromState", () => {
|
||||
|
||||
removeWalletFromState(state, 0);
|
||||
|
||||
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
|
||||
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
|
||||
expect(state.allowedSites).toEqual({ [B0]: ["https://c.example"] });
|
||||
expect(state.deniedSites).toEqual({ [C0]: ["https://e.example"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,8 +129,14 @@ function makeAddressState(overrides = {}) {
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
activeAddress: A0,
|
||||
allowedSites: { [A0]: ["a.example"], [A1]: ["b.example"] },
|
||||
deniedSites: { [A1]: ["d.example"], [B0]: ["e.example"] },
|
||||
allowedSites: {
|
||||
[A0]: ["https://a.example"],
|
||||
[A1]: ["https://b.example"],
|
||||
},
|
||||
deniedSites: {
|
||||
[A1]: ["https://d.example"],
|
||||
[B0]: ["https://e.example"],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -273,8 +282,8 @@ describe("removeAddressFromState", () => {
|
||||
|
||||
removeAddressFromState(state, 0, 1);
|
||||
|
||||
expect(state.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||
expect(state.deniedSites).toEqual({ [B0]: ["e.example"] });
|
||||
expect(state.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
||||
expect(state.deniedSites).toEqual({ [B0]: ["https://e.example"] });
|
||||
});
|
||||
|
||||
// The derivation counter is a high-water mark, never rewound: "+" derives
|
||||
|
||||
Reference in New Issue
Block a user