Author SHA1 Message Date
sneak d6aa3794c2 fix: each browser-suite run builds and removes an image of its own (closes #547)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
script/test-e2e and script/test-e2e-firefox tagged their image with one
fixed name. When two clones ran a suite at once, the second build moved
that tag, the image store deleted the first run's now-untagged image, and
that suite exited 125 without running a test. Each run now tags its image
with the suite's name and twelve random hex digits and runs it by that
tag. The cleanup that runs on exit, Ctrl-C, kill and a closed terminal
removes the run's container if docker's client left it running, then the
image. The image-ID file that guarded against a moving tag is gone.
README.md's End-to-End Tests section says which endings clean up.

Model: opus-5-5
2026-10-14 14:52:56 +00:00
29 changed files with 147 additions and 3510 deletions
+1 -15
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons enrich-tokens apply-enrichment clean dev
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons clean dev
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -111,20 +111,6 @@ vendor-blocklist:
icons:
@node script/lib/icons.js
# Fetch a name and a website for every token in src/shared/tokenList.js into
# script/lib/token-enrichment.json. Run deliberately, land the diff: the names
# come from live data, so a later run can return different ones.
enrich-tokens:
@node script/lib/enrich-tokens.js
# Write the names and websites in script/lib/token-enrichment.json into
# src/shared/tokenList.js. Commit the result with the data file:
# tests/applyEnrichment.test.js fails while the list differs from what this
# writes, or while an address in the data or in the script's table of websites
# matches no entry in the list.
apply-enrichment:
@node script/lib/apply-enrichment.js
clean:
@rm -rf dist/ release/
+26 -65
View File
@@ -250,13 +250,11 @@ provide:
nowhere in the working tree or under `dist/` outside its documented
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
on-chain name in `src/shared/tokenList.js` and in
`script/lib/token-enrichment.json`, the fetched data that file's names are
written from. Each is scoped to the paths named and fails anywhere else. Run
by the `lint` phase, so part of `make check`; it inspects `dist/` when there
is one and says loudly when there is not, and the build context of that phase
never has one. `make build` re-runs it with `--require-dist`, so a build
artifact is always covered
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
fails anywhere else. Run by the `lint` phase, so part of `make check`; it
inspects `dist/` when there is one and says loudly when there is not, and the
build context of that phase never has one. `make build` re-runs it with
`--require-dist`, so a build artifact is always covered
- `script/package` — produce the release artifacts: `make build` first, so the
archives can only ever be made from a `dist/` that has been verified against
that build's own receipt as a RELEASE build, then one self-contained,
@@ -328,19 +326,6 @@ The Makefile shims to those. It also carries a few targets that have no
that already holds the drawn image, the same IHDR and every pixel, is left
untouched. Commit the files with the drawing: `make check` fails while their
image differs from what it draws
- `make enrich-tokens` — fetch the name of each token in
`src/shared/tokenList.js` from CoinGecko's coin list and its website from the
`ethereum-lists/tokens` repository, at a pinned commit, into
`script/lib/token-enrichment.json`, by running `script/lib/enrich-tokens.js`.
CoinGecko's list is live and has no fixed version, so a later run can return
different names
- `make apply-enrichment` — write the names and URLs in
`script/lib/token-enrichment.json` into `src/shared/tokenList.js`, by running
`script/lib/apply-enrichment.js`, taking a URL from the table of project
websites in that script only where the fetched data has none. Commit the list
with the data file: `make check` fails while the list differs from what this
writes. Neither target changes which tokens are on the list, and no build runs
either
- `make clean` — remove `dist/` and `release/`
- `make dev` — run the build `make build` runs, without the checks that follow
it, then run it again after every change to a file under `src/`, `manifest/`
@@ -362,6 +347,14 @@ are outside `make check`. Neither opens the popup from the toolbar button: both
load its page in an ordinary tab, so what the toolbar popup itself adds, its
size and its closing when it loses focus, is covered by neither.
Each run builds its image under a tag of its own, `autistmask-e2e-chrome:` or
`autistmask-e2e-firefox:` followed by twelve random hex digits, so two clones
can run the same suite at once without one build taking the other's image away.
The run removes that image, and its container if one is still running, after
the suite passes or fails, on Ctrl-C, on `kill`, and when the terminal or SSH
session it runs in closes. A run killed outright (`kill -9`) cannot clean up:
its image stays under its tag until removed by hand with `docker image rm`.
### Chrome (`make test-e2e`)
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -1125,18 +1118,14 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does,
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
`Token Out` and `Min. received` keep the output side's own token and figure,
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys
USDC and then unwraps the WETH it did not spend shows USDC. The token permission
warning on the signature screen has its own amount wording, including `Unknown`;
the SignApproval section below describes it.
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1211,16 +1200,7 @@ The core hierarchy is **Wallets → Addresses**:
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
wallet list, and blocked from signing, sending and private-key export. It
is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens. In an HD or xprv wallet each
address record also stores `index`, the derivation index it was made at: 0 for
the first address, the index the import scan found it at, or `nextIndex` when
"+" added it. An address's `index` and its position in the wallet's list
differ once the scan has passed over an unused index or an address has been
removed. Sending, site transactions, site signatures and private-key export
derive the key at `index`, and refuse it unless it is that address's own key;
an HD or xprv address with no valid `index` is refused before anything is
derived (`getSignerForAddress()` in `src/shared/wallet.js`). Each refusal is a
full sentence naming the address.
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
@@ -1546,9 +1526,6 @@ view would leave a wallet one click from deletion.
change)
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change)
- "Reveal" (correct password, but this address's key is refused, see Data
Model) → full-sentence error saying why, nothing revealed (no screen
change)
- "Back" → previous screen (AddressDetail)
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
drops the address the screen was showing, so it also takes the screen off
@@ -1684,13 +1661,6 @@ view would leave a wallet one click from deletion.
be signed for another
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send**
#### WaitTx (`wait-tx`)
@@ -2119,10 +2089,8 @@ view would leave a wallet one click from deletion.
artifact can be compared with it field for field. A nonce the site supplies is
ignored: the nonce is always the account's next nonce from the node, so a site
cannot replace one of the user's pending transactions or leave this one stuck
behind a gap. A gas limit the site supplies, as `gas` or `gasLimit`, is the
one shown and signed in place of the node's estimate, but the node still
estimates the transaction with that limit, as the check that it neither
reverts nor runs out of gas; a request that gives both with different values
behind a gap. A gas limit the site supplies, as `gas` or `gasLimit`, is used
in place of the node's estimate; a request giving both with different values
is refused. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
transaction approval exists at a time: populating fixes the nonce, so a second
@@ -2158,9 +2126,6 @@ view would leave a wallet one click from deletion.
shown, exactly as shown, hands the signed transaction to the background to
broadcast, then → **WaitTx** in the same popup window
- "Confirm" (wrong password) → error line, no screen change
- "Confirm" (correct password, but the From address's key is refused, see
Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change)
- "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
@@ -2751,11 +2716,7 @@ Currently supported:
or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap
action is read only in the parameter shape deployed on mainnet and Sepolia,
not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36`
field. An action in any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.
### Non-Goals Forever
@@ -2839,7 +2800,7 @@ exact URL, the commit it is pinned to and the sha256 of the bytes that commit
serves, because a source reference nobody can verify is not a source reference.
`script/check-censored` reads the name back out of that one file and fails the
build wherever else it appears, save for three shipped-code literals it cannot
avoid — each permitted only at the paths that carry it, and listed in that
avoid — each permitted only at the one path that carries it, and listed in that
script's header.
## Author
+2 -4
View File
@@ -18,8 +18,6 @@
# (src/shared/balances.js), and this contract's on-chain name is that
# string, so censoring the repo cannot stop the wallet displaying it.
# Dropping the entry instead would cost the user MUSD spoof detection.
# script/lib/token-enrichment.json, the fetched data tokenList.js's names
# are written from, carries the same name for the same reason.
#
# Everything else fails, in the working tree and under dist/. The last two are
# literals rather than whole files, so they are enforced by counting, and each
@@ -104,8 +102,8 @@ allowed_literals_for() {
src/content/inpage.js | dist/*/src/content/inpage.js)
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
src/shared/tokenList.js | script/lib/token-enrichment.json | \
dist/*/src/background/index.js | dist/*/src/popup/index.js)
src/shared/tokenList.js | dist/*/src/background/index.js | \
dist/*/src/popup/index.js)
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
esac
-159
View File
@@ -1,159 +0,0 @@
// Writes the names and URLs in script/lib/token-enrichment.json, which
// script/lib/enrich-tokens.js fetches, into the matching entries of
// src/shared/tokenList.js: the name and url an entry already has are replaced,
// and an entry without them gets them after its decimals. Where the fetched
// data has no URL for a token, EXTRA_URLS below supplies one. An entry the data
// does not mention is left as it is. An address in the data or in EXTRA_URLS
// that matches no entry in tokenList.js, capitals included, is an error naming
// that address. Run by
// `make apply-enrichment`; no build runs it. tests/applyEnrichment.test.js
// checks that the committed tokenList.js is exactly what this writes from the
// committed data.
"use strict";
const fs = require("fs");
const path = require("path");
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
// Project websites by token address, each spelled as tokenList.js spells it. A
// token gets its URL here only where the fetched data has none, so an address
// the data has a URL for is never used.
const EXTRA_URLS = {
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "https://weth.io",
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "https://tether.to",
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": "https://www.bnbchain.org",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "https://www.circle.com/usdc",
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": "https://sky.money",
"0x2AF5D2aD76741191D15Dfe7bF6aC92d4Bd912Ca3": "https://www.bitfinex.com",
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "https://chain.link",
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "https://ethena.fi",
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d":
"https://www.worldlibertyfinancial.com",
"0x6B175474E89094C44Da98b954EedeAC495271d0F": "https://makerdao.com",
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8":
"https://www.paypal.com/us/digital-wallet/manage-money/crypto/pyusd",
"0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE": "https://shibatoken.com",
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6":
"https://www.worldlibertyfinancial.com",
"0xA0b73E1Ff0B80914AB6fe0444E65848C4C34450b": "https://crypto.com",
"0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1": "https://ton.org",
"0x68749665FF8D2d112Fa859AA293F07A622782F38": "https://gold.tether.to",
"0x196C20DA81Fbc324EcdF55501e95Ce9f0bD84d14": "https://polkadot.network",
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": "https://uniswap.org",
"0x45804880De22913dAFE09f4980848ECE6EcbAf78":
"https://www.paxos.com/paxgold",
"0x3c3a81e81dc49A522A592e7622A7E711c06bf354": "https://www.mantle.xyz",
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": "https://aave.com",
"0x75231F58b43240C9718Dd58B4967c5114342a86c": "https://www.okx.com",
"0x6982508145454Ce325dDbE47a25d4ec3d2311933": "https://www.pepe.vip",
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
"0x0F5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
"https://basicattentiontoken.org",
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
"0x5283D291DBCF85356A21bA090E6db59121208b44": "https://blur.io",
"0x43Dfc4159D86F3A37A5A4B3D4580b888ad7d4DDd": "https://dydx.exchange",
"0xcf0C122c6b73ff809C693DB761e7BaeBe62b6a2E": "https://floki.com",
"0xe53EC727dbDEB9E2d5456c3be40cFF031AB40A55": "https://superfarm.com",
"0x32353A6C91143bfd6C7d363B546e62a9A2489A20": "https://www.agora.finance",
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": "https://www.euro-coin.com",
"0xA35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
"0x1151CB3d861920e07a38e03eEAd12C32178567F6": "https://bonk.com",
"0x090185f2135308BaD17527004364eBcC2D37e5F6": "https://spell.club",
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": "https://stargate.finance",
};
// Returns tokenList, the text of tokenList.js, with the name and url of each
// token in enrichment, the parsed data file, written into its entry.
function applyEnrichment(tokenList, enrichment) {
for (const addr of Object.keys(EXTRA_URLS)) {
if (!tokenList.includes(`address: "${addr}"`)) {
throw new Error(
`No entry in src/shared/tokenList.js has the address ${addr} from EXTRA_URLS.`,
);
}
}
let result = tokenList;
for (const [addr, data] of Object.entries(enrichment)) {
// Escape special regex characters in the address
const escaped = addr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match the token block: { address: "...", symbol: "...", decimals: N, }
// and the name: "...", url: "..." after decimals when it has them.
// The closing }, may have trailing whitespace
const blockRe = new RegExp(
`(\\{\\s*address:\\s*"${escaped}",\\s*symbol:\\s*"[^"]+",\\s*decimals:\\s*\\d+)` +
`(?:,\\s*name:\\s*"(?:[^"\\\\]|\\\\.)*",\\s*url:\\s*"(?:[^"\\\\]|\\\\.)*")?` +
`,?(\\s*\\})`,
);
const match = result.match(blockRe);
if (!match) {
throw new Error(
`No entry in src/shared/tokenList.js matches the address ${addr} from script/lib/token-enrichment.json.`,
);
}
// A URL from EXTRA_URLS only where the fetched data has none.
const url = data.url || EXTRA_URLS[addr] || "";
// Build replacement: original fields + name + url
const nameEscaped = data.name
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
const urlEscaped = url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const replacement =
match[1] +
`,\n name: "${nameEscaped}",` +
`\n url: "${urlEscaped}",` +
match[2];
// A function, so a "$" in a name is not read as a replacement pattern.
result = result.replace(blockRe, () => replacement);
}
return result;
}
if (require.main === module) {
const enrichment = JSON.parse(
fs.readFileSync(path.join(__dirname, "token-enrichment.json"), "utf8"),
);
const original = fs.readFileSync(TOKEN_LIST, "utf8");
fs.writeFileSync(TOKEN_LIST, applyEnrichment(original, enrichment));
}
module.exports = { applyEnrichment };
-115
View File
@@ -1,115 +0,0 @@
// Fetches a name and a website for every token in src/shared/tokenList.js and
// writes them, keyed by address, to script/lib/token-enrichment.json, which
// script/lib/apply-enrichment.js then writes into tokenList.js. Run by
// `make enrich-tokens`; no build runs it and nothing here is shipped.
//
// Names come from CoinGecko's coin list, fetched whole in one request, matched
// on the token's Ethereum contract address. A token CoinGecko does not list
// gets its symbol as its name. The list is live data with no fixed version, so
// a later run can return different names.
//
// Websites come from the ethereum-lists/tokens repository, one file per token,
// read at a pinned commit. Most tokens have no file there and get an empty URL.
// A file that is missing is not an error; any other failed request stops the
// run, so a network fault cannot quietly empty a URL.
"use strict";
const fs = require("fs");
const path = require("path");
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
const OUTPUT = path.join(__dirname, "token-enrichment.json");
const COINGECKO_LIST =
"https://api.coingecko.com/api/v3/coins/list?include_platform=true";
// ethereum-lists/tokens at its master of 2026-02-27, when the committed
// token-enrichment.json was made.
const ETHEREUM_LISTS =
"https://raw.githubusercontent.com/ethereum-lists/tokens/c3560d499a380a38e94caa7c4968d59aeffde59a/tokens/eth";
const CONCURRENT = 10;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const src = fs.readFileSync(TOKEN_LIST, "utf8");
const tokens = [];
const re =
/\{\s*address:\s*"(0x[a-fA-F0-9]{40})",\s*symbol:\s*"([^"]+)",\s*decimals:\s*(\d+)/g;
let m;
while ((m = re.exec(src)) !== null) {
tokens.push({ address: m[1], symbol: m[2], decimals: parseInt(m[3]) });
}
console.error(`Parsed ${tokens.length} tokens`);
// --- Names from CoinGecko ---
console.error("Fetching CoinGecko coins list...");
const listResp = await fetch(COINGECKO_LIST);
if (!listResp.ok) throw new Error(`CoinGecko list HTTP ${listResp.status}`);
const coins = await listResp.json();
const addrMap = new Map();
for (const coin of coins) {
const eth = (coin.platforms?.ethereum || "").toLowerCase().trim();
if (eth && eth.startsWith("0x")) {
addrMap.set(eth, { name: coin.name, id: coin.id });
}
}
console.error(`CoinGecko map: ${addrMap.size} Ethereum tokens`);
const enriched = new Map();
let namesMatched = 0;
for (const t of tokens) {
const info = addrMap.get(t.address.toLowerCase());
if (info) namesMatched++;
enriched.set(t.address, { name: info?.name || t.symbol, url: "" });
}
console.error(`Matched ${namesMatched}/${tokens.length} names`);
// --- Websites from ethereum-lists ---
console.error("Fetching URLs from ethereum-lists GitHub...");
let urlsFound = 0;
for (let i = 0; i < tokens.length; i += CONCURRENT) {
const batch = tokens.slice(i, i + CONCURRENT);
await Promise.all(
batch.map(async (t) => {
const r = await fetch(`${ETHEREUM_LISTS}/${t.address}.json`);
if (r.status === 404) return;
if (!r.ok) {
throw new Error(
`ethereum-lists HTTP ${r.status} for ${t.address}`,
);
}
const data = await r.json();
const website = (data.website || "").trim();
if (website && website !== "https://") {
enriched.get(t.address).url = website;
urlsFound++;
}
}),
);
if (i % 100 === 0) {
console.error(
` GitHub: ${Math.min(i + CONCURRENT, tokens.length)}/${tokens.length}, ${urlsFound} URLs`,
);
}
await sleep(200);
}
console.error(`Found ${urlsFound} URLs in ethereum-lists`);
// --- Output, in the layout prettier gives it ---
const output = {};
for (const [addr, e] of enriched) {
output[addr] = { name: e.name, url: e.url };
}
fs.writeFileSync(OUTPUT, JSON.stringify(output, null, 4) + "\n");
console.error(`Done! ${tokens.length} names, ${urlsFound} URLs.`);
}
main().catch((err) => {
console.error(`enrich-tokens: ${err.message}`);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+28 -16
View File
@@ -22,13 +22,29 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IIDFILE=""
# Each run builds, runs and then removes an image under a tag of its own.
# Under one tag shared by every run, a second clone's build moves the tag
# off this run's image, and the image store can then delete that untagged
# image before or while this run uses it. The tag is random rather than
# $$ because CI jobs share one docker daemon, but each job's container
# numbers its processes from scratch.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IMAGE="$NAME:$SUFFIX"
# Both docker runs below use this container name: docker run --rm returns
# only once its container is removed, so the second never meets the first.
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# container has stopped, and a container still running keeps the image
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi
}
@@ -40,22 +56,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# HUP: the terminal or SSH session running the suite closed.
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the Chrome e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash.
# HOME=/tmp: the image's root home is not a reliable place for the
@@ -75,11 +85,12 @@ main() {
# probe. The image is pinned by digest, so this can only ever bite
# on a deliberate bump.
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/run.js
# Where chrome.storage.local lives, and what moves it: two unpacked loads
@@ -93,9 +104,10 @@ main() {
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
echo "Running the extension-id and storage-partition observations..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/storagePartition.js
}
+22 -16
View File
@@ -21,13 +21,23 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
# A tag and a container name of this run's own, as in script/test-e2e,
# which says why.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IMAGE="$NAME:$SUFFIX"
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# container has stopped, and a container still running keeps the image
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi
}
@@ -39,22 +49,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# HUP: the terminal or SSH session running the suite closed.
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the pinned Firefox e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
@@ -69,10 +73,11 @@ main() {
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it.
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI
@@ -82,10 +87,11 @@ main() {
# and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/reinstall.js
}
+4 -1
View File
@@ -1716,7 +1716,10 @@
<div class="text-xs text-muted mb-1">
Address that will be shared:
</div>
<div id="approve-address" class="text-xs mb-2"></div>
<div
id="approve-address"
class="text-xs flex items-center mb-2"
></div>
</div>
<div class="mb-3">
<label
+2 -14
View File
@@ -184,12 +184,7 @@ async function importMnemonic(ctx) {
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
@@ -210,7 +205,6 @@ async function importMnemonic(ctx) {
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
tokenBalances: [],
}));
@@ -305,12 +299,7 @@ async function importXprvKey(ctx) {
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
@@ -331,7 +320,6 @@ async function importXprvKey(ctx) {
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
tokenBalances: [],
}));
+1 -1
View File
@@ -266,7 +266,7 @@ function init(_ctx) {
// This address's private key can be derived from the stored key,
// but export goes through getSignerForAddress, which refuses a key
// that is not a master key. Without this the export screen would
// take a password and then refuse.
// take a password and then report that refusal as a wrong password.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
+7 -7
View File
@@ -913,15 +913,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
return true;
}
// Locate the wallet and the stored address record owning an address. Returns
// null when no wallet holds it. Approvals look up the address they were raised
// for, not whichever address is active now: the approval named one account,
// and signing with another is what verification refuses.
// Locate the wallet and the address index owning an address. Returns null when
// no wallet holds it. Approvals look up the address they were raised for, not
// whichever address is active now: the approval named one account, and signing
// with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) {
return { wallet, addr: wallet.addresses[i] };
return { wallet, addrIndex: i };
}
}
}
@@ -1048,7 +1048,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
active.wallet,
active.addr,
active.addrIndex,
decryptedSecret,
);
// Sign the approved transaction exactly as it was displayed. The
@@ -1176,7 +1176,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
active.wallet,
active.addr,
active.addrIndex,
decryptedSecret,
);
const sp = pendingSignParams;
+5 -14
View File
@@ -567,19 +567,6 @@ function init(_ctx) {
return;
}
// Sign as the From address this screen shows. The selection is saved
// apart from this screen, so the popup can reopen onto it with another
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
return;
}
const password = $("confirm-tx-password").value;
if (!password) {
showError(
@@ -610,7 +597,11 @@ function init(_ctx) {
let tx;
try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
+1 -12
View File
@@ -135,18 +135,7 @@ async function reveal() {
// decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written.
if (!isCurrentReveal(generation)) return;
let signer;
try {
signer = getSignerForAddress(
wallet,
wallet.addresses[addressIndex],
secret,
);
} catch (e) {
// The password was right; it is the key that was refused.
showError("export-privkey-password-error", e.message);
return;
}
const signer = getSignerForAddress(wallet, addressIndex, secret);
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
-1
View File
@@ -354,7 +354,6 @@ function render(ctx) {
);
wallet.addresses.push({
address: newAddr,
index: wallet.nextIndex,
balance: "0.0000",
tokenBalances: [],
});
+3 -14
View File
@@ -201,7 +201,6 @@ async function prepareApprovalTx(provider, from, txParams) {
throw fail("There is no active address to send this transaction from.");
}
const request = requestFrom(txParams || {}, from);
const signer = new VoidSigner(getAddress(from), provider);
let populated;
try {
@@ -209,20 +208,10 @@ async function prepareApprovalTx(provider, from, txParams) {
// nonce, gas, fee and chain id are populated exactly as they were when
// the popup did this. VoidSigner cannot sign, which is the point: the
// background prepares, the popup signs.
//
// ethers asks the node for a gas estimate only when the request has
// no gas limit, and that estimate is the only check that the
// transaction does not revert. So a request carrying the site's gas
// limit is estimated first, with that limit: one that reverts, or
// needs more gas than the site gave it, fails here as well. The
// site's gas limit is still the one populated.
populated = await withTimeout(
(async () => {
if (present(request.gasLimit)) {
await signer.estimateGas(request);
}
return signer.populateTransaction(request);
})(),
new VoidSigner(getAddress(from), provider).populateTransaction(
request,
),
POPULATE_TIMEOUT_MS,
"The transaction could not be prepared: the network did not answer in time.",
);
+2 -2
View File
@@ -576,7 +576,7 @@ const TOKENS = [
symbol: "FLOKI",
decimals: 9,
name: "FLOKI",
url: "https://floki.com",
url: "",
},
{
address: "0x7420B4b9a0110cdC71fB720908340C03F9Bc03EC",
@@ -1290,7 +1290,7 @@ const TOKENS = [
symbol: "TURBO",
decimals: 18,
name: "Turbo",
url: "https://www.turbo-eth.com",
url: "",
},
{
address: "0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2",
+18 -69
View File
@@ -53,11 +53,6 @@ function formatAmount(raw, decimals) {
// on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see
// decodeExactly()). That action names both of its currencies, so saying the
// calldata did not name one would be false.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -300,24 +295,6 @@ function v4ExactInAmount(raw) {
return raw === 0n ? OPEN_DELTA : raw;
}
// The V4 swap actions are read only in the parameter structs V4Router has
// deployed on mainnet and Sepolia. v4-periphery `main` has since added a
// `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and
// reading a shape nobody has deployed would be guessing. ethers alone would
// read the newer structs anyway: it takes whatever word sits where a
// deployed field would be, ignores bytes left over, and holds back an error
// in a field until that field is used. The newer multi-hop exact-in struct
// then yields the offset of its hop-price array as `amountIn`. So a decode
// counts only when encoding the result again gives back the same bytes;
// otherwise this throws, and decodeV4Swap() marks the step as not decoded.
function decodeExactly(types, data) {
const decoded = coder.decode(types, data);
if (coder.encode(types, decoded) !== data) {
throw new Error("Not in the deployed V4 parameter shape.");
}
return decoded;
}
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
@@ -332,10 +309,6 @@ function decodeV4Swap(input) {
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
// Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE
// and TAKE may still name both currencies.
let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) {
const actionId = actions[i];
@@ -364,7 +337,7 @@ function decodeV4Swap(input) {
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = decodeExactly(
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
@@ -382,13 +355,13 @@ function decodeV4Swap(input) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
// Fall through — SETTLE/TAKE will provide tokens
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = decodeExactly(
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
@@ -411,7 +384,7 @@ function decodeV4Swap(input) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
// Fall through
}
}
} else if (
@@ -420,7 +393,7 @@ function decodeV4Swap(input) {
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = decodeExactly(
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
@@ -432,11 +405,11 @@ function decodeV4Swap(input) {
settleToken = path[path.length - 1][0];
}
} catch {
swapUndecoded = true;
// Fall through
}
} else {
try {
const s = decodeExactly(
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
@@ -453,7 +426,7 @@ function decodeV4Swap(input) {
? poolKey[1]
: poolKey[0];
} catch {
swapUndecoded = true;
// Fall through
}
}
}
@@ -467,7 +440,6 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn,
amountOutMin,
swapUndecoded,
};
} catch {
return null;
@@ -493,7 +465,6 @@ function decode(data, toAddress, sources) {
let minOutput = null;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
@@ -596,20 +567,7 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies
// and amounts, so the step is marked and still sets
// both sides as a swap step does. A side its SETTLE or
// TAKE did not name is set undetermined, not left to
// an earlier or later step's token and figure.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
} else if (v4) {
if (v4) {
setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no
@@ -633,19 +591,16 @@ function decode(data, toAddress, sources) {
}
}
// Resolve token info. A null token on either side means no step named
// a currency for it, or a V4 step whose swap action went unread left
// it undetermined; tokenInfo() refuses rather than calling it ETH.
// UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// output side is WETH, or when no step set the output side. A step
// whose swap action went unread did set it, to undetermined. Any other
// output keeps its own token and figure: a swap that buys USDC and
// then unwraps the WETH it did not spend receives USDC.
// Resolve token info. A null token on either side means the calldata
// named no currency for it; tokenInfo() refuses rather than calling it
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side.
// Any other output keeps its own token and figure: a swap that buys
// USDC and then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset =
!present(outputToken) && !present(minOutput) && !swapUndecoded;
const outputUnset = !present(outputToken) && !present(minOutput);
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
@@ -685,10 +640,7 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default. Same
// wording as the Token Out refusal below: the two sides of this
// screen must not describe the same condition in two ways.
details.push({
label: "Token In",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
}
if (present(inputAmount)) {
@@ -751,10 +703,7 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default, and a
// Min. received figure below it is never attached to a token the
// calldata did not state.
details.push({
label: "Token Out",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
}
if (present(minOutput)) {
+6 -32
View File
@@ -110,33 +110,16 @@ function addressFromPrivateKey(key) {
return w.address;
}
// The signer for `addr`, one of the wallet's stored address records. An HD or
// xprv address is derived at its own `index`, never at its position in the
// wallet's list: the two differ once an import scan passes over an unused
// index, or once an address that is not the last is removed. An HD or xprv
// record without a valid `index` is refused before anything is derived.
// Whatever the wallet type, the key is refused unless it is the key of
// `addr.address`.
function getSignerForAddress(walletData, addr, decryptedSecret) {
if (
(walletData.type === "hd" || walletData.type === "xprv") &&
!(Number.isInteger(addr.index) && addr.index >= 0)
) {
throw new Error(
"No derivation index is stored for " +
addr.address +
", so its key cannot be derived.",
);
}
let signer;
function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
if (walletData.type === "hd") {
const node = HDNodeWallet.fromPhrase(
decryptedSecret,
"",
BIP44_ETH_PATH,
);
signer = node.deriveChild(addr.index);
} else if (walletData.type === "xprv") {
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
// Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js
@@ -154,18 +137,9 @@ function getSignerForAddress(walletData, addr, decryptedSecret) {
"is not a master key, so it cannot sign.",
);
}
signer = node.derivePath(BIP44_ETH_PATH).deriveChild(addr.index);
} else {
signer = new Wallet(decryptedSecret);
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
}
if (signer.address.toLowerCase() !== addr.address.toLowerCase()) {
throw new Error(
"The key this wallet holds for " +
addr.address +
" belongs to a different address, so it was not used.",
);
}
return signer;
return new Wallet(decryptedSecret);
}
function isValidMnemonic(mnemonic) {
-490
View File
@@ -1,490 +0,0 @@
// Sending, a site's transaction and signature requests and private key export
// each use the key of the address they name: the key at the index that address
// was derived at, not the one at its position in the wallet's list. The two
// differ after an import whose scan passed over an unused index, and after an
// address that is not the last is removed
// (https://git.eeqj.de/sneak/AutistMask/issues/510). A send is signed by the
// From address its confirmation screen shows, whichever address is selected.
//
// Driven through the real import, "+" button, address removal, confirmation
// screen, transaction and signature approval screens and export screen, with
// the node, the vault and the DOM stubbed.
"use strict";
// The addresses the stub node reports a balance for, and the signed
// transactions it was handed.
const mockNode = { funded: new Set(), broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance(address) {
return mockNode.funded.has(address) ? 1n : 0n;
}
async getTransactionCount() {
return 0;
}
async estimateGas() {
return 21000n;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async getNetwork() {
return { chainId: 1n };
}
async getCode() {
return "0x";
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// No password is hashed: what the wallet stores is the phrase itself.
jest.mock("../src/shared/vault", () => ({
encryptWithPassword: async (secret) => secret,
decryptWithPassword: async (secret) => secret,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const {
HDNodeWallet,
Mnemonic,
Transaction,
getBytes,
verifyMessage,
} = require("ethers");
// Publicly known development phrase; never fund it.
const PHRASE = "test test test test test test test test test test test junk";
// PHRASE's master extended private key, as the xprv import takes it.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// "Hello", as the hex a page sends.
const MESSAGE = "0x48656c6c6f";
// The address and key at derivation index `index` of PHRASE, worked out with
// ethers directly rather than through the wallet's own code.
function derived(index) {
return HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0/" + index);
}
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
title: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(element) {
const event = { stopPropagation: () => {} };
return Promise.all((element.listeners.click || []).map((fn) => fn(event)));
}
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// ------------------------------------------------------------ harness
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { removeAddressFromState } = require("../src/shared/walletDelete");
const addWallet = require("../src/popup/views/addWallet");
const home = require("../src/popup/views/home");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
const txStatus = require("../src/popup/views/txStatus");
// What the background answers when the approval window asks which approval it
// was opened for, and every message the popup sent it.
let approvalDetails;
let sent;
// The "+" button of the wallet list's last render. Each render attaches the
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
beforeEach(() => {
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
globalThis.document = makeDocument();
globalThis.window = { close: () => {} };
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
reply(
msg.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: null,
);
},
},
};
node("wallet-list").querySelectorAll = (selector) => {
if (selector !== ".btn-add-address") return [];
plusButton = makeElement("btn-add-address");
plusButton.dataset.wallet = "0";
return [plusButton];
};
// The explorer and price lookups these screens start fail offline, and
// say so; that is not under test.
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation(() => {});
}
txStatus.showError.mockClear();
state.wallets = [];
state.rpcUrl = "https://rpc.example.invalid";
state.networkId = "mainnet";
state.viewStack = [];
state.currentView = "main";
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
confirmTx.init({});
approval.init({});
exportPrivkey.init();
});
afterEach(() => {
jest.restoreAllMocks();
});
// Import PHRASE on the add-wallet screen, with the stub node reporting a
// balance at each of `fundedIndices`, and wait for the address scan.
async function importPhrase(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-mnemonic"));
node("wallet-mnemonic").value = PHRASE;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// The same, importing XPRV on the add-wallet screen's xprv tab.
async function importXprv(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-xprv"));
node("import-xprv-key").value = XPRV;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// Press the wallet's "+" button on Home.
async function addAddress() {
home.render({
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
await click(plusButton);
}
// The key the export screen reveals for the address at `position`.
async function exportedKey(position) {
exportPrivkey.show(0, position);
node("export-privkey-password").value = PASSWORD;
await click(node("btn-export-privkey-confirm"));
return node("export-privkey-value").textContent;
}
// Confirm a send whose From line shows `from`, with the address at position
// `selected` selected; resolves to the signed transaction the node was handed,
// or undefined when none was.
async function confirmSend(from, selected) {
state.selectedWallet = 0;
state.selectedAddress = selected;
confirmTx.show({
from,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
await click(node("btn-confirm-send"));
return mockNode.broadcast.pop();
}
// Send from the address at `position`, with that address selected, as the Send
// button leaves it.
function sendFrom(position) {
return confirmSend(state.wallets[0].addresses[position].address, position);
}
// A site asks the address at `position` to sign MESSAGE; resolves to the
// response the approval window sent the background.
async function signFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
signParams: {
method: "personal_sign",
message: MESSAGE,
from: address,
},
};
await approval.show(1);
node("approve-sign-password").value = PASSWORD;
await click(node("btn-approve-sign"));
return sent.find((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE");
}
// A site asks the address at `position` to send RECIPIENT a transaction of no
// value; resolves to the response the approval window sent the background.
async function transactFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
approvedTx: {
type: 2,
from: address,
chainId: "0x1",
nonce: "0x0",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "0x0",
data: "0x",
accessList: [],
},
};
await approval.show(1);
node("approve-tx-password").value = PASSWORD;
await click(node("btn-approve-tx"));
return sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
}
describe.each([
[
"a recovery phrase import whose scan found only the first address",
async () => {
await importPhrase([0]);
},
// The address the import stores before its scan.
{ position: 0, index: 0 },
],
[
"an xprv import whose scan found only the first address",
async () => {
await importXprv([0]);
},
{ position: 0, index: 0 },
],
[
"an import whose scan passed over index 3",
async () => {
await importPhrase([0, 1, 2, 4]);
},
// Address 4 on Home, derived at index 4.
{ position: 3, index: 4 },
],
[
"an xprv import whose scan passed over index 3",
async () => {
await importXprv([0, 1, 2, 4]);
},
{ position: 3, index: 4 },
],
[
"removing the middle one of three addresses",
async () => {
await importPhrase([0]);
await addAddress();
await addAddress();
removeAddressFromState(state, 0, 1);
},
// Address 2 on Home after the removal, derived at index 2.
{ position: 1, index: 2 },
],
])("after %s", (_name, setUp, { position, index }) => {
beforeEach(setUp);
test("the wallet lists the address derived at that index there", () => {
expect(state.wallets[0].addresses[position].address).toBe(
derived(index).address,
);
});
test("export reveals that address's key", async () => {
expect(await exportedKey(position)).toBe(derived(index).privateKey);
});
test("a send is signed by that address", async () => {
const signed = await sendFrom(position);
expect(Transaction.from(signed).from).toBe(derived(index).address);
});
test("a site's signature is made by that address", async () => {
const response = await signFor(position);
expect(verifyMessage(getBytes(MESSAGE), response.signature)).toBe(
derived(index).address,
);
});
test("a site's transaction is signed by that address", async () => {
const response = await transactFor(position);
expect(Transaction.from(response.rawSignedTx).from).toBe(
derived(index).address,
);
});
});
// The selection and the confirmation screen are saved apart, so the popup can
// reopen onto the screen with another address selected than its From line
// shows.
describe("a confirmation screen whose From is not the selected address", () => {
beforeEach(async () => {
await importPhrase([0, 1]);
});
test("the send is signed by the From address shown", async () => {
const signed = await confirmSend(derived(1).address, 0);
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
});
});
describe("an address whose stored index derives a different address", () => {
function refusal() {
return (
"The key this wallet holds for " +
derived(0).address +
" belongs to a different address, so it was not used."
);
}
beforeEach(async () => {
await importPhrase([0]);
state.wallets[0].addresses[0].index = 1;
});
test("export reveals nothing and says why", async () => {
expect(await exportedKey(0)).toBe("");
expect(node("export-privkey-password-error").textContent).toBe(
refusal(),
);
});
test("nothing is sent", async () => {
expect(await sendFrom(0)).toBeUndefined();
expect(txStatus.showError).toHaveBeenCalledWith(
expect.anything(),
null,
refusal(),
);
});
test("nothing is signed", async () => {
const response = await signFor(0);
expect(response.signature).toBeUndefined();
expect(response.error).toBe(refusal());
});
});
-19
View File
@@ -1,19 +0,0 @@
// The token names and URLs in src/shared/tokenList.js are written from
// script/lib/token-enrichment.json by script/lib/apply-enrichment.js
// (`make apply-enrichment`). This fails when the committed list differs from
// what the script writes from the committed data, and when the data or the
// script's table of websites has an address that no entry in the list matches.
const fs = require("fs");
const path = require("path");
const enrichment = require("../script/lib/token-enrichment.json");
const { applyEnrichment } = require("../script/lib/apply-enrichment");
test("src/shared/tokenList.js is what make apply-enrichment writes from the committed data", () => {
const committed = fs.readFileSync(
path.join(__dirname, "..", "src/shared/tokenList.js"),
"utf8",
);
expect(applyEnrichment(committed, enrichment)).toBe(committed);
});
+1 -3
View File
@@ -136,9 +136,7 @@ async function openTxApproval(approvedTx) {
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
},
];
approval.init({});
+3 -47
View File
@@ -139,20 +139,14 @@ describe("prepareApprovalTx", () => {
});
// `gas` is the JSON-RPC name for the gas limit and the one sites send.
// The node is still asked for its estimate, with the site's gas limit,
// because the estimate is the check that the transaction does not revert.
// It estimates 21000; the site's 200000 is what is shown and what is
// signed.
// The node would estimate 21000; the site's 200000 is what is shown and
// what is signed.
test("uses the gas limit a site sends as gas", async () => {
const estimateGas = jest.fn(async () => 21000n);
const approved = await prepareApprovalTx(
providerWith({ estimateGas }),
providerWith(),
signer.address,
{ ...TX_PARAMS, gas: "0x30d40" },
);
expect(estimateGas).toHaveBeenCalledWith(
expect.objectContaining({ gasLimit: 200000n }),
);
expect(approved.gasLimit).toBe("0x30d40");
const signed = Transaction.from(
await signer.signTransaction({ ...approved }),
@@ -303,22 +297,6 @@ describe("prepareApprovalTx", () => {
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
});
test("fails back a reverting request that sends its own gas limit", async () => {
const reverting = providerWith({
estimateGas: async () => {
throw new Error("execution reverted: ERC20: transfer amount");
},
});
await expect(
prepareApprovalTx(reverting, signer.address, {
...TX_PARAMS,
gas: "0x30d40",
}),
).rejects.toThrow(
/^The transaction could not be prepared: execution reverted/,
);
});
// Without a bound, an unreachable node leaves the page's promise pending
// with nothing on screen to explain it.
test("gives up on a node that never answers", async () => {
@@ -341,28 +319,6 @@ describe("prepareApprovalTx", () => {
jest.useRealTimers();
}
});
// The estimate of a request carrying the site's gas limit is a call of its
// own, made before populating, and is bounded the same way.
test("gives up on a node that never answers the estimate of a request that sends its own gas limit", async () => {
jest.useFakeTimers();
try {
const hanging = providerWith({
estimateGas: () => new Promise(() => {}),
});
const pending = prepareApprovalTx(hanging, signer.address, {
...TX_PARAMS,
gas: "0x30d40",
});
const settled = expect(pending).rejects.toThrow(
/did not answer in time/,
);
await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
await settled;
} finally {
jest.useRealTimers();
}
});
});
describe("serializeApprovedTx", () => {
+2 -10
View File
@@ -1454,11 +1454,7 @@ describe("background population to popup signing to verification", () => {
// What the popup does with it: signs it as given, populating nothing.
async function popupSigns(approvedTx) {
const localSigner = getSignerForAddress(
walletData,
{ address: signer.address },
SIGNER_KEY,
);
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
return localSigner.signTransaction({ ...approvedTx });
}
@@ -1553,11 +1549,7 @@ describe("background population to popup signing to verification", () => {
// address the user has switched to.
test("the approved from stops the popup signing with another key", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS);
const otherSigner = getSignerForAddress(
walletData,
{ address: other.address },
OTHER_KEY,
);
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
await expect(
otherSigner.signTransaction({ ...approvedTx }),
).rejects.toThrow(/from address mismatch/);
-3
View File
@@ -3857,9 +3857,6 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
shown.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown),
);
// The text above is there even when the row is too narrow to show it
// all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map
+3 -269
View File
@@ -163,122 +163,6 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
const POOL_KEY = "tuple(address,address,uint24,int24,address)";
const PATH_KEY = "tuple(address,uint24,int24,address,bytes)";
const WETH_USDC_POOL = [WETH_ADDR, USDC_ADDR, 100, 1, ZERO_ADDR];
const ONE_WETH = 1000000000000000000n;
const V4_SWAP_ACTION_SHAPES = [
{
action: "SWAP_EXACT_IN_SINGLE",
id: 0x06,
deployed: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]],
),
newer: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]],
),
},
{
action: "SWAP_EXACT_IN",
id: 0x07,
deployed: coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
WETH_ADDR,
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
ONE_WETH,
990000n,
],
],
),
newer: coder.encode(
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
[
[
WETH_ADDR,
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
[],
ONE_WETH,
990000n,
],
],
),
},
{
action: "SWAP_EXACT_OUT_SINGLE",
id: 0x08,
deployed: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]],
),
newer: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, 0n, "0x"]],
),
},
{
action: "SWAP_EXACT_OUT",
id: 0x09,
deployed: coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
990000n,
ONE_WETH,
],
],
),
newer: coder.encode(
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
[
[
USDC_ADDR,
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
[],
990000n,
ONE_WETH,
],
],
),
},
];
// Helper: execute() with one V4_SWAP step holding the swap action, then
// SETTLE of WETH and TAKE of USDC.
function v4SwapCalldata(actionId, swapParams) {
return buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([actionId, V4_SETTLE_ID, V4_TAKE_ID]), [
swapParams,
encodeV4Settle(WETH_ADDR),
encodeV4Take(USDC_ADDR),
]),
],
9999999999n,
);
}
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) {
return coder.encode(["address", "uint256"], [currency, maxAmount]);
}
function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
@@ -319,157 +203,9 @@ describe("uniswap decoder", () => {
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
// Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded.
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe("Permit2 Permit → V4 Swap");
});
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the deployed shape is read",
({ id, deployed }) => {
const result = uniswap.decode(
v4SwapCalldata(id, deployed),
ROUTER_ADDR,
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/358 — before it, all four
// newer structs decoded as the deployed ones without an error, and the
// newer SWAP_EXACT_IN showed the offset of its hop-price array as Amount
// ("0.0000000000000004 WETH") and its amountIn as Min. received.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer periphery shape is shown as not decoded",
({ id, newer }) => {
const result = uniswap.decode(
v4SwapCalldata(id, newer),
ROUTER_ADDR,
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
// SETTLE and TAKE keep their own shape and still name both sides.
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([id, V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID]),
[
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
encodeV4TakeAll(USDC_ADDR, 990000n),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
},
);
// The user receives the V4 step's USDC, which only its unread swap
// action names. The V3 step's WETH and its Min. received figure were
// shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Amount").value).toBe("2.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV3SwapExactIn(
USER_ADDR,
990000n,
980000000000000000n,
[USDC_ADDR, DAI_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("0.9800 DAI");
},
);
// UNWRAP_WETH makes Token Out ETH when no step set the output side. An
// unread swap action's step did set it, to undetermined.
test("an UNWRAP_WETH after an unread V4 swap action does not make Token Out ETH", () => {
const { id, newer } = V4_SWAP_ACTION_SHAPES[0];
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap");
});
test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
@@ -812,11 +548,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// The V4 swap action's parameters are empty, so that step is said to
// be undecoded.
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe(
"Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH",
"Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH",
);
});
+1 -5
View File
@@ -97,11 +97,7 @@ describe("an execute() whose input currency never decoded", () => {
]);
test("says the input token is unknown instead of naming ETH", () => {
// The swap action that would name it could not be read, so the line
// says that rather than REFUSAL's "not named in the calldata".
expect(detail(data(), "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(data(), "Token In").value).toBe(REFUSAL);
});
test("keeps a Token In line, so the screen never omits what is paid", () => {
+5 -49
View File
@@ -416,7 +416,7 @@ describe("extended key depth", () => {
expect(() =>
wallet.getSignerForAddress(
{ type: "xprv" },
{ address: VECTOR_ADDRESSES[0], index: 0 },
0,
accountXprv(VECTOR_PHRASE),
),
).toThrow(/master/i);
@@ -504,7 +504,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
{ address: VECTOR_ADDRESSES[index], index },
index,
VECTOR_PHRASE,
);
@@ -515,7 +515,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
{ address: VECTOR_ADDRESSES[index], index },
index,
masterXprv(VECTOR_PHRASE),
);
@@ -527,7 +527,7 @@ describe("getSignerForAddress", () => {
for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress(
{ type: "privkey" },
{ address: VECTOR_ADDRESSES[1], index },
index,
VECTOR_PRIVATE_KEYS[1],
);
@@ -535,54 +535,10 @@ describe("getSignerForAddress", () => {
}
});
// https://git.eeqj.de/sneak/AutistMask/issues/510: an address whose stored
// index does not derive it, or a private key that is not its key.
test.each([
["hd", { address: VECTOR_ADDRESSES[2], index: 1 }, VECTOR_PHRASE],
[
"xprv",
{ address: VECTOR_ADDRESSES[2], index: 1 },
masterXprv(VECTOR_PHRASE),
],
["privkey", { address: VECTOR_ADDRESSES[2] }, VECTOR_PRIVATE_KEYS[1]],
])(
"%s wallet refuses a key that is not the address's own",
(type, addr, secret) => {
expect(() =>
wallet.getSignerForAddress({ type }, addr, secret),
).toThrow(
"The key this wallet holds for " +
VECTOR_ADDRESSES[2] +
" belongs to a different address, so it was not used.",
);
},
);
// An HD or xprv address record without a usable `index` is refused in a
// sentence, not with what ethers says about the value it was handed.
test.each([
["hd", VECTOR_PHRASE],
["xprv", masterXprv(VECTOR_PHRASE)],
])("%s wallet refuses an address with no valid index", (type, secret) => {
for (const index of [undefined, null, -1, 1.5, "1"]) {
expect(() =>
wallet.getSignerForAddress(
{ type },
{ address: VECTOR_ADDRESSES[1], index },
secret,
),
).toThrow(
"No derivation index is stored for " +
VECTOR_ADDRESSES[1] +
", so its key cannot be derived.",
);
}
});
test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
{ address: VECTOR_ADDRESSES[1], index: 1 },
1,
VECTOR_PHRASE,
);
const message = "AutistMask derivation test";
+4 -8
View File
@@ -55,7 +55,7 @@ function healthyXprvWallet(name = "Master") {
xpub,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, index: 0, balance: "0.0000" }],
addresses: [{ address: firstAddress, balance: "0.0000" }],
};
}
@@ -74,11 +74,7 @@ function brokenXprvWallet(name = "Imported xprv") {
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [
{
address: node.deriveChild(0).address,
index: 0,
balance: "0.0000",
},
{ address: node.deriveChild(0).address, balance: "0.0000" },
],
};
}
@@ -574,7 +570,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
try {
wallet.getSignerForAddress(
{ type: "xprv" },
brokenXprvWallet().addresses[0],
0,
accountXprv(VECTOR_PHRASE),
);
} catch (e) {
@@ -589,7 +585,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
healthyXprvWallet().addresses[0],
0,
masterXprv(VECTOR_PHRASE),
);