Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e5ccfbb2 |
@@ -1277,18 +1277,22 @@ 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,
|
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
|
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
||||||
such meaning and is shown as a zero.
|
such meaning and is shown as a zero.
|
||||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when it shows
|
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
||||||
a V2, V3 or V4 swap's minimum of zero. The router passes a
|
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
||||||
`BALANCE_CHECK_ERC20` step whenever the balance is at least `minBalance`, so a
|
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
||||||
zero `minBalance` guarantees nothing. A check with a zero `minBalance` sets
|
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
||||||
neither `Token Out` nor `Min. received`. A nonzero `minBalance` sets both
|
read `0.0000` when the token's scale was known. The router passes a balance
|
||||||
lines, as a swap step does. Only a check of the transaction's sender's own
|
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
||||||
balance sets either line; a check of any other address sets neither (see
|
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||||
Transaction Decoding). A `BALANCE_CHECK_ERC20` step that the router lets fail
|
output side holds no minimum, not even a zero one, at the point the check is
|
||||||
without reverting the transaction (see Transaction Decoding) guarantees
|
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||||
nothing either, whatever its `minBalance`, and is read as one whose
|
`minBalance` sets both lines, as a swap step does. Only a check of the
|
||||||
`minBalance` is zero. A minimum stated by a V4 swap step the router lets fail
|
transaction's sender's own balance sets either line; a check of any other
|
||||||
is likewise read as zero.
|
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
|
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
|
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
||||||
@@ -2279,11 +2283,6 @@ view would leave a wallet one click from deletion.
|
|||||||
no user data and is made whether or not "Use Blockscout" is ticked.
|
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
|
- If the network changes before either "Save" stores its URL, the URL is not
|
||||||
saved and a flash message says so
|
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
|
|
||||||
- If the user taps the same "Save" again before the endpoint answers, the
|
|
||||||
earlier tap stores nothing and shows no flash message
|
|
||||||
- Etherscan:
|
- Etherscan:
|
||||||
- "Check recipients on Etherscan" checkbox, unticked until the user
|
- "Check recipients on Etherscan" checkbox, unticked until the user
|
||||||
ticks it
|
ticks it
|
||||||
@@ -3398,8 +3397,7 @@ Currently supported:
|
|||||||
`Balance Check (of another address)`. A check whose input does not decode,
|
`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
|
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
|
owner from the word's last 20 bytes), likewise sets neither, and it is listed
|
||||||
as `Balance Check (could not be decoded)`. A check with a zero `minBalance`
|
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
|
||||||
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
|
`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
|
`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
|
paid it into ETH, that token stays in the router unless a later step pays out
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ let versionClickTimer = null;
|
|||||||
// not the one in `state`.
|
// not the one in `state`.
|
||||||
let shownNetwork = null;
|
let shownNetwork = null;
|
||||||
|
|
||||||
// Each endpoint "Save" counts its taps here, under the field that it stores. A
|
|
||||||
// tap stores its URL and shows a flash message only while it is the latest tap
|
|
||||||
// of its "Save".
|
|
||||||
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
|
|
||||||
|
|
||||||
// One row per site origin, however many addresses it appears under, each with
|
// One row per site origin, however many addresses it appears under, each with
|
||||||
// an [x] that hands it to onRemove.
|
// an [x] that hands it to onRemove.
|
||||||
function renderSiteList(containerId, origins, onRemove) {
|
function renderSiteList(containerId, origins, onRemove) {
|
||||||
@@ -339,43 +334,16 @@ function showNewSettings() {
|
|||||||
// src/popup/index.js) between setting the URL and writing it. If the active
|
// 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,
|
// 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.
|
// 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`.
|
|
||||||
//
|
|
||||||
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
|
|
||||||
// the same "Save" again before this save holds the lock, nothing is stored. If
|
|
||||||
// the user taps the same "Save" again before the write ends, no flash message
|
|
||||||
// is shown.
|
|
||||||
async function saveEndpoint(field, input, url, networkId, tap) {
|
|
||||||
let refused = false;
|
let refused = false;
|
||||||
let inputChanged = false;
|
|
||||||
await saveState((stored) => {
|
await saveState((stored) => {
|
||||||
if (saveTaps[field] !== tap) return false;
|
|
||||||
refused =
|
refused =
|
||||||
state.networkId !== networkId || stored.networkId !== networkId;
|
state.networkId !== networkId || stored.networkId !== networkId;
|
||||||
inputChanged = $(input).value.trim() !== url;
|
if (refused) return false;
|
||||||
if (refused || inputChanged) return false;
|
|
||||||
state[field] = url;
|
state[field] = url;
|
||||||
stored[field] = url;
|
|
||||||
});
|
});
|
||||||
if (saveTaps[field] !== tap) return;
|
|
||||||
if (refused) {
|
if (refused) {
|
||||||
showFlash("The URL was not saved because the network changed.");
|
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 {
|
} else {
|
||||||
showFlash("Saved.");
|
showFlash("Saved.");
|
||||||
}
|
}
|
||||||
@@ -387,8 +355,6 @@ function init(_ctx) {
|
|||||||
showPhrase.init();
|
showPhrase.init();
|
||||||
|
|
||||||
$("btn-save-rpc").addEventListener("click", async () => {
|
$("btn-save-rpc").addEventListener("click", async () => {
|
||||||
saveTaps.rpcUrl++;
|
|
||||||
const tap = saveTaps.rpcUrl;
|
|
||||||
const url = $("settings-rpc").value.trim();
|
const url = $("settings-rpc").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showFlash("Please enter an RPC URL.");
|
showFlash("Please enter an RPC URL.");
|
||||||
@@ -409,8 +375,6 @@ function init(_ctx) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
// The user has tapped this "Save" again since the check began.
|
|
||||||
if (tap !== saveTaps.rpcUrl) return;
|
|
||||||
if (json.error) {
|
if (json.error) {
|
||||||
log.errorf("RPC validation error:", json.error);
|
log.errorf("RPC validation error:", json.error);
|
||||||
showFlash("Endpoint returned an error.");
|
showFlash("Endpoint returned an error.");
|
||||||
@@ -424,7 +388,6 @@ function init(_ctx) {
|
|||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
if (tap !== saveTaps.rpcUrl) return;
|
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
// Not the error's message: fetch puts the whole URL, password and
|
||||||
// key included, in the message of the error it throws for a URL
|
// key included, in the message of the error it throws for a URL
|
||||||
// with a user name and password or one it cannot parse.
|
// with a user name and password or one it cannot parse.
|
||||||
@@ -432,12 +395,10 @@ function init(_ctx) {
|
|||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
|
await saveEndpoint("rpcUrl", url, net.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||||
saveTaps.blockscoutUrl++;
|
|
||||||
const tap = saveTaps.blockscoutUrl;
|
|
||||||
const url = $("settings-blockscout").value.trim();
|
const url = $("settings-blockscout").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showFlash("Please enter a Blockscout API URL.");
|
showFlash("Please enter a Blockscout API URL.");
|
||||||
@@ -447,26 +408,18 @@ function init(_ctx) {
|
|||||||
showFlash("Testing endpoint...");
|
showFlash("Testing endpoint...");
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(url + "/stats");
|
const resp = await debugFetch(url + "/stats");
|
||||||
if (tap !== saveTaps.blockscoutUrl) return;
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
if (tap !== saveTaps.blockscoutUrl) return;
|
|
||||||
// Not the error's message, as for the RPC check above.
|
// Not the error's message, as for the RPC check above.
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveEndpoint(
|
await saveEndpoint("blockscoutUrl", url, net.id);
|
||||||
"blockscoutUrl",
|
|
||||||
"settings-blockscout",
|
|
||||||
url,
|
|
||||||
net.id,
|
|
||||||
tap,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each change is made inside the save, holding the lock. So
|
// Each change is made inside the save, holding the lock. So
|
||||||
|
|||||||
@@ -311,21 +311,10 @@ 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
|
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||||
// asked. An answer that lands after the setting went off is dropped
|
// asked. An answer that lands after the setting went off is dropped
|
||||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
// (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) {
|
async function loadFullTxDetails(txHash, chainId) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
@@ -344,7 +333,6 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
if (!holdsTransaction(txHash, chainId)) return;
|
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData, chainId);
|
||||||
|
|||||||
@@ -47,30 +47,12 @@ function clearPopupCaches() {
|
|||||||
// lock, so it is never switched while a follow of a switch saved elsewhere
|
// 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
|
// waits for the lock (followStoredSettings() in src/popup/index.js): that
|
||||||
// follow runs first, and this switch is made after it.
|
// 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.
|
|
||||||
//
|
|
||||||
// A balance 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).
|
|
||||||
async function onChainSwitch(newNetworkId) {
|
async function onChainSwitch(newNetworkId) {
|
||||||
const { state, saveState } = require("./state");
|
const { state, saveState } = require("./state");
|
||||||
let net = null;
|
let net = null;
|
||||||
await saveState((stored) => {
|
await saveState(() => {
|
||||||
if (state.networkId === newNetworkId) return false;
|
if (state.networkId === newNetworkId) return false;
|
||||||
net = applyChainSwitchFields(state, newNetworkId);
|
net = applyChainSwitchFields(state, newNetworkId);
|
||||||
stored.lastBalanceRefresh = 0;
|
|
||||||
for (const wallet of stored.wallets) {
|
|
||||||
for (const addr of wallet.addresses) {
|
|
||||||
addr.balance = "0";
|
|
||||||
addr.tokenBalances = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearPopupCaches();
|
clearPopupCaches();
|
||||||
});
|
});
|
||||||
return net;
|
return net;
|
||||||
|
|||||||
+2
-4
@@ -591,10 +591,8 @@ function reportSaveFailure(err) {
|
|||||||
//
|
//
|
||||||
// `beforeWrite`, when given, is called inside the lock with the stored record
|
// `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
|
// as just read, before this page's state is compared against it. It may change
|
||||||
// `state`, and this save writes that change. It may also change the stored
|
// `state`, and this save writes that change. When it returns false, nothing is
|
||||||
// record. A field that this page did not change is then written as
|
// written; any other return lets the save go ahead.
|
||||||
// `beforeWrite` left it. When it returns false, nothing is written; any other
|
|
||||||
// return lets the save go ahead.
|
|
||||||
function saveState(beforeWrite) {
|
function saveState(beforeWrite) {
|
||||||
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||||
// Every failed save is reported, whether or not the caller awaited this
|
// Every failed save is reported, whether or not the caller awaited this
|
||||||
|
|||||||
@@ -1629,13 +1629,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else {
|
} else {
|
||||||
// The router passes this check whenever the owner
|
// The router passes this check whenever the owner
|
||||||
// holds at least minBalance, so a zero one guarantees
|
// holds at least minBalance, so a zero one guarantees
|
||||||
// nothing. A check the router lets fail guarantees
|
// nothing and does not replace a minimum an earlier
|
||||||
// nothing either, so it is read as a zero one. A zero
|
// step stated. A check the router lets fail guarantees
|
||||||
// check sets neither Token Out nor the minimum. Any
|
// nothing either, so it is read as a zero one. Any
|
||||||
// other minBalance sets the output side as a swap
|
// other minBalance sets the output side as a swap
|
||||||
// does. The check pays nobody.
|
// does. It pays nobody.
|
||||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||||
if (minBalance !== 0n) {
|
if (!(minBalance === 0n && present(minOutput))) {
|
||||||
setOutput(i, b.token, minBalance, []);
|
setOutput(i, b.token, minBalance, []);
|
||||||
}
|
}
|
||||||
keepsRouterWeth = true;
|
keepsRouterWeth = true;
|
||||||
|
|||||||
@@ -6,10 +6,6 @@
|
|||||||
// approved a site's request, which changes only the stored record. Driven
|
// 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
|
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
||||||
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
// 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 {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -163,50 +159,3 @@ test("a refresh that returns after the background switched the network writes no
|
|||||||
|
|
||||||
expectNothingStored(page);
|
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,393 +0,0 @@
|
|||||||
// 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.",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
|
||||||
const PASSING_ANSWER = {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Both endpoint checks read this answer as an error. The RPC check reads the
|
|
||||||
// answer's `error`. The Blockscout check reads the answer's `ok`.
|
|
||||||
const ERROR_ANSWER = {
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
|
|
||||||
// the user types a second URL and taps "Save" again. The second check passes
|
|
||||||
// first. The second URL is stored, and the flash message reads "Saved.". The
|
|
||||||
// first check then ends in one of three ways:
|
|
||||||
//
|
|
||||||
// - It passes.
|
|
||||||
// - It fails, because fetch cannot reach the endpoint.
|
|
||||||
// - It gets an answer that holds an error.
|
|
||||||
//
|
|
||||||
// None of the three changes the stored URL or the flash message
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
button: "btn-save-rpc",
|
|
||||||
input: "settings-rpc",
|
|
||||||
field: "rpcUrl",
|
|
||||||
first: "passes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-rpc",
|
|
||||||
input: "settings-rpc",
|
|
||||||
field: "rpcUrl",
|
|
||||||
first: "fails",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-rpc",
|
|
||||||
input: "settings-rpc",
|
|
||||||
field: "rpcUrl",
|
|
||||||
first: "answers with an error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
first: "passes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
first: "fails",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
first: "answers with an error",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$button keeps the second tap's result when the first check $first last",
|
|
||||||
async ({ button, input, field, first }) => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const checks = [];
|
|
||||||
globalThis.fetch.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
checks.push({ resolve, reject });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const secondUrl = "https://second.example.invalid";
|
|
||||||
|
|
||||||
page.node(input).value = "https://first.example.invalid";
|
|
||||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
page.node(input).value = secondUrl;
|
|
||||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
checks[1].resolve(PASSING_ANSWER);
|
|
||||||
await Promise.all(secondTap);
|
|
||||||
await page.settle();
|
|
||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
|
||||||
if (first === "passes") {
|
|
||||||
checks[0].resolve(PASSING_ANSWER);
|
|
||||||
} else if (first === "answers with an error") {
|
|
||||||
checks[0].resolve(ERROR_ANSWER);
|
|
||||||
} else {
|
|
||||||
// fetch rejects when it cannot reach the endpoint.
|
|
||||||
checks[0].reject(new TypeError("Failed to fetch"));
|
|
||||||
}
|
|
||||||
await Promise.all(firstTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(checks).toHaveLength(2);
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The user types a URL in Settings and taps "Save". Another write holds the
|
|
||||||
// lock. So the save waits for the lock once the check passes. The user then
|
|
||||||
// taps "Save" again on the same URL. When the lock is free, the first save
|
|
||||||
// stores nothing and shows no flash message. The second check then passes, and
|
|
||||||
// the second save stores the URL.
|
|
||||||
test.each([
|
|
||||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$button stores nothing for an earlier tap still waiting for the lock",
|
|
||||||
async ({ button, input, field }) => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const storedUrl = page.value(input);
|
|
||||||
const checks = [];
|
|
||||||
globalThis.fetch.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
checks.push(resolve);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const typedUrl = "https://typed.example.invalid";
|
|
||||||
|
|
||||||
page.node(input).value = typedUrl;
|
|
||||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
let releaseLock;
|
|
||||||
const lockHeld = navigator.locks.request(
|
|
||||||
"autistmask",
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
releaseLock = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
checks[0](PASSING_ANSWER);
|
|
||||||
await page.settle();
|
|
||||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
releaseLock();
|
|
||||||
await lockHeld;
|
|
||||||
await Promise.all(firstTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Testing endpoint...");
|
|
||||||
|
|
||||||
checks[1](PASSING_ANSWER);
|
|
||||||
await Promise.all(secondTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(checks).toHaveLength(2);
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// The user taps "Save", and the check passes. The save then begins its storage
|
|
||||||
// write, and this test keeps the write waiting. The user taps "Save" again, and
|
|
||||||
// the second check cannot reach the endpoint. When the write ends, the flash
|
|
||||||
// message still reads "Could not reach endpoint.". The write stores the URL
|
|
||||||
// anyway, because the write began before the second tap.
|
|
||||||
test.each([
|
|
||||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
|
||||||
{
|
|
||||||
button: "btn-save-blockscout",
|
|
||||||
input: "settings-blockscout",
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"$button keeps the second tap's message when tapped again during the write",
|
|
||||||
async ({ button, input, field }) => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
await page.click("btn-settings");
|
|
||||||
const checks = [];
|
|
||||||
globalThis.fetch.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
checks.push({ resolve, reject });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const typedUrl = "https://typed.example.invalid";
|
|
||||||
|
|
||||||
page.node(input).value = typedUrl;
|
|
||||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
const { set } = page.storage.local;
|
|
||||||
const write = set.getMockImplementation();
|
|
||||||
let writeBegan = false;
|
|
||||||
let releaseWrite;
|
|
||||||
const writeHeld = new Promise((resolve) => {
|
|
||||||
releaseWrite = resolve;
|
|
||||||
});
|
|
||||||
set.mockImplementationOnce(async (items) => {
|
|
||||||
writeBegan = true;
|
|
||||||
await writeHeld;
|
|
||||||
await write(items);
|
|
||||||
});
|
|
||||||
checks[0].resolve(PASSING_ANSWER);
|
|
||||||
await page.settle();
|
|
||||||
expect(writeBegan).toBe(true);
|
|
||||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
|
||||||
await page.settle();
|
|
||||||
// fetch rejects when it cannot reach the endpoint.
|
|
||||||
checks[1].reject(new TypeError("Failed to fetch"));
|
|
||||||
await Promise.all(secondTap);
|
|
||||||
await page.settle();
|
|
||||||
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
|
|
||||||
releaseWrite();
|
|
||||||
await Promise.all(firstTap);
|
|
||||||
await page.settle();
|
|
||||||
|
|
||||||
expect(checks).toHaveLength(2);
|
|
||||||
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
|
|
||||||
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -177,78 +177,6 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
|
|||||||
expect(onSepolia).not.toContain("DAI");
|
expect(onSepolia).not.toContain("DAI");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
|
|
||||||
// save. The switch's save writes only the token lists that this page changed.
|
|
||||||
// So DAI stays in `trackedTokens`, the active network's list.
|
|
||||||
test("a token another page tracked before a switch in Settings stays in trackedTokens", 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([MAINNET_TOKEN]);
|
|
||||||
expect(stored.networkTrackedTokens.mainnet).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", () => {
|
describe("Back after a switch from the Settings gear", () => {
|
||||||
test("SettingsAddToken no longer offers the common tokens", async () => {
|
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||||
const boot = await bootOnAddToken("mainnet");
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
+7
-13
@@ -4380,9 +4380,10 @@ describe("uniswap decoder", () => {
|
|||||||
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
||||||
});
|
});
|
||||||
|
|
||||||
// A zero balance check sets neither Token Out nor the minimum. The route
|
// With no minimum stated before it, a zero balance check is what sets the
|
||||||
// pays only the WRAP_ETH's WETH, to the router, where it stays.
|
// output side, and it guarantees nothing. No step pays anyone, so the
|
||||||
test("a zero balance check after a WRAP_ETH into the router names no output token and no minimum", () => {
|
// minimum is not said to be received.
|
||||||
|
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]),
|
||||||
[
|
[
|
||||||
@@ -4395,16 +4396,9 @@ describe("uniswap decoder", () => {
|
|||||||
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
expect(detail(result, "Token Out").value).toContain("USDT");
|
||||||
"Unknown (not named in the calldata)",
|
expect(detail(result, "Min. paid out").value).toBe(
|
||||||
);
|
"None (no minimum guaranteed)",
|
||||||
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,62 +688,6 @@ 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.
|
// 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", () => {
|
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(
|
const result = decode(
|
||||||
@@ -2876,16 +2820,17 @@ describe("the allow-revert flag", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, names no output token and no minimum", () => {
|
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
|
||||||
const check = balanceCheck(WETH, ONE_WETH);
|
const check = balanceCheck(WETH, ONE_WETH);
|
||||||
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
|
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
|
||||||
"1.0000 WETH",
|
"1.0000 WETH",
|
||||||
);
|
);
|
||||||
|
|
||||||
const withFlag = decode(execute(flagged(check)));
|
const withFlag = decode(execute(flagged(check)));
|
||||||
expect(detail(withFlag, "Token Out").value).toBe(UNNAMED);
|
expect(detail(withFlag, "Token Out").address).toBe(WETH);
|
||||||
expect(detail(withFlag, "Min. paid out")).toBeUndefined();
|
expect(detail(withFlag, "Min. paid out").value).toBe(
|
||||||
expect(detail(withFlag, "Min. received")).toBeUndefined();
|
"None (no minimum guaranteed)",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
||||||
|
|||||||
Reference in New Issue
Block a user