Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbef39d739 |
@@ -1144,9 +1144,8 @@ fetched, without that cross-address check, and shows the row's figure.
|
|||||||
|
|
||||||
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
||||||
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
||||||
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
|
decoded swap (see TxApproval below), do not always read as a number. They can
|
||||||
only the user is paid, see Transaction Decoding), do not always read as a
|
read:
|
||||||
number. They can read:
|
|
||||||
|
|
||||||
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
|
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
|
||||||
truncated to four decimals, with the floor and the zero cases described above.
|
truncated to four decimals, with the floor and the zero cases described above.
|
||||||
@@ -1498,6 +1497,19 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
|
|||||||
"Back" always re-renders, so the wallet list reflects anything that changed
|
"Back" always re-renders, so the wallet list reflects anything that changed
|
||||||
while the user was away from it.
|
while the user was away from it.
|
||||||
|
|
||||||
|
A network switch in Settings leaves every screen beneath Settings showing the
|
||||||
|
previous network, so after it "Back" behaves as in a reopened popup: every
|
||||||
|
screen on the stack counts as not yet rendered, and the stack is cut at the
|
||||||
|
first screen that cannot be rendered from saved state, such as Send or AddToken,
|
||||||
|
or at AddressToken for any token but the native one, because that token was
|
||||||
|
picked on the previous network. The cut stack is saved with the switch, so a
|
||||||
|
popup closed straight after it and reopened onto Settings lands in the same
|
||||||
|
place. "Back" then renders the screen it lands on for the new network, except
|
||||||
|
ConfirmTx, which keeps the network its transaction was reviewed on, and anything
|
||||||
|
typed there and not saved, which belonged to the previous network, is dropped.
|
||||||
|
Without a wallet the stack is not cut, because it starts at Welcome, which shows
|
||||||
|
nothing from a network.
|
||||||
|
|
||||||
Every screen that holds secret material in the page registers a cleanup with
|
Every screen that holds secret material in the page registers a cleanup with
|
||||||
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||||
exit from that screen rather than only on its "Back" button, so nothing secret
|
exit from that screen rather than only on its "Back" button, so nothing secret
|
||||||
@@ -1795,19 +1807,22 @@ view would leave a wallet one click from deletion.
|
|||||||
it, and a disagreement is a refusal rather than a preference for either
|
it, and a disagreement is a refusal rather than a preference for either
|
||||||
value (`src/shared/transferAmount.js`)
|
value (`src/shared/transferAmount.js`)
|
||||||
- "Sign & Send" after the active network was switched in Settings since this
|
- "Sign & Send" after the active network was switched in Settings since this
|
||||||
transaction was reviewed, with this screen open or before the popup
|
transaction was reviewed → nothing is signed; "The active network changed
|
||||||
reopened onto it → nothing is signed; "The active network changed after
|
after this transaction was reviewed, so it was not sent. Please go back
|
||||||
this transaction was reviewed, so it was not sent. Please go back and
|
and review it again." in the errors box, no screen change. After a switch,
|
||||||
review it again." in the errors box, no screen change. The transaction
|
"Back" from Settings draws this screen again from the saved transaction,
|
||||||
carries the chain id of the network active when Send showed this screen,
|
as a popup reopened onto it does; when Send is beneath it on the stack, as
|
||||||
and keeps it when the screen is drawn again from the saved transaction, so
|
when Send showed it in the same popup, "Back" lands below Send instead
|
||||||
it cannot be signed for another. The screen describes it on that network,
|
(see the top of Screen Map). The transaction carries the chain id of the
|
||||||
whichever network is active: its amounts are in that network's native
|
network active when Send showed this screen, and keeps it when the screen
|
||||||
currency, its USD values are that network's, so a testnet transaction
|
is drawn again from the saved transaction, so it cannot be signed for
|
||||||
shows none, its From, To and token contract links open that network's
|
another. The screen describes it on that network, whichever network is
|
||||||
explorer, the balance read, the fee estimate and the contract and
|
active: its amounts are in that network's native currency, its USD values
|
||||||
never-sent recipient checks ask that network's RPC endpoint, and the
|
are that network's, so a testnet transaction shows none, its From, To and
|
||||||
Etherscan label check asks that network's explorer
|
token contract links open that network's explorer, the balance read, the
|
||||||
|
fee estimate and the contract and never-sent recipient checks ask that
|
||||||
|
network's RPC endpoint, and the Etherscan label check asks that network's
|
||||||
|
explorer
|
||||||
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||||||
line, no screen change
|
line, no screen change
|
||||||
- "Sign & Send" signs as the From address above, not as whichever address is
|
- "Sign & Send" signs as the From address above, not as whichever address is
|
||||||
@@ -1945,13 +1960,18 @@ view would leave a wallet one click from deletion.
|
|||||||
- Contract address input
|
- Contract address input
|
||||||
- Status line ("Looking up token...", cleared or replaced on failure)
|
- Status line ("Looking up token...", cleared or replaced on failure)
|
||||||
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
||||||
contract address input
|
contract address input. Shown on Ethereum Mainnet only, because the
|
||||||
|
bundled token list holds mainnet contracts
|
||||||
- "Add" button
|
- "Add" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
||||||
**AddressDetail**
|
**AddressDetail**
|
||||||
- "Add" with a token already tracked, a scam-listed address, or a failed
|
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||||||
contract lookup → flash message, no screen change
|
contract lookup → flash message, no screen change
|
||||||
|
- "Add", then a network switch in Settings before the contract lookup
|
||||||
|
answers → nothing is tracked; "The active network changed before the
|
||||||
|
lookup finished, so the token was not added." flash message, no screen
|
||||||
|
change
|
||||||
- "Back" → previous screen (AddressDetail)
|
- "Back" → previous screen (AddressDetail)
|
||||||
|
|
||||||
#### Settings (`settings`)
|
#### Settings (`settings`)
|
||||||
@@ -1962,16 +1982,19 @@ view would leave a wallet one click from deletion.
|
|||||||
- Wallets: one row per wallet with its name (tap to rename inline), a
|
- Wallets: one row per wallet with its name (tap to rename inline), a
|
||||||
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
|
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
|
||||||
plus a "+ Add wallet" button
|
plus a "+ Add wallet" button
|
||||||
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
|
- Tracked Tokens: one row per token tracked on the active network with an
|
||||||
plus a "+ Add token" button
|
`[x]` remove button, plus a "+ Add token" button
|
||||||
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
||||||
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
||||||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
|
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
|
||||||
network changes only here, or when the user approves a site's request on
|
network changes only here, or when the user approves a site's request on
|
||||||
**NetworkApproval**; either way, switching restores the RPC and Blockscout
|
**NetworkApproval**; either way, switching restores the RPC and Blockscout
|
||||||
endpoints last used on that network, or that network's defaults if it has
|
endpoints last used on that network, or that network's defaults if it has
|
||||||
none, and sends `chainChanged` with the new chain id to every open tab.
|
none, and the tokens tracked on that network, and sends `chainChanged`
|
||||||
Choosing the network already active changes nothing and sends nothing
|
with the new chain id to every open tab. A token is tracked only on the
|
||||||
|
network it was added on, because a contract address names a token on one
|
||||||
|
network only. Choosing the network already active changes nothing and
|
||||||
|
sends nothing
|
||||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||||
`eth_chainId` before being saved)
|
`eth_chainId` before being saved)
|
||||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||||
@@ -2016,7 +2039,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
||||||
nothing and tells the background nothing
|
nothing and tells the background nothing
|
||||||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||||
- "Back" (or Settings gear again) → previous screen (Home)
|
- "Back" (or Settings gear again) → previous screen (Home); after a network
|
||||||
|
switch, the stack is cut first (see the top of Screen Map)
|
||||||
|
|
||||||
#### ShowRecoveryPhrase (`show-phrase`)
|
#### ShowRecoveryPhrase (`show-phrase`)
|
||||||
|
|
||||||
@@ -2199,7 +2223,8 @@ view would leave a wallet one click from deletion.
|
|||||||
#### SettingsAddToken (`settings-addtoken`)
|
#### SettingsAddToken (`settings-addtoken`)
|
||||||
|
|
||||||
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
||||||
across every address, unlike AddToken which is reached from one address.
|
across every address on the active network, unlike AddToken which is reached
|
||||||
|
from one address.
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Back" button, "Add Token" heading
|
- "Back" button, "Add Token" heading
|
||||||
- Instruction text
|
- Instruction text
|
||||||
@@ -2208,11 +2233,19 @@ view would leave a wallet one click from deletion.
|
|||||||
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
||||||
"Add selected" button
|
"Add selected" button
|
||||||
- "Or enter contract address:" input, a status line, and an "Add" button
|
- "Or enter contract address:" input, a status line, and an "Add" button
|
||||||
|
- The two lists from the bundled token list, "Top tokens:" and "Or pick from
|
||||||
|
top 100:", are shown on Ethereum Mainnet only, because that list holds
|
||||||
|
mainnet contracts. On any other network the contract address is the only
|
||||||
|
add path
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Any of the three add paths, on success → adds the token and shows an
|
- Any of the add paths shown, on success → adds the token and shows an
|
||||||
"Added SYMBOL" flash message (no screen change)
|
"Added SYMBOL" flash message (no screen change)
|
||||||
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
||||||
message, no screen change
|
message, no screen change
|
||||||
|
- "Add" by contract address, then a network switch in Settings before the
|
||||||
|
lookup answers → nothing is tracked; "The active network changed before
|
||||||
|
the lookup finished, so the token was not added." flash message, no screen
|
||||||
|
change
|
||||||
- "Back" → previous screen (Settings)
|
- "Back" → previous screen (Settings)
|
||||||
|
|
||||||
#### SiteApproval (`approve-site`)
|
#### SiteApproval (`approve-site`)
|
||||||
@@ -2289,9 +2322,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- Site origin (bold, scheme and port included) + "wants to send a
|
- Site origin (bold, scheme and port included) + "wants to send a
|
||||||
transaction"
|
transaction"
|
||||||
- Decoded action (if calldata is recognized): action name, token details,
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
amounts, steps, deadline, and unless a swap pays only the From address, a
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
`Paid to` line for each other address it pays, or saying that its output
|
|
||||||
stays in the router or who it pays is unknown (see Transaction Decoding)
|
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
- Contract: color dot + full address + etherscan link, token symbol label if
|
||||||
known; for a contract creation, the same sentence as on WaitTx
|
known; for a contract creation, the same sentence as on WaitTx
|
||||||
@@ -2429,8 +2460,9 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Switch" → closes popup; the background switches the network as
|
- "Switch" → closes popup; the background switches the network as
|
||||||
**Settings** does, restoring the RPC and Blockscout endpoints last used on
|
**Settings** does, restoring the RPC and Blockscout endpoints last used on
|
||||||
that network (or that network's defaults if it has none), sends
|
that network (or that network's defaults if it has none) and the tokens
|
||||||
`chainChanged` to every open tab, and answers the site with success
|
tracked on that network, sends `chainChanged` to every open tab, and
|
||||||
|
answers the site with success
|
||||||
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
|
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
|
||||||
nothing changes
|
nothing changes
|
||||||
- Popup window closed without answering → the same as "Reject"
|
- Popup window closed without answering → the same as "Reject"
|
||||||
@@ -2926,39 +2958,7 @@ Currently supported:
|
|||||||
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other
|
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`
|
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
|
read `Unknown (could not be decoded)`, and there is no `Amount` or
|
||||||
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
|
`Min. received` line.
|
||||||
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's
|
|
||||||
sender. The router reads `address(1)` as the transaction's sender, and
|
|
||||||
`address(2)` as the router itself, which holds what it is paid until a later
|
|
||||||
step pays it out; a payment to the router, as `address(2)` or by its own
|
|
||||||
address, is not counted as paid out. When a swap step or `TAKE` pays the
|
|
||||||
router the swap's output token, the output stays in the router unless a later
|
|
||||||
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
|
|
||||||
A swap step or `TAKE` 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. The minimum reads `Min. received` only when some step pays the
|
|
||||||
transaction's sender (the From address on TxApproval, the sender the explorer
|
|
||||||
reports on TransactionDetail), no step pays any other address, no step that
|
|
||||||
may pay an address went unread, and the output does not stay in the router.
|
|
||||||
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names
|
|
||||||
each other address paid, one reads
|
|
||||||
`The router, where it stays (no later step pays it out)` when the output stays
|
|
||||||
in the router, and one more reads `Unknown (could not be decoded)` when such a
|
|
||||||
step went unread, or `Unknown (not named in the calldata)` when no step pays
|
|
||||||
anyone. A step goes unread when its recipient does not decode as an address,
|
|
||||||
or when it is a command or V4 action the decoder does not read: every command
|
|
||||||
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
|
|
||||||
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
|
|
||||||
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and
|
|
||||||
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`.
|
|
||||||
|
|
||||||
### Non-Goals Forever
|
### Non-Goals Forever
|
||||||
|
|
||||||
|
|||||||
@@ -852,7 +852,7 @@
|
|||||||
id="add-token-info"
|
id="add-token-info"
|
||||||
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div id="add-token-common" class="mb-2">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
>Or pick a common token:</label
|
>Or pick a common token:</label
|
||||||
>
|
>
|
||||||
@@ -1336,8 +1336,10 @@
|
|||||||
<h2 class="font-bold mb-2">Add Token</h2>
|
<h2 class="font-bold mb-2">Add Token</h2>
|
||||||
<p class="text-xs text-muted mb-3">
|
<p class="text-xs text-muted mb-3">
|
||||||
Pick a common token or enter a contract address manually.
|
Pick a common token or enter a contract address manually.
|
||||||
|
Common tokens are offered on Ethereum Mainnet only.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div id="settings-addtoken-common">
|
||||||
<!-- top 10 quick-pick buttons -->
|
<!-- top 10 quick-pick buttons -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
@@ -1367,6 +1369,7 @@
|
|||||||
Add selected
|
Add selected
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- manual contract address -->
|
<!-- manual contract address -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ function markViewRendered(view) {
|
|||||||
if (view) renderedViews.add(view);
|
if (view) renderedViews.add(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin a fresh page-load scope. The popup gets one by being loaded; the
|
// Begin a fresh page-load scope. The popup gets one by being loaded, and
|
||||||
|
// asks for one when the user switches the network, because every screen
|
||||||
|
// then on the page shows the previous network (views/settings.js); the
|
||||||
// unit tests, which simulate several page loads against one module
|
// unit tests, which simulate several page loads against one module
|
||||||
// instance, ask for one.
|
// instance, ask for one too.
|
||||||
function resetRenderedViews() {
|
function resetRenderedViews() {
|
||||||
renderedViews.clear();
|
renderedViews.clear();
|
||||||
}
|
}
|
||||||
@@ -217,9 +219,10 @@ function renderView(view, state, views) {
|
|||||||
// in the two cases where the view is known to be on the page already:
|
// in the two cases where the view is known to be on the page already:
|
||||||
//
|
//
|
||||||
// - It is not one the popup renders from persisted state. The restored
|
// - It is not one the popup renders from persisted state. The restored
|
||||||
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
|
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
|
||||||
// be on the stack from this page load, where forward navigation
|
// it the same way (views/settings.js), so such a view can only be on the
|
||||||
// rendered it on the way in.
|
// stack from this page load, where forward navigation rendered it on the
|
||||||
|
// way in.
|
||||||
// - This page load has rendered it. Re-rendering would re-fetch and
|
// - This page load has rendered it. Re-rendering would re-fetch and
|
||||||
// clobber what it holds; Home is rendered anyway, see above.
|
// clobber what it holds; Home is rendered anyway, see above.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -9,6 +9,12 @@ function show() {
|
|||||||
$("add-token-address").value = "";
|
$("add-token-address").value = "";
|
||||||
$("add-token-info").textContent = "";
|
$("add-token-info").textContent = "";
|
||||||
$("add-token-info").style.visibility = "hidden";
|
$("add-token-info").style.visibility = "hidden";
|
||||||
|
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
||||||
|
// on any other network.
|
||||||
|
$("add-token-common").classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
state.networkId !== "mainnet",
|
||||||
|
);
|
||||||
const list = $("common-token-list");
|
const list = $("common-token-list");
|
||||||
list.innerHTML = getTopTokens(25)
|
list.innerHTML = getTopTokens(25)
|
||||||
.map(
|
.map(
|
||||||
@@ -46,6 +52,9 @@ function init(ctx) {
|
|||||||
infoEl.textContent = "Looking up token...";
|
infoEl.textContent = "Looking up token...";
|
||||||
infoEl.style.visibility = "visible";
|
infoEl.style.visibility = "visible";
|
||||||
log.debugf("Looking up token contract", contractAddr);
|
log.debugf("Looking up token contract", contractAddr);
|
||||||
|
// The network the lookup asks, which Settings may switch away from
|
||||||
|
// before it answers, as for a transaction in confirmTx.js.
|
||||||
|
const chainId = currentNetwork().chainId;
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
contractAddr,
|
contractAddr,
|
||||||
@@ -53,6 +62,15 @@ function init(ctx) {
|
|||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
|
// A contract address names a token on one network only, so an
|
||||||
|
// answer after a switch is not added to the new network's list.
|
||||||
|
if (currentNetwork().chainId !== chainId) {
|
||||||
|
showFlash(
|
||||||
|
"The active network changed before the lookup finished," +
|
||||||
|
" so the token was not added.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.infof("Adding token", info.symbol, contractAddr);
|
log.infof("Adding token", info.symbol, contractAddr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
address: contractAddr,
|
address: contractAddr,
|
||||||
|
|||||||
@@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
|
// Try to decode calldata sent to `toAddress` on the network with chain id
|
||||||
// with chain id `chainId`, using known ABIs.
|
// `chainId`, using known ABIs.
|
||||||
// Returns { name, description, details } or null.
|
// Returns { name, description, details } or null.
|
||||||
function decodeCalldata(data, toAddress, chainId, fromAddress) {
|
function decodeCalldata(data, toAddress, chainId) {
|
||||||
if (!data || data === "0x" || data.length < 10) return null;
|
if (!data || data === "0x" || data.length < 10) return null;
|
||||||
|
|
||||||
// Where a token's scale is looked for, for every decoder below: the ERC-20
|
// Where a token's scale is looked for, for every decoder below: the ERC-20
|
||||||
@@ -191,7 +191,6 @@ function decodeCalldata(data, toAddress, chainId, fromAddress) {
|
|||||||
toAddress,
|
toAddress,
|
||||||
chainId,
|
chainId,
|
||||||
decimalsSources,
|
decimalsSources,
|
||||||
fromAddress,
|
|
||||||
);
|
);
|
||||||
if (routerResult) return routerResult;
|
if (routerResult) return routerResult;
|
||||||
|
|
||||||
@@ -278,12 +277,11 @@ function showTxApproval(details) {
|
|||||||
|
|
||||||
// If this is an ERC-20 call, try to extract the real recipient and amount.
|
// If this is an ERC-20 call, try to extract the real recipient and amount.
|
||||||
// Decoded for the network the transaction is for, which the Network line
|
// Decoded for the network the transaction is for, which the Network line
|
||||||
// names, and the address the From line names.
|
// names.
|
||||||
const decoded = decodeCalldata(
|
const decoded = decodeCalldata(
|
||||||
approvedTx.data,
|
approvedTx.data,
|
||||||
toAddr || "",
|
toAddr || "",
|
||||||
approvedTx.chainId,
|
approvedTx.chainId,
|
||||||
details.approvedFrom,
|
|
||||||
);
|
);
|
||||||
if (decoded && decoded.details) {
|
if (decoded && decoded.details) {
|
||||||
// The asset the status summary is counted in: an ERC-20 call's Token
|
// The asset the status summary is counted in: an ERC-20 call's Token
|
||||||
|
|||||||
@@ -647,9 +647,9 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The screen describes the network the transaction was reviewed for,
|
// The screen describes the network the transaction was reviewed for,
|
||||||
// which Settings may have switched away from since: with this screen
|
// which Settings may have switched away from since: "Back" from
|
||||||
// open, where Back only unhides it, or before the popup reopened onto
|
// Settings after a switch draws this screen again from the saved
|
||||||
// it.
|
// transaction, as a popup reopened onto it does.
|
||||||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
if (currentNetwork().chainId !== pendingTx.chainId) {
|
||||||
showSendError(
|
showSendError(
|
||||||
"The active network changed after this transaction was" +
|
"The active network changed after this transaction was" +
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
|
const { restorableStack } = require("../../shared/persistedState");
|
||||||
|
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
|
||||||
const {
|
const {
|
||||||
log,
|
log,
|
||||||
debugFetch,
|
debugFetch,
|
||||||
@@ -319,12 +321,33 @@ function init(ctx) {
|
|||||||
networkSelect.addEventListener("change", async () => {
|
networkSelect.addEventListener("change", async () => {
|
||||||
const newId = networkSelect.value;
|
const newId = networkSelect.value;
|
||||||
if (newId === state.networkId) return;
|
if (newId === state.networkId) return;
|
||||||
|
// The screens beneath Settings show the previous network, so Back
|
||||||
|
// finds them as it does in a reopened popup: each is rendered again
|
||||||
|
// when Back lands on it, and the stack is cut at the first one that
|
||||||
|
// cannot be rendered from saved state, such as Send or AddToken. It
|
||||||
|
// is cut at AddressToken too, unless that shows the native token: any
|
||||||
|
// other token there was picked on the previous network. Without a
|
||||||
|
// wallet the stack starts at Welcome, which that cut would drop, and
|
||||||
|
// Welcome shows nothing from a network. The cut comes before the
|
||||||
|
// switch so that the switch's save keeps it for a popup closed
|
||||||
|
// straight after.
|
||||||
|
if (state.hasWallet) {
|
||||||
|
const tokenView = state.viewStack.indexOf("address-token");
|
||||||
|
if (tokenView !== -1 && state.selectedToken !== "ETH") {
|
||||||
|
state.viewStack = state.viewStack.slice(0, tokenView);
|
||||||
|
}
|
||||||
|
state.viewStack = restorableStack(state.viewStack, "settings");
|
||||||
|
}
|
||||||
const net = await onChainSwitch(newId);
|
const net = await onChainSwitch(newId);
|
||||||
// Open pages are told by the background, as after a site's approved
|
// Open pages are told by the background, as after a site's approved
|
||||||
// switch request.
|
// switch request.
|
||||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||||
|
resetRenderedViews();
|
||||||
|
// Settings itself is brought up to date for the new network here.
|
||||||
|
markViewRendered("settings");
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
|
renderTrackedTokens();
|
||||||
showFlash("Switched to " + net.name + ".");
|
showFlash("Switched to " + net.name + ".");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
@@ -75,6 +75,12 @@ function show() {
|
|||||||
$("settings-addtoken-address").value = "";
|
$("settings-addtoken-address").value = "";
|
||||||
$("settings-addtoken-info").textContent = "";
|
$("settings-addtoken-info").textContent = "";
|
||||||
$("settings-addtoken-info").style.visibility = "hidden";
|
$("settings-addtoken-info").style.visibility = "hidden";
|
||||||
|
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
||||||
|
// on any other network.
|
||||||
|
$("settings-addtoken-common").classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
state.networkId !== "mainnet",
|
||||||
|
);
|
||||||
renderTop10();
|
renderTop10();
|
||||||
renderDropdown();
|
renderDropdown();
|
||||||
showView("settings-addtoken");
|
showView("settings-addtoken");
|
||||||
@@ -130,6 +136,9 @@ function init(_ctx) {
|
|||||||
infoEl.textContent = "Looking up token...";
|
infoEl.textContent = "Looking up token...";
|
||||||
infoEl.style.visibility = "visible";
|
infoEl.style.visibility = "visible";
|
||||||
log.debugf("Looking up token contract", addr);
|
log.debugf("Looking up token contract", addr);
|
||||||
|
// The network the lookup asks, which Settings may switch away from
|
||||||
|
// before it answers, as for a transaction in confirmTx.js.
|
||||||
|
const chainId = currentNetwork().chainId;
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
addr,
|
addr,
|
||||||
@@ -137,6 +146,15 @@ function init(_ctx) {
|
|||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
|
// A contract address names a token on one network only, so an
|
||||||
|
// answer after a switch is not added to the new network's list.
|
||||||
|
if (currentNetwork().chainId !== chainId) {
|
||||||
|
showFlash(
|
||||||
|
"The active network changed before the lookup finished," +
|
||||||
|
" so the token was not added.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.infof("Adding token", info.symbol, addr);
|
log.infof("Adding token", info.symbol, addr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
address: addr,
|
address: addr,
|
||||||
|
|||||||
@@ -316,13 +316,11 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
// Decoded against the address the transaction was sent to, as the
|
// Decoded against the address the transaction was sent to, as the
|
||||||
// explorer reports it, not the history entry's `to`: for a token
|
// explorer reports it, not the history entry's `to`: for a token
|
||||||
// transfer that is the transfer's recipient, not the token contract.
|
// transfer that is the transfer's recipient, not the token contract.
|
||||||
// On the active network, the one whose explorer was just asked, and
|
// On the active network, the one whose explorer was just asked.
|
||||||
// from the sender it reports.
|
|
||||||
const decoded = decodeCalldata(
|
const decoded = decodeCalldata(
|
||||||
inputData,
|
inputData,
|
||||||
txData.to?.hash || "",
|
txData.to?.hash || "",
|
||||||
currentNetwork().chainId,
|
currentNetwork().chainId,
|
||||||
txData.from?.hash || "",
|
|
||||||
);
|
);
|
||||||
if (decoded) {
|
if (decoded) {
|
||||||
// Render decoded calldata matching approval view style
|
// Render decoded calldata matching approval view style
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ function applyChainSwitchFields(s, newNetworkId) {
|
|||||||
blockscoutUrl: s.blockscoutUrl,
|
blockscoutUrl: s.blockscoutUrl,
|
||||||
};
|
};
|
||||||
const remembered = s.networkEndpoints[net.id] || {};
|
const remembered = s.networkEndpoints[net.id] || {};
|
||||||
|
|
||||||
|
// Tracked tokens are kept per network the same way, because a contract
|
||||||
|
// address names a token on one network only. s.trackedTokens stays the
|
||||||
|
// active network's list, so nothing that reads it changes: the outgoing
|
||||||
|
// list is put away under its network, and the incoming network's list, or
|
||||||
|
// an empty one, takes its place.
|
||||||
|
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
|
||||||
|
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
|
||||||
|
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
|
||||||
|
|
||||||
s.networkId = net.id;
|
s.networkId = net.id;
|
||||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||||
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const DEFAULT_STATE = {
|
|||||||
hasWallet: false,
|
hasWallet: false,
|
||||||
wallets: [],
|
wallets: [],
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
|
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
|
||||||
|
// trackedTokens above is the active network's list; this is what the
|
||||||
|
// others are restored from when the active network changes. See
|
||||||
|
// applyChainSwitchFields().
|
||||||
|
networkTrackedTokens: {},
|
||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: DEFAULT_RPC_URL,
|
rpcUrl: DEFAULT_RPC_URL,
|
||||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||||
@@ -289,6 +294,20 @@ function normalizePersisted(saved) {
|
|||||||
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
||||||
// value and survives.
|
// value and survives.
|
||||||
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
||||||
|
// Each remembered list is floored like trackedTokens, because
|
||||||
|
// applyChainSwitchFields() puts it straight back onto trackedTokens when
|
||||||
|
// its network becomes the active one. A key that is not a network in
|
||||||
|
// networks.js is dropped, which also keeps an own "__proto__" key from
|
||||||
|
// reaching the plain assignment below.
|
||||||
|
out.networkTrackedTokens = {};
|
||||||
|
if (isRecord(saved.networkTrackedTokens)) {
|
||||||
|
for (const netId of Object.keys(saved.networkTrackedTokens)) {
|
||||||
|
if (!isKnownNetworkId(netId)) continue;
|
||||||
|
out.networkTrackedTokens[netId] = structuredClone(
|
||||||
|
tokenRefs(saved.networkTrackedTokens[netId]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||||
// floor under it. networkId is an object KEY into networkEndpoints below,
|
// floor under it. networkId is an object KEY into networkEndpoints below,
|
||||||
// so a value that is not a network in networks.js must never get that far
|
// so a value that is not a network in networks.js must never get that far
|
||||||
|
|||||||
+12
-8
@@ -42,9 +42,11 @@ const { log } = require("./log");
|
|||||||
// and writes THIS object, never the proxy: the guard is for callers.
|
// and writes THIS object, never the proxy: the guard is for callers.
|
||||||
const rawState = {
|
const rawState = {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
// Their own objects, not the ones DEFAULT_STATE holds:
|
||||||
// mutates this map in place, and a spread copies the reference.
|
// applyChainSwitchFields() mutates these maps in place, and a spread copies
|
||||||
|
// the reference.
|
||||||
networkEndpoints: {},
|
networkEndpoints: {},
|
||||||
|
networkTrackedTokens: {},
|
||||||
currentView: null,
|
currentView: null,
|
||||||
selectedWallet: null,
|
selectedWallet: null,
|
||||||
selectedAddress: null,
|
selectedAddress: null,
|
||||||
@@ -438,12 +440,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
//
|
//
|
||||||
// Every other persisted field stays a whole-field diff:
|
// Every other persisted field stays a whole-field diff:
|
||||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||||
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
|
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
||||||
// the ones above, but nothing in src/ ever writes an entry into it — it is
|
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
||||||
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
|
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
||||||
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
||||||
// this page's own UI scratch space, not data another page has any reason to
|
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
|
||||||
// share membership of.
|
// mutation for a whole-field diff to collide with; `viewData` is this page's
|
||||||
|
// own UI scratch space, not data another page has any reason to share
|
||||||
|
// membership of.
|
||||||
//
|
//
|
||||||
// This does not make two pages that both change the SAME leaf concurrently
|
// This does not make two pages that both change the SAME leaf concurrently
|
||||||
// safe: last write wins on that one leaf, same as before. What it removes
|
// safe: last write wins on that one leaf, same as before. What it removes
|
||||||
|
|||||||
+32
-290
@@ -1,15 +1,7 @@
|
|||||||
// Decode Uniswap Universal Router execute() calldata into human-readable
|
// Decode Uniswap Universal Router execute() calldata into human-readable
|
||||||
// swap details. Designed to be extended with other DEX decoders later.
|
// swap details. Designed to be extended with other DEX decoders later.
|
||||||
|
|
||||||
const {
|
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
|
||||||
Interface,
|
|
||||||
AbiCoder,
|
|
||||||
getBytes,
|
|
||||||
formatUnits,
|
|
||||||
getCreate2Address,
|
|
||||||
keccak256,
|
|
||||||
solidityPacked,
|
|
||||||
} = require("ethers");
|
|
||||||
const { truncateAmountNeverZero } = require("./amountDisplay");
|
const { truncateAmountNeverZero } = require("./amountDisplay");
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
@@ -73,55 +65,6 @@ function routerCommandTypeMask(address, chainId) {
|
|||||||
return router ? router[1] : null;
|
return router ? router[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
|
|
||||||
// network were deployed with. A V2 swap step swaps through the pair its path
|
|
||||||
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
|
|
||||||
// CREATE2 address from the factory, with the hash of the path's first two
|
|
||||||
// tokens, sorted, as salt.
|
|
||||||
// - pairFor(): https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/uniswap/v2/UniswapV2Library.sol
|
|
||||||
// - mainnet: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeployMainnet.s.sol
|
|
||||||
// - Sepolia: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeploySepolia.s.sol
|
|
||||||
// Every router in ROUTER_ADDRESSES was deployed with these (their constructor
|
|
||||||
// arguments on the explorer), except Sepolia's UniversalRouterV1_2_V2Support,
|
|
||||||
// which was deployed with two placeholders: the V2 factory
|
|
||||||
// 0x5302086A3a25d473aAbBd0356eFf8Dd811a4d89B (the `UnsupportedProtocol` its
|
|
||||||
// deployment file lists) and a zero init code hash. On Sepolia no contract
|
|
||||||
// sits at a pair any router there finds (the hash below is not that of the
|
|
||||||
// factory's own pairs), so every V2 swap step they run reverts.
|
|
||||||
const V2_FACTORIES = new Map([
|
|
||||||
[
|
|
||||||
NETWORKS.mainnet.chainId,
|
|
||||||
{
|
|
||||||
v2Factory: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
|
|
||||||
pairInitCodeHash:
|
|
||||||
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
NETWORKS.sepolia.chainId,
|
|
||||||
{
|
|
||||||
v2Factory: "0xB7f907f7A9eBC822a80BD25E224be42Ce0A698A0",
|
|
||||||
pairInitCodeHash:
|
|
||||||
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The address of the V2 pair of `tokenA` and `tokenB` on the network with this
|
|
||||||
// chain id, found as the router finds it (see V2_FACTORIES).
|
|
||||||
function v2PairFor(chainId, tokenA, tokenB) {
|
|
||||||
const { v2Factory, pairInitCodeHash } = V2_FACTORIES.get(chainId);
|
|
||||||
const [token0, token1] =
|
|
||||||
tokenA.toLowerCase() < tokenB.toLowerCase()
|
|
||||||
? [tokenA, tokenB]
|
|
||||||
: [tokenB, tokenA];
|
|
||||||
return getCreate2Address(
|
|
||||||
v2Factory,
|
|
||||||
keccak256(solidityPacked(["address", "address"], [token0, token1])),
|
|
||||||
pairInitCodeHash,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Universal Router command types, each read from its command byte with the
|
// Universal Router command types, each read from its command byte with the
|
||||||
// router's mask (see ROUTER_ADDRESSES)
|
// router's mask (see ROUTER_ADDRESSES)
|
||||||
const COMMAND_NAMES = {
|
const COMMAND_NAMES = {
|
||||||
@@ -158,23 +101,16 @@ function formatAmount(raw, decimals) {
|
|||||||
|
|
||||||
// One wording for either side of the screen: a currency the calldata never
|
// One wording for either side of the screen: a currency the calldata never
|
||||||
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
|
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
|
||||||
// on a scale — not as a token name, and not as a quantity. The Paid to line
|
// on a scale — not as a token name, and not as a quantity.
|
||||||
// uses it too, when no step names anyone paid.
|
|
||||||
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
|
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
|
||||||
|
|
||||||
// The same refusal when a V4 swap action could not be read (see
|
// The same refusal when a V4 swap action could not be read (see
|
||||||
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
|
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
|
||||||
// commands this decoder does not read. That action names both of its
|
// commands this decoder does not read. That action names both of its
|
||||||
// currencies, and those commands may name one, so saying the calldata did not
|
// currencies, and those commands may name one, so saying the calldata did not
|
||||||
// name it would be false. The Paid to line uses it too, when a step may pay an
|
// name it would be false.
|
||||||
// address this decoder did not read.
|
|
||||||
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
|
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
|
||||||
|
|
||||||
// The Paid to line when the swap's output is paid to the router and no later
|
|
||||||
// step pays it out of the router.
|
|
||||||
const STAYS_IN_ROUTER =
|
|
||||||
"The router, where it stays (no later step pays it out)";
|
|
||||||
|
|
||||||
// Explicit presence, never truthiness. Every gate in this file that guards a
|
// Explicit presence, never truthiness. Every gate in this file that guards a
|
||||||
// decoded value goes through here: an address is never falsy once set, but an
|
// decoded value goes through here: an address is never falsy once set, but an
|
||||||
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
|
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
|
||||||
@@ -224,11 +160,6 @@ const OPEN_DELTA = Symbol("v4-open-delta");
|
|||||||
// that nothing is swapped.
|
// that nothing is swapped.
|
||||||
const ALREADY_PAID = Symbol("v2-already-paid");
|
const ALREADY_PAID = Symbol("v2-already-paid");
|
||||||
|
|
||||||
// The router's whole-balance amount, universal-router
|
|
||||||
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
|
|
||||||
// pays the pair all the router holds of the input token.
|
|
||||||
const CONTRACT_BALANCE = 2n ** 255n;
|
|
||||||
|
|
||||||
// The amount lines that state a fact instead of a quantity. Same register as
|
// The amount lines that state a fact instead of a quantity. Same register as
|
||||||
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
|
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
|
||||||
// number — and deliberately not another phrasing of "not named": these say
|
// number — and deliberately not another phrasing of "not named": these say
|
||||||
@@ -248,21 +179,6 @@ const WETH_ADDRESSES = [
|
|||||||
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Two recipient values the router reads as placeholders, not as addresses:
|
|
||||||
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
|
|
||||||
// and ADDRESS_THIS pays the router itself, which holds what it is paid until a
|
|
||||||
// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH
|
|
||||||
// map their recipient through the dispatcher's map(), and a V4 TAKE through
|
|
||||||
// V4Router's _mapRecipient(); TAKE_ALL always pays the caller.
|
|
||||||
// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol
|
|
||||||
// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol
|
|
||||||
// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
|
|
||||||
// - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol
|
|
||||||
// - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol
|
|
||||||
// - TAKE, TAKE_ALL and TAKE_PORTION: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol
|
|
||||||
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
|
|
||||||
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
|
||||||
|
|
||||||
// `decimals` is null when nothing knows this token's scale. It is not
|
// `decimals` is null when nothing knows this token's scale. It is not
|
||||||
// defaulted to 18: the swap lines land on the same approval screen as the
|
// defaulted to 18: the swap lines land on the same approval screen as the
|
||||||
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
||||||
@@ -349,10 +265,8 @@ function decodeV2SwapExactIn(input) {
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
recipient: d[0],
|
|
||||||
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
||||||
amountOutMin: d[2],
|
amountOutMin: d[2],
|
||||||
path: d[3],
|
|
||||||
tokenIn: d[3][0],
|
tokenIn: d[3][0],
|
||||||
tokenOut: d[3][d[3].length - 1],
|
tokenOut: d[3][d[3].length - 1],
|
||||||
};
|
};
|
||||||
@@ -371,7 +285,6 @@ function decodeV2SwapExactOut(input) {
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
recipient: d[0],
|
|
||||||
amountOut: d[1],
|
amountOut: d[1],
|
||||||
amountInMax: d[2],
|
amountInMax: d[2],
|
||||||
tokenIn: d[3][0],
|
tokenIn: d[3][0],
|
||||||
@@ -403,7 +316,6 @@ function decodeV3SwapExactIn(input) {
|
|||||||
const path = decodeV3Path(d[3]);
|
const path = decodeV3Path(d[3]);
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
return {
|
return {
|
||||||
recipient: d[0],
|
|
||||||
amountIn: d[1],
|
amountIn: d[1],
|
||||||
amountOutMin: d[2],
|
amountOutMin: d[2],
|
||||||
tokenIn: path.tokenIn,
|
tokenIn: path.tokenIn,
|
||||||
@@ -414,37 +326,12 @@ function decodeV3SwapExactIn(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode SWEEP (command 0x04) input bytes. The router pays its whole balance
|
// Decode WRAP_ETH (command 0x0b) input bytes.
|
||||||
// of `token` to the recipient.
|
|
||||||
// ABI: (address token, address recipient, uint256 amountMin)
|
|
||||||
function decodeSweep(input) {
|
|
||||||
try {
|
|
||||||
const d = coder.decode(["address", "address", "uint256"], input);
|
|
||||||
return { token: d[0], recipient: d[1] };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the
|
|
||||||
// recipient.
|
|
||||||
// ABI: (address recipient, uint256 amount)
|
// ABI: (address recipient, uint256 amount)
|
||||||
function decodeWrapEth(input) {
|
function decodeWrapEth(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(["address", "uint256"], input);
|
const d = coder.decode(["address", "uint256"], input);
|
||||||
return { recipient: d[0], amount: d[1] };
|
return { amount: d[1] };
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole
|
|
||||||
// WETH balance into ETH and pays it to the recipient.
|
|
||||||
// ABI: (address recipient, uint256 amountMin)
|
|
||||||
function decodeUnwrapWeth(input) {
|
|
||||||
try {
|
|
||||||
const d = coder.decode(["address", "uint256"], input);
|
|
||||||
return { recipient: d[0] };
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -459,7 +346,6 @@ const V4_SETTLE = 0x0b;
|
|||||||
const V4_SETTLE_ALL = 0x0c;
|
const V4_SETTLE_ALL = 0x0c;
|
||||||
const V4_TAKE = 0x0e;
|
const V4_TAKE = 0x0e;
|
||||||
const V4_TAKE_ALL = 0x0f;
|
const V4_TAKE_ALL = 0x0f;
|
||||||
const V4_TAKE_PORTION = 0x10;
|
|
||||||
|
|
||||||
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
|
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
|
||||||
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
|
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
|
||||||
@@ -614,10 +500,6 @@ function totalAmount(amounts) {
|
|||||||
// these rules leave unknown is set unknown, with no figure. A step with no
|
// these rules leave unknown is set unknown, with no figure. A step with no
|
||||||
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
|
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
|
||||||
// name.
|
// name.
|
||||||
//
|
|
||||||
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its
|
|
||||||
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set
|
|
||||||
// when the step has a payout this decoder did not read.
|
|
||||||
function decodeV4Swap(input) {
|
function decodeV4Swap(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(["bytes", "bytes[]"], input);
|
const d = coder.decode(["bytes", "bytes[]"], input);
|
||||||
@@ -626,8 +508,6 @@ function decodeV4Swap(input) {
|
|||||||
|
|
||||||
let settleToken = null;
|
let settleToken = null;
|
||||||
let takeToken = null;
|
let takeToken = null;
|
||||||
const takes = [];
|
|
||||||
let recipientUnread = false;
|
|
||||||
// The step's swap actions that could be read, as decodeV4SwapAction()
|
// The step's swap actions that could be read, as decodeV4SwapAction()
|
||||||
// reads them.
|
// reads them.
|
||||||
const swaps = [];
|
const swaps = [];
|
||||||
@@ -657,12 +537,10 @@ function decodeV4Swap(input) {
|
|||||||
params[i],
|
params[i],
|
||||||
);
|
);
|
||||||
takeToken = t[0];
|
takeToken = t[0];
|
||||||
takes.push({ currency: t[0], recipient: t[1] });
|
|
||||||
} else if (actionId === V4_TAKE_ALL) {
|
} else if (actionId === V4_TAKE_ALL) {
|
||||||
// TAKE_ALL: (address currency, uint256 minAmount)
|
// TAKE_ALL: (address currency, uint256 minAmount)
|
||||||
const t = coder.decode(["address", "uint256"], params[i]);
|
const t = coder.decode(["address", "uint256"], params[i]);
|
||||||
takeToken = t[0];
|
takeToken = t[0];
|
||||||
takes.push({ currency: t[0], recipient: MSG_SENDER });
|
|
||||||
} else if (
|
} else if (
|
||||||
actionId === V4_SWAP_EXACT_IN_SINGLE ||
|
actionId === V4_SWAP_EXACT_IN_SINGLE ||
|
||||||
actionId === V4_SWAP_EXACT_IN ||
|
actionId === V4_SWAP_EXACT_IN ||
|
||||||
@@ -674,19 +552,9 @@ function decodeV4Swap(input) {
|
|||||||
} catch {
|
} catch {
|
||||||
swapUndecoded = true;
|
swapUndecoded = true;
|
||||||
}
|
}
|
||||||
} else if (actionId === V4_TAKE_PORTION) {
|
|
||||||
// It pays a share of a currency to an address this decoder
|
|
||||||
// does not read
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
|
|
||||||
// V4Router reverts on any action not named here.
|
|
||||||
recipientUnread = true;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among
|
// Skip sub-actions we can't decode
|
||||||
// them is a payout this decoder did not read.
|
|
||||||
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) {
|
|
||||||
recipientUnread = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,8 +565,6 @@ function decodeV4Swap(input) {
|
|||||||
tokenOut: takeToken,
|
tokenOut: takeToken,
|
||||||
amountIn: null,
|
amountIn: null,
|
||||||
amountOutMin: null,
|
amountOutMin: null,
|
||||||
takes,
|
|
||||||
recipientUnread,
|
|
||||||
swapUndecoded,
|
swapUndecoded,
|
||||||
hasSwapAction: true,
|
hasSwapAction: true,
|
||||||
};
|
};
|
||||||
@@ -738,8 +604,6 @@ function decodeV4Swap(input) {
|
|||||||
tokenOut,
|
tokenOut,
|
||||||
amountIn,
|
amountIn,
|
||||||
amountOutMin,
|
amountOutMin,
|
||||||
takes,
|
|
||||||
recipientUnread,
|
|
||||||
swapUndecoded,
|
swapUndecoded,
|
||||||
hasSwapAction: swaps.length > 0,
|
hasSwapAction: swaps.length > 0,
|
||||||
};
|
};
|
||||||
@@ -748,14 +612,14 @@ function decodeV4Swap(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to decode a Universal Router execute() call sent from `fromAddress`, the
|
// Try to decode a Universal Router execute() call sent to `toAddress` on the
|
||||||
// user, to `toAddress` on the network with chain id `chainId`.
|
// network with chain id `chainId`.
|
||||||
// Returns { name, description, details } matching the format used by
|
// Returns { name, description, details } matching the format used by
|
||||||
// the approval UI, or null if the calldata is not a recognised execute() or
|
// the approval UI, or null if the calldata is not a recognised execute() or
|
||||||
// `toAddress` is not a Universal Router on that network: the same calldata
|
// `toAddress` is not a Universal Router on that network: the same calldata
|
||||||
// sent to any other contract is not a swap through the router, whatever that
|
// sent to any other contract is not a swap through the router, whatever that
|
||||||
// contract does with it.
|
// contract does with it.
|
||||||
function decode(data, toAddress, chainId, sources, fromAddress) {
|
function decode(data, toAddress, chainId, sources) {
|
||||||
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
|
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
|
||||||
if (commandTypeMask === null) return null;
|
if (commandTypeMask === null) return null;
|
||||||
try {
|
try {
|
||||||
@@ -814,60 +678,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
outputEstablished = true;
|
outputEstablished = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Who the steps pay, in whichever step and of whatever token.
|
|
||||||
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
|
|
||||||
// its own address, holds what it is paid until a later step pays it
|
|
||||||
// out.
|
|
||||||
const sender = (fromAddress || "").toLowerCase();
|
|
||||||
let paidToSender = false;
|
|
||||||
// Each other address paid, once.
|
|
||||||
const paidElsewhere = [];
|
|
||||||
// Set when a step may pay an address this decoder did not read.
|
|
||||||
let payeeUnread = false;
|
|
||||||
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the
|
|
||||||
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH,
|
|
||||||
// paid out.
|
|
||||||
const heldByRouter = new Set();
|
|
||||||
const isRouter = (recipient) => {
|
|
||||||
const r = recipient.toLowerCase();
|
|
||||||
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
|
|
||||||
};
|
|
||||||
// A step pays `recipient`. `output` is the token a swap step or a V4
|
|
||||||
// TAKE pays; other steps pass none.
|
|
||||||
const addPayee = (recipient, output) => {
|
|
||||||
const r = recipient.toLowerCase();
|
|
||||||
if (isRouter(recipient)) {
|
|
||||||
if (present(output)) heldByRouter.add(output.toLowerCase());
|
|
||||||
} else if (r === MSG_SENDER || r === sender) {
|
|
||||||
paidToSender = true;
|
|
||||||
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
|
|
||||||
paidElsewhere.push(recipient);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop,
|
|
||||||
// not a payout, when the very next step is a V2 exact-in whose input
|
|
||||||
// is already paid, whose path starts at `token`, and whose pair (see
|
|
||||||
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2
|
|
||||||
// pair directly, and the router enforces that next swap's own minimum.
|
|
||||||
// Its input is already paid when its amountIn is ALREADY_PAID, or
|
|
||||||
// CONTRACT_BALANCE, which Uniswap's routes send and with which the
|
|
||||||
// router adds only what it holds.
|
|
||||||
const isHop = (i, token, recipient) => {
|
|
||||||
if (i + 1 >= commandsBytes.length) return false;
|
|
||||||
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
|
|
||||||
const next = decodeV2SwapExactIn(inputs[i + 1]);
|
|
||||||
return (
|
|
||||||
next !== null &&
|
|
||||||
(next.amountIn === ALREADY_PAID ||
|
|
||||||
next.amountIn === CONTRACT_BALANCE) &&
|
|
||||||
present(token) &&
|
|
||||||
next.path.length >= 2 &&
|
|
||||||
next.tokenIn.toLowerCase() === token.toLowerCase() &&
|
|
||||||
recipient.toLowerCase() ===
|
|
||||||
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < commandsBytes.length; i++) {
|
for (let i = 0; i < commandsBytes.length; i++) {
|
||||||
const cmdId = commandsBytes[i] & commandTypeMask;
|
const cmdId = commandsBytes[i] & commandTypeMask;
|
||||||
commandNames.push(
|
commandNames.push(
|
||||||
@@ -880,27 +690,30 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// EXECUTE_SUB_PLAN runs a further list of commands held in
|
// EXECUTE_SUB_PLAN runs a further list of commands held in
|
||||||
// its input, which this decoder does not read. The step
|
// its input, which this decoder does not read. The step
|
||||||
// says so, and after this loop neither side is named.
|
// says so, and after this loop neither side is named.
|
||||||
// Those commands may pay any address.
|
|
||||||
hasSubPlan = true;
|
hasSubPlan = true;
|
||||||
commandNames[i] += " (its steps are not shown)";
|
commandNames[i] += " (its steps are not shown)";
|
||||||
payeeUnread = true;
|
}
|
||||||
} else if (cmdId === 0x0a) {
|
|
||||||
|
if (cmdId === 0x0a) {
|
||||||
const p = decodePermit2(inputs[i]);
|
const p = decodePermit2(inputs[i]);
|
||||||
// A permit states both halves itself, so it may replace an
|
// A permit states both halves itself, so it may replace an
|
||||||
// input side an earlier hop established without breaking
|
// input side an earlier hop established without breaking
|
||||||
// the invariant.
|
// the invariant.
|
||||||
if (p) setInput(p.token, p.amount);
|
if (p) setInput(p.token, p.amount);
|
||||||
} else if (cmdId === 0x0e) {
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x0e) {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
// The router passes this check whenever the owner holds at
|
// The router passes this check whenever the owner holds at
|
||||||
// least minBalance, so a zero one guarantees nothing and
|
// least minBalance, so a zero one guarantees nothing and
|
||||||
// does not replace a minimum an earlier step stated. Any
|
// does not replace a minimum an earlier step stated. Any
|
||||||
// other minBalance sets the output side as a swap does.
|
// other minBalance sets the output side as a swap does.
|
||||||
// It pays nobody.
|
|
||||||
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
||||||
setOutput(b.token, b.minBalance);
|
setOutput(b.token, b.minBalance);
|
||||||
}
|
}
|
||||||
} else if (cmdId === 0x00) {
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x00) {
|
||||||
const s = decodeV3SwapExactIn(inputs[i]);
|
const s = decodeV3SwapExactIn(inputs[i]);
|
||||||
if (s) {
|
if (s) {
|
||||||
setInputOnce(s.tokenIn, s.amountIn);
|
setInputOnce(s.tokenIn, s.amountIn);
|
||||||
@@ -908,69 +721,39 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// the last swap step determines the final output token
|
// the last swap step determines the final output token
|
||||||
// and minimum received amount.
|
// and minimum received amount.
|
||||||
setOutput(s.tokenOut, s.amountOutMin);
|
setOutput(s.tokenOut, s.amountOutMin);
|
||||||
if (!isHop(i, s.tokenOut, s.recipient)) {
|
|
||||||
addPayee(s.recipient, s.tokenOut);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
}
|
||||||
} else if (cmdId === 0x08) {
|
|
||||||
|
if (cmdId === 0x08) {
|
||||||
const s = decodeV2SwapExactIn(inputs[i]);
|
const s = decodeV2SwapExactIn(inputs[i]);
|
||||||
if (s) {
|
if (s) {
|
||||||
setInputOnce(s.tokenIn, s.amountIn);
|
setInputOnce(s.tokenIn, s.amountIn);
|
||||||
setOutput(s.tokenOut, s.amountOutMin);
|
setOutput(s.tokenOut, s.amountOutMin);
|
||||||
if (!isHop(i, s.tokenOut, s.recipient)) {
|
|
||||||
addPayee(s.recipient, s.tokenOut);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
}
|
||||||
} else if (cmdId === 0x09) {
|
|
||||||
|
if (cmdId === 0x09) {
|
||||||
// Buys exactly amountOut and spends at most amountInMax.
|
// Buys exactly amountOut and spends at most amountInMax.
|
||||||
hasV2ExactOut = true;
|
hasV2ExactOut = true;
|
||||||
const s = decodeV2SwapExactOut(inputs[i]);
|
const s = decodeV2SwapExactOut(inputs[i]);
|
||||||
if (s) {
|
if (s) {
|
||||||
setInputOnce(s.tokenIn, s.amountInMax);
|
setInputOnce(s.tokenIn, s.amountInMax);
|
||||||
setOutput(s.tokenOut, s.amountOut);
|
setOutput(s.tokenOut, s.amountOut);
|
||||||
if (!isHop(i, s.tokenOut, s.recipient)) {
|
|
||||||
addPayee(s.recipient, s.tokenOut);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
}
|
||||||
} else if (cmdId === 0x04) {
|
|
||||||
const s = decodeSweep(inputs[i]);
|
if (cmdId === 0x0b) {
|
||||||
if (s) {
|
|
||||||
addPayee(s.recipient);
|
|
||||||
if (!isRouter(s.recipient)) {
|
|
||||||
heldByRouter.delete(s.token.toLowerCase());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
|
||||||
} else if (cmdId === 0x0b) {
|
|
||||||
const w = decodeWrapEth(inputs[i]);
|
const w = decodeWrapEth(inputs[i]);
|
||||||
if (w) {
|
if (w) {
|
||||||
setInputOnce(
|
setInputOnce(
|
||||||
"0x0000000000000000000000000000000000000000",
|
"0x0000000000000000000000000000000000000000",
|
||||||
w.amount,
|
w.amount,
|
||||||
);
|
);
|
||||||
addPayee(w.recipient);
|
|
||||||
} else {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
}
|
||||||
} else if (cmdId === 0x10) {
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x10) {
|
||||||
const v4 = decodeV4Swap(inputs[i]);
|
const v4 = decodeV4Swap(inputs[i]);
|
||||||
if (v4) {
|
|
||||||
for (const t of v4.takes) {
|
|
||||||
if (!isHop(i, t.currency, t.recipient)) {
|
|
||||||
addPayee(t.recipient, t.currency);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!v4 || v4.recipientUnread) {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
|
||||||
if (v4 && v4.swapUndecoded) {
|
if (v4 && v4.swapUndecoded) {
|
||||||
// A swap action of this step went unread, so the step
|
// A swap action of this step went unread, so the step
|
||||||
// is marked.
|
// is marked.
|
||||||
@@ -1001,29 +784,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
setInputOnce(v4.tokenIn, v4.amountIn);
|
setInputOnce(v4.tokenIn, v4.amountIn);
|
||||||
setOutput(v4.tokenOut, v4.amountOutMin);
|
setOutput(v4.tokenOut, v4.amountOutMin);
|
||||||
}
|
}
|
||||||
} else if (cmdId === 0x0c) {
|
}
|
||||||
|
|
||||||
|
if (cmdId === 0x0c) {
|
||||||
hasUnwrapWeth = true;
|
hasUnwrapWeth = true;
|
||||||
const u = decodeUnwrapWeth(inputs[i]);
|
|
||||||
if (u) {
|
|
||||||
addPayee(u.recipient);
|
|
||||||
if (!isRouter(u.recipient)) {
|
|
||||||
WETH_ADDRESSES.forEach((w) =>
|
|
||||||
heldByRouter.delete(w),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Any other command may pay an address this decoder does
|
|
||||||
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER,
|
|
||||||
// PAY_PORTION and the others
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
|
|
||||||
payeeUnread = true;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip commands we can't decode. One may pay any address.
|
// Skip commands we can't decode
|
||||||
payeeUnread = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,31 +936,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The output is received by the sender only when some step pays the
|
|
||||||
// sender, none pays another address, none may pay an address this
|
|
||||||
// decoder did not read, and the output token is not left in the
|
|
||||||
// router. Otherwise each other address paid is named, and further
|
|
||||||
// lines say when the output stays in the router, a payout could not
|
|
||||||
// be read, or no step names anyone paid.
|
|
||||||
const outputHeld =
|
|
||||||
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
|
|
||||||
const received =
|
|
||||||
paidToSender &&
|
|
||||||
paidElsewhere.length === 0 &&
|
|
||||||
!payeeUnread &&
|
|
||||||
!outputHeld;
|
|
||||||
for (const address of paidElsewhere) {
|
|
||||||
details.push({ label: "Paid to", value: address, address });
|
|
||||||
}
|
|
||||||
if (outputHeld) {
|
|
||||||
details.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
|
||||||
}
|
|
||||||
if (payeeUnread) {
|
|
||||||
details.push({ label: "Paid to", value: UNDECODED_CURRENCY });
|
|
||||||
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
|
|
||||||
details.push({ label: "Paid to", value: UNNAMED_CURRENCY });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (present(minOutput)) {
|
if (present(minOutput)) {
|
||||||
// A zero floor is the one case the user most needs stated: the
|
// A zero floor is the one case the user most needs stated: the
|
||||||
// swap guarantees nothing back. It is said in words, in the same
|
// swap guarantees nothing back. It is said in words, in the same
|
||||||
@@ -1202,7 +944,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// return nothing" — and because it is true at every scale, so it
|
// return nothing" — and because it is true at every scale, so it
|
||||||
// holds even when the output token's decimals are unknown.
|
// holds even when the output token's decimals are unknown.
|
||||||
details.push({
|
details.push({
|
||||||
label: received ? "Min. received" : "Min. paid out",
|
label: "Min. received",
|
||||||
value:
|
value:
|
||||||
minOutput === 0n
|
minOutput === 0n
|
||||||
? NO_MINIMUM
|
? NO_MINIMUM
|
||||||
|
|||||||
@@ -68,13 +68,10 @@ function swapData(amountIn, amountOutMin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function swapDetail(amountIn, amountOutMin, label) {
|
function swapDetail(amountIn, amountOutMin, label) {
|
||||||
// Sent by the address the swap pays, so the output is the user's.
|
|
||||||
const decoded = uniswap.decode(
|
const decoded = uniswap.decode(
|
||||||
swapData(amountIn, amountOutMin),
|
swapData(amountIn, amountOutMin),
|
||||||
ROUTER,
|
ROUTER,
|
||||||
"0x1",
|
"0x1",
|
||||||
{},
|
|
||||||
RECIPIENT,
|
|
||||||
);
|
);
|
||||||
return decoded.details.find((d) => d.label === label);
|
return decoded.details.find((d) => d.label === label);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,17 +204,21 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||||
|
|
||||||
// And it took nothing else with it. Without the load these come back
|
// And it took nothing else with it. Without the load these come back
|
||||||
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
|
// as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the
|
||||||
// extension gone, encrypted secrets included.
|
// extension gone, encrypted secrets included.
|
||||||
expect(after.wallets).toEqual(walletFixture());
|
expect(after.wallets).toEqual(walletFixture());
|
||||||
expect(after.hasWallet).toBe(true);
|
expect(after.hasWallet).toBe(true);
|
||||||
expect(after.activeAddress).toBe(ADDRESS);
|
expect(after.activeAddress).toBe(ADDRESS);
|
||||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
||||||
expect(after.trackedTokens).toEqual([
|
expect(after.networkTrackedTokens).toEqual({
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
||||||
]);
|
});
|
||||||
expect(after.theme).toBe("dark");
|
expect(after.theme).toBe("dark");
|
||||||
|
|
||||||
|
// The mainnet token is tracked on mainnet only: Sepolia starts with
|
||||||
|
// none of its own.
|
||||||
|
expect(after.trackedTokens).toEqual([]);
|
||||||
|
|
||||||
// The user's mainnet endpoint is remembered rather than replaced by
|
// The user's mainnet endpoint is remembered rather than replaced by
|
||||||
// the public default, so switching back returns it.
|
// the public default, so switching back returns it.
|
||||||
expect(after.networkEndpoints.mainnet).toEqual({
|
expect(after.networkEndpoints.mainnet).toEqual({
|
||||||
@@ -226,6 +230,9 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
|
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
|
||||||
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
||||||
expect(bg.persisted().wallets).toEqual(walletFixture());
|
expect(bg.persisted().wallets).toEqual(walletFixture());
|
||||||
|
expect(bg.persisted().trackedTokens).toEqual([
|
||||||
|
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compares the requested chain against the stored one, not the default", async () => {
|
test("compares the requested chain against the stored one, not the default", async () => {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe.each([
|
|||||||
jest.doMock("../src/shared/state", () => ({
|
jest.doMock("../src/shared/state", () => ({
|
||||||
state: { trackedTokens: [] },
|
state: { trackedTokens: [] },
|
||||||
saveState,
|
saveState,
|
||||||
|
currentNetwork: () => ({ chainId: 1 }),
|
||||||
}));
|
}));
|
||||||
jest.doMock("../src/popup/views/helpers", () => ({
|
jest.doMock("../src/popup/views/helpers", () => ({
|
||||||
$: element,
|
$: element,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const {
|
|||||||
normalizePersisted,
|
normalizePersisted,
|
||||||
} = require("../src/shared/persistedState");
|
} = require("../src/shared/persistedState");
|
||||||
const { stateProblem } = require("../src/shared/stateSchema");
|
const { stateProblem } = require("../src/shared/stateSchema");
|
||||||
|
const { isKnownNetworkId } = require("../src/shared/networks");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -193,6 +194,29 @@ const CONTRACT = [
|
|||||||
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
||||||
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: "networkTrackedTokens",
|
||||||
|
kind: KIND.ENTRIES,
|
||||||
|
hostile: [42, "notarecord", { sepolia: "notalist" }],
|
||||||
|
floorOnly: [
|
||||||
|
[1, 2],
|
||||||
|
{ sepolia: [1, 2] },
|
||||||
|
{ mainnet: [null, {}, [TOKEN_ADDRESS]] },
|
||||||
|
{ notanetwork: [{ address: TOKEN_ADDRESS }] },
|
||||||
|
JSON.parse('{"__proto__":[{"address":"0xabc"}]}'),
|
||||||
|
],
|
||||||
|
// Each list is put straight back onto trackedTokens by
|
||||||
|
// applyChainSwitchFields() when its network becomes the active one, so
|
||||||
|
// it is floored the same way; a key that names no network is dropped.
|
||||||
|
holds: (v) =>
|
||||||
|
isRecord(v) &&
|
||||||
|
Object.getPrototypeOf(v) === Object.prototype &&
|
||||||
|
Object.keys(v).every(
|
||||||
|
(id) =>
|
||||||
|
isKnownNetworkId(id) &&
|
||||||
|
everyEntry(v[id], (t) => isRecord(t) && isText(t.address)),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "allowedSites",
|
field: "allowedSites",
|
||||||
kind: KIND.ENTRIES,
|
kind: KIND.ENTRIES,
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Tracked tokens are kept per network, and the two add-token screens offer the
|
||||||
|
// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum
|
||||||
|
// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523), including on
|
||||||
|
// the screen "Back" lands on after a switch made from the Settings gear and for
|
||||||
|
// a contract lookup that answers after one. Driven through the real popup over
|
||||||
|
// a stored profile; the same switch made by the background is in
|
||||||
|
// tests/coldWorkerChainSwitch.test.js.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
|
|
||||||
|
const SEPOLIA_TOKEN = {
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Test Token",
|
||||||
|
};
|
||||||
|
const MAINNET_TOKEN = {
|
||||||
|
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
||||||
|
symbol: "DAI",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Dai Stablecoin",
|
||||||
|
};
|
||||||
|
// Tracked on neither network.
|
||||||
|
const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||||
|
const NETWORK_CHANGED_FLASH =
|
||||||
|
"The active network changed before the lookup finished, so the token was" +
|
||||||
|
" not added.";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The popup reopened onto SettingsAddToken, on `networkId`.
|
||||||
|
function bootOnAddToken(networkId, extra) {
|
||||||
|
return bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
networkId,
|
||||||
|
currentView: "settings-addtoken",
|
||||||
|
...extra,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
|
||||||
|
// there and TST tracked on Sepolia.
|
||||||
|
function bootOnAddress() {
|
||||||
|
return bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup reopened onto AddressToken for `token`, with AddressDetail beneath
|
||||||
|
// it, over the same tracked tokens as bootOnAddress().
|
||||||
|
function bootOnToken(token) {
|
||||||
|
return bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address-token",
|
||||||
|
viewStack: ["main", "address"],
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
selectedToken: token,
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressToken for `token` opened from AddressDetail, as tapping the token's
|
||||||
|
// row there does.
|
||||||
|
async function openToken(boot, token) {
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
state.selectedToken = token;
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/addressToken").show();
|
||||||
|
await boot.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presses `addButton` with a contract lookup that answers only after the
|
||||||
|
// Settings gear and a switch to `networkId`.
|
||||||
|
async function addAcrossSwitch(boot, addButton, networkId) {
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
lookupTokenInfo.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const adding = boot.click(addButton);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, networkId);
|
||||||
|
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
|
||||||
|
await adding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks `networkId` in the Settings network selector, as the user does.
|
||||||
|
async function chooseNetwork(boot, networkId) {
|
||||||
|
const select = boot.node("settings-network");
|
||||||
|
select.value = networkId;
|
||||||
|
for (const fn of select.listeners.change) await fn();
|
||||||
|
await boot.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Settings gear, a switch to Sepolia, and Back.
|
||||||
|
async function gearSwitchBack(boot) {
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
await boot.click("btn-settings-back");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["mainnet", false],
|
||||||
|
["sepolia", true],
|
||||||
|
])("on %s", (networkId, hidden) => {
|
||||||
|
test(`the common tokens are hidden: ${hidden}`, async () => {
|
||||||
|
const boot = await bootOnAddToken(networkId);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
||||||
|
expect(boot.hidden("settings-addtoken-common")).toBe(hidden);
|
||||||
|
|
||||||
|
require("../src/popup/views/addToken").show();
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
expect(boot.hidden("add-token-common")).toBe(hidden);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => {
|
||||||
|
const boot = await bootOnAddToken("sepolia", {
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
const stored = () => boot.storage.read("autistmask");
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
lookupTokenInfo.mockResolvedValueOnce({
|
||||||
|
symbol: SEPOLIA_TOKEN.symbol,
|
||||||
|
decimals: SEPOLIA_TOKEN.decimals,
|
||||||
|
name: SEPOLIA_TOKEN.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
boot.node("settings-addtoken-address").value = TOKEN_ADDRESS;
|
||||||
|
await boot.click("btn-settings-addtoken-manual");
|
||||||
|
|
||||||
|
expect(lookupTokenInfo).toHaveBeenCalledWith(
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
expect.any(String),
|
||||||
|
"sepolia",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "mainnet");
|
||||||
|
expect(stored().networkId).toBe("mainnet");
|
||||||
|
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||||
|
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
|
||||||
|
expect(onMainnet).toContain("DAI");
|
||||||
|
expect(onMainnet).not.toContain("TST");
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
expect(stored().networkId).toBe("sepolia");
|
||||||
|
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
|
||||||
|
expect(onSepolia).toContain("TST");
|
||||||
|
expect(onSepolia).not.toContain("DAI");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Back after a switch from the Settings gear", () => {
|
||||||
|
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
expect(boot.hidden("settings-addtoken-common")).toBe(false);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
||||||
|
expect(boot.hidden("settings-addtoken-common")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AddressDetail lists the new network's tracked tokens", async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
expect(boot.node("address-balances").innerHTML).toContain("DAI");
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
const balances = boot.node("address-balances").innerHTML;
|
||||||
|
expect(balances).toContain("TST");
|
||||||
|
expect(balances).not.toContain("DAI");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Neither can be rendered from saved state, so neither is landed on.
|
||||||
|
test.each([
|
||||||
|
["btn-add-token", "add-token"],
|
||||||
|
["btn-send", "send"],
|
||||||
|
])("from over %s, it lands on AddressDetail", async (button, view) => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await boot.click(button);
|
||||||
|
expect(boot.visibleViews()).toEqual([view]);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
expect(boot.node("address-balances").innerHTML).not.toContain("DAI");
|
||||||
|
});
|
||||||
|
|
||||||
|
// DAI was picked on Ethereum Mainnet, where alone it is tracked.
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
"opened from AddressDetail",
|
||||||
|
async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await openToken(boot, MAINNET_TOKEN.address);
|
||||||
|
return boot;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)],
|
||||||
|
])(
|
||||||
|
"from AddressToken for DAI %s, it lands on AddressDetail",
|
||||||
|
async (_how, open) => {
|
||||||
|
const boot = await open();
|
||||||
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
||||||
|
expect(boot.text("address-token-title")).toContain("DAI");
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
const balances = boot.node("address-balances").innerHTML;
|
||||||
|
expect(balances).toContain("TST");
|
||||||
|
expect(balances).not.toContain("DAI");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The stack is saved cut with the switch, so a popup closed straight after
|
||||||
|
// it and reopened onto Settings lands where this one would. Loading a
|
||||||
|
// saved stack cuts it at AddToken and Send as well, but not at
|
||||||
|
// AddressToken.
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
"AddressToken for DAI",
|
||||||
|
(boot) => openToken(boot, MAINNET_TOKEN.address),
|
||||||
|
],
|
||||||
|
["AddToken", (boot) => boot.click("btn-add-token")],
|
||||||
|
["Send", (boot) => boot.click("btn-send")],
|
||||||
|
])(
|
||||||
|
"from %s, a popup reopened after the switch lands on AddressDetail",
|
||||||
|
async (_view, open) => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await open(boot);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const reopened = await bootPopup(boot.storage.read("autistmask"));
|
||||||
|
expect(reopened.visibleViews()).toEqual(["settings"]);
|
||||||
|
await reopened.click("btn-settings-back");
|
||||||
|
expect(reopened.visibleViews()).toEqual(["address"]);
|
||||||
|
const balances = reopened.node("address-balances").innerHTML;
|
||||||
|
expect(balances).toContain("TST");
|
||||||
|
expect(balances).not.toContain("DAI");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("AddressToken for the native token is drawn again for the new network", async () => {
|
||||||
|
const boot = await bootOnToken("ETH");
|
||||||
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
||||||
|
expect(boot.text("address-token-title")).toContain("SepoliaETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws
|
||||||
|
// ConfirmTx again from the saved transaction. It is drawn for the network
|
||||||
|
// the transaction was reviewed on, and Sign & Send refuses it on the new
|
||||||
|
// one.
|
||||||
|
test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
networkId: "sepolia",
|
||||||
|
currentView: "confirm-tx",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
viewData: {
|
||||||
|
pendingTx: {
|
||||||
|
token: "ETH",
|
||||||
|
from: ADDRESS,
|
||||||
|
to: ADDRESS,
|
||||||
|
amount: "1",
|
||||||
|
chainId: NETWORKS.sepolia.chainId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "mainnet");
|
||||||
|
globalThis.fetch.mockClear();
|
||||||
|
await boot.click("btn-settings-back");
|
||||||
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
// Drawn again: its Etherscan label check asks Sepolia's explorer.
|
||||||
|
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
|
||||||
|
NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS,
|
||||||
|
]);
|
||||||
|
expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer");
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
// Refused before the password is even tried.
|
||||||
|
expect(boot.text("confirm-tx-password-error")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a wallet, it lands on Welcome", async () => {
|
||||||
|
const boot = await bootPopup(undefined);
|
||||||
|
expect(boot.visibleViews()).toEqual(["welcome"]);
|
||||||
|
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
expect(boot.visibleViews()).toEqual(["welcome"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later Back onto Settings keeps what was typed there", async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
boot.node("settings-rpc").value = "https://typed.example";
|
||||||
|
|
||||||
|
await boot.click("btn-settings-add-token");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
||||||
|
await boot.click("btn-settings-addtoken-back");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.value("settings-rpc")).toBe("https://typed.example");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a contract lookup that answers after a switch adds nothing", () => {
|
||||||
|
test("from SettingsAddToken", async () => {
|
||||||
|
const boot = await bootOnAddToken("sepolia", {
|
||||||
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
||||||
|
});
|
||||||
|
const stored = () => boot.storage.read("autistmask");
|
||||||
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
|
||||||
|
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
||||||
|
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||||
|
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("from AddToken", async () => {
|
||||||
|
const boot = await bootOnAddress();
|
||||||
|
const stored = () => boot.storage.read("autistmask");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
boot.node("add-token-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
|
||||||
|
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
||||||
|
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+62
-139
@@ -14,25 +14,27 @@ const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
|||||||
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
||||||
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
// AutistMask's first-ever swap, 2026-02-27, sent from USER_ADDR.
|
// AutistMask's first-ever swap, 2026-02-27.
|
||||||
// Swapped USDT for ETH via Uniswap V4 Universal Router.
|
// Swapped USDT for ETH via Uniswap V4 Universal Router.
|
||||||
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
|
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
|
||||||
// This is that transaction's input, as a public Ethereum node returns it.
|
|
||||||
const FIRST_SWAP_CALLDATA =
|
const FIRST_SWAP_CALLDATA =
|
||||||
"0x3593564c" +
|
"0x3593564c" +
|
||||||
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" +
|
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" +
|
||||||
"0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
|
"0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" +
|
||||||
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" +
|
"0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
|
||||||
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" +
|
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" +
|
||||||
"0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
|
"0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" +
|
||||||
|
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
|
||||||
|
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
|
||||||
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
|
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
|
||||||
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
|
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
|
||||||
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
|
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
|
||||||
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" +
|
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" +
|
||||||
"0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" +
|
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" +
|
||||||
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" +
|
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" +
|
||||||
"000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" +
|
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
||||||
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
|
"dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
||||||
|
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
|
||||||
|
|
||||||
const coder = AbiCoder.defaultAbiCoder();
|
const coder = AbiCoder.defaultAbiCoder();
|
||||||
const routerIface = new Interface([
|
const routerIface = new Interface([
|
||||||
@@ -330,13 +332,7 @@ describe("uniswap decoder", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
|
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
|
||||||
FIRST_SWAP_CALLDATA,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.name).toBe("Swap USDT \u2192 ETH");
|
expect(result.name).toBe("Swap USDT \u2192 ETH");
|
||||||
@@ -360,11 +356,9 @@ describe("uniswap decoder", () => {
|
|||||||
expect(result.details.find((d) => d.label === "Amount").value).toBe(
|
expect(result.details.find((d) => d.label === "Amount").value).toBe(
|
||||||
"Unlimited",
|
"Unlimited",
|
||||||
);
|
);
|
||||||
// Its TAKE pays the From address, so the minimum is received.
|
|
||||||
expect(
|
expect(
|
||||||
result.details.find((d) => d.label === "Min. received").value,
|
result.details.find((d) => d.label === "Min. received").value,
|
||||||
).toBe("0.0002 ETH");
|
).toBe("0.0002 ETH");
|
||||||
expect(labels).not.toContain("Paid to");
|
|
||||||
|
|
||||||
// Real mainnet calldata is in the deployed shape, so its V4 step is
|
// Real mainnet calldata is in the deployed shape, so its V4 step is
|
||||||
// not said to be undecoded.
|
// not said to be undecoded.
|
||||||
@@ -379,8 +373,6 @@ describe("uniswap decoder", () => {
|
|||||||
v4SwapCalldata(id, deployed),
|
v4SwapCalldata(id, deployed),
|
||||||
ROUTER_ADDR,
|
ROUTER_ADDR,
|
||||||
"0x1",
|
"0x1",
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
);
|
||||||
expect(detail(result, "Steps").value).toBe("V4 Swap");
|
expect(detail(result, "Steps").value).toBe("V4 Swap");
|
||||||
},
|
},
|
||||||
@@ -397,8 +389,6 @@ describe("uniswap decoder", () => {
|
|||||||
v4SwapCalldata(id, newer),
|
v4SwapCalldata(id, newer),
|
||||||
ROUTER_ADDR,
|
ROUTER_ADDR,
|
||||||
"0x1",
|
"0x1",
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
);
|
||||||
expect(detail(result, "Amount")).toBeUndefined();
|
expect(detail(result, "Amount")).toBeUndefined();
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
expect(detail(result, "Min. received")).toBeUndefined();
|
||||||
@@ -429,13 +419,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(detail(result, "Steps").value).toBe(
|
expect(detail(result, "Steps").value).toBe(
|
||||||
"V4 Swap (could not be decoded)",
|
"V4 Swap (could not be decoded)",
|
||||||
);
|
);
|
||||||
@@ -456,13 +440,7 @@ describe("uniswap decoder", () => {
|
|||||||
[encodeV4Swap(new Uint8Array([id]), [newer])],
|
[encodeV4Swap(new Uint8Array([id]), [newer])],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(detail(result, "Steps").value).toBe(
|
expect(detail(result, "Steps").value).toBe(
|
||||||
"V4 Swap (could not be decoded)",
|
"V4 Swap (could not be decoded)",
|
||||||
);
|
);
|
||||||
@@ -492,13 +470,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
expect(detail(result, "Amount").value).toBe("2.0000 USDT");
|
expect(detail(result, "Amount").value).toBe("2.0000 USDT");
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
@@ -526,13 +498,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
expect(detail(result, "Token In").value).toBe(
|
expect(detail(result, "Token In").value).toBe(
|
||||||
"Unknown (could not be decoded)",
|
"Unknown (could not be decoded)",
|
||||||
@@ -554,7 +520,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
"Unknown (could not be decoded)",
|
"Unknown (could not be decoded)",
|
||||||
);
|
);
|
||||||
@@ -574,7 +540,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
||||||
|
|
||||||
@@ -601,7 +567,7 @@ describe("uniswap decoder", () => {
|
|||||||
1767225600n,
|
1767225600n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Swap USDC → WETH");
|
expect(result.name).toBe("Swap USDC → WETH");
|
||||||
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
|
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
|
||||||
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
|
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
|
||||||
@@ -633,7 +599,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Swap ETH → USDC");
|
expect(result.name).toBe("Swap ETH → USDC");
|
||||||
|
|
||||||
const amount = detail(result, "Amount");
|
const amount = detail(result, "Amount");
|
||||||
@@ -662,7 +628,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Swap USDC → ETH");
|
expect(result.name).toBe("Swap USDC → ETH");
|
||||||
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
|
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
|
||||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
@@ -705,13 +671,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(detail(result, "Token Out").address).toBe(tokenOut);
|
expect(detail(result, "Token Out").address).toBe(tokenOut);
|
||||||
expect(detail(result, "Min. received").value).toBe(minReceived);
|
expect(detail(result, "Min. received").value).toBe(minReceived);
|
||||||
},
|
},
|
||||||
@@ -732,7 +692,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Swap USDT → USDC");
|
expect(result.name).toBe("Swap USDT → USDC");
|
||||||
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
||||||
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
|
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
|
||||||
@@ -765,7 +725,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
|
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
|
||||||
});
|
});
|
||||||
@@ -784,7 +744,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
|
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
|
||||||
});
|
});
|
||||||
@@ -809,7 +769,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
"Unknown (not named in the calldata)",
|
"Unknown (not named in the calldata)",
|
||||||
);
|
);
|
||||||
@@ -832,7 +792,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
||||||
});
|
});
|
||||||
@@ -844,7 +804,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
const tokenIn = result.details.find((d) => d.label === "Token In");
|
const tokenIn = result.details.find((d) => d.label === "Token In");
|
||||||
@@ -867,13 +827,12 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
// UNWRAP_WETH means output is native ETH
|
// UNWRAP_WETH means output is native ETH
|
||||||
expect(result.name).toBe("Swap USDT \u2192 ETH");
|
expect(result.name).toBe("Swap USDT \u2192 ETH");
|
||||||
});
|
});
|
||||||
|
|
||||||
// No step pays anyone, so the minimum is not said to be received.
|
|
||||||
test("decodes BALANCE_CHECK_ERC20 for min output", () => {
|
test("decodes BALANCE_CHECK_ERC20 for min output", () => {
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
||||||
@@ -884,10 +843,10 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
const minOut = result.details.find((d) => d.label === "Min. paid out");
|
const minOut = result.details.find((d) => d.label === "Min. received");
|
||||||
expect(minOut).toBeDefined();
|
expect(minOut).toBeDefined();
|
||||||
expect(minOut.value).toContain("2.0000");
|
expect(minOut.value).toContain("2.0000");
|
||||||
expect(minOut.value).toContain("USDT");
|
expect(minOut.value).toContain("USDT");
|
||||||
@@ -904,7 +863,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
// The V4 swap action's parameters are empty, so that step is said to
|
// The V4 swap action's parameters are empty, so that step is said to
|
||||||
@@ -984,13 +943,7 @@ describe("uniswap decoder", () => {
|
|||||||
[subPlan],
|
[subPlan],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
expect(detail(result, "Steps").value).toBe(
|
expect(detail(result, "Steps").value).toBe(
|
||||||
"Execute Sub-Plan (its steps are not shown)",
|
"Execute Sub-Plan (its steps are not shown)",
|
||||||
@@ -1078,13 +1031,7 @@ describe("uniswap decoder", () => {
|
|||||||
"$beside an EXECUTE_SUB_PLAN step does not name either side",
|
"$beside an EXECUTE_SUB_PLAN step does not name either side",
|
||||||
({ commands, inputs, steps }) => {
|
({ commands, inputs, steps }) => {
|
||||||
const data = buildExecute(commands, inputs, 9999999999n);
|
const data = buildExecute(commands, inputs, 9999999999n);
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(detail(result, "Steps").value).toBe(steps);
|
expect(detail(result, "Steps").value).toBe(steps);
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
expect(detail(result, "Token In").value).toBe(
|
expect(detail(result, "Token In").value).toBe(
|
||||||
@@ -1099,13 +1046,7 @@ describe("uniswap decoder", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test("shows the deadline as a UTC date and time", () => {
|
test("shows the deadline as a UTC date and time", () => {
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
|
||||||
FIRST_SWAP_CALLDATA,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
|
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1123,7 +1064,7 @@ describe("uniswap decoder", () => {
|
|||||||
2n ** 256n - 1n,
|
2n ** 256n - 1n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
||||||
expect(detail(result, "Deadline").value).toBe(
|
expect(detail(result, "Deadline").value).toBe(
|
||||||
@@ -1138,7 +1079,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
const amount = result.details.find((d) => d.label === "Amount");
|
const amount = result.details.find((d) => d.label === "Amount");
|
||||||
@@ -1213,7 +1154,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
|
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
|
||||||
// After fix: correctly identifies both tokens from V4 sub-actions
|
// After fix: correctly identifies both tokens from V4 sub-actions
|
||||||
@@ -1247,7 +1188,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
// The amount and the token it is counted in come from the same hop.
|
// The amount and the token it is counted in come from the same hop.
|
||||||
@@ -1287,7 +1228,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
@@ -1332,7 +1273,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDC");
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
||||||
@@ -1361,7 +1302,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDC");
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
||||||
@@ -1398,7 +1339,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token In").value).toContain("WETH");
|
expect(detail(result, "Token In").value).toContain("WETH");
|
||||||
@@ -1415,12 +1356,9 @@ describe("uniswap decoder", () => {
|
|||||||
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
|
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
|
||||||
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
|
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
|
||||||
// sides to the swap actions alone.
|
// sides to the swap actions alone.
|
||||||
// `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step
|
|
||||||
// pays the user, so it is not said to be received.
|
|
||||||
const V4_STEP_ENDINGS = [
|
const V4_STEP_ENDINGS = [
|
||||||
{
|
{
|
||||||
ending: "SETTLE_ALL and TAKE_ALL",
|
ending: "SETTLE_ALL and TAKE_ALL",
|
||||||
minLabel: "Min. received",
|
|
||||||
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
|
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
|
||||||
params: [
|
params: [
|
||||||
encodeV4SettleAll(USDT_ADDR, 1000000000n),
|
encodeV4SettleAll(USDT_ADDR, 1000000000n),
|
||||||
@@ -1429,13 +1367,11 @@ describe("uniswap decoder", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ending: "SETTLE and TAKE",
|
ending: "SETTLE and TAKE",
|
||||||
minLabel: "Min. received",
|
|
||||||
ids: [V4_SETTLE_ID, V4_TAKE_ID],
|
ids: [V4_SETTLE_ID, V4_TAKE_ID],
|
||||||
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
|
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ending: "CLOSE_CURRENCY",
|
ending: "CLOSE_CURRENCY",
|
||||||
minLabel: "Min. paid out",
|
|
||||||
ids: [
|
ids: [
|
||||||
V4_CLOSE_CURRENCY_ID,
|
V4_CLOSE_CURRENCY_ID,
|
||||||
V4_CLOSE_CURRENCY_ID,
|
V4_CLOSE_CURRENCY_ID,
|
||||||
@@ -1584,7 +1520,7 @@ describe("uniswap decoder", () => {
|
|||||||
),
|
),
|
||||||
)(
|
)(
|
||||||
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
|
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
|
||||||
({ swaps, ids, params, amount, min, minLabel }) => {
|
({ swaps, ids, params, amount, min }) => {
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
"0x10",
|
"0x10",
|
||||||
[
|
[
|
||||||
@@ -1596,18 +1532,12 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(result.name).toBe("Swap USDT → USDC");
|
expect(result.name).toBe("Swap USDT → USDC");
|
||||||
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
||||||
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
||||||
expect(detail(result, "Amount")?.value).toBe(amount);
|
expect(detail(result, "Amount")?.value).toBe(amount);
|
||||||
expect(detail(result, minLabel)?.value).toBe(min);
|
expect(detail(result, "Min. received")?.value).toBe(min);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1654,13 +1584,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
data,
|
|
||||||
ROUTER_ADDR,
|
|
||||||
"0x1",
|
|
||||||
{},
|
|
||||||
USER_ADDR,
|
|
||||||
);
|
|
||||||
expect(detail(result, "Steps").value).toBe(
|
expect(detail(result, "Steps").value).toBe(
|
||||||
"V4 Swap (could not be decoded)",
|
"V4 Swap (could not be decoded)",
|
||||||
);
|
);
|
||||||
@@ -1719,7 +1643,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
expect(detail(result, "Token In").address).toBe(
|
expect(detail(result, "Token In").address).toBe(
|
||||||
USDT_ADDR.toLowerCase(),
|
USDT_ADDR.toLowerCase(),
|
||||||
@@ -1775,7 +1699,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
expect(detail(result, "Token In").value).toBe(
|
expect(detail(result, "Token In").value).toBe(
|
||||||
"Unknown (not named in the calldata)",
|
"Unknown (not named in the calldata)",
|
||||||
@@ -1798,7 +1722,7 @@ describe("uniswap decoder", () => {
|
|||||||
],
|
],
|
||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
expect(detail(result, "Token Out").value).toBe(
|
||||||
"Unknown (not named in the calldata)",
|
"Unknown (not named in the calldata)",
|
||||||
);
|
);
|
||||||
@@ -1832,7 +1756,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result.name).toBe("Swap USDT → USDC");
|
expect(result.name).toBe("Swap USDT → USDC");
|
||||||
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
|
||||||
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
|
||||||
@@ -1856,7 +1780,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token In").value).toContain("USDT");
|
expect(detail(result, "Token In").value).toContain("USDT");
|
||||||
@@ -1887,7 +1811,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("WETH");
|
expect(detail(result, "Token Out").value).toContain("WETH");
|
||||||
@@ -1908,7 +1832,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDC");
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
||||||
@@ -1916,8 +1840,7 @@ describe("uniswap decoder", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// With no minimum stated before it, a zero balance check is what sets the
|
// With no minimum stated before it, a zero balance check is what sets the
|
||||||
// output side, and it guarantees nothing. No step pays anyone, so the
|
// output side, and it guarantees nothing.
|
||||||
// minimum is not said to be received.
|
|
||||||
test("a zero balance check with no earlier minimum states no minimum", () => {
|
test("a zero balance check with no earlier minimum states no minimum", () => {
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
||||||
@@ -1928,11 +1851,11 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDT");
|
expect(detail(result, "Token Out").value).toContain("USDT");
|
||||||
expect(detail(result, "Min. paid out").value).toBe(
|
expect(detail(result, "Min. received").value).toBe(
|
||||||
"None (no minimum guaranteed)",
|
"None (no minimum guaranteed)",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1948,7 +1871,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
|
|
||||||
@@ -1968,7 +1891,7 @@ describe("uniswap decoder", () => {
|
|||||||
9999999999n,
|
9999999999n,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
expect(result.name).toBe("Uniswap Swap");
|
||||||
|
|
||||||
|
|||||||
@@ -1,953 +0,0 @@
|
|||||||
// The address a swap's output is paid to
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/594).
|
|
||||||
//
|
|
||||||
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the
|
|
||||||
// address it pays, and TAKE_ALL pays the sender. The decoder read none of
|
|
||||||
// them: a swap whose output went to another address still read "Min.
|
|
||||||
// received", as if the user received it. The router reads address(1) as the
|
|
||||||
// sender and address(2) as the router itself, which holds what it is paid
|
|
||||||
// until a later step pays it out.
|
|
||||||
//
|
|
||||||
// The screens are driven against a minimal DOM stub in the shape
|
|
||||||
// tests/uniswapRouterAddress.test.js uses.
|
|
||||||
|
|
||||||
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
|
||||||
log: {
|
|
||||||
debugf: () => {},
|
|
||||||
infof: () => {},
|
|
||||||
warnf: () => {},
|
|
||||||
errorf: () => {},
|
|
||||||
},
|
|
||||||
debugFetch: async (url) => ({
|
|
||||||
ok: true,
|
|
||||||
json: async () => mockExplorerAnswer(url),
|
|
||||||
}),
|
|
||||||
setRuntimeDebug: () => {},
|
|
||||||
isDebug: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const uniswap = require("../src/shared/uniswap");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
|
||||||
|
|
||||||
// UniversalRouterV2 on Ethereum Mainnet.
|
|
||||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|
||||||
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
|
|
||||||
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
|
|
||||||
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const OTHER = "0x7777777777777777777777777777777777777777";
|
|
||||||
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
|
|
||||||
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
|
||||||
const ZERO = "0x0000000000000000000000000000000000000000";
|
|
||||||
|
|
||||||
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
||||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
||||||
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
|
||||||
const ONE_WETH = 1000000000000000000n;
|
|
||||||
const USDT_2000 = 2000000000n;
|
|
||||||
const USDC_1990 = 1990000000n;
|
|
||||||
|
|
||||||
// The Paid to line's value when no address can be named.
|
|
||||||
const UNDECODED = "Unknown (could not be decoded)";
|
|
||||||
const UNNAMED = "Unknown (not named in the calldata)";
|
|
||||||
const STAYS_IN_ROUTER =
|
|
||||||
"The router, where it stays (no later step pays it out)";
|
|
||||||
|
|
||||||
const coder = AbiCoder.defaultAbiCoder();
|
|
||||||
const routerIface = new Interface([
|
|
||||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// execute() of `steps`, each [command, input].
|
|
||||||
function execute(...steps) {
|
|
||||||
const commands = steps.map(([command]) => command);
|
|
||||||
return routerIface.encodeFunctionData("execute", [
|
|
||||||
solidityPacked(
|
|
||||||
commands.map(() => "uint8"),
|
|
||||||
commands,
|
|
||||||
),
|
|
||||||
steps.map(([, input]) => input),
|
|
||||||
9999999999n,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout.
|
|
||||||
function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
|
|
||||||
return [
|
|
||||||
command,
|
|
||||||
coder.encode(
|
|
||||||
["address", "uint256", "uint256", "address[]", "bool"],
|
|
||||||
[recipient, amount, limit, path, payerIsUser],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
|
|
||||||
return [
|
|
||||||
0x00,
|
|
||||||
coder.encode(
|
|
||||||
["address", "uint256", "uint256", "bytes", "bool"],
|
|
||||||
[
|
|
||||||
recipient,
|
|
||||||
amountIn,
|
|
||||||
amountOutMin,
|
|
||||||
solidityPacked(
|
|
||||||
["address", "uint24", "address"],
|
|
||||||
[tokenIn, 3000, tokenOut],
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function sweep(token, recipient) {
|
|
||||||
return [
|
|
||||||
0x04,
|
|
||||||
coder.encode(["address", "address", "uint256"], [token, recipient, 0n]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapEth(recipient, amount) {
|
|
||||||
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapWeth(recipient) {
|
|
||||||
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
|
|
||||||
}
|
|
||||||
|
|
||||||
function balanceCheck(token, minBalance) {
|
|
||||||
return [
|
|
||||||
0x0e,
|
|
||||||
coder.encode(
|
|
||||||
["address", "address", "uint256"],
|
|
||||||
[USER, token, minBalance],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// `step`, a [command or action, input] pair, with the top byte of the input's
|
|
||||||
// word `index` set. The decoder cannot read that word as an address.
|
|
||||||
function withDirtyWord([id, input], index) {
|
|
||||||
const at = 2 + index * 64;
|
|
||||||
return [id, input.slice(0, at) + "ff" + input.slice(at + 2)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The V4 actions a step below is built from.
|
|
||||||
const SWAP_USDT_FOR_USDC = [
|
|
||||||
0x06, // SWAP_EXACT_IN_SINGLE
|
|
||||||
coder.encode(
|
|
||||||
[
|
|
||||||
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
||||||
],
|
|
||||||
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
|
|
||||||
const UNREADABLE_SWAP = [0x06, "0x"];
|
|
||||||
const SETTLE_USDT = [
|
|
||||||
0x0b,
|
|
||||||
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
|
|
||||||
];
|
|
||||||
function take(currency, recipient) {
|
|
||||||
return [
|
|
||||||
0x0e,
|
|
||||||
coder.encode(
|
|
||||||
["address", "address", "uint256"],
|
|
||||||
[currency, recipient, 0n],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
function takeAll(currency) {
|
|
||||||
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A V4_SWAP step of `actions`, each [action, params].
|
|
||||||
function v4Swap(...actions) {
|
|
||||||
return [
|
|
||||||
0x10,
|
|
||||||
coder.encode(
|
|
||||||
["bytes", "bytes[]"],
|
|
||||||
[
|
|
||||||
solidityPacked(
|
|
||||||
actions.map(() => "uint8"),
|
|
||||||
actions.map(([action]) => action),
|
|
||||||
),
|
|
||||||
actions.map(([, params]) => params),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function decode(data, sender = USER, router = ROUTER) {
|
|
||||||
return uniswap.decode(data, router, "0x1", {}, sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
function labels(result) {
|
|
||||||
return result.details.map((d) => d.label);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detail(result, label) {
|
|
||||||
return result.details.find((d) => d.label === label);
|
|
||||||
}
|
|
||||||
|
|
||||||
function paidTo(result) {
|
|
||||||
return result.details
|
|
||||||
.filter((d) => d.label === "Paid to")
|
|
||||||
.map((d) => d.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A swap paid to the user, the lines it has always had.
|
|
||||||
const PAID_TO_USER = [
|
|
||||||
"Protocol",
|
|
||||||
"Token In",
|
|
||||||
"Amount",
|
|
||||||
"Token Out",
|
|
||||||
"Min. received",
|
|
||||||
"Steps",
|
|
||||||
"Deadline",
|
|
||||||
];
|
|
||||||
const PAID_ELSEWHERE = [
|
|
||||||
"Protocol",
|
|
||||||
"Token In",
|
|
||||||
"Amount",
|
|
||||||
"Token Out",
|
|
||||||
"Paid to",
|
|
||||||
"Min. paid out",
|
|
||||||
"Steps",
|
|
||||||
"Deadline",
|
|
||||||
];
|
|
||||||
|
|
||||||
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
|
|
||||||
const swapToWeth = (recipient) =>
|
|
||||||
v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.wallets = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("each swap command's recipient", () => {
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
kind: "V2_SWAP_EXACT_IN",
|
|
||||||
data: (r) =>
|
|
||||||
execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])),
|
|
||||||
min: "1.0000 WETH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "V2_SWAP_EXACT_OUT",
|
|
||||||
data: (r) =>
|
|
||||||
execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])),
|
|
||||||
min: "1.0000 WETH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "V3_SWAP_EXACT_IN",
|
|
||||||
data: (r) => execute(swapToWeth(r)),
|
|
||||||
min: "1.0000 WETH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "V4_SWAP with TAKE",
|
|
||||||
data: (r) =>
|
|
||||||
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))),
|
|
||||||
min: "1990.0000 USDC",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$kind: paid to the user it reads as before; paid to another address it names that address",
|
|
||||||
({ data, min }) => {
|
|
||||||
const viaPlaceholder = decode(data(MSG_SENDER));
|
|
||||||
expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER);
|
|
||||||
expect(detail(viaPlaceholder, "Min. received").value).toBe(min);
|
|
||||||
// The sender's own address, in any case, reads the same.
|
|
||||||
expect(decode(data(USER), USER.toLowerCase()).details).toEqual(
|
|
||||||
viaPlaceholder.details,
|
|
||||||
);
|
|
||||||
|
|
||||||
const toOther = decode(data(OTHER));
|
|
||||||
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(toOther)).toEqual([OTHER]);
|
|
||||||
expect(detail(toOther, "Min. paid out").value).toBe(min);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("an output the router holds", () => {
|
|
||||||
const toRouter = v3SwapExactIn(
|
|
||||||
ADDRESS_THIS,
|
|
||||||
USDT_2000,
|
|
||||||
USDC_1990,
|
|
||||||
USDT,
|
|
||||||
USDC,
|
|
||||||
);
|
|
||||||
|
|
||||||
test("SWEEP pays it to the SWEEP's recipient", () => {
|
|
||||||
const direct = decode(
|
|
||||||
execute(
|
|
||||||
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER)));
|
|
||||||
expect(labels(toUser)).toEqual(PAID_TO_USER);
|
|
||||||
const withoutSteps = (r) =>
|
|
||||||
r.details.filter((d) => d.label !== "Steps");
|
|
||||||
expect(withoutSteps(toUser)).toEqual(withoutSteps(direct));
|
|
||||||
|
|
||||||
const toOther = decode(execute(toRouter, sweep(USDC, OTHER)));
|
|
||||||
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(toOther)).toEqual([OTHER]);
|
|
||||||
expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("with nothing paying it out, it stays in the router", () => {
|
|
||||||
const result = decode(execute(toRouter));
|
|
||||||
expect(labels(result)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the router's own address as a recipient is read as address(2) is", () => {
|
|
||||||
const toRouterAddress = v3SwapExactIn(
|
|
||||||
ROUTER,
|
|
||||||
USDT_2000,
|
|
||||||
USDC_1990,
|
|
||||||
USDT,
|
|
||||||
USDC,
|
|
||||||
);
|
|
||||||
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([
|
|
||||||
STAYS_IN_ROUTER,
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))),
|
|
||||||
).toEqual(PAID_TO_USER);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => {
|
|
||||||
const toWeth = swapToWeth(ADDRESS_THIS);
|
|
||||||
|
|
||||||
const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER)));
|
|
||||||
expect(labels(toUser)).toEqual(PAID_TO_USER);
|
|
||||||
expect(detail(toUser, "Token Out").value).toBe("ETH");
|
|
||||||
expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH");
|
|
||||||
|
|
||||||
const toOther = decode(execute(toWeth, unwrapWeth(OTHER)));
|
|
||||||
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(toOther)).toEqual([OTHER]);
|
|
||||||
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => {
|
|
||||||
const toUser = decode(
|
|
||||||
execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)),
|
|
||||||
);
|
|
||||||
expect(detail(toUser, "Token Out").value).toBe("ETH");
|
|
||||||
expect(paidTo(toUser)).toEqual([]);
|
|
||||||
|
|
||||||
const toOther = decode(
|
|
||||||
execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)),
|
|
||||||
);
|
|
||||||
expect(paidTo(toOther)).toEqual([OTHER]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => {
|
|
||||||
const step = v4Swap(
|
|
||||||
SWAP_USDT_FOR_USDC,
|
|
||||||
SETTLE_USDT,
|
|
||||||
take(USDC, ADDRESS_THIS),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toUser = decode(execute(step, sweep(USDC, MSG_SENDER)));
|
|
||||||
expect(labels(toUser)).toEqual(PAID_TO_USER);
|
|
||||||
|
|
||||||
const toOther = decode(execute(step, sweep(USDC, OTHER)));
|
|
||||||
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(toOther)).toEqual([OTHER]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the
|
|
||||||
// router's output out. A payment to the user of another token, of nothing, or
|
|
||||||
// before the swap leaves the output in the router.
|
|
||||||
describe("an output left in the router is not received", () => {
|
|
||||||
const toRouter = swapToWeth(ADDRESS_THIS);
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
kind: "a SWEEP of WETH to the user, then the swap",
|
|
||||||
data: execute(sweep(WETH, MSG_SENDER), toRouter),
|
|
||||||
min: "1.0000 WETH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "an UNWRAP_WETH to the user, then the swap",
|
|
||||||
data: execute(unwrapWeth(MSG_SENDER), toRouter),
|
|
||||||
min: "1.0000 ETH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "the swap, then a WRAP_ETH of zero to the user",
|
|
||||||
data: execute(toRouter, wrapEth(MSG_SENDER, 0n)),
|
|
||||||
min: "1.0000 WETH",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "the swap, then a SWEEP of a token it does not buy to the user",
|
|
||||||
data: execute(toRouter, sweep(USDC, MSG_SENDER)),
|
|
||||||
min: "1.0000 WETH",
|
|
||||||
},
|
|
||||||
])("$kind", ({ data, min }) => {
|
|
||||||
const result = decode(data);
|
|
||||||
expect(labels(result)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
|
|
||||||
expect(detail(result, "Min. paid out").value).toBe(min);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => {
|
|
||||||
const result = decode(
|
|
||||||
execute(
|
|
||||||
v4Swap(
|
|
||||||
SWAP_USDT_FOR_USDC,
|
|
||||||
SETTLE_USDT,
|
|
||||||
take(USDC, ADDRESS_THIS),
|
|
||||||
takeAll(USDC),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(detail(result, "Steps").value).toBe("V4 Swap");
|
|
||||||
expect(labels(result)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
|
|
||||||
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
|
|
||||||
// through it. That V2 swap spends what the pair holds: its amountIn is
|
|
||||||
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
|
|
||||||
// are the addresses the routers find for the first two tokens of that V2
|
|
||||||
// swap's path.
|
|
||||||
describe("a swap paid to the pair the next V2 swap spends", () => {
|
|
||||||
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
|
|
||||||
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
|
|
||||||
const CONTRACT_BALANCE = 2n ** 255n;
|
|
||||||
const AMOUNTS_IN = [
|
|
||||||
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
|
|
||||||
{ kind: "ALREADY_PAID", amountIn: 0n },
|
|
||||||
];
|
|
||||||
const swapTo = (recipient) =>
|
|
||||||
v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH);
|
|
||||||
const swapToPair = swapTo(PAIR);
|
|
||||||
const pairSpends = (amountIn, path) =>
|
|
||||||
v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false);
|
|
||||||
|
|
||||||
test.each(AMOUNTS_IN)(
|
|
||||||
"a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
|
|
||||||
({ amountIn }) => {
|
|
||||||
const result = decode(
|
|
||||||
execute(swapToPair, pairSpends(amountIn, [WETH, USDC])),
|
|
||||||
);
|
|
||||||
expect(result.name).toBe("Swap USDT → USDC");
|
|
||||||
expect(labels(result)).toEqual(PAID_TO_USER);
|
|
||||||
expect(detail(result, "Amount").value).toBe("2000.0000 USDT");
|
|
||||||
expect(detail(result, "Min. received").value).toBe(
|
|
||||||
"1990.0000 USDC",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each(AMOUNTS_IN)(
|
|
||||||
"a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address",
|
|
||||||
({ amountIn }) => {
|
|
||||||
const result = decode(
|
|
||||||
execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])),
|
|
||||||
);
|
|
||||||
expect(labels(result)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(result)).toEqual([OTHER]);
|
|
||||||
expect(detail(result, "Min. paid out").value).toBe(
|
|
||||||
"1990.0000 USDC",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ recipient: PAIR, lines: PAID_TO_USER, paid: [] },
|
|
||||||
{ recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] },
|
|
||||||
])(
|
|
||||||
"a V4 step whose TAKE pays $recipient, then the V2 swap",
|
|
||||||
({ recipient, lines, paid }) => {
|
|
||||||
const result = decode(
|
|
||||||
execute(
|
|
||||||
v4Swap(
|
|
||||||
SWAP_USDT_FOR_USDC,
|
|
||||||
SETTLE_USDT,
|
|
||||||
take(USDC, recipient),
|
|
||||||
),
|
|
||||||
v2Swap(
|
|
||||||
0x08,
|
|
||||||
MSG_SENDER,
|
|
||||||
CONTRACT_BALANCE,
|
|
||||||
ONE_WETH,
|
|
||||||
[USDC, WETH],
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(labels(result)).toEqual(lines);
|
|
||||||
expect(paidTo(result)).toEqual(paid);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("on Sepolia", () => {
|
|
||||||
const SEPOLIA = "0xaa36a7";
|
|
||||||
// UniversalRouterV2_1_2 on Sepolia.
|
|
||||||
const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3";
|
|
||||||
// The WETH/USDC pair the routers on Sepolia find. Sepolia's V2
|
|
||||||
// factory reports another address for it, and no contract sits at
|
|
||||||
// this one (see V2_FACTORIES in src/shared/uniswap.js).
|
|
||||||
const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb";
|
|
||||||
const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
|
|
||||||
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
|
|
||||||
const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
|
|
||||||
// UNI for WETH, paid to `recipient`, then WETH for USDC through the
|
|
||||||
// pair.
|
|
||||||
const route = (recipient, amountIn) =>
|
|
||||||
execute(
|
|
||||||
v3SwapExactIn(
|
|
||||||
recipient,
|
|
||||||
ONE_WETH,
|
|
||||||
0n,
|
|
||||||
SEPOLIA_UNI,
|
|
||||||
SEPOLIA_WETH,
|
|
||||||
),
|
|
||||||
v2Swap(
|
|
||||||
0x08,
|
|
||||||
MSG_SENDER,
|
|
||||||
amountIn,
|
|
||||||
USDC_1990,
|
|
||||||
[SEPOLIA_WETH, SEPOLIA_USDC],
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each(AMOUNTS_IN)(
|
|
||||||
"paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
|
|
||||||
({ amountIn }) => {
|
|
||||||
const result = uniswap.decode(
|
|
||||||
route(SEPOLIA_PAIR, amountIn),
|
|
||||||
SEPOLIA_ROUTER,
|
|
||||||
SEPOLIA,
|
|
||||||
{},
|
|
||||||
USER,
|
|
||||||
);
|
|
||||||
expect(labels(result)).toEqual(PAID_TO_USER);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each(AMOUNTS_IN)(
|
|
||||||
"paid to another address, then a V2 swap whose amountIn is $kind, names that address",
|
|
||||||
({ amountIn }) => {
|
|
||||||
const result = uniswap.decode(
|
|
||||||
route(OTHER, amountIn),
|
|
||||||
SEPOLIA_ROUTER,
|
|
||||||
SEPOLIA,
|
|
||||||
{},
|
|
||||||
USER,
|
|
||||||
);
|
|
||||||
expect(labels(result)).toEqual(PAID_ELSEWHERE);
|
|
||||||
expect(paidTo(result)).toEqual([OTHER]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("the same route on Ethereum Mainnet names the Sepolia pair", () => {
|
|
||||||
const result = decode(route(SEPOLIA_PAIR, 0n));
|
|
||||||
expect(paidTo(result)).toEqual([SEPOLIA_PAIR]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
kind: "takes its input from the user",
|
|
||||||
next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "spends another token",
|
|
||||||
next: pairSpends(0n, [USDC, WETH]),
|
|
||||||
},
|
|
||||||
])("when the V2 swap $kind, the pair is named", ({ next }) => {
|
|
||||||
const result = decode(execute(swapToPair, next));
|
|
||||||
expect(paidTo(result)).toEqual([PAIR]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("every step's payee counts, whichever step sets the output", () => {
|
|
||||||
test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => {
|
|
||||||
const toUser = decode(
|
|
||||||
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))),
|
|
||||||
);
|
|
||||||
expect(labels(toUser)).toEqual(PAID_TO_USER);
|
|
||||||
|
|
||||||
// TAKE with an amount of zero takes the whole open credit, so
|
|
||||||
// TAKE_ALL after it pays the user nothing.
|
|
||||||
const split = decode(
|
|
||||||
execute(
|
|
||||||
v4Swap(
|
|
||||||
SWAP_USDT_FOR_USDC,
|
|
||||||
SETTLE_USDT,
|
|
||||||
take(USDC, OTHER),
|
|
||||||
takeAll(USDC),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(paidTo(split)).toEqual([OTHER]);
|
|
||||||
expect(detail(split, "Min. received")).toBeUndefined();
|
|
||||||
expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a swap paid to another address, then a V4 step that only takes the same token", () => {
|
|
||||||
const emptyStep = v4Swap(takeAll(WETH));
|
|
||||||
|
|
||||||
const result = decode(execute(swapToWeth(OTHER), emptyStep));
|
|
||||||
expect(paidTo(result)).toEqual([OTHER]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
|
|
||||||
const checked = decode(
|
|
||||||
execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)),
|
|
||||||
);
|
|
||||||
expect(paidTo(checked)).toEqual([OTHER]);
|
|
||||||
expect(detail(checked, "Min. received")).toBeUndefined();
|
|
||||||
expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => {
|
|
||||||
const result = decode(
|
|
||||||
execute(
|
|
||||||
v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC),
|
|
||||||
v4Swap(takeAll(USDC)),
|
|
||||||
sweep(USDC, OTHER),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([OTHER]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a swap paid to another address, then a small swap paid to the user", () => {
|
|
||||||
const result = decode(
|
|
||||||
execute(
|
|
||||||
swapToWeth(OTHER),
|
|
||||||
v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([OTHER]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
expect(detail(result, "Min. paid out")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => {
|
|
||||||
const result = decode(
|
|
||||||
execute(
|
|
||||||
v4Swap(
|
|
||||||
SWAP_USDT_FOR_USDC,
|
|
||||||
SETTLE_USDT,
|
|
||||||
take(USDC, OTHER),
|
|
||||||
takeAll(USDT),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([OTHER]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a payout that cannot be read is never said to be received", () => {
|
|
||||||
test("a V4 TAKE whose recipient word has its top byte set", () => {
|
|
||||||
const result = decode(
|
|
||||||
execute(
|
|
||||||
v4Swap(
|
|
||||||
SWAP_USDT_FOR_USDC,
|
|
||||||
SETTLE_USDT,
|
|
||||||
withDirtyWord(take(USDC, OTHER), 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([UNDECODED]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
kind: "V2_SWAP_EXACT_IN",
|
|
||||||
step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "V2_SWAP_EXACT_OUT",
|
|
||||||
step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]),
|
|
||||||
},
|
|
||||||
{ kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) },
|
|
||||||
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
|
|
||||||
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
|
|
||||||
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
|
|
||||||
])(
|
|
||||||
"a $kind whose recipient word has its top byte set",
|
|
||||||
({ step, word = 0 }) => {
|
|
||||||
const result = decode(
|
|
||||||
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([UNDECODED]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
expect(detail(result, "Min. paid out")).toBeDefined();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The router reads only the low 32 bits of the action list's length, so
|
|
||||||
// the step still runs, and may pay anyone.
|
|
||||||
test("a V4_SWAP whose action list's length word has its top byte set", () => {
|
|
||||||
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER));
|
|
||||||
const result = decode(
|
|
||||||
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)),
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([UNDECODED]);
|
|
||||||
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an output set only by BALANCE_CHECK_ERC20", () => {
|
|
||||||
const result = decode(execute(balanceCheck(WETH, ONE_WETH)));
|
|
||||||
expect(paidTo(result)).toEqual([UNNAMED]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until
|
|
||||||
// then each is a payout this decoder does not read.
|
|
||||||
const transferShape = (command) => [
|
|
||||||
command,
|
|
||||||
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
|
|
||||||
];
|
|
||||||
test.each([
|
|
||||||
{ kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) },
|
|
||||||
{ kind: "TRANSFER", step: transferShape(0x05) },
|
|
||||||
{ kind: "PAY_PORTION", step: transferShape(0x06) },
|
|
||||||
{ kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) },
|
|
||||||
{
|
|
||||||
kind: "PERMIT2_TRANSFER_FROM_BATCH",
|
|
||||||
step: [
|
|
||||||
0x0d,
|
|
||||||
coder.encode(
|
|
||||||
["tuple(address,address,uint160,address)[]"],
|
|
||||||
[[[USER, OTHER, 1n, WETH]]],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "V3_SWAP_EXACT_OUT",
|
|
||||||
step: [0x01, swapToWeth(OTHER)[1]],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "EXECUTE_SUB_PLAN",
|
|
||||||
step: [
|
|
||||||
0x21,
|
|
||||||
coder.encode(
|
|
||||||
["bytes", "bytes[]"],
|
|
||||||
["0x05", [transferShape(0x05)[1]]],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "ACROSS_V4_DEPOSIT_V3",
|
|
||||||
step: [0x40, coder.encode(["address"], [OTHER])],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "V4 TAKE_PORTION",
|
|
||||||
step: v4Swap([
|
|
||||||
0x10,
|
|
||||||
coder.encode(
|
|
||||||
["address", "address", "uint256"],
|
|
||||||
[WETH, OTHER, 10000n],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
])("a swap paid to the user, then $kind", ({ step }) => {
|
|
||||||
const result = decode(
|
|
||||||
execute(swapToWeth(MSG_SENDER), step),
|
|
||||||
USER,
|
|
||||||
ROUTER_V2_1_2,
|
|
||||||
);
|
|
||||||
expect(paidTo(result)).toEqual([UNDECODED]);
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("paths whose recipients were untested", () => {
|
|
||||||
test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => {
|
|
||||||
const check = balanceCheck(WETH, ONE_WETH);
|
|
||||||
|
|
||||||
const toUser = decode(execute(swapToWeth(MSG_SENDER), check));
|
|
||||||
expect(paidTo(toUser)).toEqual([]);
|
|
||||||
expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH");
|
|
||||||
|
|
||||||
const toOther = decode(execute(swapToWeth(OTHER), check));
|
|
||||||
expect(paidTo(toOther)).toEqual([OTHER]);
|
|
||||||
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a V4 step whose swap action cannot be read still has its TAKE read", () => {
|
|
||||||
const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r));
|
|
||||||
|
|
||||||
const toUser = decode(execute(step(MSG_SENDER)));
|
|
||||||
expect(detail(toUser, "Steps").value).toBe(
|
|
||||||
"V4 Swap (could not be decoded)",
|
|
||||||
);
|
|
||||||
expect(paidTo(toUser)).toEqual([]);
|
|
||||||
|
|
||||||
expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The DOM stub both screens are drawn into.
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: () => false,
|
|
||||||
},
|
|
||||||
addEventListener: () => {},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get: () => node(id + "-parent"),
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
if (id === "debug-banner") return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement: () => makeElement("created"),
|
|
||||||
body: { prepend: () => {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id) {
|
|
||||||
return globalThis.document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
|
|
||||||
const swapPaidTo = (recipient) => execute(swapToWeth(recipient));
|
|
||||||
|
|
||||||
// Open the approval screen for a transaction from USER sending `data` to the
|
|
||||||
// router.
|
|
||||||
async function showApproval(data) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (_msg, reply) =>
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: USER,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: USER,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x30d40",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: ROUTER,
|
|
||||||
value: "0x0",
|
|
||||||
data,
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
state.networkId = "mainnet";
|
|
||||||
state.currentView = null;
|
|
||||||
await approval.show(1);
|
|
||||||
return node("approve-tx-decoded-details").innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The transaction the explorer reports: from USER, sending `mockSentData` to
|
|
||||||
// the router.
|
|
||||||
const TX_HASH = "0x" + "5".repeat(64);
|
|
||||||
let mockSentData = null;
|
|
||||||
|
|
||||||
function mockExplorerAnswer(url) {
|
|
||||||
if (url.endsWith("/transactions/" + TX_HASH)) {
|
|
||||||
return {
|
|
||||||
from: { hash: USER },
|
|
||||||
to: { hash: ROUTER },
|
|
||||||
raw_input: mockSentData,
|
|
||||||
method: "execute",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the transaction detail screen for that transaction, sending `data`.
|
|
||||||
async function showDetail(data) {
|
|
||||||
mockSentData = data;
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
state.networkId = "mainnet";
|
|
||||||
state.blockscoutUrl = "https://explorer.example/api/v2";
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
transactionDetail.show({
|
|
||||||
hash: TX_HASH,
|
|
||||||
from: USER,
|
|
||||||
to: ROUTER,
|
|
||||||
value: "",
|
|
||||||
symbol: "ETH",
|
|
||||||
timestamp: 1790000000,
|
|
||||||
isError: false,
|
|
||||||
direction: "contract",
|
|
||||||
directionLabel: "Swap",
|
|
||||||
method: "execute",
|
|
||||||
chainId: "0x1",
|
|
||||||
});
|
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
||||||
return node("tx-detail-calldata-details").innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the screens judge the output against the transaction's sender", () => {
|
|
||||||
test("the approval screen, against its From address", async () => {
|
|
||||||
const toUser = await showApproval(swapPaidTo(USER));
|
|
||||||
expect(toUser).toContain("Min. received");
|
|
||||||
expect(toUser).not.toContain("Paid to");
|
|
||||||
|
|
||||||
const toOther = await showApproval(swapPaidTo(OTHER));
|
|
||||||
expect(toOther).toContain("Paid to");
|
|
||||||
expect(toOther).toContain(OTHER);
|
|
||||||
expect(toOther).not.toContain("Min. received");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the transaction detail screen, against the sender the explorer reports", async () => {
|
|
||||||
const toUser = await showDetail(swapPaidTo(USER));
|
|
||||||
expect(toUser).toContain("Min. received");
|
|
||||||
expect(toUser).not.toContain("Paid to");
|
|
||||||
|
|
||||||
const toOther = await showDetail(swapPaidTo(OTHER));
|
|
||||||
expect(toOther).toContain("Paid to");
|
|
||||||
expect(toOther).not.toContain("Min. received");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -53,14 +53,7 @@ function unwrapData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detail(data, label, sources) {
|
function detail(data, label, sources) {
|
||||||
// Sent by the address the swap pays, so the output is the user's.
|
const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {});
|
||||||
const decoded = uniswap.decode(
|
|
||||||
data,
|
|
||||||
ROUTER,
|
|
||||||
"0x1",
|
|
||||||
sources || {},
|
|
||||||
RECIPIENT,
|
|
||||||
);
|
|
||||||
return decoded.details.find((d) => d.label === label);
|
return decoded.details.find((d) => d.label === label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function detail(data, label) {
|
|||||||
describe("an execute() whose input currency never decoded", () => {
|
describe("an execute() whose input currency never decoded", () => {
|
||||||
// A V4_SWAP whose sub-action params do not decode — an action encoding
|
// A V4_SWAP whose sub-action params do not decode — an action encoding
|
||||||
// this decoder does not know — leaves every V4 token null. The
|
// this decoder does not know — leaves every V4 token null. The
|
||||||
// BALANCE_CHECK still names the output, so the screen has a minimum
|
// BALANCE_CHECK still names the output, so the screen has a Min. received
|
||||||
// figure and a Token Out, and previously claimed the user was paying ETH
|
// figure and a Token Out, and previously claimed the user was paying ETH
|
||||||
// for them.
|
// for them.
|
||||||
const data = () =>
|
const data = () =>
|
||||||
@@ -132,8 +132,7 @@ describe("an execute() that names only an output token", () => {
|
|||||||
|
|
||||||
test("still states the output it did establish", () => {
|
test("still states the output it did establish", () => {
|
||||||
expect(detail(data(), "Token Out").value).toContain("USDT");
|
expect(detail(data(), "Token Out").value).toContain("USDT");
|
||||||
// No step pays anyone, so the minimum is not said to be received.
|
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
|
||||||
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detail(data, label) {
|
function detail(data, label) {
|
||||||
const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER);
|
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
|
||||||
expect(decoded).not.toBeNull();
|
expect(decoded).not.toBeNull();
|
||||||
return decoded.details.find((d) => d.label === label);
|
return decoded.details.find((d) => d.label === label);
|
||||||
}
|
}
|
||||||
@@ -134,9 +134,7 @@ describe("a V4 step that states a Min. received but no output currency", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("refuses to scale Min. received rather than assuming 18", () => {
|
test("refuses to scale Min. received rather than assuming 18", () => {
|
||||||
// With no TAKE, no step pays the user either, so the line is not
|
expect(detail(data(), "Min. received").value).toBe(
|
||||||
// labelled received.
|
|
||||||
expect(detail(data(), "Min. paid out").value).toBe(
|
|
||||||
unknownDecimalsAmount(MIN_OUT),
|
unknownDecimalsAmount(MIN_OUT),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ function walletsHolding(token, decimals) {
|
|||||||
// decodeCalldata() rather than uniswap.decode() directly, because the scale
|
// decodeCalldata() rather than uniswap.decode() directly, because the scale
|
||||||
// sources the screen supplies are part of what is under test.
|
// sources the screen supplies are part of what is under test.
|
||||||
function swapDetail(data, label) {
|
function swapDetail(data, label) {
|
||||||
// Sent by the address the swap pays, so the output is the user's.
|
const decoded = decodeCalldata(data, ROUTER, "0x1");
|
||||||
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
|
|
||||||
return decoded.details.find((d) => d.label === label);
|
return decoded.details.find((d) => d.label === label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user