Compare commits
8
Commits
89e5ccfbb2
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8998d6c661 | ||
|
|
08e47c39ad | ||
|
|
7ba0a5ff75 | ||
|
|
cd5951a4f8 | ||
|
|
bcdffa56a4 | ||
|
|
c0a3a02418 | ||
|
|
f550d8c8d2 | ||
|
|
fc6cffcaba |
@@ -1277,22 +1277,18 @@ see Transaction Decoding), do not always read as a number. They can read:
|
||||
step pays nothing itself and swaps whatever an earlier step sent to the pair,
|
||||
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
||||
such meaning and is shown as a zero.
|
||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
||||
read `0.0000` when the token's scale was known. The router passes a balance
|
||||
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||
output side holds no minimum, not even a zero one, at the point the check is
|
||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||
`minBalance` sets both lines, as a swap step does. Only a check of the
|
||||
transaction's sender's own balance sets either line; a check of any other
|
||||
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
|
||||
that the router lets fail without reverting the transaction (see Transaction
|
||||
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
|
||||
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
|
||||
lets fail is likewise read as zero.
|
||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
|
||||
a V2, V3 or V4 swap's minimum of zero. The router passes a
|
||||
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
|
||||
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
|
||||
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
|
||||
lines, as a swap step does. Only a check of the transaction's sender's own
|
||||
balance sets either line; a check of any other address sets neither (see
|
||||
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail
|
||||
without reverting the transaction (see Transaction Decoding) guarantees
|
||||
nothing either, whatever its `minBalance`, and is read as one whose
|
||||
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail
|
||||
is likewise read as zero.
|
||||
|
||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
||||
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
||||
@@ -2283,6 +2279,9 @@ view would leave a wallet one click from deletion.
|
||||
no user data and is made whether or not "Use Blockscout" is ticked.
|
||||
- If the network changes before either "Save" stores its URL, the URL is not
|
||||
saved and a flash message says so
|
||||
- Either "Save" stores its URL only if the input still shows that URL when
|
||||
the endpoint answers. Otherwise a flash message says that the URL was not
|
||||
saved
|
||||
- Etherscan:
|
||||
- "Check recipients on Etherscan" checkbox, unticked until the user
|
||||
ticks it
|
||||
@@ -3397,7 +3396,8 @@ Currently supported:
|
||||
`Balance Check (of another address)`. A check whose input does not decode,
|
||||
such as one whose owner word has a nonzero top byte (the router reads the
|
||||
owner from the word's last 20 bytes), likewise sets neither, and it is listed
|
||||
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
|
||||
as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
|
||||
sets neither `Token Out` nor the minimum. When a swap step, `TAKE` or
|
||||
`TAKE_PORTION` pays the router any token, the swap's output or another, a
|
||||
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
|
||||
paid it into ETH, that token stays in the router unless a later step pays out
|
||||
@@ -3566,10 +3566,14 @@ Currently supported:
|
||||
on every router but `UniversalRouterV1`, and otherwise gives the address. A
|
||||
move into the router needs none of them, because its `Paid to` line and
|
||||
`Token moved into the router` line already give the amount, the token and
|
||||
where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose
|
||||
amount does not stand as the `Amount` figure, before the step that sets the
|
||||
input side or after it, gives three lines, in step order, after those for each
|
||||
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
|
||||
where it goes, and so its amount is shown once. For a move of the zero
|
||||
address, `Moved from the sender` gives the amount in base units and
|
||||
`Token moved` gives the zero address. For such a move into the router, the
|
||||
`Paid to` line gives the amount in base units and
|
||||
`Token moved into the router` gives the zero address. Every `PERMIT2_PERMIT`
|
||||
whose amount does not stand as the `Amount` figure, before the step that sets
|
||||
the input side or after it, gives three lines, in step order, after those for
|
||||
each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
|
||||
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
|
||||
giving the token's address as the `Token In` line does; and
|
||||
`Allowance spender`, the address it lets take the token. Only a permit whose
|
||||
|
||||
+27
-10
@@ -65,7 +65,14 @@ function renderWalletList() {
|
||||
const pageClosed = new AbortController();
|
||||
window.addEventListener("pagehide", () => pageClosed.abort());
|
||||
|
||||
// While a refresh runs, a request through ctx is not dropped. The request sets
|
||||
// refreshAgain, and one more refresh runs once the running one ends. Several
|
||||
// requests in that time lead to one more refresh, not several. An import asks
|
||||
// for a refresh after its address scan. A refresh already running does not
|
||||
// have the addresses the scan found
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
|
||||
let refreshInFlight = false;
|
||||
let refreshAgain = false;
|
||||
|
||||
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
||||
// the recovery screen: there is no profile left to refresh.
|
||||
@@ -89,7 +96,10 @@ let refreshTimer = null;
|
||||
// user turned "Use Blockscout" off. So the save drops every token the user has
|
||||
// not tracked if the stored setting is off by then.
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) return;
|
||||
if (refreshInFlight) {
|
||||
refreshAgain = true;
|
||||
return;
|
||||
}
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
await followStoredSettings();
|
||||
@@ -132,28 +142,35 @@ async function doRefreshAndRender() {
|
||||
});
|
||||
renderWalletList();
|
||||
} catch (e) {
|
||||
// Every call site fires this and walks away — the boot below, the ten
|
||||
// second interval, and eight views through ctx — so it must never
|
||||
// reject: an unhandled rejection is not a report of anything. The save
|
||||
// inside it reports its own failure through onSaveFailure() (see
|
||||
// src/shared/state.js); what is left here is a failed network round
|
||||
// trip, which the next tick retries.
|
||||
// No caller waits for this function, so it must never reject. An
|
||||
// unhandled rejection reports nothing. The save inside it reports its
|
||||
// own failure through onSaveFailure() (see src/shared/state.js); what
|
||||
// is left here is a failed network round trip, which the next tick
|
||||
// retries.
|
||||
log.errorf("popup: background refresh failed:", e);
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
if (refreshAgain) {
|
||||
refreshAgain = false;
|
||||
doRefreshAndRender();
|
||||
}
|
||||
}
|
||||
|
||||
// Refreshes now and every ten seconds from then on. Called once a wallet
|
||||
// exists: by init() when the popup opens with one, and by AddWallet when it
|
||||
// adds one, which in a popup that opened on Welcome is the first
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
|
||||
// running it starts no second one, and on the recovery screen it does nothing.
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
|
||||
// refresh already started, it starts no second one. On the recovery screen it
|
||||
// does nothing.
|
||||
function startRefresh() {
|
||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||
doRefreshAndRender();
|
||||
if (refreshTimer === null) {
|
||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
||||
// A tick that finds a refresh running is dropped.
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!refreshInFlight) return doRefreshAndRender();
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,16 +334,36 @@ function showNewSettings() {
|
||||
// src/popup/index.js) between setting the URL and writing it. If the active
|
||||
// network is no longer `networkId`, on this page or in the stored record,
|
||||
// nothing is saved. The flash message then says that the network changed.
|
||||
async function saveEndpoint(field, url, networkId) {
|
||||
//
|
||||
// The save writes only the fields that differ from what this page last saved.
|
||||
// Another page may have stored a different URL since then. So `url` is set in
|
||||
// the stored record too. "Saved." then means that the stored record holds
|
||||
// `url`.
|
||||
//
|
||||
// `input` is the id of the input that the user typed `url` in. When another
|
||||
// page changes the network, "Use Blockscout" or "Check recipients on
|
||||
// Etherscan", this page draws Settings again. A redraw during the endpoint
|
||||
// check puts the URL from `state` back in the input. So if the input no longer
|
||||
// shows `url`, nothing is saved. The flash message then says that the URL was
|
||||
// changed during the check. The save compares the input and sets `url` in
|
||||
// `state` in one step. So a redraw after the comparison shows `url`.
|
||||
async function saveEndpoint(field, input, url, networkId) {
|
||||
let refused = false;
|
||||
let inputChanged = false;
|
||||
await saveState((stored) => {
|
||||
refused =
|
||||
state.networkId !== networkId || stored.networkId !== networkId;
|
||||
if (refused) return false;
|
||||
inputChanged = $(input).value.trim() !== url;
|
||||
if (refused || inputChanged) return false;
|
||||
state[field] = url;
|
||||
stored[field] = url;
|
||||
});
|
||||
if (refused) {
|
||||
showFlash("The URL was not saved because the network changed.");
|
||||
} else if (inputChanged) {
|
||||
showFlash(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
} else {
|
||||
showFlash("Saved.");
|
||||
}
|
||||
@@ -395,7 +415,7 @@ function init(_ctx) {
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
await saveEndpoint("rpcUrl", url, net.id);
|
||||
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
|
||||
});
|
||||
|
||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||
@@ -419,7 +439,7 @@ function init(_ctx) {
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
await saveEndpoint("blockscoutUrl", url, net.id);
|
||||
await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
|
||||
});
|
||||
|
||||
// Each change is made inside the save, holding the lock. So
|
||||
|
||||
@@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether TransactionDetail still holds the transaction with `txHash` on
|
||||
// `chainId`. Opening another transaction replaces the one TransactionDetail
|
||||
// holds. A visit to Settings does not.
|
||||
function holdsTransaction(txHash, chainId) {
|
||||
const tx = state.viewData.tx;
|
||||
return tx?.hash === txHash && tx?.chainId === chainId;
|
||||
}
|
||||
|
||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||
// asked. An answer that lands after the setting went off is dropped
|
||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
||||
//
|
||||
// Blockscout can answer after the user has opened another transaction. That
|
||||
// answer is dropped.
|
||||
async function loadFullTxDetails(txHash, chainId) {
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||
const section = $("tx-detail-calldata-section");
|
||||
@@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
||||
if (!resp.ok) return;
|
||||
const txData = await resp.json();
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||
if (!holdsTransaction(txHash, chainId)) return;
|
||||
|
||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||
populateOnChainDetails(txData, chainId);
|
||||
|
||||
@@ -47,12 +47,43 @@ function clearPopupCaches() {
|
||||
// lock, so it is never switched while a follow of a switch saved elsewhere
|
||||
// waits for the lock (followStoredSettings() in src/popup/index.js): that
|
||||
// follow runs first, and this switch is made after it.
|
||||
//
|
||||
// The save writes only the fields that differ from what this page last saved.
|
||||
// When this page last saved a balance of "0", clearing that balance is no
|
||||
// change. So the switch also resets these fields in the stored record:
|
||||
//
|
||||
// - every address's balance and token balances;
|
||||
// - the time of the last balance refresh;
|
||||
// - the fraud contracts.
|
||||
//
|
||||
// A balance or a fraud contract that the background or another page stored
|
||||
// since this page's last save is then not kept under the new network
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/692).
|
||||
//
|
||||
// Before the switch, `state` takes the tracked tokens that another page stored
|
||||
// since this page's last save (takeStoredTrackedTokens() in state.js). The
|
||||
// switch then puts them away under the previous network, the network they were
|
||||
// added on. When the stored record is on another network, a switch saved
|
||||
// elsewhere has not reached this page yet. The stored record's tokens are then
|
||||
// that network's, and none are taken.
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const { state, saveState } = require("./state");
|
||||
const { state, saveState, takeStoredTrackedTokens } = require("./state");
|
||||
let net = null;
|
||||
await saveState(() => {
|
||||
await saveState((stored) => {
|
||||
if (state.networkId === newNetworkId) return false;
|
||||
if (stored.networkId === state.networkId) {
|
||||
takeStoredTrackedTokens(stored);
|
||||
}
|
||||
net = applyChainSwitchFields(state, newNetworkId);
|
||||
stored.lastBalanceRefresh = 0;
|
||||
stored.fraudContracts = [];
|
||||
for (const wallet of stored.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
addr.balance = "0";
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
clearPopupCaches();
|
||||
});
|
||||
return net;
|
||||
|
||||
+37
-9
@@ -125,11 +125,12 @@ function currentNetwork() {
|
||||
return networkById(state.networkId);
|
||||
}
|
||||
|
||||
// The persisted fields as this page held them when its last loadState()
|
||||
// finished, or when its last successful saveState() began, with any network
|
||||
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
|
||||
// saveState() diffs the live state against this to find only the fields THIS
|
||||
// page changed since.
|
||||
// `baseline` holds the persisted fields as this page held them when its last
|
||||
// loadState() finished, or when its last successful saveState() began.
|
||||
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
|
||||
// this page has taken from the stored record since then. saveState() diffs the
|
||||
// live state against `baseline` to find only the fields THIS page changed
|
||||
// since.
|
||||
//
|
||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||
@@ -442,8 +443,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// milder than the other two.
|
||||
//
|
||||
// Every other persisted field stays a whole-field diff:
|
||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
||||
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
|
||||
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in
|
||||
// Settings first merges that list by contract address
|
||||
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
|
||||
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
||||
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
||||
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
||||
@@ -591,8 +594,10 @@ function reportSaveFailure(err) {
|
||||
//
|
||||
// `beforeWrite`, when given, is called inside the lock with the stored record
|
||||
// as just read, before this page's state is compared against it. It may change
|
||||
// `state`, and this save writes that change. When it returns false, nothing is
|
||||
// written; any other return lets the save go ahead.
|
||||
// `state`, and this save writes that change. It may also change the stored
|
||||
// record. A field that this page did not change is then written as
|
||||
// `beforeWrite` left it. When it returns false, nothing is written; any other
|
||||
// return lets the save go ahead.
|
||||
function saveState(beforeWrite) {
|
||||
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||
// Every failed save is reported, whether or not the caller awaited this
|
||||
@@ -613,6 +618,28 @@ function takeStoredNetwork(stored) {
|
||||
copyChainSwitchFields(baseline, structuredClone(stored));
|
||||
}
|
||||
|
||||
// Give this page the tracked tokens of `stored`, a record on this page's
|
||||
// network that a save of this page has just read (onChainSwitch() in
|
||||
// src/shared/chainSwitch.js calls this from `beforeWrite`):
|
||||
//
|
||||
// - A token that another page tracked or removed since this page's last save
|
||||
// is tracked or removed in `state` too.
|
||||
// - A token that this page tracked or removed since then stays as this page
|
||||
// has it.
|
||||
//
|
||||
// `baseline` takes the stored list, so the save stores the list in `state`
|
||||
// whenever the two differ.
|
||||
function takeStoredTrackedTokens(stored) {
|
||||
rawState.trackedTokens = mergeListByIdentity(
|
||||
baseline.trackedTokens,
|
||||
rawState.trackedTokens,
|
||||
stored.trackedTokens,
|
||||
(token) => token.address.toLowerCase(),
|
||||
(base, ours) => ours,
|
||||
);
|
||||
baseline.trackedTokens = structuredClone(stored.trackedTokens);
|
||||
}
|
||||
|
||||
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
||||
// caller that ignores the rejection gets StateNotLoadedError on the first
|
||||
@@ -659,6 +686,7 @@ module.exports = {
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
takeStoredNetwork,
|
||||
takeStoredTrackedTokens,
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
|
||||
+41
-50
@@ -479,6 +479,17 @@ function tokenInfo(address, sources) {
|
||||
};
|
||||
}
|
||||
|
||||
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
|
||||
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
|
||||
// The zero address there reads as a token nothing knows: its amount in base
|
||||
// units, and the zero address as its token.
|
||||
function permit2TokenInfo(address, sources) {
|
||||
if (address === "0x0000000000000000000000000000000000000000") {
|
||||
return { symbol: null, decimals: null, address };
|
||||
}
|
||||
return tokenInfo(address, sources);
|
||||
}
|
||||
|
||||
// A swap amount line. With a scale it is the token quantity; with none it is
|
||||
// the base-unit integer with the unknown scale stated, the same refusal the
|
||||
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
|
||||
@@ -497,16 +508,18 @@ function amountText(raw, info) {
|
||||
|
||||
// The line giving a moved token, or the token of a permit, under `label`, in
|
||||
// the shape of the Token In line, so the screens show it as they show that
|
||||
// one. ETH has no address.
|
||||
function tokenMovedLine(label, token, sources) {
|
||||
const info = tokenInfo(token, sources);
|
||||
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
|
||||
// ETH has no address.
|
||||
function tokenMovedLine(label, info) {
|
||||
if (!present(info.address)) {
|
||||
return { label, value: info.symbol };
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: info.symbol ? info.symbol + " (" + token + ")" : token,
|
||||
address: token,
|
||||
value: info.symbol
|
||||
? info.symbol + " (" + info.address + ")"
|
||||
: info.address,
|
||||
address: info.address,
|
||||
isToken: true,
|
||||
};
|
||||
}
|
||||
@@ -1616,13 +1629,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else {
|
||||
// The router passes this check whenever the owner
|
||||
// holds at least minBalance, so a zero one guarantees
|
||||
// nothing and does not replace a minimum an earlier
|
||||
// step stated. A check the router lets fail guarantees
|
||||
// nothing either, so it is read as a zero one. Any
|
||||
// nothing. A check the router lets fail guarantees
|
||||
// nothing either, so it is read as a zero one. A zero
|
||||
// check sets neither Token Out nor the minimum. Any
|
||||
// other minBalance sets the output side as a swap
|
||||
// does. It pays nobody.
|
||||
// does. The check pays nobody.
|
||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||
if (!(minBalance === 0n && present(minOutput))) {
|
||||
if (minBalance !== 0n) {
|
||||
setOutput(i, b.token, minBalance, []);
|
||||
}
|
||||
keepsRouterWeth = true;
|
||||
@@ -2329,7 +2342,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
: UNDECODED_CURRENCY,
|
||||
});
|
||||
if (present(s.token)) {
|
||||
details.push(tokenMovedLine("Token moved", s.token, sources));
|
||||
details.push(
|
||||
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
|
||||
);
|
||||
details.push({
|
||||
label: "Moved to",
|
||||
value: "The swap step that spends it",
|
||||
@@ -2340,12 +2355,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
|
||||
// where it goes, an address on a line of its own.
|
||||
for (const m of movedFromSender) {
|
||||
const info = permit2TokenInfo(m.token, sources);
|
||||
details.push({
|
||||
label: "Moved from the sender",
|
||||
value: amountText(m.amount, tokenInfo(m.token, sources))
|
||||
.display,
|
||||
value: amountText(m.amount, info).display,
|
||||
});
|
||||
details.push(tokenMovedLine("Token moved", m.token, sources));
|
||||
details.push(tokenMovedLine("Token moved", info));
|
||||
details.push(
|
||||
present(m.to)
|
||||
? { label: "Moved to", value: m.to }
|
||||
@@ -2375,36 +2390,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Permit2 has no allowance of ETH. An allowance of the zero
|
||||
// address reads as one of a token nothing knows: its amount in
|
||||
// base units, and the zero address as its token.
|
||||
if (p.token === "0x0000000000000000000000000000000000000000") {
|
||||
details.push({
|
||||
label: "Permit2 allowance",
|
||||
value:
|
||||
p.amount >= MAX_UINT160
|
||||
? "Unlimited"
|
||||
: unknownDecimalsAmount(p.amount),
|
||||
});
|
||||
details.push({
|
||||
label: "Allowance token",
|
||||
value: p.token,
|
||||
address: p.token,
|
||||
isToken: true,
|
||||
});
|
||||
} else {
|
||||
details.push({
|
||||
label: "Permit2 allowance",
|
||||
value:
|
||||
p.amount >= MAX_UINT160
|
||||
? "Unlimited"
|
||||
: amountText(p.amount, tokenInfo(p.token, sources))
|
||||
.display,
|
||||
});
|
||||
details.push(
|
||||
tokenMovedLine("Allowance token", p.token, sources),
|
||||
);
|
||||
}
|
||||
const info = permit2TokenInfo(p.token, sources);
|
||||
details.push({
|
||||
label: "Permit2 allowance",
|
||||
value:
|
||||
p.amount >= MAX_UINT160
|
||||
? "Unlimited"
|
||||
: amountText(p.amount, info).display,
|
||||
});
|
||||
details.push(tokenMovedLine("Allowance token", info));
|
||||
details.push({
|
||||
label: "Allowance spender",
|
||||
value: p.spender,
|
||||
@@ -2462,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
|
||||
}
|
||||
for (const m of movedIntoRouter) {
|
||||
const info = tokenInfo(m.token, sources);
|
||||
const info = permit2TokenInfo(m.token, sources);
|
||||
paidTo.push({
|
||||
label: "Paid to",
|
||||
value:
|
||||
@@ -2470,9 +2464,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
amountText(m.amount, info).display +
|
||||
" from the sender (later steps may not spend all of it)",
|
||||
});
|
||||
paidTo.push(
|
||||
tokenMovedLine("Token moved into the router", m.token, sources),
|
||||
);
|
||||
paidTo.push(tokenMovedLine("Token moved into the router", info));
|
||||
}
|
||||
if (outputHeld) {
|
||||
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
||||
@@ -2489,8 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
paidTo.push(
|
||||
tokenMovedLine(
|
||||
"Token moved into the router",
|
||||
getAddress(token),
|
||||
sources,
|
||||
tokenInfo(getAddress(token), sources),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
// approved a site's request, which changes only the stored record. Driven
|
||||
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
||||
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
||||
//
|
||||
// The last test covers a balance refresh by the background that is saved while
|
||||
// a switch in Settings waits for the lock
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
@@ -159,3 +163,50 @@ test("a refresh that returns after the background switched the network writes no
|
||||
|
||||
expectNothingStored(page);
|
||||
});
|
||||
|
||||
// The background's refresh saves Ethereum Mainnet's balances while the switch
|
||||
// in Settings waits for the lock. This page last saved a balance of "0" and no
|
||||
// token balances, the values that the switch sets.
|
||||
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
|
||||
const profile = unversionedValidProfile();
|
||||
profile.wallets[0].addresses[0].balance = "0";
|
||||
const page = await bootPopup(profile);
|
||||
expect(page.pageErrors).toEqual([]);
|
||||
const background = require("../src/background/state");
|
||||
await page.click("btn-settings");
|
||||
|
||||
// The background takes the lock first and holds it until the test calls
|
||||
// `letGo()`. It then saves balances as backgroundRefresh() in
|
||||
// src/background/index.js does.
|
||||
let letGo;
|
||||
const refreshSave = background.updateState(async (fresh) => {
|
||||
await new Promise((resolve) => {
|
||||
letGo = resolve;
|
||||
});
|
||||
fresh.wallets[0].addresses[0].balance = "7.0";
|
||||
fresh.wallets[0].addresses[0].tokenBalances = [
|
||||
{
|
||||
address: TOKEN_ADDRESS,
|
||||
name: "Test Token",
|
||||
symbol: "TST",
|
||||
decimals: 18,
|
||||
balance: "5.0",
|
||||
holders: null,
|
||||
},
|
||||
];
|
||||
fresh.lastBalanceRefresh = Date.now();
|
||||
});
|
||||
await page.settle();
|
||||
|
||||
const select = page.node("settings-network");
|
||||
select.value = "sepolia";
|
||||
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
||||
await page.settle();
|
||||
|
||||
letGo();
|
||||
await refreshSave;
|
||||
await switching;
|
||||
await page.settle();
|
||||
|
||||
expectNothingStored(page);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// While a refresh runs, a request through ctx is not dropped. One more refresh
|
||||
// runs once the running one ends, however many were asked for in that time
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/581). A tick of the ten-second
|
||||
// timer that finds a refresh running is dropped. Each test boots the real popup
|
||||
// over a stored profile and holds the ten-second refresh open. `readChain()`
|
||||
// sets each address's balance from `CHAIN`, as the real `refreshBalances()`
|
||||
// sets it from the node.
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
const {
|
||||
hdWalletFromMnemonic,
|
||||
deriveAddressFromXpub,
|
||||
} = require("../src/shared/wallet");
|
||||
|
||||
const PHRASE =
|
||||
"abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
// The first two addresses of PHRASE, both used on the chain.
|
||||
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
|
||||
const SECOND_ADDRESS = deriveAddressFromXpub(xpub, 1);
|
||||
|
||||
// The ETH balance the chain holds for each address, in ether.
|
||||
const CHAIN = {
|
||||
[ADDRESS]: "1.5",
|
||||
[firstAddress]: "2.0",
|
||||
[SECOND_ADDRESS]: "3.0",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
// The balance stand-in for a refresh that returns.
|
||||
async function readChain(wallets) {
|
||||
for (const wallet of wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
addr.balance = CHAIN[addr.address];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boots the popup over a profile with one wallet. From then on,
|
||||
// `refreshBalances()` never returns unless the test answers it. So an unwanted
|
||||
// refresh is counted once and then waits, instead of starting more.
|
||||
async function boot() {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
expect(page.pageErrors).toEqual([]);
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
refreshBalances.mockImplementation(() => new Promise(() => {}));
|
||||
return { page, refreshBalances };
|
||||
}
|
||||
|
||||
// Starts the ten-second refresh and holds it open until finish() is called.
|
||||
// The held refresh then writes the chain's balances. finish() resolves once
|
||||
// the held refresh has saved them and drawn Home. The one refresh after the
|
||||
// held one also returns.
|
||||
async function holdRefresh(page) {
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
let answer;
|
||||
refreshBalances
|
||||
.mockImplementationOnce(async (wallets) => {
|
||||
await new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
await readChain(wallets);
|
||||
})
|
||||
.mockImplementationOnce(readChain);
|
||||
const ticking = page.tick();
|
||||
await page.settle();
|
||||
return {
|
||||
finish: async () => {
|
||||
answer();
|
||||
await ticking;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Imports PHRASE on AddWallet. Its address scan finds the phrase's first two
|
||||
// addresses.
|
||||
async function importPhrase(page) {
|
||||
const { scanForAddresses } = require("../src/shared/balances");
|
||||
scanForAddresses.mockImplementationOnce(async () => ({
|
||||
addresses: [
|
||||
{ address: firstAddress, index: 0 },
|
||||
{ address: SECOND_ADDRESS, index: 1 },
|
||||
],
|
||||
nextIndex: 2,
|
||||
}));
|
||||
await page.click("btn-add-wallet-bottom");
|
||||
await page.click("tab-mnemonic");
|
||||
page.node("wallet-mnemonic").value = PHRASE;
|
||||
page.node("add-wallet-password").value = PASSWORD;
|
||||
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||
await page.click("btn-add-wallet-confirm");
|
||||
}
|
||||
|
||||
// Imports the private key `key` on AddWallet.
|
||||
async function importKey(page, key) {
|
||||
await page.click("btn-add-wallet-bottom");
|
||||
await page.click("tab-privkey");
|
||||
page.node("import-private-key").value = key;
|
||||
page.node("add-wallet-password").value = PASSWORD;
|
||||
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||
await page.click("btn-add-wallet-confirm");
|
||||
}
|
||||
|
||||
test("addresses that an import finds during a refresh show their balances once one more refresh returns", async () => {
|
||||
const { page } = await boot();
|
||||
const refresh = await holdRefresh(page);
|
||||
|
||||
await importPhrase(page);
|
||||
expect(page.visibleViews()).toEqual(["main"]);
|
||||
expect(page.text("flash-msg")).toBe("Found 2 addresses.");
|
||||
|
||||
await refresh.finish();
|
||||
await page.settle();
|
||||
|
||||
const list = page.node("wallet-list").innerHTML;
|
||||
expect(list).toContain("<span>2.0000</span>");
|
||||
expect(list).toContain("<span>3.0000</span>");
|
||||
});
|
||||
|
||||
test("several requests during a refresh lead to exactly one more refresh", async () => {
|
||||
const { page, refreshBalances } = await boot();
|
||||
const refresh = await holdRefresh(page);
|
||||
const calls = refreshBalances.mock.calls.length;
|
||||
|
||||
await importKey(page, "0x" + "11".repeat(32));
|
||||
await importKey(page, "0x" + "22".repeat(32));
|
||||
await importPhrase(page);
|
||||
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||
|
||||
await refresh.finish();
|
||||
await page.settle();
|
||||
|
||||
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
|
||||
});
|
||||
|
||||
test("a tick of the ten-second timer during a refresh starts no other refresh", async () => {
|
||||
const { page, refreshBalances } = await boot();
|
||||
const refresh = await holdRefresh(page);
|
||||
const calls = refreshBalances.mock.calls.length;
|
||||
|
||||
await page.tick();
|
||||
|
||||
await refresh.finish();
|
||||
await page.settle();
|
||||
|
||||
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// Another page stores a different RPC or Blockscout URL for the same network.
|
||||
// Then the user taps "Save" in Settings on this page, on the URL that this page
|
||||
// loaded. The URL on screen is stored, and the flash message reads "Saved."
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
|
||||
// popup over a stored profile on Ethereum Mainnet.
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
} = require("./support/popupBoot");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores the URL on screen after another page stored a different one",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const url = page.value(input);
|
||||
// This write sends this page no storage change event.
|
||||
page.storage.write("autistmask", {
|
||||
...page.storage.read("autistmask"),
|
||||
[field]: "https://other-page.example.invalid",
|
||||
});
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
|
||||
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
|
||||
// reads `ok`.
|
||||
globalThis.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
});
|
||||
|
||||
await page.click(button);
|
||||
|
||||
expect(page.storage.read("autistmask")[field]).toBe(url);
|
||||
expect(page.text("flash-msg")).toBe("Saved.");
|
||||
},
|
||||
);
|
||||
|
||||
// The user types a URL in Settings and taps "Save". While the check waits for
|
||||
// the endpoint's answer, another page turns "Use Blockscout" on. This page
|
||||
// follows that change and draws Settings again, with the stored URL in the
|
||||
// input. The check then passes. The typed URL is not stored. The flash message
|
||||
// says that the URL was changed during the check
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores nothing when Settings is drawn again during the check",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const storedUrl = page.value(input);
|
||||
let answer;
|
||||
globalThis.fetch.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
page.node(input).value = "https://typed.example.invalid";
|
||||
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
// The browser announces this write to this page.
|
||||
await page.storage.local.set({
|
||||
autistmask: {
|
||||
...page.storage.read("autistmask"),
|
||||
useBlockscout: true,
|
||||
},
|
||||
});
|
||||
await page.settle();
|
||||
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||
answer({
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
});
|
||||
await Promise.all(saving);
|
||||
await page.settle();
|
||||
|
||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||
expect(page.value(input)).toBe(storedUrl);
|
||||
expect(page.text("flash-msg")).toBe(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// As above, but another write holds the lock. So this page's follow of the
|
||||
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
|
||||
// input still shows the typed URL. The save then asks for the lock, after the
|
||||
// follow did. So the follow draws Settings again before the save compares the
|
||||
// input.
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores nothing when Settings is drawn again while the save waits for the lock",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const storedUrl = page.value(input);
|
||||
let answer;
|
||||
globalThis.fetch.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
const typedUrl = "https://typed.example.invalid";
|
||||
|
||||
page.node(input).value = typedUrl;
|
||||
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
let releaseLock;
|
||||
const lockHeld = navigator.locks.request(
|
||||
"autistmask",
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseLock = resolve;
|
||||
}),
|
||||
);
|
||||
// The browser announces this write to this page.
|
||||
await page.storage.local.set({
|
||||
autistmask: {
|
||||
...page.storage.read("autistmask"),
|
||||
useBlockscout: true,
|
||||
},
|
||||
});
|
||||
await page.settle();
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||
answer({
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
});
|
||||
await page.settle();
|
||||
expect(page.value(input)).toBe(typedUrl);
|
||||
releaseLock();
|
||||
await lockHeld;
|
||||
await Promise.all(saving);
|
||||
await page.settle();
|
||||
|
||||
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||
expect(page.value(input)).toBe(storedUrl);
|
||||
expect(page.text("flash-msg")).toBe(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -5,6 +5,9 @@
|
||||
// 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.
|
||||
//
|
||||
// The tests of what another page stored before a switch in Settings cover a
|
||||
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
@@ -177,6 +180,164 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
|
||||
expect(onSepolia).not.toContain("DAI");
|
||||
});
|
||||
|
||||
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
|
||||
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
|
||||
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
|
||||
const boot = await bootPopup(unversionedValidProfile());
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [MAINNET_TOKEN],
|
||||
});
|
||||
|
||||
await boot.click("btn-settings");
|
||||
await chooseNetwork(boot, "sepolia");
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkId).toBe("sepolia");
|
||||
expect(stored.trackedTokens).toEqual([]);
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||
});
|
||||
|
||||
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
|
||||
// last save. This page still holds DAI. The switch to Sepolia does not store
|
||||
// DAI under Ethereum Mainnet again.
|
||||
test("a token another page removed before a switch in Settings stays removed", async () => {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [],
|
||||
});
|
||||
|
||||
await boot.click("btn-settings");
|
||||
await chooseNetwork(boot, "sepolia");
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkId).toBe("sepolia");
|
||||
expect(stored.trackedTokens).toEqual([]);
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||
});
|
||||
|
||||
// Another popup page removes DAI after this page's last save. That page then
|
||||
// adds DAI again, typing the contract address in lower case. The two pages'
|
||||
// entries for DAI match, because contract addresses are compared ignoring
|
||||
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
|
||||
// Mainnet.
|
||||
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [
|
||||
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
|
||||
],
|
||||
});
|
||||
|
||||
await boot.click("btn-settings");
|
||||
await chooseNetwork(boot, "sepolia");
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkId).toBe("sepolia");
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||
});
|
||||
|
||||
// Another popup page saves a switch to Sepolia after this page's last save.
|
||||
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
|
||||
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
|
||||
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
|
||||
const boot = await bootPopup(unversionedValidProfile());
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
await boot.click("btn-settings");
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
networkId: "sepolia",
|
||||
trackedTokens: [SEPOLIA_TOKEN],
|
||||
});
|
||||
|
||||
await chooseNetwork(boot, "sepolia");
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkId).toBe("sepolia");
|
||||
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||
});
|
||||
|
||||
// Another popup page records a fraud contract on Ethereum Mainnet after this
|
||||
// page's last save. The switch to Sepolia drops that fraud contract, as the
|
||||
// switch drops this page's own.
|
||||
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
|
||||
const boot = await bootPopup(unversionedValidProfile());
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
fraudContracts: [NEW_TOKEN_ADDRESS],
|
||||
});
|
||||
|
||||
await boot.click("btn-settings");
|
||||
await chooseNetwork(boot, "sepolia");
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkId).toBe("sepolia");
|
||||
expect(stored.fraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
|
||||
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
|
||||
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
|
||||
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||
let answer;
|
||||
lookupTokenInfo.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||
const adding = boot.click("btn-settings-addtoken-manual");
|
||||
await boot.click("btn-settings");
|
||||
|
||||
// Another write holds the lock, so the switch's save waits for it.
|
||||
let releaseLock;
|
||||
const lockHeld = navigator.locks.request(
|
||||
"autistmask",
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseLock = resolve;
|
||||
}),
|
||||
);
|
||||
const select = boot.node("settings-network");
|
||||
select.value = "sepolia";
|
||||
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
||||
await boot.settle();
|
||||
|
||||
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
|
||||
await boot.settle();
|
||||
releaseLock();
|
||||
await lockHeld;
|
||||
await switching;
|
||||
await adding;
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkId).toBe("sepolia");
|
||||
expect(stored.trackedTokens).toEqual([]);
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
||||
{
|
||||
address: NEW_TOKEN_ADDRESS,
|
||||
symbol: "NEW",
|
||||
decimals: 18,
|
||||
name: "New Token",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Back after a switch from the Settings gear", () => {
|
||||
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// TransactionDetail shows only details that Blockscout gave for the
|
||||
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
|
||||
//
|
||||
// The screen asks Blockscout for its transaction and does not wait for the
|
||||
// answer. So the user can press Back and open another transaction before
|
||||
// Blockscout answers for the first. The first answer must then fill nothing on
|
||||
// the second transaction's screen. An answer that lands while Settings is up
|
||||
// must still fill TransactionDetail.
|
||||
//
|
||||
// Driven through the real popup over a stored profile
|
||||
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
|
||||
// opens it: the current screen is pushed, then transactionDetail.show() draws
|
||||
// the transaction.
|
||||
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
|
||||
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
||||
|
||||
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
|
||||
// network with `chainId`.
|
||||
function historyEntry(hash, chainId, symbol) {
|
||||
return {
|
||||
hash,
|
||||
from: ADDRESS,
|
||||
to: RECIPIENT,
|
||||
value: "1.0000",
|
||||
exactValue: "1.0",
|
||||
symbol,
|
||||
timestamp: 1790000000,
|
||||
isError: false,
|
||||
direction: "sent",
|
||||
directionLabel: "Sent",
|
||||
contractAddress: null,
|
||||
chainId,
|
||||
};
|
||||
}
|
||||
|
||||
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
|
||||
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
|
||||
// A transaction on Sepolia with the same hash as FIRST.
|
||||
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
|
||||
|
||||
// The URL that asks Blockscout for `entry`, on the network it was sent on.
|
||||
function requestUrl(entry) {
|
||||
const blockscout =
|
||||
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
|
||||
return blockscout + "/transactions/" + entry.hash;
|
||||
}
|
||||
|
||||
// What Blockscout reports for each transaction, by the URL that asks for it.
|
||||
const ANSWERS = {
|
||||
[requestUrl(FIRST)]: {
|
||||
block_number: 21000001,
|
||||
nonce: 4441,
|
||||
fee: { value: "21000000000000" },
|
||||
raw_input: "0x11111111",
|
||||
method: "firstMethod",
|
||||
},
|
||||
[requestUrl(SECOND)]: {
|
||||
block_number: 21000002,
|
||||
nonce: 4442,
|
||||
fee: { value: "42000000000000" },
|
||||
raw_input: "0x22222222",
|
||||
method: "secondMethod",
|
||||
},
|
||||
[requestUrl(FIRST_ON_SEPOLIA)]: {
|
||||
block_number: 21000003,
|
||||
nonce: 4443,
|
||||
fee: { value: "63000000000000" },
|
||||
raw_input: "0x33333333",
|
||||
method: "sepoliaMethod",
|
||||
},
|
||||
};
|
||||
|
||||
// The text each answer puts on screen: its block, nonce, fee, method name and
|
||||
// raw data. No text from one answer appears in another.
|
||||
const FIRST_SHOWN = [
|
||||
"21000001",
|
||||
"4441",
|
||||
"0.000021 ETH",
|
||||
"firstMethod",
|
||||
"0x11111111",
|
||||
];
|
||||
const SECOND_SHOWN = [
|
||||
"21000002",
|
||||
"4442",
|
||||
"0.000042 ETH",
|
||||
"secondMethod",
|
||||
"0x22222222",
|
||||
];
|
||||
const FIRST_ON_SEPOLIA_SHOWN = [
|
||||
"21000003",
|
||||
"4443",
|
||||
"0.000063 SepoliaETH",
|
||||
"sepoliaMethod",
|
||||
"0x33333333",
|
||||
];
|
||||
|
||||
// Everything drawn in the fields that Blockscout's answer fills.
|
||||
function drawnDetails(page) {
|
||||
return [
|
||||
"tx-detail-block",
|
||||
"tx-detail-nonce",
|
||||
"tx-detail-fee",
|
||||
"tx-detail-calldata-action",
|
||||
"tx-detail-rawdata",
|
||||
]
|
||||
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Opens `entry` on TransactionDetail, as a history screen does.
|
||||
function open(entry) {
|
||||
require("../src/popup/views/helpers").pushCurrentView();
|
||||
require("../src/popup/views/transactionDetail").show(entry);
|
||||
}
|
||||
|
||||
// The URLs of every request to Blockscout so far, in order.
|
||||
function requestedUrls() {
|
||||
return globalThis.fetch.mock.calls.map(([url]) => url);
|
||||
}
|
||||
|
||||
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
|
||||
// request at once from ANSWERS, except the request for `held`. Returns the
|
||||
// page and answer(), which lets the answer for `held` land.
|
||||
async function bootWithHeldRequest(held) {
|
||||
const page = await bootPopup(
|
||||
unversionedValidProfile({ useBlockscout: true }),
|
||||
);
|
||||
let answer;
|
||||
const heldAnswer = new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
globalThis.fetch = jest.fn(async (url) => {
|
||||
if (url === requestUrl(held)) await heldAnswer;
|
||||
return { ok: true, status: 200, json: async () => ANSWERS[url] };
|
||||
});
|
||||
return { page, answer: () => answer() };
|
||||
}
|
||||
|
||||
// The user opens `first`, whose request to Blockscout is held. Then the user
|
||||
// presses Back and opens `second`. Then the answer for `first` lands. Returns
|
||||
// the page.
|
||||
async function firstAnswerLandsOnSecond(first, second) {
|
||||
const { page, answer } = await bootWithHeldRequest(first);
|
||||
open(first);
|
||||
await page.settle();
|
||||
await page.click("btn-tx-back");
|
||||
expect(page.visibleViews()).toEqual(["main"]);
|
||||
open(second);
|
||||
await page.settle();
|
||||
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
|
||||
answer();
|
||||
await page.settle();
|
||||
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||
return page;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
|
||||
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
|
||||
|
||||
const drawn = drawnDetails(page);
|
||||
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
|
||||
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||
});
|
||||
|
||||
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
|
||||
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
|
||||
|
||||
const drawn = drawnDetails(page);
|
||||
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
|
||||
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||
});
|
||||
|
||||
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
|
||||
const { page, answer } = await bootWithHeldRequest(FIRST);
|
||||
open(FIRST);
|
||||
await page.settle();
|
||||
await page.click("btn-settings");
|
||||
expect(page.visibleViews()).toEqual(["settings"]);
|
||||
answer();
|
||||
await page.settle();
|
||||
await page.click("btn-settings");
|
||||
|
||||
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
|
||||
const drawn = drawnDetails(page);
|
||||
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
|
||||
});
|
||||
+89
-7
@@ -3440,6 +3440,82 @@ describe("uniswap decoder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/685 — Permit2 moves no
|
||||
// ETH, so a PERMIT2_TRANSFER_FROM of the zero address reads as a move
|
||||
// of a token nothing knows. On next at ec6b6b5 its lines read ETH,
|
||||
// with an ETH figure.
|
||||
describe("a PERMIT2_TRANSFER_FROM of the zero address", () => {
|
||||
const MOVED_AMOUNT =
|
||||
"1000000000000000000 base units (decimals unknown)";
|
||||
|
||||
test("to another address names the address", () => {
|
||||
const result = decodeSteps(
|
||||
[0x00, 0x02],
|
||||
[V3_600, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
|
||||
);
|
||||
expect(linesBetweenTokens(result)).toEqual([
|
||||
["Amount", "600.0000 USDT"],
|
||||
["Moved from the sender", MOVED_AMOUNT],
|
||||
["Token moved", ZERO_ADDR],
|
||||
["Moved to", OTHER_ADDR],
|
||||
]);
|
||||
expect(detail(result, "Token moved").address).toBe(ZERO_ADDR);
|
||||
});
|
||||
|
||||
test("into the router names the address", () => {
|
||||
const result = decodeSteps(
|
||||
[0x02, 0x00],
|
||||
[transfer(ZERO_ADDR, ROUTER_ADDR, ONE_WETH), V3_600],
|
||||
);
|
||||
expect(detail(result, "Paid to").value).toBe(
|
||||
"The router, " +
|
||||
MOVED_AMOUNT +
|
||||
" from the sender (later steps may not spend all of it)",
|
||||
);
|
||||
expect(detail(result, "Token moved into the router")).toEqual({
|
||||
label: "Token moved into the router",
|
||||
value: ZERO_ADDR,
|
||||
address: ZERO_ADDR,
|
||||
isToken: true,
|
||||
});
|
||||
});
|
||||
|
||||
// The V4 step's SETTLE names the user as payer and states no
|
||||
// amount. The step's lines say that the ETH amount is not known.
|
||||
test("after a V4 exact-out step of ETH, also shows that the ETH amount is not known", () => {
|
||||
const v4EthExactOut = encodeV4Swap(
|
||||
new Uint8Array([
|
||||
V4_SWAP_EXACT_OUT_SINGLE_ID,
|
||||
V4_SETTLE_ID,
|
||||
V4_TAKE_ALL_ID,
|
||||
]),
|
||||
[
|
||||
encodeV4ExactOutSingle(
|
||||
ZERO_ADDR,
|
||||
USDC_ADDR,
|
||||
590000000n,
|
||||
ONE_WETH,
|
||||
),
|
||||
encodeV4Settle(ZERO_ADDR),
|
||||
encodeV4TakeAll(USDC_ADDR, 0n),
|
||||
],
|
||||
);
|
||||
const result = decodeSteps(
|
||||
[0x10, 0x02],
|
||||
[v4EthExactOut, transfer(ZERO_ADDR, OTHER_ADDR, ONE_WETH)],
|
||||
);
|
||||
expect(detail(result, "Token In").value).toBe("ETH (native)");
|
||||
expect(linesBetweenTokens(result)).toEqual([
|
||||
["Moved from the sender", UNDECODED],
|
||||
["Token moved", "ETH"],
|
||||
["Moved to", "The swap step that spends it"],
|
||||
["Moved from the sender", MOVED_AMOUNT],
|
||||
["Token moved", ZERO_ADDR],
|
||||
["Moved to", OTHER_ADDR],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE
|
||||
// of ETH from the router's own ETH, whichever payer the SETTLE names,
|
||||
// and the router holds the transaction's value. On next at 36b2002 a
|
||||
@@ -4304,10 +4380,9 @@ describe("uniswap decoder", () => {
|
||||
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
||||
});
|
||||
|
||||
// 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
|
||||
// minimum is not said to be received.
|
||||
test("a zero balance check with no earlier minimum states no minimum", () => {
|
||||
// A zero balance check sets neither Token Out nor the minimum. The route
|
||||
// pays only the WRAP_ETH's WETH, to the router, where it stays.
|
||||
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
|
||||
const data = buildExecute(
|
||||
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
||||
[
|
||||
@@ -4320,9 +4395,16 @@ describe("uniswap decoder", () => {
|
||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
expect(detail(result, "Token Out").value).toContain("USDT");
|
||||
expect(detail(result, "Min. paid out").value).toBe(
|
||||
"None (no minimum guaranteed)",
|
||||
expect(detail(result, "Token Out").value).toBe(
|
||||
"Unknown (not named in the calldata)",
|
||||
);
|
||||
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||
expect(detail(result, "Min. received")).toBeUndefined();
|
||||
expect(detail(result, "Paid to").value).toBe(
|
||||
"The router, where it stays (no later step pays it out)",
|
||||
);
|
||||
expect(detail(result, "Token moved into the router").address).toBe(
|
||||
WETH_ADDR,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -688,6 +688,62 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
|
||||
]);
|
||||
});
|
||||
|
||||
// The same route, then a balance check of the sender's USDC with a zero
|
||||
// minimum. The check guarantees nothing, so Token Out stays ETH
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/649).
|
||||
test("the same route, then a zero balance check of USDC, still reads as ETH with no minimum", () => {
|
||||
const result = decode(
|
||||
execute(
|
||||
v4Swap(
|
||||
SWAP_USDT_FOR_WETH,
|
||||
SETTLE_USDT,
|
||||
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
|
||||
take(WETH, ADDRESS_THIS),
|
||||
),
|
||||
unwrapWeth(MSG_SENDER),
|
||||
balanceCheck(USDC, 0n),
|
||||
),
|
||||
);
|
||||
expect(result.name).toBe("Swap USDT → ETH");
|
||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||
expect(labels(result)).toEqual([
|
||||
"Protocol",
|
||||
"Token In",
|
||||
"Amount",
|
||||
"Token Out",
|
||||
"Steps",
|
||||
"Deadline",
|
||||
]);
|
||||
});
|
||||
|
||||
// A PERMIT2_TRANSFER_FROM moves the sender's WETH into the router. An
|
||||
// UNWRAP_WETH pays that WETH to the user as ETH. A zero balance check of
|
||||
// USDC sets neither Token Out nor the minimum, before the UNWRAP_WETH or
|
||||
// after it (https://git.eeqj.de/sneak/AutistMask/issues/649).
|
||||
test.each([
|
||||
{
|
||||
kind: "before the UNWRAP_WETH",
|
||||
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
|
||||
},
|
||||
{
|
||||
kind: "after the UNWRAP_WETH",
|
||||
steps: [unwrapWeth(MSG_SENDER), balanceCheck(USDC, 0n)],
|
||||
},
|
||||
])(
|
||||
"WETH moved into the router and unwrapped, with a zero balance check of USDC $kind, reads as ETH with no minimum",
|
||||
({ steps }) => {
|
||||
const result = decode(
|
||||
execute(
|
||||
permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH),
|
||||
...steps,
|
||||
),
|
||||
);
|
||||
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||
expect(detail(result, "Min. received")).toBeUndefined();
|
||||
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
// A TAKE the decoder cannot read may pay part of the WETH to the user.
|
||||
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
|
||||
const result = decode(
|
||||
@@ -2820,17 +2876,16 @@ describe("the allow-revert flag", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
|
||||
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
|
||||
const check = balanceCheck(WETH, ONE_WETH);
|
||||
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
|
||||
"1.0000 WETH",
|
||||
);
|
||||
|
||||
const withFlag = decode(execute(flagged(check)));
|
||||
expect(detail(withFlag, "Token Out").address).toBe(WETH);
|
||||
expect(detail(withFlag, "Min. paid out").value).toBe(
|
||||
"None (no minimum guaranteed)",
|
||||
);
|
||||
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
|
||||
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
|
||||
expect(detail(withFlag, "Min. received")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
||||
|
||||
Reference in New Issue
Block a user