Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b68b3e681 |
@@ -239,12 +239,8 @@ provide:
|
||||
`make check` stays non-mutating. Linting runs only in the container, because
|
||||
an ESLint result that depends on whichever ESLint the host happens to have is
|
||||
not a result, so docker is required to lint.
|
||||
- `script/fmt` — format all files (writes), on the host, with the `prettier`
|
||||
that `script/bootstrap` installs in `node_modules/`; when that one is missing
|
||||
it fails and says to run `script/bootstrap`, instead of using a `prettier`
|
||||
from the `PATH`
|
||||
- `script/fmt-check` — check formatting (read-only), on the host, with the same
|
||||
`prettier` and the same refusal
|
||||
- `script/fmt` — format all files (writes), on the host
|
||||
- `script/fmt-check` — check formatting (read-only), on the host
|
||||
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
|
||||
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
||||
nowhere in the working tree or under `dist/` outside its documented
|
||||
@@ -426,12 +422,11 @@ captured at `eth_sendRawTransaction` rather than against anything the extension
|
||||
reported, rejecting each prompt is required to return a rejection to the page
|
||||
rather than hang or resolve, a network switch request is required to leave the
|
||||
stored network unchanged and send no `chainChanged` until it is approved, a
|
||||
network switch in Settings is required to send the page `chainChanged` with the
|
||||
new chain id, a prompt raised while another approval window has focus is
|
||||
required to open a window of its own, and the password is required to be absent
|
||||
from every message the approval window sends to the background — with the
|
||||
message that would carry it required to be present, so that check cannot pass by
|
||||
observing nothing. That last one is the standing floor under
|
||||
prompt raised while another approval window has focus is required to open a
|
||||
window of its own, and the password is required to be absent from every message
|
||||
the approval window sends to the background — with the message that would carry
|
||||
it required to be present, so that check cannot pass by observing nothing. That
|
||||
last one is the standing floor under
|
||||
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||
|
||||
The limits of that coverage and of the rest of the Chrome suite, none of them
|
||||
@@ -1192,16 +1187,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).
|
||||
|
||||
@@ -1527,9 +1513,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
|
||||
@@ -1657,21 +1640,8 @@ view would leave a wallet one click from deletion.
|
||||
the contract's own answer is read at signing time only to be compared with
|
||||
it, and a disagreement is a refusal rather than a preference for either
|
||||
value (`src/shared/transferAmount.js`)
|
||||
- "Sign & Send" after the active network was switched in Settings while this
|
||||
screen was open → nothing is signed; "The active network changed after
|
||||
this transaction was reviewed, so it was not sent. Please go back and
|
||||
review it again." in the errors box, no screen change. The transaction
|
||||
carries the chain id of the network the screen was shown for, so it cannot
|
||||
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`)
|
||||
@@ -1815,12 +1785,10 @@ view would leave a wallet one click from deletion.
|
||||
plus a "+ Add token" button
|
||||
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
||||
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
||||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
|
||||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
|
||||
resets the RPC and Blockscout endpoints to that network's defaults. The
|
||||
network changes only here, or when the user approves a site's request on
|
||||
**NetworkApproval**; either way, switching restores the RPC and Blockscout
|
||||
endpoints last used on that network, or that network's defaults if it has
|
||||
none, and sends `chainChanged` with the new chain id to every open tab.
|
||||
Choosing the network already active changes nothing and sends nothing
|
||||
**NetworkApproval**
|
||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||
`eth_chainId` before being saved)
|
||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||
@@ -2100,9 +2068,7 @@ 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 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
|
||||
behind a gap. 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
|
||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
||||
@@ -2137,9 +2103,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
|
||||
@@ -2158,12 +2121,7 @@ view would leave a wallet one click from deletion.
|
||||
blocklist)
|
||||
- Site origin (bold, scheme and port included) + "wants you to sign a
|
||||
message"
|
||||
- Warning box, shown for `eth_sign`: "This site used the older eth_sign
|
||||
method. The data will be signed as a personal message." An `eth_sign`
|
||||
request names the address first and the data second. Its data is signed
|
||||
exactly as a `personal_sign` message is, as an EIP-191 personal message,
|
||||
which is what the Ethereum JSON-RPC specification defines `eth_sign` to
|
||||
return.
|
||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||
- From: color dot + full address + etherscan link
|
||||
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
||||
@@ -2178,12 +2136,7 @@ view would leave a wallet one click from deletion.
|
||||
Bytes that are not UTF-8 are shown as "This message is not text." For
|
||||
typed data, formatted domain/type/message fields (EIP-712). The primary
|
||||
type shown is the one ethers signs, derived from the typed data's `types`,
|
||||
not the type the site states. The message shows only the fields that type
|
||||
declares, at every level of nesting and in the type's order: ethers signs
|
||||
those and drops any other key. Typed data carrying other keys is not
|
||||
refused; each such key is listed after the message under "Not part of the
|
||||
signature", by its place in the message (`details.note`,
|
||||
`details[1].note`) and its value.
|
||||
not the type the site states.
|
||||
- Token permission warning, at the top of the message (typed data whose
|
||||
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
||||
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
||||
@@ -2204,20 +2157,6 @@ view would leave a wallet one click from deletion.
|
||||
- Typed data that states no primary type, or one other than the type it
|
||||
would be signed as, or that cannot be read → shown with the error line
|
||||
saying so and "Sign" disabled; only "Reject" remains
|
||||
- Typed data whose message leaves out a field its type declares, at any
|
||||
level of nesting, or gives a `bool` field a value other than `true` or
|
||||
`false` → shown with the error line naming the field and "Sign" disabled;
|
||||
only "Reject" remains. ethers signs a `bool` by whether its value is
|
||||
truthy, so the string `"false"` would be signed as true and a field left
|
||||
out as false. The background refuses such a signature too and answers the
|
||||
site with the same sentence
|
||||
- Typed data whose domain `chainId` names a chain other than the active
|
||||
network's → shown with the error line naming both chain ids and "Sign"
|
||||
disabled; only "Reject" remains. EIP-712 asks for this, because the
|
||||
signature is valid on the chain the domain names. Typed data with no
|
||||
`chainId` is not refused. The background refuses such a signature too,
|
||||
against the network active when the signature reaches it, and answers the
|
||||
site with the same sentence
|
||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
||||
signing) → shown as plain text, with the error line "This message is plain
|
||||
@@ -2258,9 +2197,8 @@ view would leave a wallet one click from deletion.
|
||||
- "Switch" / "Reject" buttons
|
||||
- **Transitions**:
|
||||
- "Switch" → closes popup; the background switches the network as
|
||||
**Settings** does, restoring the RPC and Blockscout endpoints last used on
|
||||
that network (or that network's defaults if it has none), sends
|
||||
`chainChanged` to every open tab, and answers the site with success
|
||||
**Settings** does, sends `chainChanged` to every open tab, and answers the
|
||||
site with success
|
||||
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
|
||||
nothing changes
|
||||
- Popup window closed without answering → the same as "Reject"
|
||||
@@ -2710,8 +2648,7 @@ plaintext list of anyone's domain names.
|
||||
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
||||
approval, or signature, the approval popup displays a prominent red warning
|
||||
banner alerting the user. The domain checker matches exact hostnames and all
|
||||
parent domains (subdomain matching), and checks a hostname written with a
|
||||
trailing dot (`name.`) as `name`.
|
||||
parent domains (subdomain matching).
|
||||
|
||||
#### Transaction Decoding
|
||||
|
||||
@@ -2754,7 +2691,7 @@ Currently supported:
|
||||
|
||||
### Non-Goals for 1.0
|
||||
|
||||
- Chains other than Ethereum mainnet and the Sepolia testnet
|
||||
- Multi-chain support (Ethereum mainnet only)
|
||||
- Hardware wallet support
|
||||
|
||||
## TODO
|
||||
@@ -2788,10 +2725,7 @@ Currently supported:
|
||||
## Policies
|
||||
|
||||
- We don't mention "the other wallet" by name in code or documentation. We're
|
||||
our own thing. Written exception: the injected provider in
|
||||
`src/content/inpage.js` sets the flag named after the other wallet to `true`.
|
||||
It is an interface-compatibility flag many dApps check, and without it the
|
||||
wallet stops working on their sites.
|
||||
our own thing.
|
||||
- The README is the complete authoritative technical documentation. It's ok if
|
||||
it gets big.
|
||||
|
||||
|
||||
@@ -118,7 +118,4 @@ contradicts either, the originals govern.
|
||||
- [ ] "Address" not "account" or "derived key"
|
||||
- [ ] "Password" not "encryption key" or "vault passphrase"
|
||||
- [ ] Error messages are full sentences
|
||||
- [ ] No competitor mentioned by name in code or documentation. Written
|
||||
exception: the injected provider in `src/content/inpage.js` sets the flag
|
||||
named after the other wallet to `true`, an interface-compatibility flag
|
||||
many dApps check (README.md, Policies)
|
||||
- [ ] No competitor mentioned by name in code or documentation
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
- `git pull` `next` and cut a branch from it — one branch per issue, named
|
||||
`issue-<N>-<slug>`. Never branch from `main`.
|
||||
- Do the work as one commit whose title ends with ` (closes #N)`.
|
||||
- Add no entry to Completed Steps. What landed is recorded on the tracker and in
|
||||
`git log`, where each commit title names the issue it closes.
|
||||
- Do the work as one commit whose title ends with ` (closes #N)`, with the
|
||||
`TODO.md` update in that same commit.
|
||||
- Move Next Step to the top of Completed Steps; move the top item of Future
|
||||
Steps into Next Step.
|
||||
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
|
||||
`main` may not.
|
||||
- Rebase onto current `next` immediately before pushing — other branches land on
|
||||
@@ -43,25 +44,6 @@ then continue tagging as milestones land.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-08: The browser suites no longer read a screen before the page has
|
||||
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
|
||||
for a screen used to pass as soon as the element laid out, which every view
|
||||
does until the page's stylesheet has applied, so an approval test could read
|
||||
the prompt's fields before the page's script had filled them. `visible()` in
|
||||
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
|
||||
now also wait for the page to finish loading and for neither the element nor
|
||||
anything around it to carry the `hidden` class that `showView()` puts on every
|
||||
view but the current one. No caller changed.
|
||||
|
||||
- 2026-10-08: Switching the network in Settings now tells open pages
|
||||
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
|
||||
saved, Settings asks the background to send `chainChanged` with the new chain
|
||||
id to every open tab, through the same function an approved site request uses.
|
||||
Choosing the network already active changes nothing and sends nothing. Only
|
||||
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
|
||||
drives the real Settings view against the background, and the Chrome suite
|
||||
checks that the test page hears both switches.
|
||||
|
||||
- 2026-10-07: A site can no longer switch the wallet's network by itself
|
||||
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
|
||||
`wallet_switchEthereumChain` request for the other supported network opens a
|
||||
@@ -72,20 +54,9 @@ then continue tagging as milestones land.
|
||||
or closing the prompt answers 4001. One such prompt per site at a time. The
|
||||
approval window no longer shows the connection prompt while it waits for the
|
||||
background to describe the approval, because that prompt's "Allow" answers on
|
||||
the same port as the new one; `tests/approvalWindow.test.js` checks that it
|
||||
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
|
||||
the same port as the new one. `tests/chainSwitchGate.test.js` and both browser
|
||||
suites drive the prompt.
|
||||
|
||||
- 2026-10-07: Two contradictions between the documents and the code are resolved
|
||||
as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The
|
||||
README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
|
||||
scope and other chains out of it. The injected provider's flag named after the
|
||||
other wallet stays, as an interface-compatibility flag many dApps check, and
|
||||
is written down as an exception to the rule against naming competitors in the
|
||||
README's Policies section, in `RULES.md` and in a comment beside it in
|
||||
`src/content/inpage.js`. `script/check-censored` already allows that flag only
|
||||
in that file and its built copies, so it is unchanged.
|
||||
|
||||
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
|
||||
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
|
||||
check that every swept field is driven both truthy and falsy counts only
|
||||
|
||||
@@ -25,13 +25,6 @@ run_yarn() {
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
||||
# the PATH, which may be a version that formats files differently.
|
||||
if [ ! -x node_modules/.bin/prettier ]; then
|
||||
echo "fmt: this repo's prettier is not installed in node_modules/," \
|
||||
"so run script/bootstrap first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Formatting..."
|
||||
run_yarn run fmt
|
||||
}
|
||||
|
||||
@@ -26,13 +26,6 @@ run_yarn() {
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
||||
# the PATH, which may be a version that formats files differently.
|
||||
if [ ! -x node_modules/.bin/prettier ]; then
|
||||
echo "fmt-check: this repo's prettier is not installed in" \
|
||||
"node_modules/, so run script/bootstrap first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking formatting..."
|
||||
run_yarn run fmt-check
|
||||
}
|
||||
|
||||
+7
-44
@@ -22,9 +22,6 @@ const { debugFetch, log } = require("../shared/log");
|
||||
const {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
signedPrimaryType,
|
||||
typedDataMessageRefusal,
|
||||
typedDataChainRefusal,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
sameAddress,
|
||||
@@ -635,9 +632,8 @@ function isExtensionSender(sender) {
|
||||
// had refused.
|
||||
//
|
||||
// TX and sign approvals do not decide here. They stay pending across a
|
||||
// disconnect. When their window closes, the windows.onRemoved listener below
|
||||
// rejects them, unless an approved attempt is still running, in which case
|
||||
// that attempt answers the page.
|
||||
// disconnect — the user can reopen the toolbar popup — and are rejected by the
|
||||
// windows.onRemoved listener below.
|
||||
runtime.onConnect.addListener((port) => {
|
||||
if (port.name.startsWith("approval:")) {
|
||||
const id = port.name.split(":")[1];
|
||||
@@ -665,9 +661,7 @@ runtime.onConnect.addListener((port) => {
|
||||
const approval = pendingApprovals[id];
|
||||
if (approval) {
|
||||
if (approval.type === "tx" || approval.type === "sign") {
|
||||
// When its window closes, windows.onRemoved rejects it,
|
||||
// unless an approved attempt is still running, in which
|
||||
// case that attempt answers the page.
|
||||
// Keep pending — user can reopen the toolbar popup
|
||||
return;
|
||||
}
|
||||
settleApproval(id, { approved: false, remember: false });
|
||||
@@ -990,8 +984,9 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "eth_sign") {
|
||||
signParams.dangerWarning =
|
||||
"This site used the older eth_sign method. " +
|
||||
"The data will be signed as a personal message.";
|
||||
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
|
||||
"This can be used to sign transactions that drain your funds. " +
|
||||
"Only proceed if you fully understand what you are signing.";
|
||||
}
|
||||
|
||||
if (findPendingApproval(origin, "sign")) {
|
||||
@@ -1476,7 +1471,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
||||
"AUTISTMASK_REMOVE_SITE",
|
||||
"AUTISTMASK_NETWORK_CHANGED",
|
||||
];
|
||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
@@ -1769,8 +1763,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
// Same as the transaction path: the address the approval named
|
||||
// is the one that must have signed, and a switch since then is
|
||||
// a refusal rather than a signature from another account.
|
||||
@@ -1789,29 +1782,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
signature,
|
||||
approval.approvedFrom,
|
||||
);
|
||||
// The sign screen refuses typed data whose message ethers
|
||||
// would sign differently from how it is shown, or whose
|
||||
// domain names another chain. A signature for it that arrives
|
||||
// anyway is refused here, against the network active now.
|
||||
const sp = approval.signParams;
|
||||
if (
|
||||
sp.method === "eth_signTypedData_v4" ||
|
||||
sp.method === "eth_signTypedData"
|
||||
) {
|
||||
const data = JSON.parse(sp.typedData);
|
||||
const refusal =
|
||||
typedDataMessageRefusal(
|
||||
data.types,
|
||||
signedPrimaryType(data.types),
|
||||
data.message,
|
||||
"",
|
||||
) ||
|
||||
typedDataChainRefusal(
|
||||
data.domain,
|
||||
networkById(s.networkId),
|
||||
);
|
||||
if (refusal) throw new ApprovalMismatchError(refusal);
|
||||
}
|
||||
settleApproval(msg.id, { signature }, { holdsClaim: true });
|
||||
sendResponse({ signature });
|
||||
} catch (e) {
|
||||
@@ -1884,13 +1854,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
broadcastSiteRemoved(msg.origin);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Settings switched the network and has saved it. Open tabs are told the
|
||||
// new chain id the same way as after a site's approved switch request.
|
||||
if (msg.type === "AUTISTMASK_NETWORK_CHANGED") {
|
||||
broadcastChainChanged(msg.chainId);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { PROXY_METHODS };
|
||||
|
||||
@@ -99,10 +99,7 @@
|
||||
|
||||
const provider = {
|
||||
isAutistMask: true,
|
||||
// An interface-compatibility flag many dApps check. Kept as a written
|
||||
// exception to the rule that no competitor is named in code: see
|
||||
// Policies in README.md, and RULES.md.
|
||||
isMetaMask: true,
|
||||
isMetaMask: true, // compatibility — many dApps check this
|
||||
chainId: currentChainId,
|
||||
networkVersion: currentNetworkVersion,
|
||||
selectedAddress: null,
|
||||
|
||||
+12
-13
@@ -7,10 +7,9 @@
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
||||
font and its size on body, and a Tailwind class beats it only when
|
||||
marked important: hence font-mono! and text-sm! rather than
|
||||
font-mono and text-sm. -->
|
||||
<body class="bg-bg text-fg font-mono! text-sm!">
|
||||
font on body, and a Tailwind class beats it only when marked
|
||||
important: hence font-mono! rather than font-mono. -->
|
||||
<body class="bg-bg text-fg font-mono! text-sm">
|
||||
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
||||
<!-- ============ GLOBAL TITLE BAR ============ -->
|
||||
<div
|
||||
@@ -88,14 +87,11 @@
|
||||
|
||||
<!-- Mnemonic form section -->
|
||||
<div id="add-wallet-section-mnemonic">
|
||||
<!-- The die button shares the instruction's row: on a row
|
||||
of its own it pushes Import below the fold at
|
||||
360x600. -->
|
||||
<div class="mb-2 flex justify-between items-start gap-2">
|
||||
<p>
|
||||
Enter your 12 or 24 word recovery phrase below, or
|
||||
click the button to roll the die for a new one.
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
Enter your 12 or 24 word recovery phrase below, or click
|
||||
the button to roll the die for a new one.
|
||||
</p>
|
||||
<div class="mb-1 flex justify-end">
|
||||
<button
|
||||
id="btn-generate-phrase"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
|
||||
@@ -1716,7 +1712,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
|
||||
|
||||
@@ -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: [],
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+31
-75
@@ -12,7 +12,7 @@ const {
|
||||
formatFee,
|
||||
tokenLabel,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
@@ -27,6 +27,7 @@ const {
|
||||
Interface,
|
||||
MaxUint256,
|
||||
toUtf8String,
|
||||
TypedDataEncoder,
|
||||
} = require("ethers");
|
||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||
const { ERC20_ABI } = require("../../shared/constants");
|
||||
@@ -45,12 +46,7 @@ const {
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const {
|
||||
describeSigningFailure,
|
||||
signedPrimaryType,
|
||||
typedDataMessageRefusal,
|
||||
typedDataChainRefusal,
|
||||
} = require("../../shared/approvalVerify");
|
||||
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||
const txStatus = require("./txStatus");
|
||||
const uniswap = require("../../shared/uniswap");
|
||||
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||
@@ -333,9 +329,9 @@ function showTxApproval(details) {
|
||||
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||
const usdStr = formatUsd(ethUsd);
|
||||
// In the native currency of the network the transaction is for, which the
|
||||
// Network line names, not the active network's: the active network can
|
||||
// change, in Settings or when the user approves a site's request, after
|
||||
// this transaction is prepared and change back before it is signed.
|
||||
// Network line names, not the active network's: a site can switch the
|
||||
// active network after this transaction is prepared and back before it is
|
||||
// signed.
|
||||
$("approve-tx-value").textContent =
|
||||
ethValueFormatted +
|
||||
" " +
|
||||
@@ -433,12 +429,21 @@ function markInvisibleCharacters(text) {
|
||||
);
|
||||
}
|
||||
|
||||
// The type ethers will sign typed data as. ethers does not read the page's
|
||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||
// refers to. Throws when the types name no such single struct, which ethers
|
||||
// would refuse to sign as well.
|
||||
function signedPrimaryType(types) {
|
||||
const structs = { ...types };
|
||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
||||
delete structs.EIP712Domain;
|
||||
return TypedDataEncoder.getPrimaryType(structs);
|
||||
}
|
||||
|
||||
// Why a signature request cannot be signed, as a sentence for the error line,
|
||||
// or null when it can. Only typed data is refused: the `primaryType` the page
|
||||
// states has to be the type ethers will sign, or this screen would name one
|
||||
// message while another is signed. Its message has to hold every declared
|
||||
// field, with each `bool` true or false, and its domain may name no chain but
|
||||
// the active network's.
|
||||
// message while another is signed.
|
||||
function typedDataRefusal(sp) {
|
||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
||||
let data;
|
||||
@@ -461,10 +466,7 @@ function typedDataRefusal(sp) {
|
||||
", so it cannot be signed."
|
||||
);
|
||||
}
|
||||
return (
|
||||
typedDataMessageRefusal(data.types, signed, data.message, "") ||
|
||||
typedDataChainRefusal(data.domain, currentNetwork())
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
||||
@@ -620,38 +622,10 @@ function permitWarningHtml(types, primaryType, domain, message) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// What ethers signs of a message value of type `type`: a struct keeps only
|
||||
// the fields its type declares, in the type's order, and a list is read item
|
||||
// by item. ethers drops every other key, so each one is added to `notSigned`
|
||||
// with its place in the message and its value, for the screen to list apart.
|
||||
function signedValue(types, type, value, path, notSigned) {
|
||||
const itemType = type.replace(/\[\d*\]$/, "");
|
||||
if (itemType !== type) {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map((item, i) =>
|
||||
signedValue(types, itemType, item, `${path}[${i}]`, notSigned),
|
||||
);
|
||||
}
|
||||
const fields = types[type];
|
||||
if (!fields || !value || typeof value !== "object") return value;
|
||||
const at = (name) => (path ? `${path}.${name}` : name);
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (!fields.some((f) => f.name === key)) notSigned.push([at(key), val]);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
fields.map((f) => [
|
||||
f.name,
|
||||
signedValue(types, f.type, value[f.name], at(f.name), notSigned),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// The typed data as the screen shows it. The primary type shown is the one
|
||||
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
|
||||
// from differing on anything that can be signed. The Message section shows
|
||||
// only what is signed, and any other key the page sent is listed after it as
|
||||
// not part of the signature. Only typed data that cannot be read at all is
|
||||
// shown as raw text, and typedDataRefusal() refuses it.
|
||||
// from differing on anything that can be signed. Only typed data that cannot
|
||||
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
let data;
|
||||
let primaryType;
|
||||
@@ -685,30 +659,12 @@ function formatTypedDataHtml(jsonStr) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
||||
|
||||
const lineHtml = (key, val) =>
|
||||
`<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||
|
||||
if (data.message) {
|
||||
const notSigned = [];
|
||||
const message = signedValue(
|
||||
data.types,
|
||||
primaryType,
|
||||
data.message,
|
||||
"",
|
||||
notSigned,
|
||||
);
|
||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||
for (const [key, val] of Object.entries(message)) {
|
||||
html += lineHtml(key, val);
|
||||
for (const [key, val] of Object.entries(data.message)) {
|
||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
if (notSigned.length > 0) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Not part of the signature</div>`;
|
||||
for (const [key, val] of notSigned) {
|
||||
html += lineHtml(key, val);
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
@@ -765,7 +721,7 @@ function showSignApproval(details) {
|
||||
"This message is plain text, not hex, so it cannot be signed.";
|
||||
}
|
||||
|
||||
// The warning the background attaches to an eth_sign request.
|
||||
// Display danger warning for eth_sign (raw hash signing)
|
||||
const warningEl = $("approve-sign-danger-warning");
|
||||
if (warningEl) {
|
||||
if (sp.dangerWarning) {
|
||||
@@ -907,15 +863,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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1042,7 +998,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
|
||||
@@ -1170,7 +1126,7 @@ function init(_ctx) {
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
active.wallet,
|
||||
active.addr,
|
||||
active.addrIndex,
|
||||
decryptedSecret,
|
||||
);
|
||||
const sp = pendingSignParams;
|
||||
|
||||
@@ -101,10 +101,6 @@ function show(txInfo) {
|
||||
feeStatus = FEE_PENDING;
|
||||
feeWei = null;
|
||||
maxSendFees = null;
|
||||
// The network this screen is drawn for, and the only one it sends on:
|
||||
// Settings can switch the active network while the screen is open. The
|
||||
// wait, success and error screens label the amount by it too.
|
||||
txInfo.chainId = currentNetwork().chainId;
|
||||
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
// The raw symbol is the price-table key; the capped one is what the
|
||||
@@ -494,12 +490,7 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
||||
);
|
||||
request = await contract.transfer.populateTransaction(tx.to, amount);
|
||||
}
|
||||
// The chain id of the network the screen was drawn for: ethers refuses to
|
||||
// populate it for a provider on any other network.
|
||||
const populated = await connectedSigner.populateTransaction({
|
||||
...request,
|
||||
chainId: tx.chainId,
|
||||
});
|
||||
const populated = await connectedSigner.populateTransaction(request);
|
||||
assertWithinCeilings(populated);
|
||||
return connectedSigner.sendTransaction(populated);
|
||||
}
|
||||
@@ -556,30 +547,6 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Back from Settings only unhides this screen, still drawn for the
|
||||
// network that was active when it was shown.
|
||||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
||||
showSendError(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent. Please go back and review" +
|
||||
" it again.",
|
||||
);
|
||||
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(
|
||||
@@ -608,9 +575,17 @@ function init(_ctx) {
|
||||
$("btn-confirm-send").disabled = true;
|
||||
$("btn-confirm-send").classList.add("text-muted");
|
||||
|
||||
// The network it is sent on. The wait, success and error screens
|
||||
// label its amount by this, not by the network active when they draw.
|
||||
pendingTx.chainId = currentNetwork().chainId;
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -354,7 +354,6 @@ function render(ctx) {
|
||||
);
|
||||
wallet.addresses.push({
|
||||
address: newAddr,
|
||||
index: wallet.nextIndex,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
});
|
||||
|
||||
@@ -316,11 +316,7 @@ function init(ctx) {
|
||||
const networkSelect = $("settings-network");
|
||||
networkSelect.addEventListener("change", async () => {
|
||||
const newId = networkSelect.value;
|
||||
if (newId === state.networkId) return;
|
||||
const net = await onChainSwitch(newId);
|
||||
// Open pages are told by the background, as after a site's approved
|
||||
// switch request.
|
||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
showFlash("Switched to " + net.name + ".");
|
||||
|
||||
@@ -84,9 +84,8 @@ function show(tx) {
|
||||
contractAddress: tx.contractAddress || null,
|
||||
// The network the history entry was read from. The type line and
|
||||
// the fee are in its native currency, not the active network's:
|
||||
// the active network can change, in Settings or when the user
|
||||
// approves a site's request, before a later popup shows this
|
||||
// screen again.
|
||||
// a site can switch the active network before a later popup
|
||||
// shows this screen again.
|
||||
chainId: tx.chainId,
|
||||
},
|
||||
};
|
||||
@@ -183,7 +182,7 @@ function render() {
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
loadFullTxDetails(tx.hash, tx.chainId);
|
||||
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
|
||||
|
||||
const isoStr = isoDate(tx.timestamp);
|
||||
$("tx-detail-time").innerHTML =
|
||||
@@ -291,7 +290,7 @@ function populateOnChainDetails(txData, chainId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFullTxDetails(txHash, chainId) {
|
||||
async function loadFullTxDetails(txHash, toAddress, chainId) {
|
||||
const section = $("tx-detail-calldata-section");
|
||||
const actionEl = $("tx-detail-calldata-action");
|
||||
const detailsEl = $("tx-detail-calldata-details");
|
||||
@@ -313,10 +312,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
||||
const inputData = txData.raw_input || txData.input || null;
|
||||
if (!inputData || inputData === "0x") return;
|
||||
|
||||
// Decoded against the address the transaction was sent to, as the
|
||||
// explorer reports it, not the history entry's `to`: for a token
|
||||
// transfer that is the transfer's recipient, not the token contract.
|
||||
const decoded = decodeCalldata(inputData, txData.to?.hash || "");
|
||||
const decoded = decodeCalldata(inputData, toAddress || "");
|
||||
if (decoded) {
|
||||
// Render decoded calldata matching approval view style
|
||||
actionEl.textContent = decoded.name;
|
||||
|
||||
@@ -89,9 +89,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
|
||||
// A native amount, here and on the success and error screens, is in the
|
||||
// native currency of txInfo.chainId, the network the transaction was sent
|
||||
// on, not the active network's: the active network can change, in
|
||||
// Settings or when the user approves a site's request, while this screen
|
||||
// is open or before a later popup resumes it.
|
||||
// on, not the active network's: a site can switch the active network
|
||||
// while this screen is open or before a later popup resumes it.
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? nativeCurrencyByChainId(txInfo.chainId)
|
||||
|
||||
+12
-39
@@ -1,14 +1,13 @@
|
||||
// Preparation of the transaction an approval screen displays.
|
||||
//
|
||||
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
|
||||
// sometimes the gas limit. The nonce, the fees, and the gas limit when the
|
||||
// site gives none have to be filled in from the network before anything can
|
||||
// be signed, and whoever fills them in decides what the user is shown. That
|
||||
// work used to happen in the popup, after the user had already approved: the
|
||||
// numbers on the approval screen came from the popup and were compared against
|
||||
// nothing, so a compromised popup could display one fee and sign another, and
|
||||
// the ceilings in approvalVerify.js were all that stood between the user and a
|
||||
// fee that hands the validator the balance.
|
||||
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
|
||||
// The nonce, the gas limit and the fees have to be filled in from the network
|
||||
// before anything can be signed, and whoever fills them in decides what the
|
||||
// user is shown. That work used to happen in the popup, after the user had
|
||||
// already approved: the numbers on the approval screen came from the popup and
|
||||
// were compared against nothing, so a compromised popup could display one fee
|
||||
// and sign another, and the ceilings in approvalVerify.js were all that stood
|
||||
// between the user and a fee that hands the validator the balance.
|
||||
//
|
||||
// So it happens here instead, in the background, before the approval window is
|
||||
// opened. The background populates the transaction, shows that object, and
|
||||
@@ -32,7 +31,6 @@ const {
|
||||
VoidSigner,
|
||||
accessListify,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
hexlify,
|
||||
toQuantity,
|
||||
@@ -49,11 +47,10 @@ const {
|
||||
// minutes long, which is not a wait anyone will sit through.
|
||||
const POPULATE_TIMEOUT_MS = 20000;
|
||||
|
||||
// The request fields taken from the page, plus `gas`, which requestFrom()
|
||||
// takes as the gas limit. Anything else is dropped rather than passed to
|
||||
// ethers: the object is page-controlled, and a future ethers that learns to
|
||||
// carry a new transaction field must not start picking one up out of it
|
||||
// without this module knowing.
|
||||
// The request fields taken from the page. Anything else is dropped rather than
|
||||
// passed to ethers: the object is page-controlled, and a future ethers that
|
||||
// learns to carry a new transaction field must not start picking one up out of
|
||||
// it without this module knowing.
|
||||
//
|
||||
// The nonce is not taken from the page; it is always the account's next nonce
|
||||
// from the network. A page that chose it could replace one of the user's
|
||||
@@ -87,16 +84,6 @@ function present(v) {
|
||||
return v !== null && v !== undefined && v !== "";
|
||||
}
|
||||
|
||||
// Whether two quantities from the page are the same number. One that is not a
|
||||
// number matches nothing.
|
||||
function sameQuantity(a, b) {
|
||||
try {
|
||||
return getBigInt(a) === getBigInt(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// These strings reach the user through the requesting page, so they are full
|
||||
// sentences even when the tail of one came from ethers or from the node.
|
||||
function sentence(text) {
|
||||
@@ -124,20 +111,6 @@ function requestFrom(txParams, from) {
|
||||
for (const key of REQUEST_FIELDS) {
|
||||
if (present(txParams[key])) request[key] = txParams[key];
|
||||
}
|
||||
// JSON-RPC calls the gas limit `gas`, and that is the name sites send.
|
||||
// ethers only reads `gasLimit`: left as `gas` it would be dropped and an
|
||||
// estimate signed in its place.
|
||||
if (present(txParams.gas)) {
|
||||
if (
|
||||
present(request.gasLimit) &&
|
||||
!sameQuantity(request.gasLimit, txParams.gas)
|
||||
) {
|
||||
throw fail(
|
||||
"The site asked for two different gas limits for this transaction.",
|
||||
);
|
||||
}
|
||||
request.gasLimit = txParams.gas;
|
||||
}
|
||||
if (
|
||||
present(request.type) &&
|
||||
!ALLOWED_TX_TYPES.includes(Number(request.type))
|
||||
|
||||
@@ -53,10 +53,8 @@ const {
|
||||
accessListify,
|
||||
formatEther,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
toQuantity,
|
||||
TypedDataEncoder,
|
||||
verifyMessage,
|
||||
verifyTypedData,
|
||||
} = require("ethers");
|
||||
@@ -637,90 +635,6 @@ function verifySignature(signParams, signature, expectedFrom) {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
// The type ethers will sign typed data as. ethers does not read the page's
|
||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||
// refers to. Throws when the types name no such single struct, which ethers
|
||||
// would refuse to sign as well.
|
||||
function signedPrimaryType(types) {
|
||||
const structs = { ...types };
|
||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
||||
delete structs.EIP712Domain;
|
||||
return TypedDataEncoder.getPrimaryType(structs);
|
||||
}
|
||||
|
||||
// Why a typed data message cannot be signed as the screen shows it, as a
|
||||
// sentence, or null when it can. ethers signs a `bool` field by whether its
|
||||
// value is truthy, so the string "false" is signed as true and a field left
|
||||
// out as false. Each field that `type` declares, at every level of nesting,
|
||||
// must be one of the value's own keys, and each `bool` must be true or false.
|
||||
// Called with the type ethers signs, the message, and a `path` of "".
|
||||
function typedDataMessageRefusal(types, type, value, path) {
|
||||
const itemType = type.replace(/\[\d*\]$/, "");
|
||||
if (itemType !== type) {
|
||||
// ethers refuses to sign a list field that is not a list.
|
||||
if (!Array.isArray(value)) return null;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const refusal = typedDataMessageRefusal(
|
||||
types,
|
||||
itemType,
|
||||
value[i],
|
||||
`${path}[${i}]`,
|
||||
);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "bool" && value !== true && value !== false) {
|
||||
return `The field "${path}" is not true or false, so this typed data cannot be signed.`;
|
||||
}
|
||||
for (const field of types[type] || []) {
|
||||
const at = path ? `${path}.${field.name}` : field.name;
|
||||
// An own key only: a plain lookup of `constructor` finds a function.
|
||||
const present =
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
Object.prototype.hasOwnProperty.call(value, field.name);
|
||||
if (!present) {
|
||||
return `The field "${at}" is missing from the message, so this typed data cannot be signed.`;
|
||||
}
|
||||
const refusal = typedDataMessageRefusal(
|
||||
types,
|
||||
field.type,
|
||||
value[field.name],
|
||||
at,
|
||||
);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Why typed data cannot be signed while `network` is the active network, as a
|
||||
// sentence, or null when it can. EIP-712 has a wallet refuse typed data whose
|
||||
// domain names another chain, because the signature is valid on that chain.
|
||||
// ethers signs no chain id when the domain's is null or absent, and reads any
|
||||
// other with getBigInt(), so 1, "1" and "0x1" are the same chain here too.
|
||||
function typedDataChainRefusal(domain, network) {
|
||||
const chainId = domain?.chainId;
|
||||
if (chainId === undefined || chainId === null) return null;
|
||||
let named;
|
||||
try {
|
||||
named = getBigInt(chainId);
|
||||
} catch {
|
||||
return "This typed data cannot be read, so it cannot be signed.";
|
||||
}
|
||||
const active = getBigInt(network.chainId);
|
||||
if (named === active) return null;
|
||||
return (
|
||||
"This typed data is for chain id " +
|
||||
named +
|
||||
", but the active network is " +
|
||||
network.name +
|
||||
" (chain id " +
|
||||
active +
|
||||
"), so it cannot be signed."
|
||||
);
|
||||
}
|
||||
|
||||
// The stage a transaction approval failed at. Which stage it is decides
|
||||
// whether the approval survives the failure.
|
||||
const TX_STAGE_SIGN = "sign";
|
||||
@@ -882,9 +796,6 @@ function describeSigningFailure(response, fallbackMessage) {
|
||||
module.exports = {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
signedPrimaryType,
|
||||
typedDataMessageRefusal,
|
||||
typedDataChainRefusal,
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
|
||||
@@ -110,14 +110,11 @@ function hashListed(hash) {
|
||||
* Generate hostname variants for subdomain matching.
|
||||
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
|
||||
*
|
||||
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
|
||||
* "evil.com", and the browser loads the same site from either.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function hostnameVariants(hostname) {
|
||||
const h = hostname.toLowerCase().replace(/\.$/, "");
|
||||
const h = hostname.toLowerCase();
|
||||
const variants = [h];
|
||||
const parts = h.split(".");
|
||||
// Parent domains: a.b.c.d -> b.c.d, c.d
|
||||
|
||||
+6
-32
@@ -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) {
|
||||
|
||||
@@ -36,13 +36,11 @@ const DEFECTS = {
|
||||
"derivation path beneath that key instead of from a master " +
|
||||
"key, so the addresses listed here are not the ones that key " +
|
||||
"produces under the standard path.",
|
||||
"Signing, sending and private key export are disabled for this " +
|
||||
"wallet. Anything these addresses hold can be reached outside " +
|
||||
"this wallet with the extended private key you imported, if " +
|
||||
"you kept a copy of it, or with the master extended private " +
|
||||
"key or recovery phrase it came from, in software that " +
|
||||
"repeats the same non-standard derivation. Check the " +
|
||||
"addresses in a block explorer before deciding what to do.",
|
||||
"Signing and sending are disabled for this wallet. The addresses " +
|
||||
"do descend from the extended private key you imported, so " +
|
||||
"anything they hold is still reachable by software that " +
|
||||
"repeats the same non-standard derivation. Check them in a " +
|
||||
"block explorer before deciding what to do.",
|
||||
"To see the addresses this key produces under the standard path, " +
|
||||
"import the master extended private key, or the recovery " +
|
||||
"phrase it came from, as a new wallet. Nothing here has been " +
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// requesting page or the RPC node can say turns it into an approval that
|
||||
// should never have been raised.
|
||||
|
||||
const { Network, Transaction, Wallet, toQuantity } = require("ethers");
|
||||
const { Network, Wallet } = require("ethers");
|
||||
const {
|
||||
prepareApprovalTx,
|
||||
serializeApprovedTx,
|
||||
@@ -138,43 +138,6 @@ describe("prepareApprovalTx", () => {
|
||||
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
||||
});
|
||||
|
||||
// `gas` is the JSON-RPC name for the gas limit and the one sites send.
|
||||
// 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 approved = await prepareApprovalTx(
|
||||
providerWith(),
|
||||
signer.address,
|
||||
{ ...TX_PARAMS, gas: "0x30d40" },
|
||||
);
|
||||
expect(approved.gasLimit).toBe("0x30d40");
|
||||
const signed = Transaction.from(
|
||||
await signer.signTransaction({ ...approved }),
|
||||
);
|
||||
expect(signed.gasLimit).toBe(200000n);
|
||||
});
|
||||
|
||||
test("accepts gas and gasLimit that name the same number", async () => {
|
||||
const approved = await prepareApprovalTx(
|
||||
providerWith(),
|
||||
signer.address,
|
||||
{ ...TX_PARAMS, gas: "0x30d40", gasLimit: 200000 },
|
||||
);
|
||||
expect(approved.gasLimit).toBe("0x30d40");
|
||||
});
|
||||
|
||||
test("refuses gas and gasLimit that disagree", async () => {
|
||||
await expect(
|
||||
prepareApprovalTx(providerWith(), signer.address, {
|
||||
...TX_PARAMS,
|
||||
gas: "0x30d40",
|
||||
gasLimit: "0x5208",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"The site asked for two different gas limits for this transaction.",
|
||||
);
|
||||
});
|
||||
|
||||
test("carries an access list the request asked for", async () => {
|
||||
const approved = await prepareApprovalTx(
|
||||
providerWith(),
|
||||
@@ -249,15 +212,6 @@ describe("prepareApprovalTx", () => {
|
||||
).rejects.toThrow(/gas limit no network this wallet supports/);
|
||||
});
|
||||
|
||||
test("refuses a gas limit the site sent above the ceiling", async () => {
|
||||
await expect(
|
||||
prepareApprovalTx(providerWith(), signer.address, {
|
||||
...TX_PARAMS,
|
||||
gas: toQuantity(MAX_GAS_LIMIT + 1n),
|
||||
}),
|
||||
).rejects.toThrow(/gas limit no network this wallet supports/);
|
||||
});
|
||||
|
||||
// The combined bound at population: a gas limit and a fee that are each
|
||||
// under their own ceiling but multiply to thousands of ETH is refused
|
||||
// before the approval window opens, so the user is never shown a
|
||||
|
||||
@@ -58,6 +58,7 @@ const TX_PARAMS = {
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0xdeadbeef",
|
||||
gas: "0x5208",
|
||||
};
|
||||
|
||||
// The values populateTransaction() fills in when the dApp fixed none of them.
|
||||
@@ -1454,11 +1455,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 +1550,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/);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// The approval window shows no screen until the background has described the
|
||||
// approval it answers (https://git.eeqj.de/sneak/AutistMask/issues/408). The
|
||||
// connection prompt's "Allow" and the network switch prompt's "Switch" answer
|
||||
// on the same port, so a window that showed the connection prompt while it
|
||||
// waited could approve a network switch.
|
||||
//
|
||||
// Booted through the real popup entry point, which is where the connection
|
||||
// prompt used to be put up before the background had answered.
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
settle,
|
||||
unversionedValidProfile,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
afterEach(cleanupPopup);
|
||||
|
||||
test("the approval window shows no screen until the background describes the approval", async () => {
|
||||
// The background's answer, held until the test gives it.
|
||||
let answer = null;
|
||||
const env = await bootPopup(unversionedValidProfile(), {
|
||||
search: "?approval=approval-1",
|
||||
runtime: {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(answer).not.toBeNull();
|
||||
// No screen at all, the connection prompt included.
|
||||
expect(env.visibleViews()).toEqual([]);
|
||||
|
||||
answer({
|
||||
type: "network",
|
||||
origin: "https://dapp.example",
|
||||
currentNetworkId: "mainnet",
|
||||
requestedNetworkId: "sepolia",
|
||||
isPhishingDomain: false,
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(env.visibleViews()).toEqual(["approve-network"]);
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
});
|
||||
@@ -384,26 +384,20 @@ function loadBackground(options) {
|
||||
|
||||
// The same through eth_signTypedData_v4, whose params name the address
|
||||
// first and the typed data second.
|
||||
function requestTypedData(typedData = TYPED_DATA) {
|
||||
function requestTypedData() {
|
||||
let rpcResult = null;
|
||||
messageListener(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_signTypedData_v4",
|
||||
params: [signer.address, typedData],
|
||||
params: [signer.address, TYPED_DATA],
|
||||
},
|
||||
{ origin: ORIGIN },
|
||||
(r) => {
|
||||
rpcResult = r;
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: () =>
|
||||
new URL(created[created.length - 1].url).searchParams.get(
|
||||
"approval",
|
||||
),
|
||||
result: () => rpcResult,
|
||||
};
|
||||
return { result: () => rpcResult };
|
||||
}
|
||||
|
||||
// A dApp asking to connect. The origin defaults to one the persisted
|
||||
@@ -1490,91 +1484,6 @@ describe("what the approval is verified against", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Typed data whose domain names another chain is refused on the sign
|
||||
// screen; a signature for it that reaches the background anyway is not
|
||||
// handed to the page (https://git.eeqj.de/sneak/AutistMask/issues/519).
|
||||
// TYPED_DATA names mainnet.
|
||||
async function answerTypedData(bg, pending, typedData = TYPED_DATA) {
|
||||
const { domain, types, message } = JSON.parse(typedData);
|
||||
delete types.EIP712Domain;
|
||||
const signature = await signer.signTypedData(domain, types, message);
|
||||
const answer = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id: pending.id(),
|
||||
approved: true,
|
||||
signature,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
return { answer, signature };
|
||||
}
|
||||
|
||||
test("typed data for the active chain is handed back signed", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTypedData();
|
||||
await settle();
|
||||
|
||||
const { answer, signature } = await answerTypedData(bg, pending);
|
||||
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({ signature });
|
||||
expect(pending.result()).toEqual({ result: signature });
|
||||
});
|
||||
|
||||
test("typed data for another chain is refused, not handed back", async () => {
|
||||
const bg = loadBackground();
|
||||
bg.setNetwork(SEPOLIA);
|
||||
const pending = bg.requestTypedData();
|
||||
await settle();
|
||||
|
||||
const { answer } = await answerTypedData(bg, pending);
|
||||
|
||||
const refusal =
|
||||
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: refusal,
|
||||
retryable: false,
|
||||
});
|
||||
expect(pending.result()).toEqual({ error: { message: refusal } });
|
||||
});
|
||||
|
||||
// ethers signs a `bool` field sent as the string "false" as true, and one
|
||||
// left out as false. The sign screen refuses both; a signature for either
|
||||
// that reaches the background anyway is not handed to the page
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/552).
|
||||
test.each([
|
||||
[
|
||||
'"false"',
|
||||
"false",
|
||||
'The field "approved" is not true or false, so this typed data cannot be signed.',
|
||||
],
|
||||
[
|
||||
"left out",
|
||||
undefined,
|
||||
'The field "approved" is missing from the message, so this typed data cannot be signed.',
|
||||
],
|
||||
])(
|
||||
"typed data with a bool %s is refused, not handed back",
|
||||
async (_, approved, refusal) => {
|
||||
const data = JSON.parse(TYPED_DATA);
|
||||
data.types.Note.push({ name: "approved", type: "bool" });
|
||||
data.message.approved = approved;
|
||||
const typedData = JSON.stringify(data);
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTypedData(typedData);
|
||||
await settle();
|
||||
|
||||
const { answer } = await answerTypedData(bg, pending, typedData);
|
||||
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: refusal,
|
||||
retryable: false,
|
||||
});
|
||||
expect(pending.result()).toEqual({ error: { message: refusal } });
|
||||
},
|
||||
);
|
||||
|
||||
test("a sign request from another address raises no approval at all", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestSign(other.address);
|
||||
@@ -1642,55 +1551,6 @@ describe("what the approval is verified against", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// eth_sign names the address first and the data second, the reverse of
|
||||
// personal_sign, and is answered as the JSON-RPC specification defines it:
|
||||
// with an EIP-191 personal message signature over the data, the same
|
||||
// signature personal_sign returns
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/409). The signature below is
|
||||
// made by the test; that the sign screen makes the same one is tested in
|
||||
// tests/ethSign.test.js.
|
||||
describe("eth_sign", () => {
|
||||
test("is read address first, and returns a personal message signature over the data", async () => {
|
||||
const bg = loadBackground();
|
||||
const page = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_sign",
|
||||
params: [signer.address, MESSAGE],
|
||||
},
|
||||
{ origin: ORIGIN },
|
||||
);
|
||||
await settle();
|
||||
const id = approvalIdIn(bg.created.map((c) => c.url));
|
||||
|
||||
const details = bg.send(
|
||||
{ type: "AUTISTMASK_GET_APPROVAL", id },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
const { signParams } = details.sendResponse.mock.calls[0][0];
|
||||
expect(signParams).toMatchObject({
|
||||
method: "eth_sign",
|
||||
message: MESSAGE,
|
||||
from: signer.address,
|
||||
});
|
||||
expect(signParams.dangerWarning).toBe(
|
||||
"This site used the older eth_sign method. " +
|
||||
"The data will be signed as a personal message.",
|
||||
);
|
||||
|
||||
const signature = await signer.signMessage(
|
||||
Buffer.from(MESSAGE.slice(2), "hex"),
|
||||
);
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: true, signature },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(page.sendResponse).toHaveBeenCalledWith({ result: signature });
|
||||
});
|
||||
});
|
||||
|
||||
// The interlock must not cost the retry the approval exists to allow.
|
||||
describe("the interlock releases a failed attempt", () => {
|
||||
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
//
|
||||
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
|
||||
// covers the popup's chain switch; this file covers the background's, which
|
||||
// goes through storage rather than the shared state singleton. The last block
|
||||
// covers what the background tells open tabs when the user switches the
|
||||
// network in Settings.
|
||||
// goes through storage rather than the shared state singleton.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
@@ -227,14 +225,6 @@ function loadBackground() {
|
||||
answerPrompt,
|
||||
closePrompt,
|
||||
opened,
|
||||
// A message to the background from `sender`, and its answer.
|
||||
send: (msg, sender) => {
|
||||
let reply = null;
|
||||
messageListener(msg, sender, (r) => {
|
||||
reply = r;
|
||||
});
|
||||
return reply;
|
||||
},
|
||||
walletState: () => storage.read("autistmask"),
|
||||
chainChangedEvents: () =>
|
||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||
@@ -400,109 +390,3 @@ describe("only the user switches the network", () => {
|
||||
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
||||
});
|
||||
});
|
||||
|
||||
// Switching the network in Settings tells open pages, as an approved site
|
||||
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
|
||||
// the real Settings view over the background's storage, with what it sends
|
||||
// delivered to the background from the extension's own page.
|
||||
describe("switching the network in Settings tells every open tab", () => {
|
||||
const POPUP = { url: EXT_URL + "src/popup/index.html" };
|
||||
|
||||
// A stand-in for one DOM node: enough of an element for init() to set
|
||||
// properties on it and hang listeners off it.
|
||||
function fakeElement() {
|
||||
return {
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
listeners: {},
|
||||
addEventListener(event, handler) {
|
||||
this.listeners[event] = handler;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
// Settings, opened the way the popup opens it. Returns its network
|
||||
// selector.
|
||||
async function openSettings(bg) {
|
||||
const elements = {};
|
||||
const element = (id) => (elements[id] ||= fakeElement());
|
||||
jest.doMock("../src/popup/views/helpers", () => ({
|
||||
$: element,
|
||||
showView: () => {},
|
||||
updateDebugBanner: () => {},
|
||||
showFlash: () => {},
|
||||
escapeHtml: (s) => s,
|
||||
flashCopyFeedback: () => {},
|
||||
goBack: () => {},
|
||||
pushCurrentView: () => {},
|
||||
onViewLeave: () => {},
|
||||
VIEWS: [],
|
||||
}));
|
||||
global.chrome.runtime.sendMessage = (msg) => {
|
||||
bg.send(msg, POPUP);
|
||||
};
|
||||
await require("../src/shared/state").loadState();
|
||||
require("../src/popup/views/settings").init({
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
const select = element("settings-network");
|
||||
select.value = "mainnet";
|
||||
return select;
|
||||
}
|
||||
|
||||
// The user picks `networkId` in the selector.
|
||||
async function choose(select, networkId) {
|
||||
select.value = networkId;
|
||||
await select.listeners.change();
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.dontMock("../src/popup/views/helpers");
|
||||
});
|
||||
|
||||
test("switching to the other network sends chainChanged once, with its chain id", async () => {
|
||||
const bg = loadBackground();
|
||||
const select = await openSettings(bg);
|
||||
|
||||
await choose(select, "sepolia");
|
||||
|
||||
expect(bg.walletState().networkId).toBe("sepolia");
|
||||
expect(bg.chainChangedEvents()).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "chainChanged",
|
||||
data: SEPOLIA.chainId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("choosing the network already active changes nothing and sends nothing", async () => {
|
||||
const bg = loadBackground();
|
||||
const select = await openSettings(bg);
|
||||
const before = bg.walletState();
|
||||
|
||||
await choose(select, "mainnet");
|
||||
|
||||
expect(bg.walletState()).toEqual(before);
|
||||
expect(bg.chainChangedEvents()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a page cannot make the background send chainChanged", async () => {
|
||||
const bg = loadBackground();
|
||||
|
||||
const reply = bg.send(
|
||||
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
|
||||
{ url: CONNECTED_ORIGIN + "/" },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(reply).toEqual({ error: "Unauthorized sender" });
|
||||
expect(bg.chainChangedEvents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
// A transfer is sent only on the network its confirmation screen was drawn for
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/524).
|
||||
//
|
||||
// Settings is reachable from the confirmation screen, and Back from it only
|
||||
// unhides the screen, still labelled for the network it was drawn for. Sign &
|
||||
// Send used to take the network at the moment of the click, so 1 SepoliaETH
|
||||
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
|
||||
// Settings in between.
|
||||
//
|
||||
// Driven through the real confirmation screen, signer and chain switch, with
|
||||
// only the node and the DOM stubbed. The stub node is on the network its
|
||||
// provider was built for, so a broadcast records the network it went to.
|
||||
|
||||
"use strict";
|
||||
|
||||
// The signed transactions the stub node was handed, with its network's chain id.
|
||||
const mockNode = { broadcast: [] };
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
class StubProvider {
|
||||
constructor(url, network) {
|
||||
this.network = network;
|
||||
}
|
||||
async getNetwork() {
|
||||
return this.network;
|
||||
}
|
||||
async getFeeData() {
|
||||
return {
|
||||
maxFeePerGas: 20000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
gasPrice: 10000000000n,
|
||||
};
|
||||
}
|
||||
async estimateGas() {
|
||||
return 21000n;
|
||||
}
|
||||
async getCode() {
|
||||
return "0x";
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return 1;
|
||||
}
|
||||
async lookupAddress() {
|
||||
return null;
|
||||
}
|
||||
async broadcastTransaction(signed) {
|
||||
mockNode.broadcast.push({ chainId: this.network.chainId, signed });
|
||||
return { hash: "0x" + "ab".repeat(32) };
|
||||
}
|
||||
}
|
||||
return { ...actual, JsonRpcProvider: StubProvider };
|
||||
});
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [] }),
|
||||
})),
|
||||
urlOrigin: () => "",
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
// 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(),
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
// A stub DOM: every id resolves to a recording element.
|
||||
const elements = new Map();
|
||||
|
||||
function makeEl(id) {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
toggle() {},
|
||||
contains: () => false,
|
||||
},
|
||||
handlers,
|
||||
addEventListener(name, fn) {
|
||||
handlers.set(name, fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
remove() {},
|
||||
focus() {},
|
||||
};
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: (tag) => makeEl(tag),
|
||||
body: { prepend() {}, appendChild() {} },
|
||||
addEventListener() {},
|
||||
};
|
||||
global.navigator = { clipboard: { writeText() {} } };
|
||||
|
||||
const { BaseWallet, Transaction, Wallet } = require("ethers");
|
||||
const { encryptWithPassword } = require("../src/shared/vault");
|
||||
const { state, loadState } = require("../src/shared/state");
|
||||
const { onChainSwitch } = require("../src/shared/chainSwitch");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
|
||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
let encryptedSecret;
|
||||
let signTransaction;
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadState();
|
||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
mockNode.broadcast = [];
|
||||
txStatus.showWait.mockClear();
|
||||
txStatus.showError.mockClear();
|
||||
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
signTransaction.mockRestore();
|
||||
});
|
||||
|
||||
// Show the confirmation screen for 1 of the native token on `networkId`, as
|
||||
// Review on the Send screen does, with its fee estimate settled.
|
||||
async function review(networkId) {
|
||||
await onChainSwitch(networkId);
|
||||
state.wallets = [
|
||||
{
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret,
|
||||
addresses: [{ address: HOLDER, balance: "5.0" }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
confirmTx.init({});
|
||||
confirmTx.show({
|
||||
from: HOLDER,
|
||||
to: RECIPIENT,
|
||||
ensName: null,
|
||||
amount: "1",
|
||||
token: "ETH",
|
||||
balance: "5.0",
|
||||
tokenSymbol: null,
|
||||
tokenBalance: null,
|
||||
tokenDecimals: null,
|
||||
max: false,
|
||||
});
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
async function signAndSend() {
|
||||
el("confirm-tx-password").value = PASSWORD;
|
||||
await el("btn-confirm-send").handlers.get("click")();
|
||||
}
|
||||
|
||||
test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => {
|
||||
await review("sepolia");
|
||||
expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer");
|
||||
expect(el("btn-confirm-send").disabled).toBe(false);
|
||||
await signAndSend();
|
||||
expect(mockNode.broadcast).toHaveLength(1);
|
||||
expect(mockNode.broadcast[0].chainId).toBe(11155111n);
|
||||
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
|
||||
11155111n,
|
||||
);
|
||||
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["sepolia", "mainnet", "SepoliaETH"],
|
||||
["mainnet", "sepolia", "ETH"],
|
||||
])(
|
||||
"a transfer reviewed on %s, with %s chosen in Settings before Sign & Send",
|
||||
(reviewedOn, switchedTo, symbol) => {
|
||||
test("is neither signed nor sent, and the screen says why", async () => {
|
||||
await review(reviewedOn);
|
||||
expect(el("confirm-type").textContent).toBe(
|
||||
"Native " + symbol + " transfer",
|
||||
);
|
||||
// Settings' network select, then Back, which only unhides the
|
||||
// screen as it was drawn.
|
||||
await onChainSwitch(switchedTo);
|
||||
await signAndSend();
|
||||
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
expect(txStatus.showWait).not.toHaveBeenCalled();
|
||||
expect(txStatus.showError).not.toHaveBeenCalled();
|
||||
expect(el("confirm-errors").style.visibility).toBe("visible");
|
||||
expect(el("confirm-errors").innerHTML).toContain(
|
||||
"The active network changed after this transaction was" +
|
||||
" reviewed, so it was not sent. Please go back and review" +
|
||||
" it again.",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// The check in the click handler tells the user; the send itself carries the
|
||||
// chain id the screen was drawn for, so a signer on another network's node
|
||||
// cannot sign it even if the network changes after that check.
|
||||
test("the send is refused by a signer on another network's node", async () => {
|
||||
const { getProvider } = require("../src/shared/balances");
|
||||
const signer = new Wallet(PRIVATE_KEY).connect(
|
||||
getProvider("https://rpc.example.invalid", "mainnet"),
|
||||
);
|
||||
await expect(
|
||||
confirmTx.populateVerifyAndSend(signer, {
|
||||
token: "ETH",
|
||||
to: RECIPIENT,
|
||||
amount: "1",
|
||||
chainId: NETWORKS.sepolia.chainId,
|
||||
}),
|
||||
).rejects.toThrow("transaction chainId mismatch");
|
||||
expect(signTransaction).not.toHaveBeenCalled();
|
||||
expect(mockNode.broadcast).toEqual([]);
|
||||
});
|
||||
@@ -279,18 +279,12 @@ class Driver {
|
||||
|
||||
// Shown means shown: in the popup a view is switched by toggling a
|
||||
// "hidden" class, and an element that is present but collapsed is not
|
||||
// the thing a test means by visible. Until the page's stylesheet has
|
||||
// applied that class hides nothing and every view lays out, so the page
|
||||
// must also have finished loading, which it does only after its
|
||||
// stylesheet, and neither the element nor anything enclosing it may
|
||||
// carry the class (https://git.eeqj.de/sneak/AutistMask/issues/502).
|
||||
// the thing a test means by visible.
|
||||
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
|
||||
return this.waitFor(
|
||||
"selector " + selector + " to be visible",
|
||||
`const el = document.querySelector(arguments[0]);
|
||||
if (document.readyState !== "complete" || !el) return false;
|
||||
if (el.closest(".hidden")) return false;
|
||||
if (getComputedStyle(el).visibility !== "visible") return false;
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;`,
|
||||
[selector],
|
||||
|
||||
@@ -128,18 +128,6 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
|
||||
);
|
||||
});
|
||||
|
||||
// The same check as the Chrome suite's (#456), so both browsers are held to
|
||||
// the same size.
|
||||
step("the popup's text is drawn at the 14px text-sm declares", async (env) => {
|
||||
const size = await env.driver.execute(
|
||||
"return getComputedStyle(document.body).fontSize;",
|
||||
);
|
||||
assert(
|
||||
size === "14px",
|
||||
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
|
||||
);
|
||||
});
|
||||
|
||||
// The recovery screen (#361): the Chrome suite's four cases, run before any
|
||||
// wallet exists for the same reason. With no wallet nothing saves on a timer,
|
||||
// so no save can write a good record over the unreadable one. The last of them
|
||||
|
||||
+1
-26
@@ -333,33 +333,8 @@ async function launch(routeOpts) {
|
||||
|
||||
const PASSWORD = "e2e-harness-password";
|
||||
|
||||
// Waits until the page shows `selector`, not only until it lays out. Until the
|
||||
// page's stylesheet has applied, the `hidden` class that showView() keeps on
|
||||
// every view but the current one hides nothing and every view lays out, so a
|
||||
// test could read a screen before the page's script had filled it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/502). So the page must also
|
||||
// have finished loading, which it does only after its stylesheet, and neither
|
||||
// the element nor anything enclosing it may carry `hidden`. Polled on a timer:
|
||||
// animation frames are not guaranteed to a window that is not in front.
|
||||
async function visible(page, selector, timeout = 15000) {
|
||||
await page
|
||||
.waitForFunction(
|
||||
(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (document.readyState !== "complete" || !el) return false;
|
||||
if (el.closest(".hidden")) return false;
|
||||
if (getComputedStyle(el).visibility !== "visible") return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
},
|
||||
selector,
|
||||
{ polling: 50, timeout },
|
||||
)
|
||||
.catch((e) => {
|
||||
throw new Error(
|
||||
"the page did not show " + selector + ": " + e.message,
|
||||
);
|
||||
});
|
||||
await page.waitForSelector(selector, { state: "visible", timeout });
|
||||
}
|
||||
|
||||
// An empty WebAssembly module: magic number and version header, no
|
||||
|
||||
@@ -113,18 +113,6 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
|
||||
);
|
||||
});
|
||||
|
||||
// The same stylesheet sets the size on body to 75%, which would draw the
|
||||
// popup at 12px.
|
||||
test("the popup's text is drawn at the 14px text-sm declares (#456)", async (env) => {
|
||||
const size = await env.page.evaluate(
|
||||
() => getComputedStyle(document.body).fontSize,
|
||||
);
|
||||
assert(
|
||||
size === "14px",
|
||||
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
|
||||
);
|
||||
});
|
||||
|
||||
// The empirical half of #182. The manifest change is only a claim about
|
||||
// what the CSP permits; this is the observation. Two things have to hold
|
||||
// together, and the run covers both: the popup realm compiles WASM (here),
|
||||
@@ -3857,9 +3845,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
|
||||
@@ -4553,42 +4538,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
|
||||
);
|
||||
});
|
||||
|
||||
// Switching the network in Settings tells the page too, as an approved site
|
||||
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). The wallet
|
||||
// goes back to mainnet the same way at the end.
|
||||
test("a network switch in Settings tells the page (#500)", async (env) => {
|
||||
const { mainnet, sepolia } = NETWORKS;
|
||||
const before = await storedNetwork(env.page);
|
||||
assert(
|
||||
before.networkId === "mainnet",
|
||||
"this test starts on mainnet, not on " + before.networkId,
|
||||
);
|
||||
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
|
||||
await openSettings(env.page);
|
||||
|
||||
await env.page.selectOption("#settings-network", "sepolia");
|
||||
let events = await chainChangedEvents(env.dapp, eventsBefore + 1);
|
||||
assert(
|
||||
events.length === eventsBefore + 1 &&
|
||||
events[events.length - 1].data === sepolia.chainId,
|
||||
"the page was not told of the switch to Sepolia: " +
|
||||
JSON.stringify(events),
|
||||
);
|
||||
|
||||
await env.page.selectOption("#settings-network", "mainnet");
|
||||
events = await chainChangedEvents(env.dapp, eventsBefore + 2);
|
||||
assert(
|
||||
events.length === eventsBefore + 2 &&
|
||||
events[events.length - 1].data === mainnet.chainId,
|
||||
"the page was not told of the switch back to mainnet: " +
|
||||
JSON.stringify(events),
|
||||
);
|
||||
assert(
|
||||
isDeepStrictEqual(await storedNetwork(env.page), before),
|
||||
"switching back did not restore the mainnet network and endpoints",
|
||||
);
|
||||
});
|
||||
|
||||
// The closing pass over both boundaries at once. Every message the section
|
||||
// put on either channel is re-read here and required to be free of the
|
||||
// password — and required to be there at all, method by method, so the
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// The sign screen signs an eth_sign request the way it signs personal_sign: as
|
||||
// an EIP-191 personal message over the bytes of the data
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/409). Booted through the real
|
||||
// popup entry point as the approval window. The background's half, which reads
|
||||
// the address first and the data second and checks the signature, is tested in
|
||||
// tests/backgroundApproval.test.js.
|
||||
|
||||
// Mocked so that no password has to be hashed: the stored secret decrypts to
|
||||
// the test key below.
|
||||
jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
encryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Wallet, getBytes } = require("ethers");
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
// A published test key, never used for real funds.
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
const signer = new Wallet(SIGNER_KEY);
|
||||
// "Hello AutistMask", as the hex a page sends.
|
||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
||||
const ORIGIN = "https://dapp.example";
|
||||
|
||||
afterEach(cleanupPopup);
|
||||
|
||||
test("the sign screen answers eth_sign with a personal message signature over the data", async () => {
|
||||
const sent = [];
|
||||
const page = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
wallets: [
|
||||
{
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret: "encrypted-key",
|
||||
addresses: [
|
||||
{
|
||||
address: signer.address,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: signer.address,
|
||||
allowedSites: { [signer.address]: [ORIGIN] },
|
||||
}),
|
||||
{
|
||||
search: "?approval=approval-1",
|
||||
runtime: {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
// Answered as the background answers: with the eth_sign
|
||||
// approval this window was opened for, and by handing back
|
||||
// the signature the popup sends.
|
||||
sendMessage: (msg, reply) => {
|
||||
sent.push(msg);
|
||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||
reply({
|
||||
type: "sign",
|
||||
origin: ORIGIN,
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: signer.address,
|
||||
signParams: {
|
||||
method: "eth_sign",
|
||||
message: MESSAGE,
|
||||
from: signer.address,
|
||||
},
|
||||
});
|
||||
} else if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
|
||||
reply({ signature: msg.signature });
|
||||
} else {
|
||||
reply(undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
globalThis.window.close = jest.fn();
|
||||
const { decryptWithPassword } = require("../src/shared/vault");
|
||||
decryptWithPassword.mockResolvedValue(SIGNER_KEY);
|
||||
|
||||
expect(page.visibleViews()).toEqual(["approve-sign"]);
|
||||
page.node("approve-sign-password").value = "any password";
|
||||
await page.click("btn-approve-sign");
|
||||
|
||||
expect(
|
||||
sent.filter((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE"),
|
||||
).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id: "approval-1",
|
||||
approved: true,
|
||||
signature: await signer.signMessage(getBytes(MESSAGE)),
|
||||
},
|
||||
]);
|
||||
expect(globalThis.window.close).toHaveBeenCalled();
|
||||
});
|
||||
@@ -345,10 +345,10 @@ describe.each([
|
||||
});
|
||||
|
||||
// A transaction's value and fee are in the native currency of the network the
|
||||
// transaction is on, which need not be the active one. The active network can
|
||||
// change, in Settings or when the user approves a site's request, after a
|
||||
// transaction is prepared and change back before it is signed, and a popup
|
||||
// opened after a switch shows a sent or listed transaction again. The wallet's balances follow the active network; these do not.
|
||||
// transaction is on, which need not be the active one. A site can switch the
|
||||
// active network after its transaction is prepared and back before it is
|
||||
// signed, and a popup opened after a switch shows a sent or listed transaction
|
||||
// again. The wallet's balances follow the active network; these do not.
|
||||
describe.each([
|
||||
["mainnet", "sepolia", "ETH"],
|
||||
["sepolia", "mainnet", "SepoliaETH"],
|
||||
|
||||
@@ -89,12 +89,6 @@ describe("vendored blocklist", () => {
|
||||
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
|
||||
});
|
||||
|
||||
test("a hostname ending in a dot is the same host", () => {
|
||||
expect(isPhishingDomain(LISTED[1] + ".")).toBe(true);
|
||||
expect(isPhishingDomain("wallet." + LISTED[1] + ".")).toBe(true);
|
||||
expect(isPhishingDomain(CLEAN[0] + ".")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for an empty or missing hostname", () => {
|
||||
expect(isPhishingDomain("")).toBe(false);
|
||||
expect(isPhishingDomain(null)).toBe(false);
|
||||
@@ -139,13 +133,6 @@ describe("hostnameVariants", () => {
|
||||
test("lowercases hostnames", () => {
|
||||
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
|
||||
});
|
||||
|
||||
test("drops the trailing dot of a fully qualified hostname", () => {
|
||||
expect(hostnameVariants("sub.evil.com.")).toEqual([
|
||||
"sub.evil.com",
|
||||
"evil.com",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domain hashing", () => {
|
||||
|
||||
@@ -238,10 +238,6 @@ async function settle() {
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.storage] a storage stub from makeStorageStub(), for
|
||||
* a test that needs to make writes fail or to watch the round trips.
|
||||
* @param {string} [options.search] the page URL's query string, such as
|
||||
* "?approval=" and an id for the popup opened as an approval window.
|
||||
* @param {object} [options.runtime] members of chrome.runtime that replace the
|
||||
* stub's own, for a test that has to answer the background's messages.
|
||||
* @returns {Promise<object>} handles onto the booted page.
|
||||
*/
|
||||
async function bootPopup(stored, options) {
|
||||
@@ -285,13 +281,12 @@ async function bootPopup(stored, options) {
|
||||
sendMessage: jest.fn(async () => ({})),
|
||||
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||
onMessage: { addListener: () => {} },
|
||||
...(options && options.runtime),
|
||||
},
|
||||
};
|
||||
globalThis.document = document;
|
||||
globalThis.window = {
|
||||
location: {
|
||||
search: (options && options.search) || "",
|
||||
search: "",
|
||||
href: "chrome-extension://autistmask/src/popup/index.html",
|
||||
reload: () => reloads.push(Date.now()),
|
||||
},
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
// The decoded details on the transaction detail screen of a token transfer
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/515).
|
||||
//
|
||||
// A history row for a token transfer holds the transfer's recipient as its
|
||||
// `to`, not the contract the transaction was sent to. The screen used to decode
|
||||
// the transaction's data against that `to`, so its Token line named the
|
||||
// recipient, and the amount read "decimals unknown" for a token the wallet
|
||||
// tracks. It decodes against the address the explorer reports the transaction
|
||||
// was sent to.
|
||||
//
|
||||
// The row is the one the real history parser makes from the explorer's answer,
|
||||
// opened through transactionDetail.show(), which is what a click on the row
|
||||
// calls. Driven against a minimal DOM stub in the shape
|
||||
// tests/contractCreation.test.js uses.
|
||||
|
||||
const { Interface } = require("ethers");
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: async (url) => ({
|
||||
ok: true,
|
||||
json: async () => mockExplorerAnswer(url),
|
||||
}),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { state } = require("../src/shared/state");
|
||||
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||
|
||||
const EXPLORER = "https://explorer.example/api/v2";
|
||||
const HOLDER = "0x0000000000000000000000000000000000000a11";
|
||||
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
||||
// A token the wallet tracks and the bundled token list does not name.
|
||||
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
|
||||
// HOLDER sends 1234.5 of the 6-decimal token to RECIPIENT.
|
||||
const TRANSFER_DATA = new Interface([
|
||||
"function transfer(address to, uint256 amount)",
|
||||
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
|
||||
|
||||
// The explorer's answers: the transfer in HOLDER's token transfers, and the
|
||||
// transaction itself, sent to the token contract.
|
||||
function mockExplorerAnswer(url) {
|
||||
if (
|
||||
url ===
|
||||
EXPLORER + "/addresses/" + HOLDER + "/token-transfers?type=ERC-20"
|
||||
) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
transaction_hash: TX_HASH,
|
||||
block_number: 100,
|
||||
timestamp: "2026-10-01T00:00:00Z",
|
||||
from: { hash: HOLDER },
|
||||
to: { hash: RECIPIENT },
|
||||
total: { value: "1234500000", decimals: "6" },
|
||||
token: {
|
||||
address_hash: TOKEN,
|
||||
symbol: "TDOL",
|
||||
holders_count: "5000",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (url === EXPLORER + "/transactions/" + TX_HASH) {
|
||||
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
|
||||
}
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: () => false,
|
||||
},
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
appendChild: () => {},
|
||||
};
|
||||
// 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"),
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
// One line of the decoded details: its label and what is drawn under it.
|
||||
function decodedLine(label) {
|
||||
return node("tx-detail-calldata-details")
|
||||
.innerHTML.split('<div class="mb-2">')
|
||||
.find((line) =>
|
||||
line.startsWith(`<div class="text-muted">${label}</div>`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" } };
|
||||
state.blockscoutUrl = EXPLORER;
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Main",
|
||||
type: "key",
|
||||
addresses: [{ address: HOLDER, balance: "0.0000" }],
|
||||
},
|
||||
];
|
||||
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
});
|
||||
|
||||
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
|
||||
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
|
||||
expect(row.to).toBe(RECIPIENT);
|
||||
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
|
||||
|
||||
transactionDetail.show(row);
|
||||
// The decoded details are drawn once the explorer has answered.
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(node("tx-detail-calldata-action").textContent).toBe(
|
||||
"Token Transfer",
|
||||
);
|
||||
const token = decodedLine("Token");
|
||||
expect(token).toContain('<div class="font-bold">TDOL</div>');
|
||||
expect(token).toContain(TOKEN);
|
||||
expect(token).not.toContain(RECIPIENT);
|
||||
expect(decodedLine("Recipient")).toContain(RECIPIENT);
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
||||
);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { getAddress, MaxUint256, TypedDataEncoder } = require("ethers");
|
||||
const { getAddress, MaxUint256 } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { decryptWithPassword } = require("../src/shared/vault");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
@@ -233,26 +233,6 @@ function warningOf(data) {
|
||||
return html.slice(0, html.indexOf(">Domain<"));
|
||||
}
|
||||
|
||||
const NOT_SIGNED = ">Not part of the signature<";
|
||||
|
||||
// The Message section alone, without the keys listed after it as not part of
|
||||
// the signature.
|
||||
function messageOf(data) {
|
||||
const html = formatTypedDataHtml(JSON.stringify(data));
|
||||
const end = html.indexOf(NOT_SIGNED);
|
||||
return html.slice(
|
||||
html.indexOf(">Message<"),
|
||||
end === -1 ? html.length : end,
|
||||
);
|
||||
}
|
||||
|
||||
// The keys listed as not part of the signature, or "" when there are none.
|
||||
function notSignedOf(data) {
|
||||
const html = formatTypedDataHtml(JSON.stringify(data));
|
||||
const start = html.indexOf(NOT_SIGNED);
|
||||
return start === -1 ? "" : html.slice(start);
|
||||
}
|
||||
|
||||
function request(data) {
|
||||
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
|
||||
}
|
||||
@@ -260,7 +240,6 @@ function request(data) {
|
||||
beforeEach(() => {
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
state.networkId = "mainnet";
|
||||
});
|
||||
|
||||
describe("a token permission is warned about, naming spender and amount", () => {
|
||||
@@ -332,8 +311,7 @@ describe("the warning reads only the fields the signed type declares", () => {
|
||||
});
|
||||
|
||||
// A Permit whose type names its spender `operator`; the page adds a
|
||||
// `spender` key the type does not declare. The Message section names
|
||||
// only the operator, and the added key is listed apart as not signed.
|
||||
// `spender` key the type does not declare.
|
||||
test("an added key is not named as the spender", () => {
|
||||
const data = {
|
||||
...PERMIT,
|
||||
@@ -352,10 +330,6 @@ describe("the warning reads only the fields the signed type declares", () => {
|
||||
const warning = warningOf(data);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).not.toContain(DECOY);
|
||||
expect(messageOf(data)).toContain(SPENDER);
|
||||
expect(messageOf(data)).not.toContain(DECOY);
|
||||
expect(notSignedOf(data)).toContain("spender:");
|
||||
expect(notSignedOf(data)).toContain(DECOY);
|
||||
});
|
||||
|
||||
// The permit for a Uniswap v3 position NFT: a `Permit` with no amount
|
||||
@@ -396,47 +370,6 @@ describe("the warning reads only the fields the signed type declares", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ethers signs only the declared fields at every level of nesting, so an
|
||||
// added key inside a struct is not signed either. Such typed data is not
|
||||
// refused; the added key is listed apart, with where it sits in the message.
|
||||
describe("the Message section shows only the fields that are signed", () => {
|
||||
const NOTE = "This signature only signs you in";
|
||||
|
||||
test("an added key inside a nested struct is listed as not signed", () => {
|
||||
const data = {
|
||||
...PERMIT_SINGLE,
|
||||
message: {
|
||||
...PERMIT_SINGLE.message,
|
||||
details: { ...PERMIT_SINGLE.message.details, note: NOTE },
|
||||
},
|
||||
};
|
||||
expect(messageOf(data)).toContain("expiration");
|
||||
expect(messageOf(data)).not.toContain(NOTE);
|
||||
expect(notSignedOf(data)).toContain("details.note:");
|
||||
expect(notSignedOf(data)).toContain(NOTE);
|
||||
expect(typedDataRefusal(request(data))).toBeNull();
|
||||
});
|
||||
|
||||
test("an added key inside a list item is listed with its index", () => {
|
||||
const [first, second] = PERMIT_BATCH.message.details;
|
||||
const data = {
|
||||
...PERMIT_BATCH,
|
||||
message: {
|
||||
...PERMIT_BATCH.message,
|
||||
details: [first, { ...second, note: NOTE }],
|
||||
},
|
||||
};
|
||||
expect(messageOf(data)).not.toContain(NOTE);
|
||||
expect(notSignedOf(data)).toContain("details[1].note:");
|
||||
expect(notSignedOf(data)).toContain(NOTE);
|
||||
});
|
||||
|
||||
test("nothing is listed apart when every key is declared", () => {
|
||||
expect(notSignedOf(PERMIT_SINGLE)).toBe("");
|
||||
expect(notSignedOf(LOGIN)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the primary type shown is the one ethers signs", () => {
|
||||
// A drain stated as a sign-in: the page says Login, the types say
|
||||
// PermitSingle, and ethers would sign PermitSingle.
|
||||
@@ -599,170 +532,3 @@ describe("the sign screen refuses a mismatched primary type", () => {
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// EIP-712: a wallet refuses typed data whose domain names a chain other than
|
||||
// the active one, because the signature is valid on the chain it names
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/519). The wallet is on Sepolia
|
||||
// and the site asks for a mainnet USDC Permit.
|
||||
describe("the sign screen refuses typed data for another chain", () => {
|
||||
const REFUSAL =
|
||||
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
|
||||
|
||||
function withChainId(chainId) {
|
||||
return { ...PERMIT, domain: { ...PERMIT.domain, chainId } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
decryptWithPassword.mockClear();
|
||||
state.networkId = "sepolia";
|
||||
});
|
||||
|
||||
test("another chain shows the error, with Sign disabled", async () => {
|
||||
await openSignScreen(PERMIT);
|
||||
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("Sign clicked anyway decrypts and signs nothing", async () => {
|
||||
const sent = await openSignScreen(PERMIT);
|
||||
node("approve-sign-password").value = "any password";
|
||||
await click("btn-approve-sign");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
);
|
||||
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
|
||||
});
|
||||
|
||||
// ethers signs 1, "1" and "0x1" as the same chain id.
|
||||
test("the chain id is refused in each form ethers signs", () => {
|
||||
for (const chainId of [1, "1", "0x1"]) {
|
||||
expect(typedDataRefusal(request(withChainId(chainId)))).toBe(
|
||||
REFUSAL,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a chain id that is not a number is refused", () => {
|
||||
expect(typedDataRefusal(request(withChainId("mainnet")))).toBe(
|
||||
"This typed data cannot be read, so it cannot be signed.",
|
||||
);
|
||||
});
|
||||
|
||||
test("the active chain leaves Sign enabled", async () => {
|
||||
await openSignScreen(withChainId("0xaa36a7"));
|
||||
expect(node("approve-sign-error").textContent).toBe("");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("no chain id leaves Sign enabled", async () => {
|
||||
await openSignScreen(withChainId(undefined));
|
||||
expect(node("approve-sign-error").textContent).toBe("");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ethers signs a `bool` field by whether its value is truthy in JavaScript:
|
||||
// the string "false" is signed as true, and a field left out as false. The
|
||||
// screen would show one value while another is signed, so such typed data is
|
||||
// refused, and so is any declared field left out of the message
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/552). DAI's older permit
|
||||
// declares the `bool` field `allowed`.
|
||||
describe("the sign screen refuses a message ethers would sign differently", () => {
|
||||
const NOT_A_BOOL =
|
||||
'The field "allowed" is not true or false, so this typed data cannot be signed.';
|
||||
const MISSING =
|
||||
'The field "allowed" is missing from the message, so this typed data cannot be signed.';
|
||||
|
||||
function daiPermit(allowed) {
|
||||
return { ...DAI_PERMIT, message: { ...DAI_PERMIT.message, allowed } };
|
||||
}
|
||||
|
||||
const withoutAllowed = {
|
||||
...DAI_PERMIT,
|
||||
message: { ...DAI_PERMIT.message },
|
||||
};
|
||||
delete withoutAllowed.message.allowed;
|
||||
|
||||
beforeEach(() => {
|
||||
decryptWithPassword.mockClear();
|
||||
});
|
||||
|
||||
test('ethers signs "false" as true, and a bool left out as false', () => {
|
||||
const hash = (data) =>
|
||||
TypedDataEncoder.hash(
|
||||
data.domain,
|
||||
{ Permit: data.types.Permit },
|
||||
data.message,
|
||||
);
|
||||
expect(hash(daiPermit("false"))).toBe(hash(daiPermit(true)));
|
||||
expect(hash(withoutAllowed)).toBe(hash(daiPermit(false)));
|
||||
});
|
||||
|
||||
test('a bool sent as the string "false" shows the error, with Sign disabled', async () => {
|
||||
await openSignScreen(daiPermit("false"));
|
||||
expect(node("approve-sign-error").textContent).toBe(NOT_A_BOOL);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("a bool left out shows the error, with Sign disabled", async () => {
|
||||
await openSignScreen(withoutAllowed);
|
||||
expect(node("approve-sign-error").textContent).toBe(MISSING);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("Sign clicked anyway decrypts and signs nothing", async () => {
|
||||
const sent = await openSignScreen(daiPermit("false"));
|
||||
node("approve-sign-password").value = "any password";
|
||||
await click("btn-approve-sign");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
);
|
||||
expect(node("approve-sign-error").textContent).toBe(NOT_A_BOOL);
|
||||
});
|
||||
|
||||
test("a well-formed message leaves Sign enabled", async () => {
|
||||
for (const allowed of [true, false]) {
|
||||
await openSignScreen(daiPermit(allowed));
|
||||
expect(node("approve-sign-error").textContent).toBe("");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("a field left out inside a list item is named by its place", () => {
|
||||
const [first, second] = PERMIT_BATCH.message.details;
|
||||
const withoutNonce = { ...second };
|
||||
delete withoutNonce.nonce;
|
||||
const data = {
|
||||
...PERMIT_BATCH,
|
||||
message: {
|
||||
...PERMIT_BATCH.message,
|
||||
details: [first, withoutNonce],
|
||||
},
|
||||
};
|
||||
expect(typedDataRefusal(request(data))).toBe(
|
||||
'The field "details[1].nonce" is missing from the message, so this typed data cannot be signed.',
|
||||
);
|
||||
});
|
||||
|
||||
// A plain lookup of a field named after a built-in object property reads
|
||||
// back a function, which ethers would sign as true.
|
||||
test("a field named after a built-in object property is missing when left out", () => {
|
||||
const data = {
|
||||
...LOGIN,
|
||||
types: {
|
||||
...LOGIN.types,
|
||||
Login: [
|
||||
...LOGIN.types.Login,
|
||||
{ name: "constructor", type: "bool" },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(typedDataRefusal(request(data))).toBe(
|
||||
'The field "constructor" is missing from the message, so this typed data cannot be signed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-49
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -157,18 +153,6 @@ describe("the explanatory copy", () => {
|
||||
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
|
||||
});
|
||||
|
||||
// The wallet holds the imported key but will not export it, so the copy
|
||||
// must name every key the user may hold that reaches the addresses
|
||||
// outside it.
|
||||
test("it names the keys that reach the addresses outside this wallet", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
expect(body).toMatch(/private key export are disabled/);
|
||||
expect(body).toMatch(
|
||||
/with the extended private key you imported, if you kept a copy of it, or with the master extended private key or recovery phrase it came from, in software that repeats the same non-standard derivation/,
|
||||
);
|
||||
});
|
||||
|
||||
test("it says the wallet is not deleted and what the user can do", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
@@ -574,7 +558,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 +573,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),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user