Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08d760d195 | ||
|
|
5376723c41 | ||
|
|
671b4d3216 | ||
|
|
e46d894527 | ||
|
|
96446f4661 | ||
|
|
30dca1454e | ||
|
|
2c882c75d6 | ||
|
|
d6fb002656 | ||
|
|
cab7cb02b6 | ||
|
|
3db066413e | ||
|
|
c99772a520 | ||
|
|
a73fb17295 | ||
|
|
99269675b7 | ||
|
|
20e4f1652f |
@@ -418,6 +418,10 @@ Each group of these assertions records a coverage key and a final case demands
|
||||
the exact set, so a section that silently stopped running reddens the suite
|
||||
instead of quietly shrinking it.
|
||||
|
||||
It also covers the red banner on the Settings screen: switching the network
|
||||
there to Sepolia must put up the `[TESTNET]` banner, and switching back to
|
||||
mainnet must take it down, both while Settings is still on screen.
|
||||
|
||||
It also covers the confirmation screen, for both a native ETH send and an ERC-20
|
||||
send: Send disabled while the fee estimate is in flight, enabled once it lands,
|
||||
the fee block quoting the expected cost and the reserve separately, the distinct
|
||||
@@ -430,6 +434,15 @@ reserve while sitting on the same side of the estimate, so swapping the two in
|
||||
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||
previously correct by reading only.
|
||||
|
||||
It also covers a send that Sign & Send refuses for its network fee: the stub
|
||||
node quotes an ordinary fee while the confirmation screen is drawn and one over
|
||||
the bound when Sign & Send asks again, and the refusal must broadcast nothing
|
||||
and leave the password field and Sign & Send where they were. It checks the same
|
||||
for a send refused because the selected wallet does not hold its From address:
|
||||
with the confirmation screen showing a send from the HD wallet, the key wallet
|
||||
is selected by writing it straight into extension storage, as another popup
|
||||
selecting it would save it, and the popup is reopened onto that screen.
|
||||
|
||||
It also covers both ways the wait for a sent transaction's receipt ends on the
|
||||
error screen: lookups that still find no receipt 60 seconds after the broadcast,
|
||||
and six lookups in a row that fail. Each must show its own message, and Done
|
||||
@@ -1077,23 +1090,29 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||
confirmation screens display goes through the floored one — the ERC-20 amount,
|
||||
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
|
||||
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.
|
||||
`Current balance` (`src/popup/views/send.js`), and the amount, balance and
|
||||
network fee on the confirmation screen for the wallet's own send
|
||||
(`src/popup/views/confirmTx.js`), which hands the wait, success and error
|
||||
screens the amount as it showed it. The transaction is still signed for the
|
||||
exact amount, and its USD value is priced from it. 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
|
||||
scale below — and no truncation happens at all.
|
||||
On the approval screen and the wait, success and error screens that carry its
|
||||
amount forward, the floor applies only where the token's scale is known. Where
|
||||
it is not, they state base units instead of a quantity — see Unknown token scale
|
||||
below — and no truncation happens at all. On ConfirmTx and the wait, success and
|
||||
error screens that follow it, the amount of a token whose scale is unknown,
|
||||
which cannot be sent, is the text from Send's amount field cut to four decimals
|
||||
with the same floor (`.5` reads `.5000`).
|
||||
|
||||
**Specific Exception — unknown token scale:** Calldata carries base units and no
|
||||
scale, so every amount on the dApp approval screen needs the token's `decimals`.
|
||||
@@ -1170,14 +1189,8 @@ number. They can read:
|
||||
`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 the step that sets the input side: the last `PERMIT2_PERMIT` step if
|
||||
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
|
||||
or V4 swap step that names an input token or amount. A V4 step with a swap
|
||||
action sets the input side when no earlier step set it, even when it names
|
||||
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
|
||||
a V4 swap step the `amountIn` of the swap action that spends its input
|
||||
currency.
|
||||
whole open delta", so the calldata states no quantity. Transaction Decoding
|
||||
below gives the steps whose amounts the line shows.
|
||||
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
|
||||
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
|
||||
The router reads that zero as "the pair already holds the input tokens": the
|
||||
@@ -1193,7 +1206,13 @@ number. They can read:
|
||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||
output side holds no minimum, not even a zero one, at the point the check is
|
||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||
`minBalance` sets both lines, as a swap step does.
|
||||
`minBalance` sets both lines, as a swap step does. Only a check of the
|
||||
transaction's sender's own balance sets either line; a check of any other
|
||||
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
|
||||
that the router lets fail without reverting the transaction (see Transaction
|
||||
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
|
||||
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
|
||||
lets fail is likewise read as zero.
|
||||
|
||||
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
|
||||
@@ -1752,14 +1771,18 @@ view would leave a wallet one click from deletion.
|
||||
- Token contract: full address + etherscan link (ERC-20 only)
|
||||
- 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). An ETH amount Send's "Max"
|
||||
filled in is worked out again from this screen's own fee estimate when it
|
||||
arrives, as the balance minus the reserve, and the transaction is signed
|
||||
with that estimate's fee fields, so a fee fetched again at signing cannot
|
||||
exceed what the amount leaves behind. The address keeps whatever part of
|
||||
the reserve the transaction does not use. If the balance could not be read
|
||||
or no longer covers the fee, the amount is left as it was and one of the
|
||||
errors below blocks the send
|
||||
- Amount: value + symbol (USD in parentheses), the value to four decimal
|
||||
places with the nonzero floor, as on TxApproval (see Display Consistency),
|
||||
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
|
||||
For a token whose scale is unknown, which cannot be sent, the value is the
|
||||
text from Send's amount field cut the same way (`.5` reads `.5000`). An
|
||||
ETH amount Send's "Max" filled in is worked out again from this screen's
|
||||
own fee estimate when it arrives, as the balance minus the reserve, and
|
||||
the transaction is signed with that estimate's fee fields, so a fee
|
||||
fetched again at signing cannot exceed what the amount leaves behind. The
|
||||
address keeps whatever part of the reserve the transaction does not use.
|
||||
If the balance could not be read or no longer covers the fee, the amount
|
||||
is left as it was and one of the errors below blocks the send
|
||||
- Your balance: the From address's balance on the network its transaction
|
||||
was reviewed on, read from that network's RPC endpoint each time the
|
||||
screen is shown, so a screen the popup reopens onto states and checks that
|
||||
@@ -1793,6 +1816,10 @@ view would leave a wallet one click from deletion.
|
||||
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
|
||||
- Three more reserved error boxes, made visible when "Sign & Send" refuses a
|
||||
send because the active network changed, because the selected wallet does
|
||||
not hold the From address, or because of its gas limit or fee (see
|
||||
Transitions), so the refusal moves nothing
|
||||
- Password: an inline field on this screen, not a modal, with its own error
|
||||
line
|
||||
- "Sign & Send" button (disabled if errors, while the balance is being read,
|
||||
@@ -1810,7 +1837,7 @@ view would leave a wallet one click from deletion.
|
||||
- "Sign & Send" after the active network was switched in Settings since this
|
||||
transaction was reviewed → nothing is signed; "The active network changed
|
||||
after this transaction was reviewed, so it was not sent. Please go back
|
||||
and review it again." in the errors box, no screen change. After a switch,
|
||||
and review it again." in its own box, no screen change. After a switch,
|
||||
"Back" from Settings draws this screen again from the saved transaction,
|
||||
as a popup reopened onto it does; when Send is beneath it on the stack, as
|
||||
when Send showed it in the same popup, "Back" lands below Send instead
|
||||
@@ -1824,13 +1851,21 @@ view would leave a wallet one click from deletion.
|
||||
fee estimate and the contract and never-sent recipient checks ask that
|
||||
network's RPC endpoint, and the Etherscan label check asks that network's
|
||||
explorer
|
||||
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
|
||||
total fee the RPC endpoint fills in at signing is over its bound in
|
||||
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
|
||||
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
|
||||
is more than this wallet will sign for, so it was not sent. Please go back
|
||||
and try again." in its own box, the same sentence for all three, no screen
|
||||
change
|
||||
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||||
line, no screen change
|
||||
- "Sign & Send" signs as the From address above, not as whichever address is
|
||||
selected: the two are saved separately, so the popup can reopen onto this
|
||||
screen with another address selected. When the selected wallet does not
|
||||
hold the From address → full-sentence error on the password error line,
|
||||
nothing sent (no screen change)
|
||||
hold the From address → nothing is signed; "The selected wallet does not
|
||||
hold the From address above, so nothing was sent. Please go back and send
|
||||
again." in its own box, no screen change
|
||||
- "Sign & Send" (correct password, but the From address's key is refused,
|
||||
see Data Model) → nothing is signed → **ErrorTx** saying why
|
||||
- "Back" → **Send**
|
||||
@@ -1850,14 +1885,17 @@ view would leave a wallet one click from deletion.
|
||||
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
|
||||
persisted: closing and reopening the popup resumes the poll, with the elapsed
|
||||
counter and the timeout deadline still measured from the original broadcast. A
|
||||
lookup that fails is retried on the next tick rather than counted as a missing
|
||||
receipt, because a failed lookup says nothing about the transaction; but six
|
||||
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
|
||||
that never answers cannot leave it running indefinitely. Any lookup that
|
||||
answers resets that count.
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen
|
||||
describes the transaction on the network it was sent on, whichever network is
|
||||
active: the receipt is asked of that network's RPC endpoint, the one Settings
|
||||
has for it, and the To and hash links open that network's explorer. The wait
|
||||
is persisted: closing and reopening the popup resumes the poll, with the
|
||||
elapsed counter and the timeout deadline still measured from the original
|
||||
broadcast. A lookup that fails is retried on the next tick rather than counted
|
||||
as a missing receipt, because a failed lookup says nothing about the
|
||||
transaction; but six failures in a row (60 seconds at the poll cadence) end
|
||||
the wait, so an RPC that never answers cannot leave it running indefinitely.
|
||||
Any lookup that answers resets that count.
|
||||
- **Transitions**:
|
||||
- Receipt found → **SuccessTx**
|
||||
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
|
||||
@@ -1878,9 +1916,12 @@ view would leave a wallet one click from deletion.
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link, or for a contract creation
|
||||
the same sentence as on WaitTx
|
||||
- Block number
|
||||
- Block number + etherscan link
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- "Done" button
|
||||
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
|
||||
explorer of the network the transaction was sent on, whichever network is
|
||||
active.
|
||||
- **Transitions**:
|
||||
- "Done" in the approval popup → closes the popup window
|
||||
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||||
@@ -1900,6 +1941,9 @@ view would leave a wallet one click from deletion.
|
||||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||||
full hash (tap to copy) + etherscan link
|
||||
- "Done" button
|
||||
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
|
||||
explorer of the network the transaction was sent on, whichever network is
|
||||
active.
|
||||
- **Transitions**:
|
||||
- "Done" in the approval popup → closes the popup window
|
||||
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||||
@@ -1922,7 +1966,8 @@ view would leave a wallet one click from deletion.
|
||||
#### TransactionDetail (`transaction`)
|
||||
|
||||
- **When**: User tapped a transaction row on Home, AddressDetail, or
|
||||
AddressToken.
|
||||
AddressToken. Its etherscan links open the explorer of the network the
|
||||
transaction was sent on, whichever network is active.
|
||||
- **Elements** (grouped into logical blocks using light well containers; field
|
||||
labels are self-explanatory so groups have no headings):
|
||||
- "Transaction" heading, "Back" button
|
||||
@@ -2323,15 +2368,19 @@ view would leave a wallet one click from deletion.
|
||||
- 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, and unless a swap pays only the From address, a
|
||||
`Paid to` line for each other address it pays, or saying that its output
|
||||
stays in the router or who it pays is unknown (see Transaction Decoding)
|
||||
amounts, what each swap step the user pays for spends of a token other
|
||||
than `Token In`, what each `PERMIT2_TRANSFER_FROM` moves and where to,
|
||||
steps, deadline, and unless a swap pays only the From address, a `Paid to`
|
||||
line for each other address it pays, or saying that a token stays in the
|
||||
router or who it pays is unknown (see Transaction Decoding)
|
||||
- From: color dot + full address + etherscan link
|
||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
||||
known; for a contract creation, the same sentence as on WaitTx
|
||||
- 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
|
||||
- Value: amount in ETH (4 decimal places with the nonzero floor, USD in
|
||||
parentheses)
|
||||
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places with
|
||||
the nonzero floor, USD in parentheses), with the gas limit and the fee per
|
||||
gas in gwei below it
|
||||
- Network and nonce
|
||||
- Raw data: full calldata displayed inline (shown if present)
|
||||
- Password input and an error line
|
||||
@@ -2950,18 +2999,44 @@ Currently supported:
|
||||
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
|
||||
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
|
||||
any other shape is not read, and its step is listed as
|
||||
`V4 Swap (could not be decoded)`. Each step is named by its command type,
|
||||
which the decoder reads from the command byte with the mask the router at that
|
||||
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
|
||||
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
|
||||
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
|
||||
(`0x80`) therefore does not change a step's name. Only the routers with the
|
||||
`0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as
|
||||
`Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as
|
||||
`Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed
|
||||
there as `Command 0x07`. The decoder does not read the commands inside an
|
||||
`EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend
|
||||
the user's tokens or pay the user. That step is listed as
|
||||
`V4 Swap (could not be decoded)`. Each step is named and read as the command
|
||||
that the release of the router at that address runs for its command type, as
|
||||
that release's `contracts/libraries/Commands.sol` names it. The decoder reads
|
||||
the type from the command byte with the mask that router uses: `0x1f` for
|
||||
`UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and
|
||||
`UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and
|
||||
`UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not
|
||||
change the command a step is read as. Releases run different commands for some
|
||||
types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to
|
||||
`0x13` are NFT commands, where the later routers run `V4_SWAP`,
|
||||
`V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and
|
||||
`V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
|
||||
and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
|
||||
on `UniversalRouterV1`). A type a release names no command for is listed by
|
||||
its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
|
||||
`BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
|
||||
transaction when a step fails, unless the step's command byte has that flag
|
||||
set and its command is one the router lets fail, as each release's
|
||||
`contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
|
||||
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
|
||||
`PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
|
||||
`V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
|
||||
and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
|
||||
`BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
|
||||
owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
|
||||
checks. Such a step may then fail while the transaction goes on to the next
|
||||
step, and it is listed with ` (allowed to fail)` after its name, for example
|
||||
`Balance Check (allowed to fail)`. On every other command, among them each V2,
|
||||
V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no
|
||||
effect: the step succeeds or the whole transaction reverts, so the step reads
|
||||
as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail
|
||||
guarantees nothing, so it is read as one whose `minBalance` is zero. Only the
|
||||
routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`),
|
||||
listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`),
|
||||
listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it
|
||||
is listed there as `Command 0x07`. The decoder does not read the commands
|
||||
inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they
|
||||
may spend the user's tokens or pay the user. That step is listed as
|
||||
`Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
|
||||
state, the summary names no swap direction, `Token In` and `Token Out` read
|
||||
`Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
|
||||
@@ -2973,19 +3048,62 @@ Currently supported:
|
||||
itself, which holds what it is paid until a later step pays it out; a payment
|
||||
to the router, as `address(2)` or by its own address, is not counted as paid
|
||||
out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
|
||||
or `address(2)` as those addresses, and the decoder names them. When a swap
|
||||
step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
|
||||
output stays in the router unless a later step pays all the router holds of it
|
||||
to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
|
||||
WETH, or a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or
|
||||
a `PAY_PORTION_FULL_PRECISION` of 1e18 of that token. A step paying a token to
|
||||
the V2 pair that the very next step spends, as a V2 exact-in of that token
|
||||
whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a hop, not
|
||||
a payout: Uniswap's mixed routes pay the next V2 pair directly, and the router
|
||||
enforces that swap's own minimum. The decoder checks that the address paid is
|
||||
that pair, deriving it from the V2 factory and pair init code hash the
|
||||
network's routers were deployed with and the first two tokens of that step's
|
||||
path; a payment to any other address is named. (Sepolia's
|
||||
or `address(2)` as those addresses, and the decoder names them. A
|
||||
`PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
|
||||
because the decoder does not check that later steps spend all of it.
|
||||
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
|
||||
another network and refunds a deposit nobody fills to its depositor; the
|
||||
router passes both on as written, so `address(1)` and `address(2)` there are
|
||||
addresses, and each that is not the transaction's sender is named. A
|
||||
`BALANCE_CHECK_ERC20` pays nobody: the router checks that its owner holds at
|
||||
least `minBalance` of its token, and reads the owner as written, so
|
||||
`address(1)` and `address(2)` there are addresses too. A check of any address
|
||||
but the transaction's sender's says nothing about what the user receives: it
|
||||
sets neither `Token Out` nor the minimum, and it is listed as
|
||||
`Balance Check (of another address)`. A check whose input does not decode,
|
||||
such as one whose owner word has a nonzero top byte (the router reads the
|
||||
owner from the word's last 20 bytes), likewise sets neither, and it is listed
|
||||
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
|
||||
`TAKE_PORTION` pays the router any token, the swap's output or another, a
|
||||
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
|
||||
paid it into ETH, that token stays in the router unless a later step pays out
|
||||
or spends all the router holds of it: a `SWEEP` of that token to anyone but
|
||||
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
|
||||
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
|
||||
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
|
||||
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
|
||||
`CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2
|
||||
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
|
||||
token from the router (`payerIsUser` false). V2 and V3 exact-in swap steps and
|
||||
V4 `SETTLE`s that pay WETH from the router also spend all the WETH a
|
||||
`WRAP_ETH` paid it once together they spend the amount the `WRAP_ETH` states,
|
||||
as a swap paid in ETH does, but only when that amount is not
|
||||
`CONTRACT_BALANCE` and the router held no WETH from an earlier step when the
|
||||
`WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1
|
||||
WETH from it reads as leaving the WETH there. A `SETTLE` of `OPEN_DELTA`
|
||||
(zero), and a `SETTLE_ALL`, pay only what the step still owes of their token
|
||||
when they run. The decoder takes that to be the amounts the step's swap
|
||||
actions state they spend of that token only when every action in the step
|
||||
could be read, every swap action names the token it spends and the token it
|
||||
buys (a swap action with an empty path swaps nothing), the `SETTLE` or
|
||||
`SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and
|
||||
comes after every swap action, no swap action buys that token, no `TAKE`,
|
||||
`TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount
|
||||
adds to what the step owes), and the swap actions that spend it state a
|
||||
quantity together; otherwise it does not know the amount, and such a `SETTLE`
|
||||
from the router counts as spending nothing. So the rest of a V4 `SETTLE` of
|
||||
more than its swap spends, taken to the router, the WETH of a `WRAP_ETH`, and
|
||||
a token a swap pays the router on the way to the output, each stays in the
|
||||
router when later steps do not spend all of it, where anyone can take it in a
|
||||
later transaction. The router sends ETH as written, so a `TRANSFER` of
|
||||
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
|
||||
token to the V2 pair that the very next step spends, as a V2 exact-in of that
|
||||
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
|
||||
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
|
||||
the router enforces that swap's own minimum. The decoder checks that the
|
||||
address paid is that pair, deriving it from the V2 factory and pair init code
|
||||
hash the network's routers were deployed with and the first two tokens of that
|
||||
step's path; a payment to any other address is named. (Sepolia's
|
||||
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
|
||||
values, so no V2 swap step can succeed on that router.) Every other payment
|
||||
counts, in whichever step and of whatever token, a share of the output
|
||||
@@ -2993,20 +3111,75 @@ Currently supported:
|
||||
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
|
||||
because the swap's minimum includes the fee. The minimum reads `Min. received`
|
||||
only when some step pays the transaction's sender (the From address on
|
||||
TxApproval, the sender the explorer reports on TransactionDetail), no step
|
||||
pays any other address, no step that may pay an address went unread, and the
|
||||
output does not stay in the router. Otherwise it reads `Min. paid out`, a
|
||||
`Paid to` line after `Token Out` names each other address paid, one reads
|
||||
`The router, where it stays (no later step pays it out)` when the output stays
|
||||
in the router, and one more reads `Unknown (could not be decoded)` when such a
|
||||
step went unread, or `Unknown (not named in the calldata)` when no step pays
|
||||
anyone. A step goes unread when its recipient does not decode as an address,
|
||||
or when it is a command the decoder does not read: every command other than
|
||||
`PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`,
|
||||
`V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
|
||||
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `WRAP_ETH`, `UNWRAP_WETH` and
|
||||
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`,
|
||||
`EXECUTE_SUB_PLAN` and `ACROSS_V4_DEPOSIT_V3` among them).
|
||||
TxApproval, the sender the explorer reports on TransactionDetail) and no
|
||||
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
|
||||
address paid; one reads
|
||||
`The bridge, which pays the deposit's recipient on another network` for a
|
||||
bridge deposit; one reads, for example,
|
||||
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
|
||||
for each move into the router, followed by a `Token moved into the router`
|
||||
line that gives the moved token's address as the `Token In` line does; one
|
||||
reads `The router, where it stays (no later step pays it out)` for each token
|
||||
that stays in the router, followed, for any token but the output, by a
|
||||
`Token moved into the router` line giving that token in the same way (ETH by
|
||||
name); and one more reads `Unknown (could not be decoded)` when a step that
|
||||
may pay an address went unread. When no step pays anyone, a single line reads
|
||||
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
|
||||
minimum reads `Min. paid out`. The `Amount` line gives the amount of the step
|
||||
that sets the input side: the last `PERMIT2_PERMIT` step if there is one,
|
||||
otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap
|
||||
step that names an input token or amount. A V4 step with a swap action sets
|
||||
the input side when no earlier step set it, even when it names neither (see
|
||||
Display Consistency). A V2 exact-out step gives its `amountInMax`, and a V4
|
||||
swap step the `amountIn` of the swap action that spends its input currency.
|
||||
Each later V2 or V3 exact-in or V2 exact-out step whose `payerIsUser` is true
|
||||
also takes its amount, found in the same way, from the user's wallet. So does
|
||||
every V4 step but the one that sets the input side, with a swap action or
|
||||
without: it takes what its `SETTLE`s paid by the user (`payerIsUser` true) and
|
||||
its `SETTLE_ALL`s pay, for each token on its own. A `SETTLE` of any amount but
|
||||
`OPEN_DELTA` pays that amount, and a `SETTLE` of `OPEN_DELTA` or a
|
||||
`SETTLE_ALL` pays what the step owes, as above. What a step takes of
|
||||
`Token In` is added to the line's, unless a `PERMIT2_PERMIT` set the input
|
||||
side, whose amount the line keeps. A permit whose spender is the router and
|
||||
which the router does not let fail caps what the router may take of its token
|
||||
after it; any other permit caps nothing the router takes. So a V3 exact-in of
|
||||
600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read
|
||||
`1000.0000 USDT`. When either amount is not a quantity (a V4 open delta,
|
||||
`ALREADY_PAID`, or an amount the decoder does not know), the total is not
|
||||
known and there is no `Amount` line. A step paid from the router spends what
|
||||
the router holds and adds nothing, and so does a V2 exact-in whose `amountIn`
|
||||
is `ALREADY_PAID`, which takes nothing from the user. What a step takes of a
|
||||
token other than `Token In`, or of `Token In` when the decoder does not know
|
||||
the amount or when a permit that caps nothing set the input side, gives three
|
||||
lines after the `Amount` line (after `Token In` when there is none), in step
|
||||
order: `Moved from the sender`, its amount, worded as the `Amount` line words
|
||||
one, or `Unknown (could not be decoded)` when the decoder does not know it;
|
||||
`Token moved`, giving the token's address as the `Token In` line does; and
|
||||
`Moved to`, which reads `The swap step that spends it`. A step may also take
|
||||
from the user's wallet what the decoder did not read: a V4 step, other than
|
||||
the one that sets the input side, whose input or one of whose `SETTLE`s or
|
||||
`SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3 exact-in
|
||||
or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it cannot read;
|
||||
and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one
|
||||
`Moved from the sender` line, among those lines in step order, reading
|
||||
`Unknown (could not be decoded)`, and there is then no `Amount` line, whose
|
||||
figure might be too low. Then each `PERMIT2_TRANSFER_FROM` but one into the
|
||||
router, in step order, gives three lines: `Moved from the sender`, the amount
|
||||
it moves, for example `1000000.0000 USDT`; `Token moved`, giving the token's
|
||||
address as the `Token In` line does; and `Moved to`, which reads
|
||||
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
|
||||
on every router but `UniversalRouterV1`, and otherwise gives the address. A
|
||||
move into the router needs none of them, because its `Paid to` line and
|
||||
`Token moved into the router` line already give the amount, the token and
|
||||
where it goes, and so its amount is shown once. These lines change neither the
|
||||
`Paid to` lines nor the minimum's label. A step goes unread when its recipient
|
||||
does not decode as an address, or when it is a command the decoder does not
|
||||
read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
|
||||
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
|
||||
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
|
||||
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
|
||||
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and
|
||||
the NFT commands among them), or a type its release names no command for.
|
||||
|
||||
### Non-Goals Forever
|
||||
|
||||
|
||||
@@ -697,6 +697,33 @@
|
||||
id="confirm-fee-unknown-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
></div>
|
||||
<!-- Shown when Sign & Send refuses a send for its network, for
|
||||
a From address the selected wallet does not hold, or for its
|
||||
gas limit or fee. Each holds its space from the first paint, so
|
||||
the refusal moves nothing. -->
|
||||
<div
|
||||
id="confirm-network-changed-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
>
|
||||
The active network changed after this transaction was
|
||||
reviewed, so it was not sent. Please go back and review it
|
||||
again.
|
||||
</div>
|
||||
<div
|
||||
id="confirm-from-address-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
>
|
||||
The selected wallet does not hold the From address above, so
|
||||
nothing was sent. Please go back and send again.
|
||||
</div>
|
||||
<div
|
||||
id="confirm-fee-bound-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||
>
|
||||
The gas limit or fee the RPC endpoint quoted for this
|
||||
transaction is more than this wallet will sign for, so it
|
||||
was not sent. Please go back and try again.
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
|
||||
+18
-5
@@ -127,28 +127,33 @@ function isRenderableDecoded(value) {
|
||||
}
|
||||
|
||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
||||
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
||||
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
|
||||
// show() fills in when it is absent, picks the network whose explorer the
|
||||
// address links open, so it has to name one.
|
||||
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
||||
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
||||
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
|
||||
// when it is absent, picks the network whose explorer the address links open,
|
||||
// so it has to name one.
|
||||
function isRenderablePendingTx(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isText(value.token) &&
|
||||
isAddressText(value.from) &&
|
||||
isAddressText(value.to) &&
|
||||
isText(value.amount) &&
|
||||
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
||||
);
|
||||
}
|
||||
|
||||
// The stored transaction transactionDetail.render() shows. contractAddress is
|
||||
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
||||
// `chainId` picks the network whose explorer the links open, so when it is
|
||||
// there it has to name one.
|
||||
function isRenderableTx(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isAddressText(value.from) &&
|
||||
isAddressText(value.to) &&
|
||||
(!value.contractAddress || isAddressText(value.contractAddress))
|
||||
(!value.contractAddress || isAddressText(value.contractAddress)) &&
|
||||
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,11 +205,19 @@ function renderView(view, state, views) {
|
||||
if (!data.hash) return false;
|
||||
if (!isAddressText(data.to)) return false;
|
||||
if (!isRenderableDecoded(data.decoded)) return false;
|
||||
// `chainId` picks the network whose explorer the links open, so
|
||||
// when it is there it has to name one. Likewise on error-tx.
|
||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
||||
return false;
|
||||
}
|
||||
views.txStatus.renderSuccess();
|
||||
return true;
|
||||
case "error-tx":
|
||||
if (!data.message) return false;
|
||||
if (!isAddressText(data.to)) return false;
|
||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
||||
return false;
|
||||
}
|
||||
views.txStatus.renderError();
|
||||
return true;
|
||||
default:
|
||||
|
||||
@@ -23,12 +23,10 @@ const {
|
||||
goBack,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
reviewedNetworkProvider,
|
||||
} = require("./helpers");
|
||||
const { state, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
networkByChainId,
|
||||
nativeCurrencyByChainId,
|
||||
} = require("../../shared/networks");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
@@ -45,8 +43,8 @@ const {
|
||||
transferAmountUnits,
|
||||
} = require("../../shared/transferAmount");
|
||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||
// The balance line and the fee reserve go through it, as the approval screen's
|
||||
// amounts do.
|
||||
// The amount, the balance line and the fee reserve go through it, as the
|
||||
// approval screen's amounts do.
|
||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||
const {
|
||||
CODES,
|
||||
@@ -100,8 +98,32 @@ function valueWithUsd(text, usdAmount) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
||||
// once its amount is re-derived from the fee estimate.
|
||||
// The amount as this screen and the wait, success and error screens show it,
|
||||
// written as the approval screen writes a site's amount: the amount being
|
||||
// signed, parsed at the scale it is encoded with and written back as plain
|
||||
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
|
||||
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
|
||||
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
|
||||
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
|
||||
function shownAmount(txInfo) {
|
||||
let exact = txInfo.amount;
|
||||
try {
|
||||
if (txInfo.token === "ETH") {
|
||||
exact = formatEther(parseEther(txInfo.amount));
|
||||
} else {
|
||||
const decimals = displayedDecimals(txInfo.tokenDecimals);
|
||||
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
|
||||
}
|
||||
} catch {
|
||||
// A token whose scale is unknown, which is never signed, or an amount
|
||||
// the amount check refuses: cut as Send's amount field held it.
|
||||
}
|
||||
return truncateAmountNeverZero(exact);
|
||||
}
|
||||
|
||||
// The Amount line, with its USD value, which is priced from the exact amount.
|
||||
// A max ETH send's line is drawn again once its amount is re-derived from the
|
||||
// fee estimate.
|
||||
function renderAmount(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const rawSymbol = isErc20
|
||||
@@ -110,7 +132,7 @@ function renderAmount(txInfo) {
|
||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||
$("confirm-amount").textContent = valueWithUsd(
|
||||
txInfo.amount + " " + displaySymbol(rawSymbol),
|
||||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||||
amountUsd,
|
||||
);
|
||||
}
|
||||
@@ -291,9 +313,13 @@ function show(txInfo) {
|
||||
|
||||
renderValidation(txInfo);
|
||||
|
||||
// Reset password field and error
|
||||
// Reset password field and error, and any refusal of an earlier Sign &
|
||||
// Send.
|
||||
$("confirm-tx-password").value = "";
|
||||
hideError("confirm-tx-password-error");
|
||||
setVisible("confirm-network-changed-error", false);
|
||||
setVisible("confirm-from-address-error", false);
|
||||
setVisible("confirm-fee-bound-error", false);
|
||||
|
||||
// Gas estimate — show placeholder then fetch async
|
||||
$("confirm-fee").style.visibility = "visible";
|
||||
@@ -430,20 +456,6 @@ function setVisible(id, visible) {
|
||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
// The node of the network this transaction was reviewed for, which need not be
|
||||
// the active one: the popup can reopen onto this screen after Settings switched
|
||||
// the network. Its RPC URL is the one Settings has for that network: the
|
||||
// active network's is state.rpcUrl, any other's the one remembered for it (see
|
||||
// applyChainSwitchFields()).
|
||||
function reviewedNetworkProvider(txInfo) {
|
||||
const net = networkByChainId(txInfo.chainId);
|
||||
if (net.id === state.networkId) {
|
||||
return getProvider(state.rpcUrl, net.id);
|
||||
}
|
||||
const remembered = state.networkEndpoints[net.id] || {};
|
||||
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
|
||||
}
|
||||
|
||||
async function estimateGas(txInfo) {
|
||||
try {
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
@@ -556,16 +568,17 @@ 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. Apart
|
||||
// from a max ETH send, which passes its estimate's fee fields as `fees`, 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.
|
||||
// Populate the transaction this send describes, enforce the bounds against the
|
||||
// gas limit and fees that were actually filled in, then sign and broadcast it.
|
||||
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
|
||||
// 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 gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
|
||||
// over its bound, and the caller then shows the one refusal reserved for all
|
||||
// three rather than sending.
|
||||
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||
let request;
|
||||
if (tx.token === "ETH") {
|
||||
@@ -593,15 +606,6 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||
return connectedSigner.sendTransaction(populated);
|
||||
}
|
||||
|
||||
// Show a full-sentence send failure in the reserved errors box, the same
|
||||
// element and markup renderValidation() uses for the invalid-amount message,
|
||||
// so it never moves anything on the screen.
|
||||
function showSendError(message) {
|
||||
const el = $("confirm-errors");
|
||||
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
|
||||
el.style.visibility = "visible";
|
||||
}
|
||||
|
||||
async function checkRecipientHistory(txInfo) {
|
||||
try {
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
@@ -609,6 +613,9 @@ async function checkRecipientHistory(txInfo) {
|
||||
fromAddress: txInfo.from,
|
||||
chainId: txInfo.chainId,
|
||||
});
|
||||
// The user may have left this transaction while the checks were in
|
||||
// flight; its warnings must not reach another recipient's screen.
|
||||
if (pendingTx !== txInfo) return;
|
||||
for (const w of asyncWarnings) {
|
||||
if (w.type === "contract") {
|
||||
$("confirm-contract-warning").style.visibility = "visible";
|
||||
@@ -637,6 +644,11 @@ function init(_ctx) {
|
||||
onViewLeave("confirm-tx", clearPassword);
|
||||
|
||||
$("btn-confirm-send").addEventListener("click", async () => {
|
||||
// A press shows only its own refusal, if any.
|
||||
setVisible("confirm-network-changed-error", false);
|
||||
setVisible("confirm-from-address-error", false);
|
||||
setVisible("confirm-fee-bound-error", false);
|
||||
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
// Every Send button refuses a defective wallet before this screen,
|
||||
// but the popup also reopens onto it from a saved view.
|
||||
@@ -651,11 +663,7 @@ function init(_ctx) {
|
||||
// Settings after a switch draws this screen again from the saved
|
||||
// transaction, as a popup reopened onto it does.
|
||||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
||||
showSendError(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent. Please go back and review" +
|
||||
" it again.",
|
||||
);
|
||||
setVisible("confirm-network-changed-error", true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -664,11 +672,7 @@ function init(_ctx) {
|
||||
// address selected.
|
||||
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
|
||||
if (!addr) {
|
||||
showError(
|
||||
"confirm-tx-password-error",
|
||||
"The selected wallet does not hold the From address above, " +
|
||||
"so nothing was sent. Please go back and send again.",
|
||||
);
|
||||
setVisible("confirm-from-address-error", true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -700,6 +704,10 @@ function init(_ctx) {
|
||||
$("btn-confirm-send").disabled = true;
|
||||
$("btn-confirm-send").classList.add("text-muted");
|
||||
|
||||
// The wait, success and error screens show the amount as this screen
|
||||
// does, so they are handed a copy carrying that text. pendingTx keeps
|
||||
// the exact amount, which is the one signed.
|
||||
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
|
||||
let tx;
|
||||
try {
|
||||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
||||
@@ -716,19 +724,23 @@ function init(_ctx) {
|
||||
// Note: JS strings are immutable; this nulls the reference but
|
||||
// the original string may persist in memory until GC.
|
||||
decryptedSecret = null;
|
||||
txStatus.showWait(pendingTx, tx.hash);
|
||||
txStatus.showWait(shownTx, 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.
|
||||
// A gas limit or fee over its bound is refused before anything is
|
||||
// broadcast, so there is no transaction that may have reached the
|
||||
// network to warn about: the refusal stays on the confirmation
|
||||
// screen where the user can go back, rather than routing to the
|
||||
// sent/failed screen. Its sentence is fixed in index.html, so that
|
||||
// its box can hold its space from the first paint; the reason in
|
||||
// full, naming the bound, is logged.
|
||||
if (e && e.approvalMismatch) {
|
||||
showSendError(e.message);
|
||||
log.errorf("send refused:", e.message);
|
||||
setVisible("confirm-fee-bound-error", true);
|
||||
return;
|
||||
}
|
||||
const hash = tx ? tx.hash : null;
|
||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
|
||||
} finally {
|
||||
$("btn-confirm-send").disabled = false;
|
||||
$("btn-confirm-send").classList.remove("text-muted");
|
||||
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { DEBUG } = require("../../shared/constants");
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
@@ -128,7 +129,8 @@ function showView(name) {
|
||||
}
|
||||
|
||||
// Create or update the debug/insecure warning banner.
|
||||
// Called on every view switch and after the settings debug toggle changes.
|
||||
// Called on every view switch, after the settings debug toggle changes, and
|
||||
// after a network switch in Settings.
|
||||
// The banner is shown when the compile-time DEBUG constant is true OR when
|
||||
// the user has enabled runtime debug mode via the settings easter egg, OR
|
||||
// when the active network is a testnet.
|
||||
@@ -606,6 +608,20 @@ const EXT_ICON =
|
||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||
`</svg></span>`;
|
||||
|
||||
// The node of the network a transaction was reviewed for, the one its chain id
|
||||
// names, which need not be the active one: the popup can reopen onto ConfirmTx
|
||||
// or WaitTx after Settings switched the network. Its RPC URL is the one
|
||||
// Settings has for that network: the active network's is state.rpcUrl, any
|
||||
// other's the one remembered for it (see applyChainSwitchFields()).
|
||||
function reviewedNetworkProvider(txInfo) {
|
||||
const net = networkByChainId(txInfo.chainId);
|
||||
if (net.id === state.networkId) {
|
||||
return getProvider(state.rpcUrl, net.id);
|
||||
}
|
||||
const remembered = state.networkEndpoints[net.id] || {};
|
||||
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
|
||||
}
|
||||
|
||||
// Block-explorer URLs. The origin is a per-network constant from
|
||||
// src/shared/networks.js, the explorer of the network with `chainId`, or of
|
||||
// the active network when no chain id is given; only the path segment is
|
||||
@@ -761,6 +777,7 @@ module.exports = {
|
||||
attachCopyHandlers,
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
reviewedNetworkProvider,
|
||||
EXT_ICON,
|
||||
truncateMiddle,
|
||||
isoDate,
|
||||
|
||||
@@ -343,11 +343,13 @@ function init(ctx) {
|
||||
// switch request.
|
||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||
resetRenderedViews();
|
||||
// Settings itself is brought up to date for the new network here.
|
||||
// Settings itself, and the testnet banner above it, are brought up
|
||||
// to date for the new network here.
|
||||
markViewRendered("settings");
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
renderTrackedTokens();
|
||||
updateDebugBanner(state.currentView);
|
||||
showFlash("Switched to " + net.name + ".");
|
||||
});
|
||||
|
||||
|
||||
@@ -48,16 +48,16 @@ function getTransactionType(tx) {
|
||||
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
||||
}
|
||||
|
||||
function txAddressHtml(address, ensName, title) {
|
||||
function txAddressHtml(address, ensName, title, chainId) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
`<div class="mb-1">${blockie}</div>` +
|
||||
renderAddressHtml(address, { title, ensName })
|
||||
renderAddressHtml(address, { title, ensName, chainId })
|
||||
);
|
||||
}
|
||||
|
||||
function txHashHtml(hash) {
|
||||
const link = explorerUrl("tx", hash);
|
||||
function txHashHtml(hash, chainId) {
|
||||
const link = explorerUrl("tx", hash, chainId);
|
||||
const extLink = etherscanLinkHtml(link);
|
||||
return copyableHtml(hash, "break-all") + extLink;
|
||||
}
|
||||
@@ -83,10 +83,10 @@ function show(tx) {
|
||||
method: tx.method || null,
|
||||
contractAddress: tx.contractAddress || null,
|
||||
// The network the history entry was read from. The type line and
|
||||
// the fee are in its native currency, not the active network's:
|
||||
// the active network can change, in Settings or when the user
|
||||
// approves a site's request, before a later popup shows this
|
||||
// screen again.
|
||||
// the fee are in its native currency, not the active network's,
|
||||
// and the links open its explorer: the active network can change,
|
||||
// in Settings or when the user approves a site's request, before
|
||||
// a later popup shows this screen again.
|
||||
chainId: tx.chainId,
|
||||
},
|
||||
};
|
||||
@@ -96,18 +96,24 @@ function show(tx) {
|
||||
function render() {
|
||||
const tx = state.viewData.tx;
|
||||
if (!tx) return;
|
||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
|
||||
|
||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||
tx.from,
|
||||
tx.fromEns,
|
||||
fromTitle,
|
||||
tx.chainId,
|
||||
);
|
||||
// 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);
|
||||
$("tx-detail-to").innerHTML = txAddressHtml(
|
||||
tx.to,
|
||||
tx.toEns,
|
||||
toTitle,
|
||||
tx.chainId,
|
||||
);
|
||||
} else {
|
||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
}
|
||||
@@ -146,7 +152,7 @@ function render() {
|
||||
if (tokenContractSection && tokenContractEl) {
|
||||
if (tx.contractAddress) {
|
||||
const dot = addressDotHtml(tx.contractAddress);
|
||||
const link = explorerUrl("token", tx.contractAddress);
|
||||
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
|
||||
// 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
|
||||
@@ -204,7 +210,11 @@ function showDetailField(sectionId, contentId, value) {
|
||||
function populateOnChainDetails(txData, chainId) {
|
||||
// Block number
|
||||
if (txData.block_number != null) {
|
||||
const blockLink = explorerUrl("block", String(txData.block_number));
|
||||
const blockLink = explorerUrl(
|
||||
"block",
|
||||
String(txData.block_number),
|
||||
chainId,
|
||||
);
|
||||
const blockSection = $("tx-detail-block-section");
|
||||
const blockEl = $("tx-detail-block");
|
||||
if (blockSection && blockEl) {
|
||||
@@ -340,9 +350,9 @@ async function loadFullTxDetails(txHash, chainId) {
|
||||
if (tokenSymbol) {
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||
}
|
||||
detailsHtml += renderAddressHtml(d.address);
|
||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||
} else if (d.address) {
|
||||
detailsHtml += renderAddressHtml(d.address);
|
||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||
} else {
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
|
||||
+38
-28
@@ -14,10 +14,13 @@ const {
|
||||
displaySymbol,
|
||||
clearViewStack,
|
||||
tokenLabel,
|
||||
reviewedNetworkProvider,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const {
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
nativeCurrencyByChainId,
|
||||
} = require("../../shared/networks");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||
@@ -61,21 +64,22 @@ function endWait() {
|
||||
}
|
||||
|
||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
||||
// writes `to: toAddr || ""`).
|
||||
function toAddressHtml(address) {
|
||||
// writes `to: toAddr || ""`). The links below open the explorer of the network
|
||||
// with `chainId`, or of the active network when none is given.
|
||||
function toAddressHtml(address, chainId) {
|
||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
const title = addressTitle(address, state.wallets);
|
||||
return renderAddressHtml(address, { title });
|
||||
return renderAddressHtml(address, { title, chainId });
|
||||
}
|
||||
|
||||
function txHashHtml(hash) {
|
||||
const link = explorerUrl("tx", hash);
|
||||
function txHashHtml(hash, chainId) {
|
||||
const link = explorerUrl("tx", hash, chainId);
|
||||
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
||||
}
|
||||
|
||||
function blockNumberHtml(blockNumber) {
|
||||
function blockNumberHtml(blockNumber, chainId) {
|
||||
const num = String(blockNumber);
|
||||
const link = explorerUrl("block", num);
|
||||
const link = explorerUrl("block", num, chainId);
|
||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||
}
|
||||
|
||||
@@ -91,9 +95,11 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
// native currency of txInfo.chainId, the network the transaction was sent
|
||||
// on, not the active network's: the active network can change, in
|
||||
// Settings or when the user approves a site's request, while this screen
|
||||
// is open or before a later popup resumes it. A null token is a swap whose
|
||||
// amount was not decoded (see showTxApproval() in approval.js): the amount
|
||||
// says so, and no symbol follows it.
|
||||
// is open or before a later popup resumes it. The links on all three
|
||||
// screens open that network's explorer, and this screen's receipt is
|
||||
// asked of that network's node, for the same reason. A null token is a
|
||||
// swap whose amount was not decoded (see showTxApproval() in
|
||||
// approval.js): the amount says so, and no symbol follows it.
|
||||
let symbol = "";
|
||||
if (txInfo.token === "ETH") {
|
||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
||||
@@ -102,8 +108,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
}
|
||||
$("wait-tx-summary").textContent =
|
||||
txInfo.amount + (symbol ? " " + symbol : "");
|
||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
||||
attachCopyHandlers("view-wait-tx");
|
||||
|
||||
// Persisted so closing and reopening the popup resumes this wait
|
||||
@@ -128,7 +134,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
renderElapsed();
|
||||
}, 1000);
|
||||
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
async function poll() {
|
||||
@@ -207,10 +213,11 @@ function showWait(txInfo, txHash) {
|
||||
// an object merely missing one of them throws a TypeError out of
|
||||
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
|
||||
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
|
||||
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
|
||||
// ETH.
|
||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
|
||||
// the node the receipt is asked of and the explorer the links open, so it has
|
||||
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
|
||||
// unchecked: they are compared and coalesced rather than dereferenced, and
|
||||
// tokenSymbol is null for ETH.
|
||||
function restoreWait() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.pendingWait) return false;
|
||||
@@ -226,6 +233,7 @@ function restoreWait() {
|
||||
// path itself created.
|
||||
if (typeof info.to !== "string") return false;
|
||||
if (typeof info.amount !== "string") return false;
|
||||
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
|
||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||
return false;
|
||||
}
|
||||
@@ -250,12 +258,13 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
||||
hash: txHash,
|
||||
blockNumber: blockNumber,
|
||||
decoded: txInfo.decoded || null,
|
||||
chainId: txInfo.chainId,
|
||||
};
|
||||
renderSuccess();
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
function decodedDetailsHtml(decoded) {
|
||||
function decodedDetailsHtml(decoded, chainId) {
|
||||
if (!decoded || !decoded.details) return "";
|
||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
if (decoded.name) {
|
||||
@@ -273,9 +282,9 @@ function decodedDetailsHtml(decoded) {
|
||||
if (d.isToken) {
|
||||
const sym = tokenLabel(d.address) || "Unknown token";
|
||||
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
|
||||
html += toAddressHtml(d.address);
|
||||
html += toAddressHtml(d.address, chainId);
|
||||
} else {
|
||||
html += toAddressHtml(d.address);
|
||||
html += toAddressHtml(d.address, chainId);
|
||||
}
|
||||
} else {
|
||||
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
@@ -304,16 +313,16 @@ function renderSuccess() {
|
||||
toSection.classList.remove("hidden");
|
||||
$("success-tx-summary").textContent =
|
||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
||||
$("success-tx-to").innerHTML = toAddressHtml(d.to);
|
||||
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
||||
}
|
||||
|
||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
|
||||
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
|
||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
|
||||
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
||||
|
||||
// Show decoded calldata details if present
|
||||
const decodedEl = $("success-tx-decoded");
|
||||
if (decodedEl && hasDecoded) {
|
||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
|
||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
|
||||
decodedEl.classList.remove("hidden");
|
||||
} else if (decodedEl) {
|
||||
decodedEl.classList.add("hidden");
|
||||
@@ -339,6 +348,7 @@ function showError(txInfo, txHash, message) {
|
||||
to: txInfo.to,
|
||||
hash: txHash || null,
|
||||
message: message,
|
||||
chainId: txInfo.chainId,
|
||||
};
|
||||
renderError();
|
||||
}
|
||||
@@ -348,11 +358,11 @@ function renderError() {
|
||||
if (!d || !d.message) return;
|
||||
$("error-tx-summary").textContent =
|
||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
||||
$("error-tx-to").innerHTML = toAddressHtml(d.to);
|
||||
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
||||
$("error-tx-message").textContent = d.message;
|
||||
|
||||
if (d.hash) {
|
||||
$("error-tx-hash").innerHTML = txHashHtml(d.hash);
|
||||
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
||||
$("error-tx-hash-section").classList.remove("hidden");
|
||||
attachCopyHandlers("view-error-tx");
|
||||
} else {
|
||||
|
||||
+854
-172
File diff suppressed because it is too large
Load Diff
@@ -442,11 +442,25 @@ describe("a confirmation screen whose From is not the selected address", () => {
|
||||
expect(Transaction.from(signed).from).toBe(derived(1).address);
|
||||
});
|
||||
|
||||
// The refusal's sentence is fixed in index.html, in a box of its own that
|
||||
// holds its space from the first paint, so showing it moves nothing.
|
||||
test("nothing is sent when the selected wallet does not hold it", async () => {
|
||||
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
|
||||
expect(node("confirm-tx-password-error").textContent).toBe(
|
||||
"The selected wallet does not hold the From address above, " +
|
||||
"so nothing was sent. Please go back and send again.",
|
||||
expect(node("confirm-from-address-error").style.visibility).toBe(
|
||||
"visible",
|
||||
);
|
||||
expect(node("confirm-tx-password-error").textContent).toBe("");
|
||||
|
||||
// Reviewing a send again draws the screen again, which hides it.
|
||||
confirmTx.show({
|
||||
from: derived(0).address,
|
||||
to: RECIPIENT,
|
||||
amount: "0.5",
|
||||
token: "ETH",
|
||||
});
|
||||
await settle();
|
||||
expect(node("confirm-from-address-error").style.visibility).toBe(
|
||||
"hidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// The amount of the wallet's own send on the confirmation, wait, success and
|
||||
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
|
||||
//
|
||||
// These screens printed the text from Send's amount field as it was typed or
|
||||
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
|
||||
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
|
||||
// decimal places with the nonzero floor, as the approval screen does, and the
|
||||
// transaction is still signed for the exact amount.
|
||||
//
|
||||
// Driven through the real Send screen, confirmation screen, Sign & Send and
|
||||
// wait screen, with only the node, the explorer and the DOM stubbed.
|
||||
|
||||
"use strict";
|
||||
|
||||
// What the stub node answers, and the signed transactions it was handed. The
|
||||
// token's own decimals() answers 6, as the explorer does.
|
||||
const mockNode = {
|
||||
balanceWei: 0n,
|
||||
tokenUnits: 0n,
|
||||
tokenDecimals: 6n,
|
||||
broadcast: [],
|
||||
broadcastError: null,
|
||||
receipt: null,
|
||||
};
|
||||
|
||||
// The token rows the stub explorer reports for the address.
|
||||
const mockExplorer = { items: [] };
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
// The selector of decimals(); every other contract call is balanceOf().
|
||||
const DECIMALS = "0x313ce567";
|
||||
class StubProvider {
|
||||
async getBalance() {
|
||||
return mockNode.balanceWei;
|
||||
}
|
||||
async call(tx) {
|
||||
return actual.AbiCoder.defaultAbiCoder().encode(
|
||||
["uint256"],
|
||||
[
|
||||
tx.data.startsWith(DECIMALS)
|
||||
? mockNode.tokenDecimals
|
||||
: mockNode.tokenUnits,
|
||||
],
|
||||
);
|
||||
}
|
||||
async lookupAddress() {
|
||||
return null;
|
||||
}
|
||||
// 20 gwei reserved per gas.
|
||||
async getFeeData() {
|
||||
return {
|
||||
maxFeePerGas: 20000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
gasPrice: 10000000000n,
|
||||
};
|
||||
}
|
||||
async estimateGas() {
|
||||
return 21000n;
|
||||
}
|
||||
async getCode() {
|
||||
return "0x";
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return 1;
|
||||
}
|
||||
async getNetwork() {
|
||||
return { chainId: 1n };
|
||||
}
|
||||
async broadcastTransaction(signed) {
|
||||
if (mockNode.broadcastError) throw mockNode.broadcastError;
|
||||
mockNode.broadcast.push(signed);
|
||||
return { hash: "0x" + "ab".repeat(32) };
|
||||
}
|
||||
async getTransactionReceipt() {
|
||||
return mockNode.receipt;
|
||||
}
|
||||
}
|
||||
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");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
// 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() {},
|
||||
// The success screen hides whole sections through .parentElement.
|
||||
get parentElement() {
|
||||
return global.document.getElementById(id + "-parent");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
Interface,
|
||||
Transaction,
|
||||
Wallet,
|
||||
getAddress,
|
||||
parseEther,
|
||||
} = require("ethers");
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
const { encryptWithPassword } = require("../src/shared/vault");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { clearPrices } = require("../src/shared/prices");
|
||||
const send = require("../src/popup/views/send");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
|
||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
const GWEI = 1000000000n;
|
||||
// 1.234567890123456789 ETH.
|
||||
const BALANCE_WEI = 1234567890123456789n;
|
||||
|
||||
// A token the bundled list does not know, with enough holders to be listed,
|
||||
// at 6 decimal places.
|
||||
const TOKEN = "0x" + "d".repeat(40);
|
||||
|
||||
const transferIface = new Interface([
|
||||
"function transfer(address to, uint256 amount)",
|
||||
]);
|
||||
|
||||
let encryptedSecret;
|
||||
|
||||
// The confirmation screen Review leads to, once shown.
|
||||
let confirmed = null;
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
function text(id) {
|
||||
return el(id).textContent;
|
||||
}
|
||||
|
||||
// show() reads the balance and then estimates the fee without awaiting
|
||||
// either; this lets both settle.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
|
||||
// base units of TOKEN, fetched and stored where the popup stores them. Send
|
||||
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
|
||||
async function openSend(token, tokenUnits = null) {
|
||||
mockNode.balanceWei = BALANCE_WEI;
|
||||
mockNode.tokenUnits = tokenUnits ?? 0n;
|
||||
mockExplorer.items =
|
||||
tokenUnits === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: String(tokenUnits),
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: TOKEN,
|
||||
symbol: "TOK",
|
||||
name: "Token",
|
||||
decimals: "6",
|
||||
holders_count: "50000",
|
||||
},
|
||||
},
|
||||
];
|
||||
state.wallets = [
|
||||
{
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret,
|
||||
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",
|
||||
);
|
||||
send.init({
|
||||
showConfirmTx: (info) => (confirmed = info),
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
confirmTx.init({});
|
||||
send.resetSendValidation();
|
||||
state.currentView = "send";
|
||||
state.selectedToken = token;
|
||||
el("send-to").value = RECIPIENT;
|
||||
el("send-amount").value = "";
|
||||
}
|
||||
|
||||
// Press Review and show the confirmation screen with its fee estimate settled.
|
||||
async function review() {
|
||||
await el("btn-send-review").handlers.get("click")();
|
||||
confirmTx.show(confirmed);
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function signAndSend() {
|
||||
el("confirm-tx-password").value = PASSWORD;
|
||||
await el("btn-confirm-send").handlers.get("click")();
|
||||
}
|
||||
|
||||
// The one transaction the stub node was handed.
|
||||
function broadcastTx() {
|
||||
expect(mockNode.broadcast).toHaveLength(1);
|
||||
return Transaction.from(mockNode.broadcast[0]);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
clearPrices();
|
||||
confirmed = null;
|
||||
state.selectedToken = null;
|
||||
state.trackedTokens = [];
|
||||
state.fraudContracts = [];
|
||||
state.currentView = null;
|
||||
mockNode.broadcast = [];
|
||||
mockNode.broadcastError = null;
|
||||
mockNode.receipt = null;
|
||||
txStatus.init({
|
||||
doRefreshAndRender() {},
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
});
|
||||
|
||||
describe("the wallet's own send shows its amount to four decimal places", () => {
|
||||
test("for a short amount", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
||||
});
|
||||
|
||||
// Send accepts both. The approval screen writes the same value `0.5000`.
|
||||
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = typed;
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
||||
});
|
||||
|
||||
test("for a token amount typed as .5", async () => {
|
||||
// 100 TOK.
|
||||
await openSend(TOKEN, 100000000n);
|
||||
el("send-amount").value = ".5";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 TOK");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
|
||||
const [, amount] = transferIface.decodeFunctionData(
|
||||
"transfer",
|
||||
broadcastTx().data,
|
||||
);
|
||||
expect(amount).toBe(500000n);
|
||||
});
|
||||
|
||||
test("for a Max amount with many decimals", async () => {
|
||||
await openSend("ETH");
|
||||
await el("btn-send-max").handlers.get("click")();
|
||||
// The balance minus 21000 gas at 20 gwei.
|
||||
expect(el("send-amount").value).toBe("1.234147890123456789");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
|
||||
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
|
||||
});
|
||||
|
||||
// Cut to four decimals it would read 0.0000, so it is shown to its first
|
||||
// significant digit instead, and signed in full.
|
||||
test("for a tiny nonzero amount", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.000012345";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.00001 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
|
||||
});
|
||||
|
||||
test("for a token amount", async () => {
|
||||
// 100 TOK.
|
||||
await openSend(TOKEN, 100000000n);
|
||||
el("send-amount").value = "12.345678";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("12.3456 TOK");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
|
||||
const tx = broadcastTx();
|
||||
expect(tx.to).toBe(getAddress(TOKEN));
|
||||
const [to, amount] = transferIface.decodeFunctionData(
|
||||
"transfer",
|
||||
tx.data,
|
||||
);
|
||||
expect(to).toBe(RECIPIENT);
|
||||
expect(amount).toBe(12345678n);
|
||||
});
|
||||
|
||||
test("on the success screen", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
await signAndSend();
|
||||
// A popup opened later resumes the wait, and the receipt is there.
|
||||
mockNode.receipt = { blockNumber: 21000000 };
|
||||
txStatus.restoreWait();
|
||||
await settle();
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(text("success-tx-summary")).toBe("0.5000 ETH");
|
||||
});
|
||||
|
||||
test("on the error screen", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
mockNode.broadcastError = new Error("the node refused it");
|
||||
await signAndSend();
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(text("error-tx-summary")).toBe("0.5000 ETH");
|
||||
});
|
||||
});
|
||||
@@ -11,20 +11,115 @@
|
||||
//
|
||||
// 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.
|
||||
// and sendTransaction() records whether the send actually happened.
|
||||
//
|
||||
// The last test drives the real confirmation screen instead, with only the
|
||||
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
|
||||
// is refused in the same box as a fee over the bound
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
|
||||
// nothing on the screen is checked by the Chrome e2e suite.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
|
||||
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
|
||||
// it was handed.
|
||||
const mockNode = {
|
||||
gasLimit: 150000000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
broadcast: [],
|
||||
};
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
class StubProvider {
|
||||
constructor(url, network) {
|
||||
this.network = network;
|
||||
}
|
||||
async getNetwork() {
|
||||
return this.network;
|
||||
}
|
||||
async getBalance() {
|
||||
return actual.parseEther("5");
|
||||
}
|
||||
async getCode() {
|
||||
return "0x";
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return 1;
|
||||
}
|
||||
async getFeeData() {
|
||||
return {
|
||||
maxFeePerGas: mockNode.maxFeePerGas,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
gasPrice: 1000000000n,
|
||||
};
|
||||
}
|
||||
async estimateGas() {
|
||||
return mockNode.gasLimit;
|
||||
}
|
||||
async broadcastTransaction(signed) {
|
||||
mockNode.broadcast.push(signed);
|
||||
return { hash: "0x" + "ab".repeat(32) };
|
||||
}
|
||||
}
|
||||
return { ...actual, JsonRpcProvider: StubProvider };
|
||||
});
|
||||
|
||||
// The screens a send that was not refused goes on to.
|
||||
jest.mock("../src/popup/views/txStatus", () => ({
|
||||
showWait: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
}));
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
|
||||
// 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,
|
||||
addEventListener(name, fn) {
|
||||
handlers.set(name, fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
};
|
||||
|
||||
const { Wallet } = require("ethers");
|
||||
const { encryptWithPassword } = require("../src/shared/vault");
|
||||
const { state, loadState } = require("../src/shared/state");
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
const { populateVerifyAndSend } = confirmTx;
|
||||
const {
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
MAX_TOTAL_FEE,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
|
||||
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sign & Send on the confirmation screen", () => {
|
||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
let encryptedSecret;
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadState();
|
||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
||||
});
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
function shown(id) {
|
||||
return el(id).style.visibility === "visible";
|
||||
}
|
||||
|
||||
// show() reads the balance and estimates the fee without awaiting
|
||||
// either; this lets both finish.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
|
||||
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
|
||||
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
|
||||
MAX_TOTAL_FEE,
|
||||
);
|
||||
|
||||
const from = new Wallet(PRIVATE_KEY).address;
|
||||
state.wallets = [
|
||||
{
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret,
|
||||
addresses: [{ address: from }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
confirmTx.init({});
|
||||
confirmTx.show({
|
||||
from,
|
||||
to: RECIPIENT,
|
||||
ensName: null,
|
||||
amount: "1",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
tokenDecimals: null,
|
||||
max: false,
|
||||
});
|
||||
await settle();
|
||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
||||
expect(shown("confirm-fee-bound-error")).toBe(false);
|
||||
|
||||
el("confirm-tx-password").value = PASSWORD;
|
||||
await el("btn-confirm-send").handlers.get("click")();
|
||||
|
||||
expect(shown("confirm-fee-bound-error")).toBe(true);
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// A late answer for a confirmation screen the user has left changes nothing on
|
||||
// the screen shown since (https://git.eeqj.de/sneak/AutistMask/issues/575).
|
||||
//
|
||||
// The confirmation screen asks the node and the explorer about its transaction
|
||||
// without waiting for the answers: the recipient checks, which can show three
|
||||
// warning boxes, the balance read and the fee estimate. Back, a new recipient
|
||||
// and Review again show the screen for another transaction while those
|
||||
// questions may still be open. The recipient checks showed their boxes
|
||||
// whenever they answered, so a box about the first recipient could appear on
|
||||
// the second recipient's screen.
|
||||
//
|
||||
// Driven through the real confirmation screen, with only the node, the
|
||||
// explorer and the DOM stubbed. Each test holds back the first screen's answer
|
||||
// to one question, and gives it once the screen it tests is shown.
|
||||
|
||||
"use strict";
|
||||
|
||||
// The From address of every transaction here. The jest.mock() factory below
|
||||
// may only name outside variables whose names start with "mock".
|
||||
const mockFrom = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
// Every answer is a mock, so a test can hold one back. Every address has
|
||||
// sent one transaction and has no code, and only the From address holds
|
||||
// anything: 5 ETH.
|
||||
class StubProvider {}
|
||||
Object.assign(StubProvider.prototype, {
|
||||
getBalance: jest.fn(async (address) =>
|
||||
address === mockFrom ? actual.parseEther("5") : 0n,
|
||||
),
|
||||
getCode: jest.fn(async () => "0x"),
|
||||
getTransactionCount: jest.fn(async () => 1),
|
||||
// 10 gwei expected, 20 gwei reserved per gas.
|
||||
getFeeData: jest.fn(async () => ({
|
||||
maxFeePerGas: 20000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
gasPrice: 10000000000n,
|
||||
})),
|
||||
estimateGas: jest.fn(async () => 21000n),
|
||||
});
|
||||
return { ...actual, JsonRpcProvider: StubProvider };
|
||||
});
|
||||
|
||||
// An explorer address page whose title is `title`.
|
||||
function explorerPage(title) {
|
||||
return { ok: true, text: async () => "<title>" + title + "</title>" };
|
||||
}
|
||||
|
||||
// The explorer labels no address.
|
||||
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
// A stub DOM: every id resolves to a recording element.
|
||||
const elements = new Map();
|
||||
|
||||
function makeEl(id) {
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
toggle() {},
|
||||
contains: () => false,
|
||||
},
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => [],
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
};
|
||||
|
||||
const { JsonRpcProvider, parseEther } = require("ethers");
|
||||
const { loadState } = require("../src/shared/state");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
|
||||
const node = JsonRpcProvider.prototype;
|
||||
const FIRST = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const SECOND = "0xE2E0000000000000000000000000000000000E2e";
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
function shown(id) {
|
||||
return el(id).style.visibility === "visible";
|
||||
}
|
||||
|
||||
// Show the confirmation screen for 1 ETH to `to` on mainnet, as Review on the
|
||||
// Send screen does.
|
||||
function review(to) {
|
||||
confirmTx.show({
|
||||
from: mockFrom,
|
||||
to,
|
||||
ensName: null,
|
||||
amount: "1",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
tokenDecimals: null,
|
||||
max: false,
|
||||
});
|
||||
}
|
||||
|
||||
// show() does not wait for its questions; this lets every answer not held
|
||||
// back arrive.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// Hold back the next answer `question` gives. Returns the function that gives
|
||||
// it.
|
||||
function holdNextAnswer(question) {
|
||||
let give;
|
||||
question.mockImplementationOnce(() => new Promise((r) => (give = r)));
|
||||
return (answer) => give(answer);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadState();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
});
|
||||
|
||||
// The three boxes the recipient checks show, each with the question whose
|
||||
// answer shows it for FIRST, and that answer. FIRST holds no ETH, so the
|
||||
// answer that it has sent no transaction is what shows the box saying so.
|
||||
describe.each([
|
||||
{
|
||||
box: "confirm-contract-warning",
|
||||
question: node.getCode,
|
||||
answer: "0x6080604052",
|
||||
},
|
||||
{
|
||||
box: "confirm-recipient-warning",
|
||||
question: node.getTransactionCount,
|
||||
answer: 0,
|
||||
},
|
||||
{
|
||||
box: "confirm-etherscan-warning",
|
||||
question: global.fetch,
|
||||
answer: explorerPage(
|
||||
"Fake_Phishing1 | Address: " + FIRST + " | Etherscan",
|
||||
),
|
||||
},
|
||||
])("the $box box", ({ box, question, answer }) => {
|
||||
test("is shown when the answer about the recipient arrives while its screen is shown", async () => {
|
||||
const give = holdNextAnswer(question);
|
||||
review(FIRST);
|
||||
await settle();
|
||||
expect(shown(box)).toBe(false);
|
||||
|
||||
give(answer);
|
||||
await settle();
|
||||
expect(shown(box)).toBe(true);
|
||||
});
|
||||
|
||||
test("is not shown on the next recipient's screen when the answer about the first arrives after that screen is shown", async () => {
|
||||
const give = holdNextAnswer(question);
|
||||
review(FIRST);
|
||||
await settle();
|
||||
review(SECOND);
|
||||
await settle();
|
||||
expect(shown(box)).toBe(false);
|
||||
|
||||
give(answer);
|
||||
await settle();
|
||||
expect(shown(box)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a balance read answered after the next screen is shown leaves that screen's balance and balance check as they are", async () => {
|
||||
const give = holdNextAnswer(node.getBalance);
|
||||
review(FIRST);
|
||||
await settle();
|
||||
review(SECOND);
|
||||
await settle();
|
||||
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
||||
|
||||
give(parseEther("0.5"));
|
||||
await settle();
|
||||
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
|
||||
expect(shown("confirm-balance-error")).toBe(false);
|
||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("a fee estimate answered after the next screen is shown leaves that screen's fee as it is", async () => {
|
||||
const give = holdNextAnswer(node.getFeeData);
|
||||
review(FIRST);
|
||||
await settle();
|
||||
review(SECOND);
|
||||
await settle();
|
||||
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
||||
|
||||
// 100 times the fee the second screen was quoted.
|
||||
give({
|
||||
maxFeePerGas: 2000000000000n,
|
||||
maxPriorityFeePerGas: 100000000000n,
|
||||
gasPrice: 1000000000000n,
|
||||
});
|
||||
await settle();
|
||||
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
|
||||
expect(el("confirm-fee-reserve").textContent).toBe(
|
||||
"up to 0.0004 ETH reserved",
|
||||
);
|
||||
});
|
||||
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
|
||||
"Native " + symbol + " transfer",
|
||||
);
|
||||
expect(el("confirm-amount").textContent).toBe(
|
||||
"1 " + symbol + usdOn(networkId, "$2,000.00"),
|
||||
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
|
||||
);
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
||||
@@ -440,12 +440,8 @@ describe.each([
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
||||
expect(el("confirm-errors").style.visibility).toBe("visible");
|
||||
expect(el("confirm-errors").innerHTML).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent. Please go back and review" +
|
||||
" it again.",
|
||||
);
|
||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
||||
expect(shown("confirm-fee-bound-error")).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -497,10 +493,7 @@ describe.each([
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
||||
expect(el("confirm-errors").innerHTML).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent.",
|
||||
);
|
||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
||||
});
|
||||
|
||||
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
|
||||
@@ -579,10 +572,7 @@ describe.each([
|
||||
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(errors()).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent.",
|
||||
);
|
||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
||||
});
|
||||
|
||||
const tokenSend = {
|
||||
@@ -629,10 +619,7 @@ describe.each([
|
||||
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(errors()).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent.",
|
||||
);
|
||||
expect(shown("confirm-network-changed-error")).toBe(true);
|
||||
});
|
||||
|
||||
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
|
||||
@@ -662,7 +649,7 @@ describe.each([
|
||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
||||
);
|
||||
expect(el("confirm-amount").textContent).toBe(
|
||||
"10 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||
);
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
||||
|
||||
@@ -192,6 +192,7 @@ describe("the wait, success and error screens", () => {
|
||||
amount: "0.0000",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
chainId: "0x1",
|
||||
};
|
||||
const transfer = { ...creation, to: RECIPIENT };
|
||||
|
||||
|
||||
@@ -226,8 +226,9 @@ const RPC_RESULTS = {
|
||||
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
|
||||
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
|
||||
// reserve and the estimate collapse to the same number, and the gate tests
|
||||
// stop being able to distinguish them.
|
||||
function latestBlock() {
|
||||
// stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
|
||||
// read at request time like every other fixture switch.
|
||||
function latestBlock(opts) {
|
||||
return {
|
||||
hash: "0x" + "11".repeat(32),
|
||||
parentHash: "0x" + "22".repeat(32),
|
||||
@@ -239,7 +240,7 @@ function latestBlock() {
|
||||
gasUsed: "0xf4240",
|
||||
miner: STUB_COUNTERPARTY,
|
||||
extraData: "0x",
|
||||
baseFeePerGas: hex(BASE_FEE_WEI),
|
||||
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
|
||||
transactions: [],
|
||||
};
|
||||
}
|
||||
@@ -481,7 +482,7 @@ function rpcReply(req, opts, report) {
|
||||
});
|
||||
}
|
||||
if (req.method === "eth_getBlockByNumber") {
|
||||
return Object.assign(envelope, { result: latestBlock() });
|
||||
return Object.assign(envelope, { result: latestBlock(opts) });
|
||||
}
|
||||
// The end of the dApp transaction round trip: the raw signed transaction
|
||||
// the background hands to the node. It is recorded verbatim so a test can
|
||||
@@ -625,6 +626,8 @@ function traceEnabled(raw) {
|
||||
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
|
||||
* defaults to zero, which is what every test that predates the funded
|
||||
* fixture expects.
|
||||
* @param {string} [opts.baseFeeWei] hex wei answered as the latest block's
|
||||
* baseFeePerGas; defaults to BASE_FEE_WEI. Read at request time.
|
||||
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
|
||||
* node-side refusal.
|
||||
* @param {boolean} [opts.holdBalance] hold every batch containing an
|
||||
|
||||
+276
-2
@@ -1824,6 +1824,65 @@ test("the Settings assertions above all ran (#229)", async (env) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------ testnet banner (#511)
|
||||
|
||||
// This suite runs a release build, so the red banner at the top reads
|
||||
// "[TESTNET]" on Sepolia and is absent on mainnet. A network switch in
|
||||
// Settings must change it while Settings is still on screen; it used to
|
||||
// change only when the next screen came up
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/511).
|
||||
|
||||
// The banner's text, or null when there is none, once the switch to `net`
|
||||
// has finished. The switch's flash is the last thing its handler does, and
|
||||
// showView() clears the flash, so the banner read while that flash is up is
|
||||
// the one the switch left on the screen it was made from.
|
||||
async function bannerAfterSwitch(page, net) {
|
||||
await page.waitForFunction(
|
||||
(text) => document.getElementById("flash-msg").textContent === text,
|
||||
"Switched to " + net.name + ".",
|
||||
);
|
||||
return page.evaluate(() => {
|
||||
const banner = document.getElementById("debug-banner");
|
||||
return {
|
||||
text: banner === null ? null : banner.textContent,
|
||||
onSettings: !document
|
||||
.getElementById("view-settings")
|
||||
.classList.contains("hidden"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("a network switch in Settings shows and removes the testnet banner at once (#511)", async (env) => {
|
||||
const { mainnet, sepolia } = NETWORKS;
|
||||
await openSettings(env.page);
|
||||
assert(
|
||||
(await env.page.locator("#debug-banner").count()) === 0,
|
||||
"a banner is up on mainnet before the switch",
|
||||
);
|
||||
|
||||
await env.page.selectOption("#settings-network", "sepolia");
|
||||
let after = await bannerAfterSwitch(env.page, sepolia);
|
||||
assert(after.onSettings, "Settings left the screen on the switch");
|
||||
assert(
|
||||
after.text === "[TESTNET]",
|
||||
"after the switch to Sepolia, Settings shows the banner " +
|
||||
JSON.stringify(after.text) +
|
||||
', expected "[TESTNET]"',
|
||||
);
|
||||
|
||||
await env.page.selectOption("#settings-network", "mainnet");
|
||||
after = await bannerAfterSwitch(env.page, mainnet);
|
||||
assert(after.onSettings, "Settings left the screen on the switch back");
|
||||
assert(
|
||||
after.text === null,
|
||||
"after the switch back to mainnet, Settings still shows the banner " +
|
||||
JSON.stringify(after.text),
|
||||
);
|
||||
|
||||
await env.page.click("#btn-settings-back");
|
||||
await visible(env.page, "#view-main");
|
||||
});
|
||||
|
||||
// ------------------------------------------------ dust threshold (#233)
|
||||
|
||||
// The popup size README documents the UI as designed for. Pages in this
|
||||
@@ -2232,7 +2291,8 @@ function feeEth(wei) {
|
||||
|
||||
// What the confirmation screen is showing right now, read out of the DOM in
|
||||
// one pass: whether sending is allowed, which reason it is giving, what the
|
||||
// fee block says, how tall the whole view is, and where Sign & Send sits in it.
|
||||
// fee block says, how tall the whole view is, and where the password field and
|
||||
// Sign & Send sit in it.
|
||||
async function confirmState(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
@@ -2245,6 +2305,9 @@ async function confirmState(page) {
|
||||
};
|
||||
return {
|
||||
height: el("view-confirm-tx").getBoundingClientRect().height,
|
||||
passwordTop:
|
||||
el("confirm-tx-password").getBoundingClientRect().top -
|
||||
el("view-confirm-tx").getBoundingClientRect().top,
|
||||
sendTop:
|
||||
el("btn-confirm-send").getBoundingClientRect().top -
|
||||
el("view-confirm-tx").getBoundingClientRect().top,
|
||||
@@ -2262,6 +2325,12 @@ async function confirmState(page) {
|
||||
amountFeeError: shown("confirm-amount-fee-error"),
|
||||
gasError: shown("confirm-gas-error"),
|
||||
feeUnknownError: shown("confirm-fee-unknown-error"),
|
||||
// Its sentence is wrapped across lines in index.html.
|
||||
feeBoundError: shown("confirm-fee-bound-error")
|
||||
? el("confirm-fee-bound-error")
|
||||
.textContent.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
: "",
|
||||
sendDisabled: el("btn-confirm-send").disabled,
|
||||
};
|
||||
});
|
||||
@@ -2905,9 +2974,10 @@ async function goToTokenConfirm(env) {
|
||||
amount: TOKEN_AMOUNT,
|
||||
});
|
||||
await waitForEstimate(env.page);
|
||||
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
|
||||
const shown = await shownAmount(env.page);
|
||||
assert(
|
||||
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
|
||||
shown === "0.2500 " + STUB_TOKEN.symbol,
|
||||
"the confirmation screen is not showing the amount that was entered: " +
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
@@ -3057,6 +3127,210 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------- a send Sign & Send refuses moves nothing (#583)
|
||||
//
|
||||
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
|
||||
// screen when the active network changed since review and when its gas limit
|
||||
// or fee is over its bound, and each refusal's sentence sits in a box that held
|
||||
// its space from the first paint. This drives the total fee refusal, the longer
|
||||
// of the two sentences.
|
||||
|
||||
// The base fee the stub node quotes once Sign & Send is pressed. ethers asks
|
||||
// twice it plus the 1 gwei priority fee per gas, 50,001 gwei, so the 21,000-gas
|
||||
// send would allow a fee of about 1.05 ETH, over the 1 ETH this wallet signs
|
||||
// for (MAX_TOTAL_FEE in src/shared/approvalVerify.js).
|
||||
const OVER_BOUND_BASE_FEE_WEI = 25000n * 10n ** 9n;
|
||||
|
||||
test("a send refused for its fee leaves the password field and Sign & Send where they were (#583)", async (env) => {
|
||||
env.errors.expect("confirmTx logging the refused send", /send refused/);
|
||||
try {
|
||||
await goToConfirm(env.page, {
|
||||
token: "ETH",
|
||||
balance: FUNDED_ETH_TEXT + " ETH",
|
||||
amount: COMFORTABLE_AMOUNT,
|
||||
});
|
||||
await waitForEstimate(env.page);
|
||||
const before = await confirmState(env.page);
|
||||
assert(
|
||||
!before.sendDisabled && before.feeBoundError === "",
|
||||
"the screen is not ready to send: " + JSON.stringify(before),
|
||||
);
|
||||
|
||||
// Only now, with the screen drawn and its fee estimated, does the
|
||||
// node quote a fee over the bound.
|
||||
env.routeOpts.baseFeeWei = toHexWei(OVER_BOUND_BASE_FEE_WEI);
|
||||
const sent = env.routeOpts.broadcastTransactions.length;
|
||||
await fillPasswordAndSend(env.page);
|
||||
await env.page.waitForFunction(
|
||||
() =>
|
||||
getComputedStyle(
|
||||
document.getElementById("confirm-fee-bound-error"),
|
||||
).visibility === "visible",
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const after = await confirmState(env.page);
|
||||
|
||||
// Printed pass or fail, as the dust threshold test does.
|
||||
console.log(
|
||||
"# refused send: password field top " +
|
||||
before.passwordTop +
|
||||
" -> " +
|
||||
after.passwordTop +
|
||||
"px, Sign & Send top " +
|
||||
before.sendTop +
|
||||
" -> " +
|
||||
after.sendTop +
|
||||
"px",
|
||||
);
|
||||
assert(
|
||||
env.routeOpts.broadcastTransactions.length === sent,
|
||||
"a send over the fee bound reached the RPC",
|
||||
);
|
||||
assert(
|
||||
after.feeBoundError ===
|
||||
"The gas limit or fee the RPC endpoint quoted for this" +
|
||||
" transaction is more than this wallet will sign for, so" +
|
||||
" it was not sent. Please go back and try again.",
|
||||
"wrong fee refusal: " + JSON.stringify(after.feeBoundError),
|
||||
);
|
||||
assert(
|
||||
after.passwordTop === before.passwordTop,
|
||||
"the password field moved " +
|
||||
(after.passwordTop - before.passwordTop) +
|
||||
"px when the send was refused",
|
||||
);
|
||||
assert(
|
||||
after.sendTop === before.sendTop,
|
||||
"Sign & Send moved " +
|
||||
(after.sendTop - before.sendTop) +
|
||||
"px when the send was refused",
|
||||
);
|
||||
} finally {
|
||||
env.routeOpts.baseFeeWei = undefined;
|
||||
await backToAddress(env.page);
|
||||
}
|
||||
});
|
||||
|
||||
// --------- a send from an address the selected wallet does not hold (#609)
|
||||
//
|
||||
// The selection is saved apart from the confirmation screen, so the popup can
|
||||
// reopen onto the screen with a wallet selected that does not hold its From
|
||||
// address, and Sign & Send then refuses the send. Another popup selecting the
|
||||
// key wallet would save that selection; here it is written into storage, and
|
||||
// the popup reopened.
|
||||
|
||||
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
|
||||
try {
|
||||
// From the HD wallet's address.
|
||||
await goToConfirm(env.page, {
|
||||
token: "ETH",
|
||||
balance: FUNDED_ETH_TEXT + " ETH",
|
||||
amount: COMFORTABLE_AMOUNT,
|
||||
});
|
||||
// The screen's own save lands first, so it cannot write over the
|
||||
// selection below.
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"currentView",
|
||||
"confirm-tx",
|
||||
"before selecting the key wallet",
|
||||
);
|
||||
await env.page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.get("autistmask", (r) => {
|
||||
const record = {
|
||||
...r.autistmask,
|
||||
selectedWallet: 1,
|
||||
selectedAddress: 0,
|
||||
};
|
||||
chrome.storage.local.set(
|
||||
{ autistmask: record },
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
await reopenPopup(env, "confirm-tx");
|
||||
await waitForEstimate(env.page);
|
||||
// The refusal shown anywhere on the screen, not only in its own box:
|
||||
// shown on the password error line instead, the test fails on the
|
||||
// moves checked below rather than on the wait for it.
|
||||
const refusal = env.page
|
||||
.locator("#view-confirm-tx")
|
||||
.getByText("does not hold the From address above")
|
||||
.filter({ visible: true });
|
||||
const before = await confirmState(env.page);
|
||||
assert(
|
||||
!before.sendDisabled && (await refusal.count()) === 0,
|
||||
"the screen is not ready to send: " + JSON.stringify(before),
|
||||
);
|
||||
|
||||
const sent = env.routeOpts.broadcastTransactions.length;
|
||||
await fillPasswordAndSend(env.page);
|
||||
await refusal.waitFor({ timeout: 60000 });
|
||||
const after = await confirmState(env.page);
|
||||
|
||||
// Printed pass or fail, as the dust threshold test does.
|
||||
console.log(
|
||||
"# refused From address: password field top " +
|
||||
before.passwordTop +
|
||||
" -> " +
|
||||
after.passwordTop +
|
||||
"px, Sign & Send top " +
|
||||
before.sendTop +
|
||||
" -> " +
|
||||
after.sendTop +
|
||||
"px",
|
||||
);
|
||||
assert(
|
||||
env.routeOpts.broadcastTransactions.length === sent,
|
||||
"a send from an address the selected wallet does not hold" +
|
||||
" reached the RPC",
|
||||
);
|
||||
assert(
|
||||
after.passwordTop === before.passwordTop,
|
||||
"the password field moved " +
|
||||
(after.passwordTop - before.passwordTop) +
|
||||
"px when the send was refused",
|
||||
);
|
||||
assert(
|
||||
after.sendTop === before.sendTop,
|
||||
"Sign & Send moved " +
|
||||
(after.sendTop - before.sendTop) +
|
||||
"px when the send was refused",
|
||||
);
|
||||
// Only now, where the refusal is shown. Its sentence is wrapped across
|
||||
// lines in index.html.
|
||||
const shownIn = await refusal.evaluate((el) => ({
|
||||
id: el.id,
|
||||
text: el.textContent.replace(/\s+/g, " ").trim(),
|
||||
}));
|
||||
assert(
|
||||
shownIn.id === "confirm-from-address-error" &&
|
||||
shownIn.text ===
|
||||
"The selected wallet does not hold the From address above," +
|
||||
" so nothing was sent. Please go back and send again.",
|
||||
"wrong From address refusal: " + JSON.stringify(shownIn),
|
||||
);
|
||||
} finally {
|
||||
// A reopened popup restores no Send screen beneath this one, so Back
|
||||
// lands on the selected address. Then Home, and the HD wallet's
|
||||
// address, which the later tests send from.
|
||||
if (await env.page.isVisible("#view-confirm-tx")) {
|
||||
await env.page.click("#btn-confirm-back");
|
||||
}
|
||||
if (await env.page.isVisible("#view-send")) {
|
||||
await env.page.click("#btn-send-back");
|
||||
}
|
||||
await visible(env.page, "#view-address");
|
||||
await env.page.click("#btn-address-back");
|
||||
await visible(env.page, "#view-main");
|
||||
await openAddressDetail(env.page);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------ the wait for a receipt ending in error (#315)
|
||||
//
|
||||
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
|
||||
|
||||
@@ -245,7 +245,7 @@ describe.each([
|
||||
test("the value", async () => {
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
|
||||
expect(text("confirm-amount")).toBe("0.1 " + symbol);
|
||||
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
|
||||
await approveTx(NETWORKS[networkId].chainId);
|
||||
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||
});
|
||||
@@ -429,10 +429,10 @@ describe.each([
|
||||
await global.document
|
||||
.getElementById("btn-confirm-send")
|
||||
.handlers.get("click")();
|
||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||
state.networkId = activeNetworkId;
|
||||
txStatus.restoreWait();
|
||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||
});
|
||||
|
||||
test("the transaction detail screen's type line and fee", async () => {
|
||||
|
||||
@@ -441,6 +441,17 @@ const CONTRACT = [
|
||||
views: ["error-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// A chain id naming no network has no explorer to link to.
|
||||
{
|
||||
value: { hash: "0x1", to: ADDRESS, chainId: "0x5" },
|
||||
views: ["success-tx"],
|
||||
restored: false,
|
||||
},
|
||||
{
|
||||
value: { message: "boom", to: ADDRESS, chainId: "0x5" },
|
||||
views: ["error-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// transaction passes on `data.tx`.
|
||||
{
|
||||
value: { tx: { hash: "0x1" } },
|
||||
@@ -459,6 +470,19 @@ const CONTRACT = [
|
||||
views: ["transaction"],
|
||||
restored: false,
|
||||
},
|
||||
// A chain id naming no network has no explorer to link to.
|
||||
{
|
||||
value: {
|
||||
tx: {
|
||||
hash: "0x1",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
chainId: "0x5",
|
||||
},
|
||||
},
|
||||
views: ["transaction"],
|
||||
restored: false,
|
||||
},
|
||||
// confirm-tx passes on `data.pendingTx`.
|
||||
{
|
||||
value: { pendingTx: { amount: "1" } },
|
||||
@@ -477,6 +501,19 @@ const CONTRACT = [
|
||||
views: ["confirm-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// The amount line splits the amount to cut it to four decimals.
|
||||
{
|
||||
value: {
|
||||
pendingTx: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: 42,
|
||||
},
|
||||
},
|
||||
views: ["confirm-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// A chain id naming no network has no explorer to link to.
|
||||
{
|
||||
value: {
|
||||
@@ -808,7 +845,7 @@ const WELL_FORMED_DATA = {
|
||||
},
|
||||
pendingWait: {
|
||||
hash: "0x1",
|
||||
txInfo: { to: ADDRESS, amount: "1" },
|
||||
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
|
||||
broadcastTime: BROADCAST_TIME,
|
||||
},
|
||||
};
|
||||
|
||||
+12
-6
@@ -306,7 +306,8 @@ describe("Max on an ETH send", () => {
|
||||
openSend();
|
||||
await pressMax();
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
||||
expect(el("confirm-errors").innerHTML).toBe("");
|
||||
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
||||
expect(canSend()).toBe(true);
|
||||
@@ -322,14 +323,16 @@ describe("Max on an ETH send", () => {
|
||||
// Kept, the Send screen's amount would be refused for want of funds.
|
||||
mockNode.feeData = fees(30n * GWEI);
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2339 ETH");
|
||||
expect(canSend()).toBe(true);
|
||||
|
||||
// And falls when the screen is shown again, as on reopening the popup.
|
||||
mockNode.feeData = fees(10n * GWEI);
|
||||
confirmTx.restore();
|
||||
await settle();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2343 ETH");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -526,7 +529,8 @@ describe("Max on an ETH send", () => {
|
||||
el("send-amount").handlers.get("input")();
|
||||
mockNode.feeData = fees(30n * GWEI);
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5 ETH");
|
||||
expect(confirmed.amount).toBe("0.5");
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,7 +544,8 @@ describe("Max on a token send", () => {
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(confirmed.amount).toBe("1234.567890123456789012");
|
||||
expect(text("confirm-amount")).toBe("1234.5678 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -553,7 +558,8 @@ describe("Max on a token send", () => {
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(confirmed.amount).toBe("1234.567890123456789012");
|
||||
expect(text("confirm-amount")).toBe("1234.5678 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -225,7 +225,12 @@ describe("a popup already open when the stored profile becomes unreadable", () =
|
||||
viewData: {
|
||||
pendingWait: {
|
||||
hash: "0x1",
|
||||
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
|
||||
txInfo: {
|
||||
to: ADDRESS,
|
||||
amount: "1",
|
||||
token: "ETH",
|
||||
chainId: "0x1",
|
||||
},
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
// tracks. It decodes against the address the explorer reports the transaction
|
||||
// was sent to.
|
||||
//
|
||||
// Its links open the explorer of the network the transaction was sent on,
|
||||
// whichever network is active
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
|
||||
//
|
||||
// The row is the one the real history parser makes from the explorer's answer,
|
||||
// opened through transactionDetail.show(), which is what a click on the row
|
||||
// calls. Driven against a minimal DOM stub in the shape
|
||||
@@ -35,6 +39,7 @@ globalThis.chrome = {
|
||||
};
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||
|
||||
@@ -77,7 +82,11 @@ function mockExplorerAnswer(url) {
|
||||
};
|
||||
}
|
||||
if (url === EXPLORER + "/transactions/" + TX_HASH) {
|
||||
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
|
||||
return {
|
||||
to: { hash: TOKEN },
|
||||
raw_input: TRANSFER_DATA,
|
||||
block_number: 100,
|
||||
};
|
||||
}
|
||||
return { items: [] };
|
||||
}
|
||||
@@ -154,7 +163,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
|
||||
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
|
||||
const [row] = await fetchRecentTransactions(
|
||||
HOLDER,
|
||||
EXPLORER,
|
||||
NETWORKS.mainnet.chainId,
|
||||
);
|
||||
expect(row.to).toBe(RECIPIENT);
|
||||
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
|
||||
|
||||
@@ -174,3 +187,33 @@ test("a token transfer's decoded details name the token contract and the amount
|
||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
||||
);
|
||||
});
|
||||
|
||||
// Mainnet is active and the row was read from Sepolia's explorer. The stub
|
||||
// explorer answers whichever network asks, so every link on the screen is
|
||||
// drawn.
|
||||
test("with mainnet active, a Sepolia transaction's links open the Sepolia explorer", async () => {
|
||||
state.networkId = "mainnet";
|
||||
const [row] = await fetchRecentTransactions(
|
||||
HOLDER,
|
||||
EXPLORER,
|
||||
NETWORKS.sepolia.chainId,
|
||||
);
|
||||
|
||||
transactionDetail.show(row);
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const sepolia = "https://sepolia.etherscan.io";
|
||||
const links = (id) =>
|
||||
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
|
||||
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
|
||||
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
|
||||
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
|
||||
expect(links("tx-detail-token-contract")).toEqual([
|
||||
`${sepolia}/token/${TOKEN.toLowerCase()}`,
|
||||
]);
|
||||
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
|
||||
expect(links("tx-detail-calldata-details")).toEqual([
|
||||
`${sepolia}/address/${TOKEN}`,
|
||||
`${sepolia}/address/${RECIPIENT}`,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -313,9 +313,9 @@ describe("Back after a switch from the Settings gear", () => {
|
||||
boot.node("confirm-tx-password").value = "any password";
|
||||
await boot.click("btn-confirm-send");
|
||||
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
||||
expect(boot.node("confirm-errors").innerHTML).toContain(
|
||||
"The active network changed after this transaction was reviewed",
|
||||
);
|
||||
expect(
|
||||
boot.node("confirm-network-changed-error").style.visibility,
|
||||
).toBe("visible");
|
||||
// Refused before the password is even tried.
|
||||
expect(boot.text("confirm-tx-password-error")).toBe("");
|
||||
});
|
||||
|
||||
+149
-3
@@ -29,8 +29,13 @@ jest.mock("../src/shared/log", () => ({
|
||||
}));
|
||||
|
||||
const mockReceiptLookup = jest.fn();
|
||||
// The RPC URL and network of every node a wait was given.
|
||||
const mockNodes = [];
|
||||
jest.mock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
|
||||
getProvider: (rpcUrl, networkId) => {
|
||||
mockNodes.push({ rpcUrl, networkId });
|
||||
return { getTransactionReceipt: mockReceiptLookup };
|
||||
},
|
||||
refreshBalances: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -107,6 +112,7 @@ global.chrome = { storage };
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { log } = require("../src/shared/log");
|
||||
|
||||
@@ -118,6 +124,7 @@ const TX_INFO = {
|
||||
amount: "0.0050",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
chainId: NETWORKS.mainnet.chainId,
|
||||
};
|
||||
|
||||
// True when a view element is not hidden.
|
||||
@@ -137,7 +144,11 @@ beforeEach(() => {
|
||||
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
||||
elements.clear();
|
||||
mockReceiptLookup.mockReset();
|
||||
mockNodes.length = 0;
|
||||
log.errorf.mockClear();
|
||||
state.networkId = "mainnet";
|
||||
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
|
||||
state.networkEndpoints = {};
|
||||
state.wallets = [];
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
@@ -349,14 +360,14 @@ describe("WaitTx persistence across popup close", () => {
|
||||
// out of restoreView().
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { to: 42, amount: "0.0050" },
|
||||
txInfo: { ...TX_INFO, to: 42 },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
// Otherwise complete but an array: only Array.isArray() rejects
|
||||
// it, since typeof [] is "object" and the fields are present.
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
||||
txInfo: Object.assign([], TX_INFO),
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
{
|
||||
@@ -364,6 +375,18 @@ describe("WaitTx persistence across popup close", () => {
|
||||
txInfo: { to: RECIPIENT },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
// Otherwise complete but for a chain id naming no network: there
|
||||
// is no node to ask for the receipt, and no explorer to link to.
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { ...TX_INFO, chainId: undefined },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
{
|
||||
hash: TX_HASH,
|
||||
txInfo: { ...TX_INFO, chainId: "0x5" },
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
]) {
|
||||
state.viewData = { pendingWait: bad };
|
||||
expect(txStatus.restoreWait()).toBe(false);
|
||||
@@ -499,6 +522,129 @@ describe("WaitTx when the popup closes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The popup can reopen onto WaitTx after Settings switched the network. The
|
||||
// wait asked the active network's node for the receipt, never found it, and
|
||||
// reported a timeout, and its links went to the active network's explorer
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
|
||||
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
|
||||
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
|
||||
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
|
||||
|
||||
beforeEach(() => {
|
||||
state.rpcUrl = "https://mainnet.node.example.invalid";
|
||||
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
|
||||
});
|
||||
|
||||
test("the receipt is asked of the Sepolia node Settings has", async () => {
|
||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||
state.viewData = {
|
||||
pendingWait: {
|
||||
txInfo: sepoliaTx,
|
||||
hash: TX_HASH,
|
||||
broadcastTime: Date.now(),
|
||||
},
|
||||
};
|
||||
expect(txStatus.restoreWait()).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockNodes).toEqual([
|
||||
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
|
||||
]);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
});
|
||||
|
||||
test("the recipient and hash link to the Sepolia explorer", () => {
|
||||
mockReceiptLookup.mockResolvedValue(null);
|
||||
txStatus.showWait(sepoliaTx, TX_HASH);
|
||||
|
||||
expect(getElement("wait-tx-to").innerHTML).toContain(
|
||||
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
|
||||
);
|
||||
expect(getElement("wait-tx-hash").innerHTML).toContain(
|
||||
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but
|
||||
// their links went to the active network's explorer
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the
|
||||
// screen again after rendering it from its stored view data alone, as a
|
||||
// reopened popup does.
|
||||
describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => {
|
||||
const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io";
|
||||
const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
|
||||
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
|
||||
|
||||
test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => {
|
||||
function expectSepoliaLinks() {
|
||||
expect(getElement("success-tx-to").innerHTML).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
|
||||
);
|
||||
expect(getElement("success-tx-block").innerHTML).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/block/21000000"`,
|
||||
);
|
||||
expect(getElement("success-tx-hash").innerHTML).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
|
||||
);
|
||||
}
|
||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||
txStatus.showWait(sepoliaTx, TX_HASH);
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expectSepoliaLinks();
|
||||
|
||||
elements.clear();
|
||||
txStatus.renderSuccess();
|
||||
expectSepoliaLinks();
|
||||
});
|
||||
|
||||
test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => {
|
||||
function expectSepoliaLinks() {
|
||||
const html = getElement("success-tx-decoded").innerHTML;
|
||||
expect(html).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`,
|
||||
);
|
||||
expect(html).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
|
||||
);
|
||||
}
|
||||
const decoded = {
|
||||
name: "Transfer",
|
||||
details: [
|
||||
{ label: "Token", address: TOKEN, isToken: true },
|
||||
{ label: "Recipient", address: RECIPIENT },
|
||||
],
|
||||
};
|
||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expectSepoliaLinks();
|
||||
|
||||
elements.clear();
|
||||
txStatus.renderSuccess();
|
||||
expectSepoliaLinks();
|
||||
});
|
||||
|
||||
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
|
||||
function expectSepoliaLinks() {
|
||||
expect(getElement("error-tx-to").innerHTML).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
|
||||
);
|
||||
expect(getElement("error-tx-hash").innerHTML).toContain(
|
||||
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
|
||||
);
|
||||
}
|
||||
txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed.");
|
||||
expectSepoliaLinks();
|
||||
|
||||
elements.clear();
|
||||
txStatus.renderError();
|
||||
expectSepoliaLinks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||
|
||||
+556
-9
@@ -739,7 +739,9 @@ describe("uniswap decoder", () => {
|
||||
});
|
||||
|
||||
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
|
||||
// UNWRAP_WETH makes the output ETH.
|
||||
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
|
||||
// USDC the exact-out step bought stays in the router, and the minimum is
|
||||
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
|
||||
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(
|
||||
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
|
||||
|
||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
|
||||
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
|
||||
});
|
||||
|
||||
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
|
||||
@@ -987,13 +989,23 @@ describe("uniswap decoder", () => {
|
||||
);
|
||||
|
||||
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
|
||||
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
|
||||
// not read. With the allow-revert flag (0x80) the command byte is 0xa1,
|
||||
// and the router lets the step fail
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
|
||||
test.each([
|
||||
{ flag: "without", command: 0x21 },
|
||||
{ flag: "with", command: 0xa1 },
|
||||
{
|
||||
flag: "without",
|
||||
command: 0x21,
|
||||
steps: "Execute Sub-Plan (its steps are not shown)",
|
||||
},
|
||||
{
|
||||
flag: "with",
|
||||
command: 0xa1,
|
||||
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
|
||||
},
|
||||
])(
|
||||
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
|
||||
({ command }) => {
|
||||
({ command, steps }) => {
|
||||
const subPlan = encodeSubPlan("0x00", [
|
||||
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
|
||||
USDT_ADDR,
|
||||
@@ -1013,9 +1025,7 @@ describe("uniswap decoder", () => {
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result.name).toBe("Uniswap Swap");
|
||||
expect(detail(result, "Steps").value).toBe(
|
||||
"Execute Sub-Plan (its steps are not shown)",
|
||||
);
|
||||
expect(detail(result, "Steps").value).toBe(steps);
|
||||
// The sub-plan names both currencies, so the lines must not say
|
||||
// the calldata did not.
|
||||
expect(detail(result, "Token In").value).toBe(
|
||||
@@ -1279,6 +1289,543 @@ describe("uniswap decoder", () => {
|
||||
expect(detail(result, "Amount").value).not.toContain("500000000000");
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across
|
||||
// two swap steps the user pays for, with no PERMIT2_PERMIT, takes both
|
||||
// steps' amounts from the wallet. On next at 671b4d3 the Amount line read
|
||||
// only the first step's 600.0000 USDT.
|
||||
describe("a swap split across steps the user pays for", () => {
|
||||
const USDT_600 = 600000000n;
|
||||
const USDT_400 = 400000000n;
|
||||
const DAI_400 = 400000000000000000000n;
|
||||
const OTHER_ADDR = "0x1111111111111111111111111111111111111111";
|
||||
const UNDECODED = "Unknown (could not be decoded)";
|
||||
|
||||
// The lines between Token In and Token Out, as [label, value].
|
||||
function linesBetweenTokens(result) {
|
||||
const lines = result.details.map((d) => [d.label, d.value]);
|
||||
const from = lines.findIndex(([label]) => label === "Token In");
|
||||
const to = lines.findIndex(([label]) => label === "Token Out");
|
||||
return lines.slice(from + 1, to);
|
||||
}
|
||||
|
||||
// execute() of these command bytes and inputs, decoded as sent by
|
||||
// the user.
|
||||
function decodeSteps(commands, inputs) {
|
||||
const data = buildExecute(
|
||||
solidityPacked(
|
||||
commands.map(() => "uint8"),
|
||||
commands,
|
||||
),
|
||||
inputs,
|
||||
9999999999n,
|
||||
);
|
||||
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
||||
}
|
||||
|
||||
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
|
||||
// user, which sets the input side.
|
||||
const V3_600 = encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]);
|
||||
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
|
||||
// `currency` to `recipient`.
|
||||
const settle = (currency, amount, payerIsUser) =>
|
||||
coder.encode(
|
||||
["address", "uint256", "bool"],
|
||||
[currency, amount, payerIsUser],
|
||||
);
|
||||
const take = (currency, recipient, amount) =>
|
||||
coder.encode(
|
||||
["address", "address", "uint256"],
|
||||
[currency, recipient, amount],
|
||||
);
|
||||
// `input` with the top byte of its word at `index` set. This decoder
|
||||
// cannot read such a word as an address or a length; the router reads
|
||||
// its low bytes and runs the step.
|
||||
const dirtyWord = (input, index) => {
|
||||
const at = 2 + index * 64;
|
||||
return input.slice(0, at) + "ff" + input.slice(at + 2);
|
||||
};
|
||||
|
||||
test("a split across a V3 and a V2 step the user pays for shows the total", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
|
||||
[
|
||||
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
],
|
||||
9999999999n,
|
||||
);
|
||||
|
||||
const result = uniswap.decode(
|
||||
data,
|
||||
ROUTER_ADDR,
|
||||
"0x1",
|
||||
{},
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(detail(result, "Token In").address.toLowerCase()).toBe(
|
||||
USDT_ADDR.toLowerCase(),
|
||||
);
|
||||
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
|
||||
expect(detail(result, "Amount").rawValue).toBe("1000.0000");
|
||||
expect(detail(result, "Moved from the sender")).toBeUndefined();
|
||||
});
|
||||
|
||||
// In the order of Uniswap's own V4 routes: the swap, then a SETTLE of
|
||||
// OPEN_DELTA by the user, which pays what the swap spends.
|
||||
test("a split across a V3 step and a V4 step the user pays for shows the total", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
||||
[
|
||||
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
encodeV4Swap(
|
||||
new Uint8Array([
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SETTLE_ID,
|
||||
V4_TAKE_ID,
|
||||
]),
|
||||
[
|
||||
encodeV4ExactInSingle(
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
USDT_400,
|
||||
390000000n,
|
||||
),
|
||||
encodeV4Settle(USDT_ADDR), // paid by the user
|
||||
encodeV4Take(USDC_ADDR),
|
||||
],
|
||||
),
|
||||
],
|
||||
9999999999n,
|
||||
);
|
||||
|
||||
const result = uniswap.decode(
|
||||
data,
|
||||
ROUTER_ADDR,
|
||||
"0x1",
|
||||
{},
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
|
||||
expect(detail(result, "Moved from the sender")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a second step the user pays for that spends another token shows that token's amount", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
|
||||
[
|
||||
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
encodeV2SwapExactIn(
|
||||
USER_ADDR,
|
||||
400000000000000000000n, // 400 DAI
|
||||
390000000n,
|
||||
[DAI_ADDR, USDC_ADDR],
|
||||
),
|
||||
],
|
||||
9999999999n,
|
||||
);
|
||||
|
||||
const result = uniswap.decode(
|
||||
data,
|
||||
ROUTER_ADDR,
|
||||
"0x1",
|
||||
{},
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(linesBetweenTokens(result)).toEqual([
|
||||
["Amount", "600.0000 USDT"],
|
||||
["Moved from the sender", "400.0000 DAI"],
|
||||
["Token moved", "DAI (" + DAI_ADDR + ")"],
|
||||
["Moved to", "The swap step that spends it"],
|
||||
]);
|
||||
});
|
||||
|
||||
// Every later V4 step takes from the sender what its SETTLEs paid by
|
||||
// the user and its SETTLE_ALLs pay, each currency on its own, with a
|
||||
// swap action or without. Each of these read 600.0000 USDT alone
|
||||
// before.
|
||||
const USDT_FOR_USDC_400 = encodeV4ExactInSingle(
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
USDT_400,
|
||||
390000000n,
|
||||
);
|
||||
const DAI_FOR_USDC_400 = encodeV4ExactInSingle(
|
||||
DAI_ADDR,
|
||||
USDC_ADDR,
|
||||
DAI_400,
|
||||
390000000n,
|
||||
);
|
||||
const DAI_GROUP = [
|
||||
["Moved from the sender", "400.0000 DAI"],
|
||||
["Token moved", "DAI (" + DAI_ADDR + ")"],
|
||||
["Moved to", "The swap step that spends it"],
|
||||
];
|
||||
test.each([
|
||||
{
|
||||
kind: "a V4 step paying USDT and DAI by a SETTLE_ALL of each",
|
||||
actions: [
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SETTLE_ALL_ID,
|
||||
V4_SETTLE_ALL_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
USDT_FOR_USDC_400,
|
||||
DAI_FOR_USDC_400,
|
||||
encodeV4SettleAll(USDT_ADDR, USDT_400),
|
||||
encodeV4SettleAll(DAI_ADDR, DAI_400),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
lines: [["Amount", "1000.0000 USDT"], ...DAI_GROUP],
|
||||
},
|
||||
{
|
||||
kind: "a V4 step with a SETTLE of USDT by the user, then a SETTLE of WETH from the router",
|
||||
actions: [
|
||||
V4_SETTLE_ID,
|
||||
V4_SETTLE_ID,
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
settle(USDT_ADDR, USDT_400, true),
|
||||
settle(WETH_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
|
||||
USDT_FOR_USDC_400,
|
||||
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
lines: [["Amount", "1000.0000 USDT"]],
|
||||
},
|
||||
{
|
||||
kind: "a V4 step with no swap action: a SETTLE of 400 USDT by the user and a TAKE of it to another address",
|
||||
actions: [V4_SETTLE_ID, V4_TAKE_ID],
|
||||
params: [
|
||||
settle(USDT_ADDR, USDT_400, true),
|
||||
take(USDT_ADDR, OTHER_ADDR, USDT_400),
|
||||
],
|
||||
lines: [["Amount", "1000.0000 USDT"]],
|
||||
},
|
||||
// The SETTLE's amount, not the swap's, is what the user pays.
|
||||
{
|
||||
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address",
|
||||
actions: [
|
||||
V4_SETTLE_ID,
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_TAKE_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
settle(USDT_ADDR, 5000000000n, true),
|
||||
USDT_FOR_USDC_400,
|
||||
take(USDT_ADDR, OTHER_ADDR, 0n),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
lines: [["Amount", "5600.0000 USDT"]],
|
||||
},
|
||||
// A SETTLE_ALL pays all the step owes, here what the TAKE takes as
|
||||
// well as what the swap spends, so its amount is not known.
|
||||
{
|
||||
kind: "a V4 step with no swap action: a TAKE of 400 USDT to another address, then a SETTLE_ALL of USDT",
|
||||
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
|
||||
params: [
|
||||
take(USDT_ADDR, OTHER_ADDR, USDT_400),
|
||||
encodeV4SettleAll(USDT_ADDR, USDT_400),
|
||||
],
|
||||
lines: [
|
||||
["Moved from the sender", UNDECODED],
|
||||
["Token moved", "USDT (" + USDT_ADDR + ")"],
|
||||
["Moved to", "The swap step that spends it"],
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V4 step swapping 400 USDT, with a TAKE of 400 USDT to another address before its SETTLE_ALL of USDT",
|
||||
actions: [
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_TAKE_ID,
|
||||
V4_SETTLE_ALL_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
USDT_FOR_USDC_400,
|
||||
take(USDT_ADDR, OTHER_ADDR, USDT_400),
|
||||
encodeV4SettleAll(USDT_ADDR, 800000000n),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
lines: [
|
||||
["Moved from the sender", UNDECODED],
|
||||
["Token moved", "USDT (" + USDT_ADDR + ")"],
|
||||
["Moved to", "The swap step that spends it"],
|
||||
],
|
||||
},
|
||||
])(
|
||||
"a V3 step of 600 USDT, then $kind",
|
||||
({ actions, params, lines }) => {
|
||||
const result = decodeSteps(
|
||||
[0x00, 0x10],
|
||||
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
|
||||
);
|
||||
expect(linesBetweenTokens(result)).toEqual(lines);
|
||||
},
|
||||
);
|
||||
|
||||
// A step that may take from the sender what the decoder did not read
|
||||
// leaves no Amount figure, which may be too low, and one line says so.
|
||||
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
|
||||
test.each([
|
||||
{
|
||||
kind: "a V4 step whose SETTLE by the user cannot be read",
|
||||
actions: [
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SETTLE_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
USDT_FOR_USDC_400,
|
||||
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V4 step whose SETTLE_ALL cannot be read",
|
||||
actions: [
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SETTLE_ALL_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
USDT_FOR_USDC_400,
|
||||
dirtyWord(encodeV4SettleAll(USDT_ADDR, USDT_400), 0),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V4 step whose swap action and SETTLE_ALL cannot be read",
|
||||
actions: [
|
||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
||||
V4_SETTLE_ALL_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
],
|
||||
params: [
|
||||
UNREAD_SWAP,
|
||||
dirtyWord(encodeV4SettleAll(WETH_ADDR, ONE_WETH), 0),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
},
|
||||
])("a V3 step of 600 USDT, then $kind", ({ actions, params }) => {
|
||||
const result = decodeSteps(
|
||||
[0x00, 0x10],
|
||||
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
|
||||
);
|
||||
expect(linesBetweenTokens(result)).toEqual([
|
||||
["Moved from the sender", UNDECODED],
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
|
||||
commands: [0x00, 0x10],
|
||||
inputs: [
|
||||
V3_600,
|
||||
dirtyWord(
|
||||
encodeV4Swap(new Uint8Array([V4_SETTLE_ID]), [
|
||||
settle(USDT_ADDR, USDT_400, true),
|
||||
]),
|
||||
2,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V4 step with no swap action whose SETTLE_ALL cannot be read, then a V3 step of 600 USDT",
|
||||
commands: [0x10, 0x00],
|
||||
inputs: [
|
||||
encodeV4Swap(
|
||||
new Uint8Array([V4_TAKE_ID, V4_SETTLE_ALL_ID]),
|
||||
[
|
||||
take(USDT_ADDR, OTHER_ADDR, USDT_400),
|
||||
dirtyWord(
|
||||
encodeV4SettleAll(USDT_ADDR, USDT_400),
|
||||
0,
|
||||
),
|
||||
],
|
||||
),
|
||||
V3_600,
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V3 step of 600 USDT, then a V2 step the user pays for whose input cannot be read",
|
||||
commands: [0x00, 0x08],
|
||||
inputs: [
|
||||
V3_600,
|
||||
dirtyWord(
|
||||
encodeV2SwapExactIn(OTHER_ADDR, USDT_400, 1n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
0,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM whose input cannot be read",
|
||||
commands: [0x00, 0x02],
|
||||
inputs: [
|
||||
V3_600,
|
||||
dirtyWord(
|
||||
coder.encode(
|
||||
["address", "address", "uint160"],
|
||||
[USDT_ADDR, OTHER_ADDR, USDT_400],
|
||||
),
|
||||
1,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM_BATCH",
|
||||
commands: [0x00, 0x0d],
|
||||
inputs: [
|
||||
V3_600,
|
||||
coder.encode(
|
||||
["tuple(address,address,uint160,address)[]"],
|
||||
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
|
||||
),
|
||||
],
|
||||
},
|
||||
])("$kind", ({ commands, inputs }) => {
|
||||
const result = decodeSteps(commands, inputs);
|
||||
expect(linesBetweenTokens(result)).toEqual([
|
||||
["Moved from the sender", UNDECODED],
|
||||
]);
|
||||
});
|
||||
|
||||
// A PERMIT2_PERMIT that sets the input side keeps its own amount: the
|
||||
// steps it lets the router spend do not add to it.
|
||||
test("a split after a PERMIT2_PERMIT shows the permit's amount", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]),
|
||||
[
|
||||
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT
|
||||
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
],
|
||||
9999999999n,
|
||||
);
|
||||
|
||||
const result = uniswap.decode(
|
||||
data,
|
||||
ROUTER_ADDR,
|
||||
"0x1",
|
||||
{},
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(detail(result, "Amount").value).toBe("5000.0000 USDT");
|
||||
expect(detail(result, "Moved from the sender")).toBeUndefined();
|
||||
});
|
||||
|
||||
// A permit to another spender, or one the router lets fail (0x80 on
|
||||
// the command byte), caps nothing the router takes, so what each
|
||||
// later step takes of its token gets its own lines.
|
||||
test.each([
|
||||
{
|
||||
kind: "to another spender",
|
||||
command: 0x0a,
|
||||
spender: OTHER_ADDR,
|
||||
},
|
||||
{
|
||||
kind: "that the router lets fail",
|
||||
command: 0x8a,
|
||||
spender: ROUTER_ADDR,
|
||||
},
|
||||
])(
|
||||
"a split after a PERMIT2_PERMIT $kind shows what each step takes",
|
||||
({ command, spender }) => {
|
||||
const result = decodeSteps(
|
||||
[command, 0x00, 0x08],
|
||||
[
|
||||
encodePermit2(USDT_ADDR, 1000000n, spender), // 1 USDT
|
||||
V3_600,
|
||||
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
],
|
||||
);
|
||||
// The V3 step names its token as its path spells it, in
|
||||
// lower case.
|
||||
const moved = ["Moved to", "The swap step that spends it"];
|
||||
expect(linesBetweenTokens(result)).toEqual([
|
||||
["Amount", "1.0000 USDT"],
|
||||
["Moved from the sender", "600.0000 USDT"],
|
||||
["Token moved", "USDT (" + USDT_ADDR.toLowerCase() + ")"],
|
||||
moved,
|
||||
["Moved from the sender", "400.0000 USDT"],
|
||||
["Token moved", "USDT (" + USDT_ADDR + ")"],
|
||||
moved,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
// A V2 exact-out step makes every amount the swap steps spend a maximum,
|
||||
// the total included.
|
||||
test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8"], [0x00, 0x09]),
|
||||
[
|
||||
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [
|
||||
USDT_ADDR,
|
||||
USDC_ADDR,
|
||||
]),
|
||||
],
|
||||
9999999999n,
|
||||
);
|
||||
|
||||
const result = uniswap.decode(
|
||||
data,
|
||||
ROUTER_ADDR,
|
||||
"0x1",
|
||||
{},
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
|
||||
});
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
|
||||
// the shape the issue measured: a V4 step that states a minimum of zero
|
||||
// and names no output currency.
|
||||
|
||||
+1760
-20
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,8 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
|
||||
|
||||
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
|
||||
// uint256 minBalance). It names the output token and nothing about the input.
|
||||
// Its owner is USER, the transaction's sender: a check of any other address
|
||||
// names no output token.
|
||||
function balanceCheck(token, minBalance) {
|
||||
return coder.encode(
|
||||
["address", "address", "uint256"],
|
||||
@@ -78,8 +80,13 @@ function balanceCheck(token, minBalance) {
|
||||
);
|
||||
}
|
||||
|
||||
// A transaction from USER.
|
||||
function decode(data) {
|
||||
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
|
||||
}
|
||||
|
||||
function detail(data, label) {
|
||||
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
|
||||
const decoded = decode(data);
|
||||
expect(decoded).not.toBeNull();
|
||||
return decoded.details.find((d) => d.label === label);
|
||||
}
|
||||
@@ -113,9 +120,7 @@ describe("an execute() whose input currency never decoded", () => {
|
||||
});
|
||||
|
||||
test("does not name ETH in the swap title either", () => {
|
||||
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
|
||||
"Uniswap Swap",
|
||||
);
|
||||
expect(decode(data()).name).toBe("Uniswap Swap");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,9 +130,7 @@ describe("an execute() that names only an output token", () => {
|
||||
|
||||
test("refuses the input rather than defaulting it to ETH", () => {
|
||||
expect(detail(data(), "Token In").value).toBe(REFUSAL);
|
||||
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
|
||||
"Uniswap Swap",
|
||||
);
|
||||
expect(decode(data()).name).toBe("Uniswap Swap");
|
||||
});
|
||||
|
||||
test("still states the output it did establish", () => {
|
||||
@@ -175,9 +178,7 @@ describe("native ETH in, which V4 spells as the zero address", () => {
|
||||
|
||||
expect(detail(data, "Token In").value).toBe("ETH (native)");
|
||||
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
|
||||
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
|
||||
"Swap ETH → USDT",
|
||||
);
|
||||
expect(decode(data).name).toBe("Swap ETH → USDT");
|
||||
});
|
||||
|
||||
test("WRAP_ETH is still ETH at 18 decimals", () => {
|
||||
|
||||
Reference in New Issue
Block a user